From 2426e5346566f8d188e3adabb9064d4b126e2d2f Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:32:45 +0300 Subject: [PATCH 1/6] feat: add Caddyfile.example with perimeter rate limit, CrowdSec and asset rules --- Caddyfile.example | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Caddyfile.example diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..a42c305 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,65 @@ +# Caddyfile for hyperhack. Copy it, replace example.com and /srv/hyperhack. +# +# Needs Caddy built with two plugins (README → Caddy): +# xcaddy build v2.11.7 \ +# --with github.com/mholt/caddy-ratelimit@v0.1.0 \ +# --with github.com/hslatman/caddy-crowdsec-bouncer/http@v0.14.1 +# +# CROWDSEC_API_URL and CROWDSEC_API_KEY come from Caddy's environment. +# The CrowdSec LAPI also defaults to port 8080: move one of them if both run on the host. +{ + order crowdsec first + order rate_limit after crowdsec + crowdsec { + api_url {$CROWDSEC_API_URL} + api_key {$CROWDSEC_API_KEY} + } +} + +example.com { + crowdsec + + # Per client IP, all paths. One page view is about 10 requests. + rate_limit { + zone per_client { + key {remote_host} + events 300 + window 1m + } + } + + header { + Strict-Transport-Security "max-age=31536000" + -Server + } + + encode zstd gzip + + # Private endpoints. VictoriaMetrics and /reindex callers reach the service directly. + # A handle block, because handle runs before a bare respond. + @private path /metrics /metrics/* /reindex /reindex/* + handle @private { + respond 404 + } + + # Assets straight from the mirror (vault/assets/), allowed extensions only. + handle /assets/* { + @allowed path *.png *.jpg *.jpeg *.webp *.avif *.gif *.svg *.mp4 *.webm + handle @allowed { + root * /srv/hyperhack/vault + header { + X-Content-Type-Options nosniff + Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox" + } + file_server + } + respond 404 + } + + handle { + request_body /api/* { + max_size 16KB + } + reverse_proxy 127.0.0.1:8080 + } +} From 55c65fc05a5a02410738921e799b89582341873f Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:32:45 +0300 Subject: [PATCH 2/6] feat: add Grafana dashboard for post, search, HTTP and sync metrics --- grafana/hyperhack.json | 1811 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1811 insertions(+) create mode 100644 grafana/hyperhack.json diff --git a/grafana/hyperhack.json b/grafana/hyperhack.json new file mode 100644 index 0000000..adc1b1e --- /dev/null +++ b/grafana/hyperhack.json @@ -0,0 +1,1811 @@ +{ + "title": "hyperhack", + "uid": "hyperhack", + "description": "Per-post metrics, search, HTTP and sync of the hyperhack blog.", + "tags": [ + "hyperhack" + ], + "editable": true, + "graphTooltip": 1, + "schemaVersion": 41, + "version": 1, + "time": { + "from": "now-30d", + "to": "now" + }, + "refresh": "5m", + "timezone": "browser", + "templating": { + "list": [ + { + "name": "ds", + "label": "Data source", + "type": "datasource", + "query": "prometheus", + "current": {}, + "hide": 0, + "refresh": 1, + "regex": "", + "options": [] + }, + { + "name": "slug", + "label": "Post", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "query": { + "query": "label_values(hyperhack_post_likes, slug)", + "refId": "slug" + }, + "definition": "label_values(hyperhack_post_likes, slug)", + "refresh": 2, + "multi": true, + "includeAll": true, + "allValue": ".*", + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "sort": 1, + "hide": 0, + "regex": "", + "options": [] + } + ] + }, + "annotations": { + "list": [] + }, + "links": [], + "panels": [ + { + "type": "row", + "id": 1, + "title": "Overview", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + } + }, + { + "type": "stat", + "id": 2, + "title": "Posts", + "description": "Published posts in the index.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "hyperhack_posts", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 3, + "title": "Views", + "description": "One per visitor per post per day, in the selected time range.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 4, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(increase(hyperhack_post_views_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 4, + "title": "Avg time on page", + "description": "Visible reading time per view that reported it.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 8, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(increase(hyperhack_post_read_seconds_total{slug=~\"$slug\"}[$__range])) / sum(increase(hyperhack_post_read_views_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + }, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 5, + "title": "Likes", + "description": "Current likes.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(hyperhack_post_likes{slug=~\"$slug\"})", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 6, + "title": "Dislikes", + "description": "Current dislikes.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 16, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(hyperhack_post_dislikes{slug=~\"$slug\"})", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 7, + "title": "Since last sync", + "description": "Time since the last successful bucket sync. Orange after 10 min, red after 1 h.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 20, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "time() - (hyperhack_sync_last_success_timestamp_seconds > 0)", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "noValue": "never", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0 + }, + { + "color": "orange", + "value": 600 + }, + { + "color": "red", + "value": 3600 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "row", + "id": 8, + "title": "Posts", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 5, + "w": 24, + "h": 1 + } + }, + { + "type": "timeseries", + "id": 9, + "title": "Views per day", + "description": "Views per post per day. A bar covers the day that starts at its time, so today is included.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 6, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (increase(hyperhack_post_views_total{slug=~\"$slug\"}[$__interval] offset -$__interval))", + "legendFormat": "{{slug}}", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "min": 0 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "sum" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "interval": "1d" + }, + { + "type": "table", + "id": 10, + "title": "Posts in range", + "description": "Per post in the selected range. Read to end = readers who reached 100 % / views.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 14, + "w": 24, + "h": 9 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (increase(hyperhack_post_views_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + }, + { + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (increase(hyperhack_post_read_seconds_total{slug=~\"$slug\"}[$__range])) / sum by (slug) (increase(hyperhack_post_read_views_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + }, + { + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (increase(hyperhack_post_read_depth_total{slug=~\"$slug\",depth=\"100\"}[$__range])) / sum by (slug) (increase(hyperhack_post_views_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + }, + { + "refId": "D", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (increase(hyperhack_post_code_copies_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + }, + { + "refId": "E", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (hyperhack_post_likes{slug=~\"$slug\"})", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + }, + { + "refId": "F", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (slug) (hyperhack_post_dislikes{slug=~\"$slug\"})", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Read to end" + }, + "properties": [ + { + "id": "unit", + "value": "percentunit" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Avg time (s)" + }, + "properties": [ + { + "id": "unit", + "value": "s" + }, + { + "id": "decimals", + "value": 0 + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "sortBy": [ + { + "displayName": "Views", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "slug": "Post", + "Value #A": "Views", + "Value #B": "Avg time (s)", + "Value #C": "Read to end", + "Value #D": "Code copies", + "Value #E": "Likes", + "Value #F": "Dislikes" + } + } + } + ] + }, + { + "type": "bargauge", + "id": 11, + "title": "Read depth", + "description": "Share of views that reached 25 / 50 / 75 / 100 % of the text. sort_by_label_numeric is a VictoriaMetrics function.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 23, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sort_by_label_numeric(sum by (depth) (increase(hyperhack_post_read_depth_total{slug=~\"$slug\"}[$__range])) / scalar(sum(increase(hyperhack_post_views_total{slug=~\"$slug\"}[$__range]))), \"depth\")", + "legendFormat": "{{depth}} %", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1, + "color": { + "mode": "fixed", + "fixedColor": "blue" + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "options": { + "orientation": "horizontal", + "displayMode": "basic", + "showUnfilled": true, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "timeseries", + "id": 12, + "title": "Votes per day", + "description": "Likes and dislikes cast per day.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 23, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (vote) (increase(hyperhack_post_votes_total{slug=~\"$slug\"}[$__interval] offset -$__interval))", + "legendFormat": "{{vote}}", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "min": 0 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "sum" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "interval": "1d" + }, + { + "type": "row", + "id": 13, + "title": "Readers", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 31, + "w": 24, + "h": 1 + } + }, + { + "type": "piechart", + "id": 14, + "title": "Traffic source", + "description": "Views by referrer, mapped to a fixed list.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 32, + "w": 6, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (source) (increase(hyperhack_post_source_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "{{source}}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + } + } + }, + { + "type": "piechart", + "id": 15, + "title": "Device", + "description": "Views by viewport width class.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 6, + "y": 32, + "w": 6, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (device) (increase(hyperhack_post_device_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "{{device}}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + } + } + }, + { + "type": "piechart", + "id": 16, + "title": "UI language", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 32, + "w": 6, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (lang) (increase(hyperhack_post_ui_lang_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "{{lang}}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + } + } + }, + { + "type": "piechart", + "id": 17, + "title": "Theme", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 18, + "y": 32, + "w": 6, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (theme) (increase(hyperhack_post_ui_theme_total{slug=~\"$slug\"}[$__range]))", + "legendFormat": "{{theme}}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + } + } + }, + { + "type": "table", + "id": 18, + "title": "Outbound clicks", + "description": "Top 20 external hosts readers clicked, in the selected range.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 40, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sort_desc(topk(20, sum by (host) (increase(hyperhack_post_outbound_clicks_total{slug=~\"$slug\"}[$__range]))))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "sortBy": [ + { + "displayName": "Clicks", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "host": "Host", + "Value": "Clicks" + } + } + } + ] + }, + { + "type": "table", + "id": 19, + "title": "Outbound clicks by post", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 40, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sort_desc(topk(20, sum by (slug, host) (increase(hyperhack_post_outbound_clicks_total{slug=~\"$slug\"}[$__range]))))", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "sortBy": [ + { + "displayName": "Clicks", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "slug": "Post", + "host": "Host", + "Value": "Clicks" + } + } + } + ] + }, + { + "type": "row", + "id": 20, + "title": "Search", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 48, + "w": 24, + "h": 1 + } + }, + { + "type": "timeseries", + "id": 21, + "title": "Searches per hour", + "description": "Settled queries (not search-as-you-type requests). Resets on restart are handled by increase().", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 49, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(increase(hyperhack_search_queries_total[$__interval] offset -$__interval))", + "legendFormat": "queries", + "instant": false, + "range": true + }, + { + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum(increase(hyperhack_search_zero_results_total[$__interval] offset -$__interval))", + "legendFormat": "zero results", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "min": 0 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "sum" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "interval": "1h" + }, + { + "type": "timeseries", + "id": 22, + "title": "Search latency", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 49, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "histogram_quantile(0.5, sum by (le) (rate(hyperhack_search_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "p50", + "instant": false, + "range": true + }, + { + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(hyperhack_search_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "p95", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "table", + "id": 23, + "title": "Top queries", + "description": "Top 20 stored queries (last 90 days), refreshed every 5 min.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 57, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "hyperhack_search_top_queries", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "sortBy": [ + { + "displayName": "Count", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true + }, + "renameByName": { + "query": "Query", + "Value": "Count" + } + } + } + ] + }, + { + "type": "table", + "id": 24, + "title": "Top zero-result queries", + "description": "What readers look for and do not find.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 57, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "hyperhack_search_top_zero_result_queries", + "legendFormat": "", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "sortBy": [ + { + "displayName": "Count", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true + }, + "renameByName": { + "query": "Query", + "Value": "Count" + } + } + } + ] + }, + { + "type": "row", + "id": 25, + "title": "HTTP", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 65, + "w": 24, + "h": 1 + } + }, + { + "type": "timeseries", + "id": 26, + "title": "Requests per second", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 66, + "w": 8, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (route) (rate(hyperhack_http_requests_total[$__rate_interval]))", + "legendFormat": "{{route}}", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "timeseries", + "id": 27, + "title": "Errors and rate limits", + "description": "5xx responses and 429 (rate limit hit).", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 8, + "y": 66, + "w": 8, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "sum by (code) (rate(hyperhack_http_requests_total{code=~\"5..|429\"}[$__rate_interval]))", + "legendFormat": "{{code}}", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "timeseries", + "id": 28, + "title": "Latency p95", + "description": "", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 16, + "y": 66, + "w": 8, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "histogram_quantile(0.95, sum by (route, le) (rate(hyperhack_http_request_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "{{route}}", + "instant": false, + "range": true + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "row", + "id": 29, + "title": "Sync", + "collapsed": false, + "panels": [], + "gridPos": { + "x": 0, + "y": 74, + "w": 24, + "h": 1 + } + }, + { + "type": "stat", + "id": 30, + "title": "Bucket objects", + "description": "Keys in the last bucket listing, ignored ones too.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 0, + "y": 75, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "hyperhack_sync_objects", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 31, + "title": "Sync errors", + "description": "Failed syncs in the selected range.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 6, + "y": 75, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "increase(hyperhack_sync_errors_total[$__range])", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 32, + "title": "Failed downloads", + "description": "Objects that failed to download in the selected range; the old copy is kept.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 12, + "y": 75, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "increase(hyperhack_sync_fetch_failures_total[$__range])", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + }, + { + "type": "stat", + "id": 33, + "title": "Since last attempt", + "description": "Time since the last sync started.", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "gridPos": { + "x": 18, + "y": 75, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${ds}" + }, + "expr": "time() - hyperhack_sync_last_attempt_timestamp_seconds", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "text" + }, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value", + "justifyMode": "auto" + } + } + ] +} From c558c99c001747a00443251d3a00f2caeeb3c1c6 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:32:45 +0300 Subject: [PATCH 3/6] docs: add full README in English and Russian --- README.md | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.ru.md | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 842 insertions(+) create mode 100644 README.md create mode 100644 README.ru.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a8aa0f --- /dev/null +++ b/README.md @@ -0,0 +1,421 @@ +# hyperhack + +Russian version: [README.ru.md](README.ru.md). + +A light, fast blog for one author. It replaces Ghost when Ghost is too heavy. + +You write posts in Obsidian. The vault syncs to an S3 bucket (Garage) and the blog renders it. The whole service is one Go binary with SQLite. There is no admin panel and no database server. + +- Posts are Markdown files with Obsidian properties (YAML frontmatter). +- Pages are rendered on the server and work without JS. +- Light and dark themes. The UI is in English and Russian. +- Search in English and Russian, with typo tolerance and search-as-you-type. +- Tags, RSS, sitemap, Open Graph, JSON-LD, `llms.txt` and IndexNow. +- Each post shows its reading time, view count, likes and dislikes. Votes are anonymous and need an ALTCHA proof of work. +- Private per-post metrics on `/metrics` (Prometheus format), with a Grafana dashboard. + +The design is in [SPEC.md](SPEC.md). + +## How it fits together + +``` +Obsidian ──Remotely Save──▶ Garage bucket ◀──poll── hyperhack ──▶ ./data (SQLite) + │ + └──mirror──▶ ./vault +reader ──▶ Caddy ──┬──▶ hyperhack :8080 (pages, /api/*) + └──▶ ./vault/assets/ (/assets/*, served by Caddy) +VictoriaMetrics ──scrape /metrics──▶ hyperhack ; Grafana ──▶ VictoriaMetrics +``` + +The bucket is the source of truth. The service polls it and mirrors it to `./vault`. Caddy serves images straight from the mirror, so pages and images keep working while S3 is down. + +## Requirements + +- Docker with Docker Compose. +- An S3 API bucket over HTTPS. Garage is what this is built and tested for. +- Caddy as the reverse proxy, built with the `caddy-ratelimit` and CrowdSec bouncer plugins. See [Caddy](#caddy). +- Optional: VictoriaMetrics and Grafana for metrics, and CrowdSec. + +## Quick start + +1. Make a folder with the compose file and the env file: + + ```sh + mkdir hyperhack && cd hyperhack + curl -fsSLO https://raw.githubusercontent.com/Mimic890/hyperhack/main/docker-compose.yml + curl -fsSL -o .env https://raw.githubusercontent.com/Mimic890/hyperhack/main/.env.example + chmod 600 .env + ``` + +2. Edit `.env` (see [Configuration](#configuration)). Generate each secret with: + + ```sh + openssl rand -base64 48 + ``` + +3. Create the data folders. The container runs as user `10001`: + + ```sh + mkdir data vault + sudo chown 10001:10001 data vault + ``` + + Caddy must be able to read `vault/assets/`. The service writes folders as `0755` and files as `0644`. The SQLite file is `0600`. + +4. In `docker-compose.yml`, set `image:` to a released version and remove the `build: .` line. Tags are semver and are never overwritten. There is no `latest` tag. + +5. Verify the image and start the service: + + ```sh + gh attestation verify oci://ghcr.io/mimic890/hyperhack: -R Mimic890/hyperhack + docker compose up -d + docker compose ps # wait for "healthy" + docker compose logs -f + ``` + + On the first start the service needs the bucket. If S3 is unreachable and there is no mirror yet, it exits with code 1. Later starts build the index from the mirror and do not wait for S3. + +6. Set up Caddy (see [Caddy](#caddy)) and set `TRUSTED_PROXIES`. + +The service listens on port `8080` inside the container. Compose publishes it on `127.0.0.1:8080` only. Do not expose it to the internet: Caddy is the only public entry. + +## Garage: bucket and keys + +The blog gets its own bucket and two keys: a read-only key for the service and a read-write key for Obsidian. + +```sh +garage bucket create example-blog +garage key create hyperhack-ro +garage bucket allow --read example-blog --key hyperhack-ro +garage key create obsidian +garage bucket allow --read --write example-blog --key obsidian +``` + +Put the `hyperhack-ro` key into `.env` as `S3_ACCESS_KEY` and `S3_SECRET_KEY`. `S3_ENDPOINT` must be `https://`. The service uses path-style addressing. + +## Configuration + +All settings come from environment variables in `.env`. [.env.example](.env.example) lists them all. The service checks every value at startup. If any value is invalid, it prints every problem and exits with code 1. Errors name the variable, never its value. + +| Variable | Default | Purpose | +|---|---|---| +| `SITE_NAME` | required | Site name in the top bar, titles and RSS. | +| `SITE_URL` | required | Public URL, `https://example.com`. Used for canonical links, RSS, the sitemap and the `Origin` check. | +| `AUTHOR_NAME` | `SITE_NAME` | Author in JSON-LD and RSS. | +| `DEFAULT_LANG` | `en` | UI language and default post language: `en` or `ru`. | +| `GITHUB_URL` | empty | GitHub link in the top bar. | +| `S3_ENDPOINT` | required | Garage S3 API URL, `https://` only. | +| `S3_REGION` | `garage` | S3 region. | +| `S3_BUCKET` | required | Bucket name. | +| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | required | The read-only key. | +| `POLL_INTERVAL` | `60s` | Bucket polling interval, 10s to 24h. | +| `REINDEX_TOKEN` | required | Bearer token for `POST /reindex`. At least 32 bytes. | +| `ALTCHA_HMAC_KEY` | required | Signs ALTCHA challenges. At least 32 bytes. | +| `VISITOR_SECRET` | required | Key for the daily visitor hash. At least 32 bytes. | +| `ALTCHA_EXPIRES` | `5m` | Challenge lifetime, 10s to 1h. | +| `ALTCHA_MAX_NUMBER` | `100000` | Challenge difficulty, 1 000 to 10 000 000. | +| `READING_WPM` | `200` | Words per minute for the reading time, 50 to 1000. | +| `POPULAR_DAYS`, `POPULAR_LIMIT` | `30`, `5` | "Popular": most views in the last N days, top M posts. | +| `PAGE_SIZE` | `10` | Posts per page on the main page and tag pages. | +| `RSS_LIMIT` | `20` | Posts in `/rss.xml`. | +| `DEFAULT_OG_IMAGE` | empty | Link preview image for pages and posts without `cover`. A file inside `assets/`, e.g. `assets/og.png`. | +| `INDEXNOW_KEY` | empty | Turns on IndexNow pings (Bing, Yandex). 8 to 128 characters `a-z A-Z 0-9 -`. Served at `/.txt`. | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. | +| `LOG_FORMAT` | `json` | `json` for Docker and Loki, `text` for local runs. | +| `DATA_DIR` | `/data` | SQLite folder (`hyperhack.db`). | +| `VAULT_DIR` | `/vault` | Bucket mirror folder. | +| `RATE_LIMIT_VOTE` | `10/1m` | Per client, `/api/altcha` and `/api/vote` together. | +| `RATE_LIMIT_BEACON` | `30/1m` | Per client, `/api/beacon`. | +| `RATE_LIMIT_SEARCH` | `30/1m` | Per client, `/search`. | +| `RATE_LIMIT_REINDEX` | `5/1m` | Per client, `/reindex`. | +| `METRICS_ALLOW` | loopback and private ranges | Comma-separated CIDRs allowed to read `/metrics`. | +| `REINDEX_ALLOW` | loopback and private ranges | Comma-separated CIDRs allowed to call `/reindex`. | +| `TRUSTED_PROXIES` | empty | IPs or CIDRs allowed to set `X-Forwarded-For`. See [Caddy](#caddy). | +| `MAX_POST_BYTES` | `1048576` (1 MiB) | Larger `.md` files are skipped. | +| `MAX_ASSET_BYTES` | `52428800` (50 MiB) | Larger assets are skipped. | +| `MAX_OBJECTS` | `10000` | Maximum keys in the bucket, ignored keys included. | + +Rates are `count/period`, for example `10/1m`. The private ranges are `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `fc00::/7`. + +## Writing posts + +Each `.md` file is one post, at any depth in the vault. Folders are only for you: the URL is always `/posts/`, so moving a file does not break links. The service skips files and folders whose name starts with `.` or `_`. Keep templates and helper notes in `_templates/` or another `_` folder. + +```markdown +--- +title: Leaving Ghost +description: Why Ghost is heavy and what I wrote instead +date: 2026-11-03 +updated: 2026-11-05 +tags: + - go + - selfhost +cover: assets/covers/ghost.webp +slug: leaving-ghost +lang: en +draft: false +--- + +Post text… +``` + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `title` | string | yes | Post title. | +| `description` | string | no | Short summary for the post list, RSS, meta tags and link previews. | +| `date` | date | yes | Publish date. "Newest" is sorted by it. | +| `updated` | date | no | Last update. Shown on the post and in RSS. | +| `tags` | list | no | Tags. A single string also works. | +| `cover` | string | no | Cover image, a file inside `assets/`. | +| `slug` | string | no | URL part. Default: made from the file name. | +| `lang` | string | no | Post language, two lower-case letters (`en`, `ru`). Default: `DEFAULT_LANG`. | +| `draft` | bool | no | `true` keeps the post unpublished. Default `false`. | + +Rules: + +- Dates: `2026-11-03` or `2026-11-03T20:59`, as Obsidian writes them. A time without a zone is UTC. +- Without `title` or `date`, or with invalid YAML, the post is skipped and a WARN is logged. An invalid `date`, `updated`, `slug`, `lang`, `cover` or `draft` also skips the post. +- Slug from the file name: lower case, Russian letters transliterated (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` and `ь` dropped), and any other run of characters becomes `-`. A slug in frontmatter must match `^[a-z0-9-]{1,100}$`. +- Tags are lower-cased and duplicates merged. A tag is 1 to 50 letters, digits, `-`, `_` or `/`. At most 20 tags per post. +- If two posts have the same slug, the first by object key wins. The second is skipped and an ERROR is logged. +- Unknown fields are ignored, so Obsidian can add its own. +- Images: standard Markdown, `![Alt text](assets/photo.webp)`. A path `assets/…` becomes `/assets/…` on the site. External images are blocked by the CSP. +- Allowed asset types: `png jpg jpeg webp avif gif svg mp4 webm`. +- `assets/logo.svg`, if present, replaces the built-in logo in the top bar. + +## Publishing from Obsidian + +Use a separate Obsidian vault for the blog, so personal notes never reach the bucket. + +1. Install the **Remotely Save** plugin and choose S3. +2. Endpoint, region, bucket: the same as in `.env`. Keys: the `obsidian` read-write key, not the service key. +3. Use path-style URLs. +4. Leave the remote prefix empty. The blog has its own bucket. +5. Leave encryption off. With encryption on, the service cannot read the files. + +Obsidian settings, **Files and links**: + +| Setting | Value | +|---|---| +| Use [[Wikilinks]] | off | +| New link format | Absolute path in vault | +| Default location for new attachments | In the folder specified below → `assets` | + +With these settings Obsidian inserts images as standard Markdown, `![](assets/abstract-5.jpg)`. Wikilinks (`![[…]]`) are not supported. + +A new post appears after the next poll, within `POLL_INTERVAL`. To publish at once, call `/reindex` from the private network: + +```sh +curl -X POST -H "Authorization: Bearer $REINDEX_TOKEN" http://127.0.0.1:8080/reindex +``` + +It waits for the sync and returns `204` when done, `401` for a bad token, `403` for an address outside `REINDEX_ALLOW`, `429` when over the rate limit and `503` when the sync failed. + +## Caddy + +Caddy terminates TLS and adds the perimeter controls: per-client rate limit, CrowdSec, HSTS and the request body limit. It blocks `/metrics` and `/reindex` and serves `/assets/*` from the mirror. + +Build Caddy with the two plugins: + +```sh +xcaddy build v2.11.7 \ + --with github.com/mholt/caddy-ratelimit@v0.1.0 \ + --with github.com/hslatman/caddy-crowdsec-bouncer/http@v0.14.1 +``` + +This is [Caddyfile.example](Caddyfile.example). Replace `example.com` and `/srv/hyperhack`: + +```caddyfile +{ + order crowdsec first + order rate_limit after crowdsec + crowdsec { + api_url {$CROWDSEC_API_URL} + api_key {$CROWDSEC_API_KEY} + } +} + +example.com { + crowdsec + + # Per client IP, all paths. One page view is about 10 requests. + rate_limit { + zone per_client { + key {remote_host} + events 300 + window 1m + } + } + + header { + Strict-Transport-Security "max-age=31536000" + -Server + } + + encode zstd gzip + + # Private endpoints. VictoriaMetrics and /reindex callers reach the service directly. + # A handle block, because handle runs before a bare respond. + @private path /metrics /metrics/* /reindex /reindex/* + handle @private { + respond 404 + } + + # Assets straight from the mirror (vault/assets/), allowed extensions only. + handle /assets/* { + @allowed path *.png *.jpg *.jpeg *.webp *.avif *.gif *.svg *.mp4 *.webm + handle @allowed { + root * /srv/hyperhack/vault + header { + X-Content-Type-Options nosniff + Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox" + } + file_server + } + respond 404 + } + + handle { + request_body /api/* { + max_size 16KB + } + reverse_proxy 127.0.0.1:8080 + } +} +``` + +- `CROWDSEC_API_URL` and `CROWDSEC_API_KEY` are read from Caddy's environment when the config loads. The CrowdSec LAPI also defaults to port `8080`, so move one of them if both run on the host. +- The service sets its own CSP and security headers. Caddy adds only HSTS and removes `Server`. +- Without the plugins, delete the `order`, `crowdsec` and `rate_limit` lines. The service still rate-limits its write endpoints. + +### TRUSTED_PROXIES + +Behind Caddy the service sees Caddy's address, not the reader's. Without `TRUSTED_PROXIES`, every reader shares one rate limit and one visitor hash. With it, the service reads the client IP from `X-Forwarded-For`. + +With Caddy on the host and the port published on `127.0.0.1:8080`, requests reach the container from the Docker network gateway. Find it with: + +```sh +docker network inspect hyperhack_default -f '{{(index .IPAM.Config 0).Gateway}}' +``` + +Set `TRUSTED_PROXIES` to that address (for example `172.18.0.1`) and restart. If Caddy runs in the same Compose network, use Caddy's container address or the network's subnet. + +## Monitoring + +`/metrics` serves Prometheus text format on the main port. It answers only `METRICS_ALLOW` addresses, and Caddy blocks it on the public domain. Scrape it directly, not through Caddy. + +VictoriaMetrics scrape config (`-promscrape.config`): + +```yaml +scrape_configs: + - job_name: hyperhack + scrape_interval: 30s + static_configs: + - targets: ["127.0.0.1:8080"] +``` + +If VictoriaMetrics runs in Docker, use an address it can reach, e.g. put it in the same network and use `hyperhack:8080`. + +### Grafana + +[grafana/hyperhack.json](grafana/hyperhack.json) is the dashboard. In Grafana: **Dashboards → New → Import**, upload the file, then pick the data source. It needs a Prometheus-type data source pointed at VictoriaMetrics. Panels: + +- Overview: posts, views, average time on page, likes, dislikes, time since the last sync. +- Posts: views per day, a per-post table (views, time, read to end, code copies, votes), read depth, votes per day. +- Readers: traffic source, device, UI language, theme, outbound clicks. +- Search: queries and zero-result queries per hour, latency, top queries, top zero-result queries. +- HTTP: requests per route, 5xx and 429, p95 latency. +- Sync: bucket objects, failed syncs, failed downloads, time since the last attempt. + +The `Post` variable filters the post panels. + +### Metrics + +| Metric | Type | Labels | +|---|---|---| +| `hyperhack_post_views_total` | counter | `slug` | +| `hyperhack_post_read_seconds_total` | counter | `slug` | +| `hyperhack_post_read_views_total` | counter | `slug` | +| `hyperhack_post_read_depth_total` | counter | `slug`, `depth` (25, 50, 75, 100) | +| `hyperhack_post_source_total` | counter | `slug`, `source` | +| `hyperhack_post_device_total` | counter | `slug`, `device` | +| `hyperhack_post_ui_lang_total` | counter | `slug`, `lang` | +| `hyperhack_post_ui_theme_total` | counter | `slug`, `theme` | +| `hyperhack_post_outbound_clicks_total` | counter | `slug`, `host` | +| `hyperhack_post_code_copies_total` | counter | `slug` | +| `hyperhack_post_votes_total` | counter | `slug`, `vote` | +| `hyperhack_post_likes`, `hyperhack_post_dislikes` | gauge | `slug` | +| `hyperhack_posts` | gauge | | +| `hyperhack_sync_last_attempt_timestamp_seconds`, `hyperhack_sync_last_success_timestamp_seconds` | gauge | | +| `hyperhack_sync_errors_total`, `hyperhack_sync_fetch_failures_total` | counter | | +| `hyperhack_sync_objects` | gauge | | +| `hyperhack_search_queries_total`, `hyperhack_search_zero_results_total` | counter | | +| `hyperhack_search_duration_seconds` | histogram | | +| `hyperhack_search_top_queries`, `hyperhack_search_top_zero_result_queries` | gauge | `query` (top 20) | +| `hyperhack_http_requests_total` | counter | `route`, `code` | +| `hyperhack_http_request_duration_seconds` | histogram | `route` | + +The post counters are stored in SQLite and survive restarts. The search and HTTP counters are in memory and restart from zero. `increase()` and `rate()` handle both. Average time on page is `read_seconds / read_views`. + +Views and read depth count once per visitor per post per day. A visitor is a daily HMAC of the IP (IPv6 `/64`) and the user agent. It lives in memory only, and raw IPs are never stored or logged. Readers without JS are not counted. + +## Logs + +JSON to stdout, one record per line, with `time`, `level`, `msg` and `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `LOG_LEVEL=debug` adds sync details and every HTTP request. Secrets, raw IPs and visitor hashes are never logged. + +## Backups + +- `data/` holds the SQLite database: views, votes and search stats. Back it up. For a consistent copy, stop the container or copy `hyperhack.db` together with `hyperhack.db-wal` and `hyperhack.db-shm`. +- `vault/` is a mirror of the bucket and does not need a backup. Delete it to force a full resync. +- The bucket is the source of the posts. Back it up with Garage or keep the Obsidian vault in git. + +## Rotating secrets + +Change the value in `.env`, then `docker compose up -d` to restart. + +- `REINDEX_TOKEN`: update every caller of `/reindex` at the same time. +- `ALTCHA_HMAC_KEY`: challenges issued before the restart fail, and readers click again. Votes are kept. +- `VISITOR_SECRET`: only affects view dedup, which is in memory and resets on restart anyway. A reader counted earlier that day can be counted once more. +- S3 keys: create a new read-only key in Garage, put it into `.env`, restart, check the logs for a successful sync, then delete the old key. + +Vote cookies (`vid`) are random and do not depend on any secret. + +## Upgrading + +Set the new version in `docker-compose.yml`, verify it with `gh attestation verify`, then: + +```sh +docker compose pull && docker compose up -d +``` + +## Development + +Go 1.27 (`toolchain go1.27.3`). + +```sh +set -a; . ./.env; set +a +DATA_DIR=./data VAULT_DIR=./vault LOG_FORMAT=text go run . +# or: docker compose up --build +``` + +Checks, run before every commit and in CI: + +```sh +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./... +go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... +go test -race ./... +``` + +The ALTCHA test runs the browser solver in Node when `node` is installed. With `CI` set, a missing `node` fails the test. + +Contributions: one change per branch and PR, Conventional Commits, and every design decision in both SPEC files. + +## Security + +Report vulnerabilities privately, see [SECURITY.md](SECURITY.md). + +## License + +[AGPL-3.0](LICENSE). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..15c0275 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,421 @@ +# hyperhack + +English version: [README.md](README.md). + +Лёгкий и быстрый блог для одного автора. Замена Ghost, когда Ghost слишком тяжёлый. + +Вы пишете посты в Obsidian. Хранилище синхронизируется с бакетом S3 (Garage), а блог рендерит его. Весь сервис — один бинарник на Go с SQLite. Нет админки и нет сервера базы данных. + +- Посты — файлы Markdown со свойствами Obsidian (YAML frontmatter). +- Страницы рендерятся на сервере и работают без JS. +- Светлая и тёмная темы. Интерфейс на английском и русском. +- Поиск на английском и русском: с учётом опечаток и поиском по мере ввода. +- Теги, RSS, sitemap, Open Graph, JSON-LD, `llms.txt` и IndexNow. +- На каждом посте — время чтения, число просмотров, лайки и дизлайки. Голоса анонимные и требуют решения ALTCHA (proof of work). +- Приватные метрики по постам на `/metrics` (формат Prometheus) и дашборд Grafana. + +Устройство описано в [SPEC.ru.md](SPEC.ru.md). + +## Как всё связано + +``` +Obsidian ──Remotely Save──▶ бакет Garage ◀──опрос── hyperhack ──▶ ./data (SQLite) + │ + └──зеркало──▶ ./vault +читатель ──▶ Caddy ──┬──▶ hyperhack :8080 (страницы, /api/*) + └──▶ ./vault/assets/ (/assets/*, отдаёт Caddy) +VictoriaMetrics ──scrape /metrics──▶ hyperhack ; Grafana ──▶ VictoriaMetrics +``` + +Источник истины — бакет. Сервис опрашивает его и зеркалирует в `./vault`. Caddy отдаёт картинки прямо из зеркала, поэтому страницы и картинки работают, даже когда S3 недоступен. + +## Что нужно + +- Docker с Docker Compose. +- Бакет с S3 API по HTTPS. Сервис сделан и проверен под Garage. +- Caddy как обратный прокси, собранный с плагинами `caddy-ratelimit` и CrowdSec bouncer. См. [Caddy](#caddy). +- По желанию: VictoriaMetrics и Grafana для метрик, CrowdSec. + +## Быстрый старт + +1. Создайте папку с compose-файлом и файлом окружения: + + ```sh + mkdir hyperhack && cd hyperhack + curl -fsSLO https://raw.githubusercontent.com/Mimic890/hyperhack/main/docker-compose.yml + curl -fsSL -o .env https://raw.githubusercontent.com/Mimic890/hyperhack/main/.env.example + chmod 600 .env + ``` + +2. Заполните `.env` (см. [Настройки](#настройки)). Каждый секрет генерируйте так: + + ```sh + openssl rand -base64 48 + ``` + +3. Создайте папки для данных. Контейнер работает от пользователя `10001`: + + ```sh + mkdir data vault + sudo chown 10001:10001 data vault + ``` + + Caddy должен иметь право читать `vault/assets/`. Сервис создаёт папки с правами `0755`, файлы — `0644`. Файл SQLite — `0600`. + +4. В `docker-compose.yml` укажите в `image:` выпущенную версию и удалите строку `build: .`. Теги — semver, они никогда не перезаписываются. Тега `latest` нет. + +5. Проверьте образ и запустите сервис: + + ```sh + gh attestation verify oci://ghcr.io/mimic890/hyperhack: -R Mimic890/hyperhack + docker compose up -d + docker compose ps # дождитесь "healthy" + docker compose logs -f + ``` + + При первом запуске сервису нужен бакет. Если S3 недоступен, а зеркала ещё нет, сервис завершается с кодом 1. При следующих запусках индекс строится из зеркала, и сервис не ждёт S3. + +6. Настройте Caddy (см. [Caddy](#caddy)) и задайте `TRUSTED_PROXIES`. + +Внутри контейнера сервис слушает порт `8080`. Compose публикует его только на `127.0.0.1:8080`. Не открывайте его в интернет: единственный публичный вход — Caddy. + +## Garage: бакет и ключи + +У блога свой бакет и два ключа: ключ только на чтение для сервиса и ключ на чтение и запись для Obsidian. + +```sh +garage bucket create example-blog +garage key create hyperhack-ro +garage bucket allow --read example-blog --key hyperhack-ro +garage key create obsidian +garage bucket allow --read --write example-blog --key obsidian +``` + +Ключ `hyperhack-ro` впишите в `.env` как `S3_ACCESS_KEY` и `S3_SECRET_KEY`. `S3_ENDPOINT` должен начинаться с `https://`. Сервис использует path-style адресацию. + +## Настройки + +Все настройки берутся из переменных окружения в `.env`. В [.env.example](.env.example) перечислены все. Сервис проверяет каждое значение при старте. Если что-то неверно, он выводит все ошибки сразу и завершается с кодом 1. В ошибках называется переменная, но никогда её значение. + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `SITE_NAME` | обязательна | Название сайта в верхней панели, заголовках и RSS. | +| `SITE_URL` | обязательна | Публичный адрес, `https://example.com`. Нужен для canonical-ссылок, RSS, sitemap и проверки `Origin`. | +| `AUTHOR_NAME` | `SITE_NAME` | Автор в JSON-LD и RSS. | +| `DEFAULT_LANG` | `en` | Язык интерфейса и язык постов по умолчанию: `en` или `ru`. | +| `GITHUB_URL` | пусто | Ссылка на GitHub в верхней панели. | +| `S3_ENDPOINT` | обязательна | Адрес S3 API Garage, только `https://`. | +| `S3_REGION` | `garage` | Регион S3. | +| `S3_BUCKET` | обязательна | Имя бакета. | +| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | обязательны | Ключ только на чтение. | +| `POLL_INTERVAL` | `60s` | Интервал опроса бакета, от 10s до 24h. | +| `REINDEX_TOKEN` | обязательна | Bearer-токен для `POST /reindex`. Не меньше 32 байт. | +| `ALTCHA_HMAC_KEY` | обязательна | Подпись задач ALTCHA. Не меньше 32 байт. | +| `VISITOR_SECRET` | обязательна | Ключ дневного хеша посетителя. Не меньше 32 байт. | +| `ALTCHA_EXPIRES` | `5m` | Время жизни задачи, от 10s до 1h. | +| `ALTCHA_MAX_NUMBER` | `100000` | Сложность задачи, от 1 000 до 10 000 000. | +| `READING_WPM` | `200` | Слов в минуту для времени чтения, от 50 до 1000. | +| `POPULAR_DAYS`, `POPULAR_LIMIT` | `30`, `5` | «Популярное»: больше всего просмотров за последние N дней, первые M постов. | +| `PAGE_SIZE` | `10` | Постов на странице на главной и на страницах тегов. | +| `RSS_LIMIT` | `20` | Постов в `/rss.xml`. | +| `DEFAULT_OG_IMAGE` | пусто | Картинка превью ссылки для страниц и постов без `cover`. Файл внутри `assets/`, например `assets/og.png`. | +| `INDEXNOW_KEY` | пусто | Включает пинги IndexNow (Bing, Яндекс). От 8 до 128 символов `a-z A-Z 0-9 -`. Отдаётся по адресу `/.txt`. | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` или `error`. | +| `LOG_FORMAT` | `json` | `json` для Docker и Loki, `text` для локального запуска. | +| `DATA_DIR` | `/data` | Папка SQLite (`hyperhack.db`). | +| `VAULT_DIR` | `/vault` | Папка зеркала бакета. | +| `RATE_LIMIT_VOTE` | `10/1m` | На клиента, `/api/altcha` и `/api/vote` вместе. | +| `RATE_LIMIT_BEACON` | `30/1m` | На клиента, `/api/beacon`. | +| `RATE_LIMIT_SEARCH` | `30/1m` | На клиента, `/search`. | +| `RATE_LIMIT_REINDEX` | `5/1m` | На клиента, `/reindex`. | +| `METRICS_ALLOW` | loopback и частные диапазоны | CIDR через запятую, которым можно читать `/metrics`. | +| `REINDEX_ALLOW` | loopback и частные диапазоны | CIDR через запятую, которым можно вызывать `/reindex`. | +| `TRUSTED_PROXIES` | пусто | IP или CIDR, которым разрешено задавать `X-Forwarded-For`. См. [Caddy](#caddy). | +| `MAX_POST_BYTES` | `1048576` (1 МиБ) | Файлы `.md` больше этого пропускаются. | +| `MAX_ASSET_BYTES` | `52428800` (50 МиБ) | Ассеты больше этого пропускаются. | +| `MAX_OBJECTS` | `10000` | Максимум ключей в бакете, включая игнорируемые. | + +Лимиты записываются как `число/период`, например `10/1m`. Частные диапазоны: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` и `fc00::/7`. + +## Как писать посты + +Каждый файл `.md` — один пост, на любой глубине хранилища. Папки нужны только вам: адрес всегда `/posts/`, поэтому перенос файла не ломает ссылки. Сервис пропускает файлы и папки, имя которых начинается с `.` или `_`. Шаблоны и служебные заметки держите в `_templates/` или другой папке на `_`. + +```markdown +--- +title: Leaving Ghost +description: Why Ghost is heavy and what I wrote instead +date: 2026-11-03 +updated: 2026-11-05 +tags: + - go + - selfhost +cover: assets/covers/ghost.webp +slug: leaving-ghost +lang: en +draft: false +--- + +Текст поста… +``` + +| Поле | Тип | Обязательно | Назначение | +|---|---|---|---| +| `title` | строка | да | Заголовок поста. | +| `description` | строка | нет | Короткое описание для списка постов, RSS, мета-тегов и превью ссылок. | +| `date` | дата | да | Дата публикации. По ней сортируется «Новое». | +| `updated` | дата | нет | Дата обновления. Показывается в посте и в RSS. | +| `tags` | список | нет | Теги. Одна строка тоже подходит. | +| `cover` | строка | нет | Обложка, файл внутри `assets/`. | +| `slug` | строка | нет | Часть адреса. По умолчанию — из имени файла. | +| `lang` | строка | нет | Язык поста, две строчные буквы (`en`, `ru`). По умолчанию `DEFAULT_LANG`. | +| `draft` | bool | нет | `true` — пост не публикуется. По умолчанию `false`. | + +Правила: + +- Даты: `2026-11-03` или `2026-11-03T20:59`, как их пишет Obsidian. Время без часового пояса считается UTC. +- Без `title` или `date` или с неверным YAML пост пропускается, в лог пишется WARN. Неверные `date`, `updated`, `slug`, `lang`, `cover` или `draft` тоже пропускают пост. +- Slug из имени файла: строчные буквы, русские буквы транслитерируются (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` и `ь` выпадают), любая другая последовательность символов становится `-`. Slug во frontmatter должен соответствовать `^[a-z0-9-]{1,100}$`. +- Теги приводятся к нижнему регистру, дубли сливаются. Тег — от 1 до 50 букв, цифр, `-`, `_` или `/`. Не больше 20 тегов на пост. +- Если у двух постов одинаковый slug, побеждает первый по ключу объекта. Второй пропускается, в лог пишется ERROR. +- Неизвестные поля игнорируются, так что Obsidian может добавлять свои. +- Картинки — обычный Markdown, `![Подпись](assets/photo.webp)`. Путь `assets/…` на сайте становится `/assets/…`. Внешние картинки блокирует CSP. +- Разрешённые типы ассетов: `png jpg jpeg webp avif gif svg mp4 webm`. +- `assets/logo.svg`, если есть, заменяет встроенный логотип в верхней панели. + +## Публикация из Obsidian + +Заведите для блога отдельное хранилище Obsidian, чтобы личные заметки никогда не попали в бакет. + +1. Установите плагин **Remotely Save** и выберите S3. +2. Endpoint, регион, бакет — те же, что в `.env`. Ключи — ключ `obsidian` на чтение и запись, не ключ сервиса. +3. Используйте path-style адреса. +4. Оставьте префикс на удалённой стороне пустым. У блога свой бакет. +5. Оставьте шифрование выключенным. С шифрованием сервис не сможет прочитать файлы. + +Настройки Obsidian, **Files and links** («Файлы и ссылки»): + +| Настройка | Значение | +|---|---| +| Use [[Wikilinks]] | выкл. | +| New link format | Absolute path in vault | +| Default location for new attachments | In the folder specified below → `assets` | + +С этими настройками Obsidian вставляет картинки обычным Markdown: `![](assets/abstract-5.jpg)`. Вики-ссылки (`![[…]]`) не поддерживаются. + +Новый пост появляется после следующего опроса, в пределах `POLL_INTERVAL`. Чтобы опубликовать сразу, вызовите `/reindex` из частной сети: + +```sh +curl -X POST -H "Authorization: Bearer $REINDEX_TOKEN" http://127.0.0.1:8080/reindex +``` + +Вызов ждёт окончания синхронизации и возвращает `204`, когда всё готово, `401` при неверном токене, `403` для адреса вне `REINDEX_ALLOW`, `429` при превышении лимита и `503`, если синхронизация не удалась. + +## Caddy + +Caddy завершает TLS и добавляет защиту периметра: лимит запросов на клиента, CrowdSec, HSTS и лимит тела запроса. Он закрывает `/metrics` и `/reindex` и отдаёт `/assets/*` из зеркала. + +Соберите Caddy с двумя плагинами: + +```sh +xcaddy build v2.11.7 \ + --with github.com/mholt/caddy-ratelimit@v0.1.0 \ + --with github.com/hslatman/caddy-crowdsec-bouncer/http@v0.14.1 +``` + +Это [Caddyfile.example](Caddyfile.example). Замените `example.com` и `/srv/hyperhack`: + +```caddyfile +{ + order crowdsec first + order rate_limit after crowdsec + crowdsec { + api_url {$CROWDSEC_API_URL} + api_key {$CROWDSEC_API_KEY} + } +} + +example.com { + crowdsec + + # Per client IP, all paths. One page view is about 10 requests. + rate_limit { + zone per_client { + key {remote_host} + events 300 + window 1m + } + } + + header { + Strict-Transport-Security "max-age=31536000" + -Server + } + + encode zstd gzip + + # Private endpoints. VictoriaMetrics and /reindex callers reach the service directly. + # A handle block, because handle runs before a bare respond. + @private path /metrics /metrics/* /reindex /reindex/* + handle @private { + respond 404 + } + + # Assets straight from the mirror (vault/assets/), allowed extensions only. + handle /assets/* { + @allowed path *.png *.jpg *.jpeg *.webp *.avif *.gif *.svg *.mp4 *.webm + handle @allowed { + root * /srv/hyperhack/vault + header { + X-Content-Type-Options nosniff + Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox" + } + file_server + } + respond 404 + } + + handle { + request_body /api/* { + max_size 16KB + } + reverse_proxy 127.0.0.1:8080 + } +} +``` + +- `CROWDSEC_API_URL` и `CROWDSEC_API_KEY` берутся из окружения Caddy при загрузке конфига. LAPI CrowdSec по умолчанию тоже занимает порт `8080`, поэтому, если оба работают на хосте, перенесите один из них. +- Сервис сам ставит CSP и заголовки безопасности. Caddy добавляет только HSTS и убирает `Server`. +- Без плагинов удалите строки `order`, `crowdsec` и `rate_limit`. Сервис всё равно ограничивает свои эндпоинты записи. + +### TRUSTED_PROXIES + +За Caddy сервис видит адрес Caddy, а не читателя. Без `TRUSTED_PROXIES` у всех читателей общий лимит запросов и общий хеш посетителя. С ним сервис берёт IP клиента из `X-Forwarded-For`. + +Если Caddy работает на хосте, а порт опубликован на `127.0.0.1:8080`, запросы приходят в контейнер со шлюза сети Docker. Узнать его можно так: + +```sh +docker network inspect hyperhack_default -f '{{(index .IPAM.Config 0).Gateway}}' +``` + +Впишите этот адрес в `TRUSTED_PROXIES` (например, `172.18.0.1`) и перезапустите сервис. Если Caddy работает в той же сети Compose, укажите адрес контейнера Caddy или подсеть сети. + +## Мониторинг + +`/metrics` отдаёт текстовый формат Prometheus на основном порту. Он отвечает только адресам из `METRICS_ALLOW`, а на публичном домене его закрывает Caddy. Собирайте метрики напрямую, не через Caddy. + +Конфиг сбора для VictoriaMetrics (`-promscrape.config`): + +```yaml +scrape_configs: + - job_name: hyperhack + scrape_interval: 30s + static_configs: + - targets: ["127.0.0.1:8080"] +``` + +Если VictoriaMetrics работает в Docker, укажите адрес, до которого она достаёт: например, подключите её к той же сети и используйте `hyperhack:8080`. + +### Grafana + +Дашборд — [grafana/hyperhack.json](grafana/hyperhack.json). В Grafana: **Dashboards → New → Import**, загрузите файл и выберите источник данных. Нужен источник данных типа Prometheus, направленный на VictoriaMetrics. Панели: + +- Overview: число постов, просмотры, среднее время на странице, лайки, дизлайки, время с последней синхронизации. +- Posts: просмотры по дням, таблица по постам (просмотры, время, дочитывания, копирования кода, голоса), глубина чтения, голоса по дням. +- Readers: источник трафика, устройство, язык интерфейса, тема, переходы по внешним ссылкам. +- Search: запросы и запросы без результатов по часам, задержка, топ запросов, топ запросов без результатов. +- HTTP: запросы по маршрутам, 5xx и 429, задержка p95. +- Sync: объекты в бакете, неудачные синхронизации, неудачные загрузки, время с последней попытки. + +Переменная `Post` фильтрует панели постов. + +### Метрики + +| Метрика | Тип | Метки | +|---|---|---| +| `hyperhack_post_views_total` | counter | `slug` | +| `hyperhack_post_read_seconds_total` | counter | `slug` | +| `hyperhack_post_read_views_total` | counter | `slug` | +| `hyperhack_post_read_depth_total` | counter | `slug`, `depth` (25, 50, 75, 100) | +| `hyperhack_post_source_total` | counter | `slug`, `source` | +| `hyperhack_post_device_total` | counter | `slug`, `device` | +| `hyperhack_post_ui_lang_total` | counter | `slug`, `lang` | +| `hyperhack_post_ui_theme_total` | counter | `slug`, `theme` | +| `hyperhack_post_outbound_clicks_total` | counter | `slug`, `host` | +| `hyperhack_post_code_copies_total` | counter | `slug` | +| `hyperhack_post_votes_total` | counter | `slug`, `vote` | +| `hyperhack_post_likes`, `hyperhack_post_dislikes` | gauge | `slug` | +| `hyperhack_posts` | gauge | | +| `hyperhack_sync_last_attempt_timestamp_seconds`, `hyperhack_sync_last_success_timestamp_seconds` | gauge | | +| `hyperhack_sync_errors_total`, `hyperhack_sync_fetch_failures_total` | counter | | +| `hyperhack_sync_objects` | gauge | | +| `hyperhack_search_queries_total`, `hyperhack_search_zero_results_total` | counter | | +| `hyperhack_search_duration_seconds` | histogram | | +| `hyperhack_search_top_queries`, `hyperhack_search_top_zero_result_queries` | gauge | `query` (топ 20) | +| `hyperhack_http_requests_total` | counter | `route`, `code` | +| `hyperhack_http_request_duration_seconds` | histogram | `route` | + +Счётчики постов хранятся в SQLite и переживают перезапуск. Счётчики поиска и HTTP живут в памяти и после перезапуска начинают с нуля. `increase()` и `rate()` справляются с обоими случаями. Среднее время на странице — `read_seconds / read_views`. + +Просмотры и глубина чтения считаются один раз на посетителя, пост и день. Посетитель — дневной HMAC от IP (для IPv6 — `/64`) и user agent. Хеш живёт только в памяти, сырые IP никогда не хранятся и не пишутся в лог. Читатели без JS не учитываются. + +## Логи + +JSON в stdout, одна запись на строку, с полями `time`, `level`, `msg` и `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `LOG_LEVEL=debug` добавляет подробности синхронизации и каждый HTTP-запрос. Секреты, сырые IP и хеши посетителей никогда не пишутся в лог. + +## Резервные копии + +- В `data/` лежит база SQLite: просмотры, голоса и статистика поиска. Её нужно копировать. Для целостной копии остановите контейнер или копируйте `hyperhack.db` вместе с `hyperhack.db-wal` и `hyperhack.db-shm`. +- `vault/` — зеркало бакета, копировать его не нужно. Удалите его, чтобы запустить полную пересинхронизацию. +- Источник постов — бакет. Делайте его копии средствами Garage или держите хранилище Obsidian в git. + +## Смена секретов + +Поменяйте значение в `.env` и перезапустите сервис: `docker compose up -d`. + +- `REINDEX_TOKEN`: одновременно обновите всех, кто вызывает `/reindex`. +- `ALTCHA_HMAC_KEY`: задачи, выданные до перезапуска, не пройдут, читателю придётся нажать ещё раз. Голоса сохраняются. +- `VISITOR_SECRET`: влияет только на отсев повторных просмотров, а он живёт в памяти и всё равно сбрасывается при перезапуске. Читатель, уже учтённый в этот день, может быть учтён ещё раз. +- Ключи S3: создайте в Garage новый ключ только на чтение, впишите его в `.env`, перезапустите, убедитесь по логам, что синхронизация прошла, и удалите старый ключ. + +Cookie голосов (`vid`) случайные и не зависят ни от одного секрета. + +## Обновление + +Укажите новую версию в `docker-compose.yml`, проверьте её через `gh attestation verify`, затем: + +```sh +docker compose pull && docker compose up -d +``` + +## Разработка + +Go 1.27 (`toolchain go1.27.3`). + +```sh +set -a; . ./.env; set +a +DATA_DIR=./data VAULT_DIR=./vault LOG_FORMAT=text go run . +# или: docker compose up --build +``` + +Проверки, перед каждым коммитом и в CI: + +```sh +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./... +go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... +go test -race ./... +``` + +Тест ALTCHA запускает браузерный решатель в Node, если `node` установлен. Если задана переменная `CI`, отсутствие `node` роняет тест. + +Изменения: одно изменение — одна ветка и один PR, Conventional Commits, каждое решение по устройству — в обоих файлах SPEC. + +## Безопасность + +Об уязвимостях сообщайте приватно, см. [SECURITY.ru.md](SECURITY.ru.md). + +## Лицензия + +[AGPL-3.0](LICENSE). From 235010b122cc8ba04df1c1c1287dbd76aa495b9e Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:32:45 +0300 Subject: [PATCH 4/6] docs: record Grafana, Caddy and README decisions in both SPEC files --- SPEC.md | 3 ++- SPEC.ru.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index c2e36ac..a26e55a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -356,7 +356,7 @@ Rules: - The service exposes Prometheus text format on `/metrics`, on the main port. - The reverse proxy blocks `/metrics` on the public domain. - VictoriaMetrics scrapes the service directly, not through the proxy. No Prometheus server. -- A Grafana dashboard (JSON) is in the repo. +- A Grafana dashboard (JSON) is in the repo: `grafana/hyperhack.json`. - Metrics: all post metrics (see Post metrics); sync status (last poll, errors, object count); search latency; HTTP requests, errors, latency. ## Logging @@ -616,3 +616,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-04 — Pages: theme and language switch via a no-JS form `POST /prefs`; cookies `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 year`, `Secure` on https). Logo from `assets/logo.svg`, built-in mark otherwise. Fonts Inter + JetBrains Mono, self-hosted. Pagination `/?page=N`: page 1 is `/`, other out-of-range values → `404`. `` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`. - 2026-11-04 — Search, tags, RSS, SEO: Bleve `v2.6.1`, in-memory index, one document mapping per language (`en`, `ru`). Fuzziness by word length: ≤ 3 letters 0, ≤ 6 letters 1, longer 2; prefix on the last word. Snippets keep only ``. Search works without JS; `search.js` swaps the results while typing (debounced). Results page `noindex`. Tags lower case, Obsidian characters, `/tags/` paginated like the main page. 24 tag hue buckets (FNV-1a hash), contrast checked in tests. RSS: `dc:creator` from `AUTHOR_NAME`, `atom:updated` from `updated`. Image `width`/`height` read from the mirror (`golang.org/x/image` for WebP); an asset change re-renders all posts. IndexNow: one ping per sync with new, changed and removed post URLs, not on the first index load. New `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` is a path inside `assets/`. - 2026-11-04 — Metrics, votes, limits: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). All-time counters per post and label (Grafana gets daily trends with `increase()`), daily views only for "Popular" (pruned after `POPULAR_DAYS`). Search queries: live requests from `search.js` (`X-Search: live`) are not stored, the settled query is; TTL 90 days, at most 10 000 rows, top 20 in `/metrics`, refreshed every 5 min. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, v2 challenge with SHA-256, cost 1, `uint32` counter up to `ALTCHA_MAX_NUMBER`, key signature; own small solver `static/altcha.js` instead of the widget; signing keys derived from `ALTCHA_HMAC_KEY`; replay map capped at 100 000. Beacon: a delta on every hide and `pagehide`, sent with `fetch(…, {keepalive: true})` (JSON body), each with a random per-page-view id; the server sums time per view and clamps the sum to 4 h; time on page = average over views with reading time. Views and read depth: once per visitor per post per day; daily hashes in memory only (cap 200 000), `ip_prefix` = IPv4 address or IPv6 `/64`. Source `internal` for referrers from the site itself. Outbound hosts limited to links in the post. `/api/altcha` and `/api/vote` share `RATE_LIMIT_VOTE`; limiter tracks at most 10 000 clients. Vote cookie stored as SHA-256. "Popular" after "Newest" on page 1. `/metrics` in Prometheus text format written with the standard library. +- 2026-11-04 — Grafana, Caddy, README: dashboard `grafana/hyperhack.json` for a Prometheus-type data source on VictoriaMetrics (uses the VictoriaMetrics function `sort_by_label_numeric`); daily bars cover the day that starts at the bar, so today is shown. `Caddyfile.example` for Caddy `v2.11.7` with `caddy-ratelimit` `v0.1.0` (300 requests per minute per client IP, all paths) and `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` and `/reindex` (and their subpaths) answer `404` on the public domain; compression `zstd`/`gzip`. README: behind Caddy on the host, `TRUSTED_PROXIES` is the gateway of the Compose network. Full README in English and Russian. diff --git a/SPEC.ru.md b/SPEC.ru.md index fc4f312..55618a0 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -356,7 +356,7 @@ Garage не шлёт уведомления об изменениях в бак - Сервис отдаёт формат Prometheus на `/metrics`, на основном порту. - Reverse proxy закрывает `/metrics` на публичном домене. - VictoriaMetrics собирает метрики напрямую с сервиса, мимо proxy. Prometheus-сервера нет. -- Дашборд Grafana (JSON) лежит в репозитории. +- Дашборд Grafana (JSON) лежит в репозитории: `grafana/hyperhack.json`. - Метрики: все метрики постов (см. «Метрики постов»); статус синхронизации (последний опрос, ошибки, число объектов); время поиска; HTTP-запросы, ошибки, задержки. ## Логирование @@ -616,3 +616,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-04 — Страницы: тема и язык переключаются формой без JS `POST /prefs`; cookie `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 год`, `Secure` на https). Логотип из `assets/logo.svg`, иначе встроенный знак. Шрифты Inter + JetBrains Mono, свои. Пагинация `/?page=N`: страница 1 — это `/`, другие значения вне диапазона → `404`. `` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`. - 2026-11-04 — Поиск, теги, RSS, SEO: Bleve `v2.6.1`, индекс в памяти, своя схема документа на каждый язык (`en`, `ru`). Опечатки по длине слова: ≤ 3 букв 0, ≤ 6 букв 1, длиннее 2; префикс по последнему слову. В сниппетах остаётся только ``. Поиск работает без JS; `search.js` подменяет результаты при наборе (с задержкой). У страницы результатов `noindex`. Теги в нижнем регистре, символы как в Obsidian, `/tags/` с пагинацией как на главной. 24 оттенка для тегов (хеш FNV-1a), контраст проверяется в тестах. RSS: `dc:creator` из `AUTHOR_NAME`, `atom:updated` из `updated`. `width`/`height` картинок читаются из зеркала (`golang.org/x/image` для WebP); изменение ассета перерисовывает все посты. IndexNow: один пинг за синхронизацию с URL новых, изменённых и удалённых постов, не при первой загрузке индекса. Новая `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` — путь внутри `assets/`. - 2026-11-04 — Метрики, голоса, лимиты: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). Счётчики за всё время по посту и метке (дневные тренды Grafana считает через `increase()`), дневные просмотры — только для «Популярных» (удаляются старше `POPULAR_DAYS`). Поисковые запросы: живые запросы `search.js` (`X-Search: live`) не сохраняются, сохраняется устоявшийся запрос; TTL 90 дней, не больше 10 000 строк, топ-20 в `/metrics`, обновляется раз в 5 минут. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, задача v2 с SHA-256, cost 1, счётчик `uint32` до `ALTCHA_MAX_NUMBER`, подпись ключа; свой маленький решатель `static/altcha.js` вместо виджета; ключи подписи выводятся из `ALTCHA_HMAC_KEY`; карта повторов ограничена 100 000. Beacon: дельта при каждом скрытии и `pagehide`, отправка через `fetch(…, {keepalive: true})` (тело JSON), в каждой — случайный id просмотра страницы; сервер суммирует время по просмотру и ограничивает сумму 4 ч; время на странице — среднее по просмотрам со временем чтения. Просмотры и глубина чтения: один раз на посетителя, пост и день; суточные хеши только в памяти (не больше 200 000), `ip_prefix` — адрес IPv4 или `/64` IPv6. Источник `internal` для переходов с самого сайта. Внешние домены — только из ссылок поста. `/api/altcha` и `/api/vote` делят `RATE_LIMIT_VOTE`; лимитер помнит не больше 10 000 клиентов. Cookie голоса хранится как SHA-256. «Популярные» после «Новых» на первой странице. `/metrics` в текстовом формате Prometheus, написан на стандартной библиотеке. +- 2026-11-04 — Grafana, Caddy, README: дашборд `grafana/hyperhack.json` для источника данных типа Prometheus на VictoriaMetrics (использует функцию VictoriaMetrics `sort_by_label_numeric`); дневной столбец покрывает день, который начинается в его точке, поэтому сегодняшний день виден. `Caddyfile.example` для Caddy `v2.11.7` с `caddy-ratelimit` `v0.1.0` (300 запросов в минуту на IP клиента, все пути) и `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` и `/reindex` (и их подпути) на публичном домене отвечают `404`; сжатие `zstd`/`gzip`. README: если Caddy на хосте, `TRUSTED_PROXIES` — шлюз сети Compose. Полный README на английском и русском. From 8c133c8d5a04f2c0e3d9857e5471374cd2230e25 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:45:31 +0300 Subject: [PATCH 5/6] feat: exclude static files and assets from the Caddy rate limit and raise it to 1200/min --- Caddyfile.example | 7 ++++--- README.md | 10 ++++++---- README.ru.md | 10 ++++++---- SPEC.md | 4 ++-- SPEC.ru.md | 4 ++-- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Caddyfile.example b/Caddyfile.example index a42c305..9d62ec8 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -19,11 +19,12 @@ example.com { crowdsec - # Per client IP, all paths. One page view is about 10 requests. - rate_limit { + # Per client IP. Static files and assets are not counted. + @limited not path /static/* /assets/* + rate_limit @limited { zone per_client { key {remote_host} - events 300 + events 1200 window 1m } } diff --git a/README.md b/README.md index 4a8aa0f..b0a2295 100644 --- a/README.md +++ b/README.md @@ -238,11 +238,12 @@ This is [Caddyfile.example](Caddyfile.example). Replace `example.com` and `/srv/ example.com { crowdsec - # Per client IP, all paths. One page view is about 10 requests. - rate_limit { + # Per client IP. Static files and assets are not counted. + @limited not path /static/* /assets/* + rate_limit @limited { zone per_client { key {remote_host} - events 300 + events 1200 window 1m } } @@ -286,7 +287,8 @@ example.com { - `CROWDSEC_API_URL` and `CROWDSEC_API_KEY` are read from Caddy's environment when the config loads. The CrowdSec LAPI also defaults to port `8080`, so move one of them if both run on the host. - The service sets its own CSP and security headers. Caddy adds only HSTS and removes `Server`. -- Without the plugins, delete the `order`, `crowdsec` and `rate_limit` lines. The service still rate-limits its write endpoints. +- The site-wide limit is 1200 requests per minute per client IP. It counts pages and API calls; `/static/*` and `/assets/*` are not counted, so images and fonts never use it up. +- Without the plugins, delete the `order`, `crowdsec`, `@limited` and `rate_limit` lines. The service still rate-limits its write endpoints. ### TRUSTED_PROXIES diff --git a/README.ru.md b/README.ru.md index 15c0275..5d5eb90 100644 --- a/README.ru.md +++ b/README.ru.md @@ -238,11 +238,12 @@ xcaddy build v2.11.7 \ example.com { crowdsec - # Per client IP, all paths. One page view is about 10 requests. - rate_limit { + # Per client IP. Static files and assets are not counted. + @limited not path /static/* /assets/* + rate_limit @limited { zone per_client { key {remote_host} - events 300 + events 1200 window 1m } } @@ -286,7 +287,8 @@ example.com { - `CROWDSEC_API_URL` и `CROWDSEC_API_KEY` берутся из окружения Caddy при загрузке конфига. LAPI CrowdSec по умолчанию тоже занимает порт `8080`, поэтому, если оба работают на хосте, перенесите один из них. - Сервис сам ставит CSP и заголовки безопасности. Caddy добавляет только HSTS и убирает `Server`. -- Без плагинов удалите строки `order`, `crowdsec` и `rate_limit`. Сервис всё равно ограничивает свои эндпоинты записи. +- Общий лимит — 1200 запросов в минуту на IP клиента. Он считает страницы и вызовы API; `/static/*` и `/assets/*` не учитываются, поэтому картинки и шрифты его не расходуют. +- Без плагинов удалите строки `order`, `crowdsec`, `@limited` и `rate_limit`. Сервис всё равно ограничивает свои эндпоинты записи. ### TRUSTED_PROXIES diff --git a/SPEC.md b/SPEC.md index a26e55a..394d8d8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -397,7 +397,7 @@ Trust boundaries: internet → Caddy → service; bucket → service (semi-trust ### Perimeter (Caddy, outside the service) -- `caddy-ratelimit` + CrowdSec bouncer. +- `caddy-ratelimit` + CrowdSec bouncer. Rate limit: 1200 requests per minute per client IP; `/static/*` and `/assets/*` are not counted. - Blocked on the public domain: `/metrics`, `/reindex`. - `/assets/*`: - Extension allowlist: `png jpg jpeg webp avif gif svg mp4 webm`. Others → `404`. @@ -616,4 +616,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-04 — Pages: theme and language switch via a no-JS form `POST /prefs`; cookies `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 year`, `Secure` on https). Logo from `assets/logo.svg`, built-in mark otherwise. Fonts Inter + JetBrains Mono, self-hosted. Pagination `/?page=N`: page 1 is `/`, other out-of-range values → `404`. `` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`. - 2026-11-04 — Search, tags, RSS, SEO: Bleve `v2.6.1`, in-memory index, one document mapping per language (`en`, `ru`). Fuzziness by word length: ≤ 3 letters 0, ≤ 6 letters 1, longer 2; prefix on the last word. Snippets keep only ``. Search works without JS; `search.js` swaps the results while typing (debounced). Results page `noindex`. Tags lower case, Obsidian characters, `/tags/` paginated like the main page. 24 tag hue buckets (FNV-1a hash), contrast checked in tests. RSS: `dc:creator` from `AUTHOR_NAME`, `atom:updated` from `updated`. Image `width`/`height` read from the mirror (`golang.org/x/image` for WebP); an asset change re-renders all posts. IndexNow: one ping per sync with new, changed and removed post URLs, not on the first index load. New `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` is a path inside `assets/`. - 2026-11-04 — Metrics, votes, limits: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). All-time counters per post and label (Grafana gets daily trends with `increase()`), daily views only for "Popular" (pruned after `POPULAR_DAYS`). Search queries: live requests from `search.js` (`X-Search: live`) are not stored, the settled query is; TTL 90 days, at most 10 000 rows, top 20 in `/metrics`, refreshed every 5 min. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, v2 challenge with SHA-256, cost 1, `uint32` counter up to `ALTCHA_MAX_NUMBER`, key signature; own small solver `static/altcha.js` instead of the widget; signing keys derived from `ALTCHA_HMAC_KEY`; replay map capped at 100 000. Beacon: a delta on every hide and `pagehide`, sent with `fetch(…, {keepalive: true})` (JSON body), each with a random per-page-view id; the server sums time per view and clamps the sum to 4 h; time on page = average over views with reading time. Views and read depth: once per visitor per post per day; daily hashes in memory only (cap 200 000), `ip_prefix` = IPv4 address or IPv6 `/64`. Source `internal` for referrers from the site itself. Outbound hosts limited to links in the post. `/api/altcha` and `/api/vote` share `RATE_LIMIT_VOTE`; limiter tracks at most 10 000 clients. Vote cookie stored as SHA-256. "Popular" after "Newest" on page 1. `/metrics` in Prometheus text format written with the standard library. -- 2026-11-04 — Grafana, Caddy, README: dashboard `grafana/hyperhack.json` for a Prometheus-type data source on VictoriaMetrics (uses the VictoriaMetrics function `sort_by_label_numeric`); daily bars cover the day that starts at the bar, so today is shown. `Caddyfile.example` for Caddy `v2.11.7` with `caddy-ratelimit` `v0.1.0` (300 requests per minute per client IP, all paths) and `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` and `/reindex` (and their subpaths) answer `404` on the public domain; compression `zstd`/`gzip`. README: behind Caddy on the host, `TRUSTED_PROXIES` is the gateway of the Compose network. Full README in English and Russian. +- 2026-11-04 — Grafana, Caddy, README: dashboard `grafana/hyperhack.json` for a Prometheus-type data source on VictoriaMetrics (uses the VictoriaMetrics function `sort_by_label_numeric`); daily bars cover the day that starts at the bar, so today is shown. `Caddyfile.example` for Caddy `v2.11.7` with `caddy-ratelimit` `v0.1.0` (1200 requests per minute per client IP; `/static/*` and `/assets/*` are not counted) and `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` and `/reindex` (and their subpaths) answer `404` on the public domain; compression `zstd`/`gzip`. README: behind Caddy on the host, `TRUSTED_PROXIES` is the gateway of the Compose network. Full README in English and Russian. diff --git a/SPEC.ru.md b/SPEC.ru.md index 55618a0..6123f93 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -397,7 +397,7 @@ Garage не шлёт уведомления об изменениях в бак ### Периметр (Caddy, вне сервиса) -- `caddy-ratelimit` + CrowdSec bouncer. +- `caddy-ratelimit` + CrowdSec bouncer. Лимит: 1200 запросов в минуту на IP клиента; `/static/*` и `/assets/*` не учитываются. - На публичном домене закрыт `/metrics`, `/reindex`. - `/assets/*`: - Разрешённые расширения: `png jpg jpeg webp avif gif svg mp4 webm`. Остальное → `404`. @@ -616,4 +616,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-04 — Страницы: тема и язык переключаются формой без JS `POST /prefs`; cookie `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 год`, `Secure` на https). Логотип из `assets/logo.svg`, иначе встроенный знак. Шрифты Inter + JetBrains Mono, свои. Пагинация `/?page=N`: страница 1 — это `/`, другие значения вне диапазона → `404`. `` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`. - 2026-11-04 — Поиск, теги, RSS, SEO: Bleve `v2.6.1`, индекс в памяти, своя схема документа на каждый язык (`en`, `ru`). Опечатки по длине слова: ≤ 3 букв 0, ≤ 6 букв 1, длиннее 2; префикс по последнему слову. В сниппетах остаётся только ``. Поиск работает без JS; `search.js` подменяет результаты при наборе (с задержкой). У страницы результатов `noindex`. Теги в нижнем регистре, символы как в Obsidian, `/tags/` с пагинацией как на главной. 24 оттенка для тегов (хеш FNV-1a), контраст проверяется в тестах. RSS: `dc:creator` из `AUTHOR_NAME`, `atom:updated` из `updated`. `width`/`height` картинок читаются из зеркала (`golang.org/x/image` для WebP); изменение ассета перерисовывает все посты. IndexNow: один пинг за синхронизацию с URL новых, изменённых и удалённых постов, не при первой загрузке индекса. Новая `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` — путь внутри `assets/`. - 2026-11-04 — Метрики, голоса, лимиты: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). Счётчики за всё время по посту и метке (дневные тренды Grafana считает через `increase()`), дневные просмотры — только для «Популярных» (удаляются старше `POPULAR_DAYS`). Поисковые запросы: живые запросы `search.js` (`X-Search: live`) не сохраняются, сохраняется устоявшийся запрос; TTL 90 дней, не больше 10 000 строк, топ-20 в `/metrics`, обновляется раз в 5 минут. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, задача v2 с SHA-256, cost 1, счётчик `uint32` до `ALTCHA_MAX_NUMBER`, подпись ключа; свой маленький решатель `static/altcha.js` вместо виджета; ключи подписи выводятся из `ALTCHA_HMAC_KEY`; карта повторов ограничена 100 000. Beacon: дельта при каждом скрытии и `pagehide`, отправка через `fetch(…, {keepalive: true})` (тело JSON), в каждой — случайный id просмотра страницы; сервер суммирует время по просмотру и ограничивает сумму 4 ч; время на странице — среднее по просмотрам со временем чтения. Просмотры и глубина чтения: один раз на посетителя, пост и день; суточные хеши только в памяти (не больше 200 000), `ip_prefix` — адрес IPv4 или `/64` IPv6. Источник `internal` для переходов с самого сайта. Внешние домены — только из ссылок поста. `/api/altcha` и `/api/vote` делят `RATE_LIMIT_VOTE`; лимитер помнит не больше 10 000 клиентов. Cookie голоса хранится как SHA-256. «Популярные» после «Новых» на первой странице. `/metrics` в текстовом формате Prometheus, написан на стандартной библиотеке. -- 2026-11-04 — Grafana, Caddy, README: дашборд `grafana/hyperhack.json` для источника данных типа Prometheus на VictoriaMetrics (использует функцию VictoriaMetrics `sort_by_label_numeric`); дневной столбец покрывает день, который начинается в его точке, поэтому сегодняшний день виден. `Caddyfile.example` для Caddy `v2.11.7` с `caddy-ratelimit` `v0.1.0` (300 запросов в минуту на IP клиента, все пути) и `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` и `/reindex` (и их подпути) на публичном домене отвечают `404`; сжатие `zstd`/`gzip`. README: если Caddy на хосте, `TRUSTED_PROXIES` — шлюз сети Compose. Полный README на английском и русском. +- 2026-11-04 — Grafana, Caddy, README: дашборд `grafana/hyperhack.json` для источника данных типа Prometheus на VictoriaMetrics (использует функцию VictoriaMetrics `sort_by_label_numeric`); дневной столбец покрывает день, который начинается в его точке, поэтому сегодняшний день виден. `Caddyfile.example` для Caddy `v2.11.7` с `caddy-ratelimit` `v0.1.0` (1200 запросов в минуту на IP клиента; `/static/*` и `/assets/*` не учитываются) и `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` и `/reindex` (и их подпути) на публичном домене отвечают `404`; сжатие `zstd`/`gzip`. README: если Caddy на хосте, `TRUSTED_PROXIES` — шлюз сети Compose. Полный README на английском и русском. From 6b246f2e62a62d7cc97c449d0a4edf62237b6de5 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:45:31 +0300 Subject: [PATCH 6/6] test: check that the README Caddy blocks match Caddyfile.example --- readme_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 readme_test.go diff --git a/readme_test.go b/readme_test.go new file mode 100644 index 0000000..435bac0 --- /dev/null +++ b/readme_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// TestReadmeCaddyBlock keeps the Caddy block in both READMEs equal to Caddyfile.example +// without its leading comment. +func TestReadmeCaddyBlock(t *testing.T) { + example, err := os.ReadFile("Caddyfile.example") + if err != nil { + t.Fatal(err) + } + want := string(example) + for strings.HasPrefix(want, "#") || strings.HasPrefix(want, "\n") { + _, want, _ = strings.Cut(want, "\n") + } + if want == "" { + t.Fatal("Caddyfile.example has no config after its comment") + } + for _, name := range []string{"README.md", "README.ru.md"} { + readme, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + blocks := strings.Split(string(readme), "```caddyfile\n") + if len(blocks) != 2 { + t.Errorf("%s: want exactly one caddyfile block, got %d", name, len(blocks)-1) + continue + } + got, _, ok := strings.Cut(blocks[1], "```") + if !ok { + t.Errorf("%s: caddyfile block not closed", name) + continue + } + if got != want { + t.Errorf("%s: caddyfile block differs from Caddyfile.example; copy it from there", name) + } + } +}