From e7116c54656fc03ae06a1ac910d10ff874bae7bb Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:42:56 +0300 Subject: [PATCH 1/7] feat: add config, logging and HTTP server with /healthz --- go.mod | 5 + internal/config/config.go | 311 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 126 +++++++++++++ internal/server/server.go | 93 ++++++++++ internal/server/server_test.go | 53 ++++++ main.go | 101 +++++++++++ 6 files changed, 689 insertions(+) create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5eae055 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/Mimic890/hyperhack + +go 1.27 + +toolchain go1.27.3 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fbb07a2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,311 @@ +// Package config loads and validates settings from environment variables. +package config + +import ( + "errors" + "fmt" + "log/slog" + "net/netip" + "net/url" + "strconv" + "strings" + "time" +) + +// Rate is a request limit: N requests per Per. +type Rate struct { + N int + Per time.Duration +} + +// Config holds all settings. Secrets are never logged; use Summary for logs. +type Config struct { + SiteName string + SiteURL *url.URL + DefaultLang string + GitHubURL string + + S3Endpoint *url.URL + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + + PollInterval time.Duration + + ReindexToken string + AltchaHMACKey string + VisitorSecret string + + AltchaExpires time.Duration + AltchaMaxNumber int64 + + ReadingWPM int + + LogLevel slog.Level + LogFormat string + + DataDir string + VaultDir string + + RateVote Rate + RateBeacon Rate + RateReindex Rate + RateSearch Rate + + PopularDays int + PopularLimit int + PageSize int + RSSLimit int + + DefaultOGImage string + IndexNowKey string + + MetricsAllow []netip.Prefix + ReindexAllow []netip.Prefix + TrustedProxies []netip.Prefix + + MaxPostBytes int64 + MaxAssetBytes int64 + MaxObjects int +} + +const minSecretLen = 32 + +// privateRanges is the default for METRICS_ALLOW and REINDEX_ALLOW: loopback and private ranges. +const privateRanges = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7" + +// Load reads the config through getenv (os.Getenv in production). +// It returns every problem at once. Errors name variables, never their values. +func Load(getenv func(string) string) (*Config, error) { + l := loader{getenv: getenv} + c := &Config{ + SiteName: l.required("SITE_NAME"), + SiteURL: l.url("SITE_URL", true, "http", "https"), + DefaultLang: l.enum("DEFAULT_LANG", "en", "en", "ru"), + GitHubURL: l.optionalURL("GITHUB_URL"), + + S3Endpoint: l.url("S3_ENDPOINT", true, "https"), + S3Region: l.str("S3_REGION", "garage"), + S3Bucket: l.required("S3_BUCKET"), + S3AccessKey: l.required("S3_ACCESS_KEY"), + S3SecretKey: l.required("S3_SECRET_KEY"), + + PollInterval: l.duration("POLL_INTERVAL", "60s", 10*time.Second, 24*time.Hour), + + ReindexToken: l.secret("REINDEX_TOKEN"), + AltchaHMACKey: l.secret("ALTCHA_HMAC_KEY"), + VisitorSecret: l.secret("VISITOR_SECRET"), + + AltchaExpires: l.duration("ALTCHA_EXPIRES", "5m", 10*time.Second, time.Hour), + AltchaMaxNumber: int64(l.int("ALTCHA_MAX_NUMBER", 100000, 1000, 10_000_000)), + + ReadingWPM: l.int("READING_WPM", 200, 50, 1000), + + LogLevel: l.level("LOG_LEVEL"), + LogFormat: l.enum("LOG_FORMAT", "json", "json", "text"), + + DataDir: l.str("DATA_DIR", "/data"), + VaultDir: l.str("VAULT_DIR", "/vault"), + + RateVote: l.rate("RATE_LIMIT_VOTE", "10/1m"), + RateBeacon: l.rate("RATE_LIMIT_BEACON", "30/1m"), + RateReindex: l.rate("RATE_LIMIT_REINDEX", "5/1m"), + RateSearch: l.rate("RATE_LIMIT_SEARCH", "30/1m"), + + PopularDays: l.int("POPULAR_DAYS", 30, 1, 3650), + PopularLimit: l.int("POPULAR_LIMIT", 5, 1, 100), + PageSize: l.int("PAGE_SIZE", 10, 1, 100), + RSSLimit: l.int("RSS_LIMIT", 20, 1, 1000), + + DefaultOGImage: l.str("DEFAULT_OG_IMAGE", ""), + IndexNowKey: l.str("INDEXNOW_KEY", ""), + + MetricsAllow: l.prefixes("METRICS_ALLOW", privateRanges), + ReindexAllow: l.prefixes("REINDEX_ALLOW", privateRanges), + TrustedProxies: l.prefixes("TRUSTED_PROXIES", ""), + + MaxPostBytes: int64(l.int("MAX_POST_BYTES", 1<<20, 1024, 64<<20)), + MaxAssetBytes: int64(l.int("MAX_ASSET_BYTES", 50<<20, 1024, 1<<30)), + MaxObjects: l.int("MAX_OBJECTS", 10000, 1, 1_000_000), + } + if err := errors.Join(l.errs...); err != nil { + return nil, err + } + return c, nil +} + +// Summary returns non-secret settings for the startup log. +func (c *Config) Summary() []any { + return []any{ + "site_url", c.SiteURL.String(), + "default_lang", c.DefaultLang, + "s3_endpoint", c.S3Endpoint.String(), + "poll_interval", c.PollInterval, + "log_level", c.LogLevel.String(), + "log_format", c.LogFormat, + "data_dir", c.DataDir, + "vault_dir", c.VaultDir, + "indexnow", c.IndexNowKey != "", + "trusted_proxies", len(c.TrustedProxies), + } +} + +type loader struct { + getenv func(string) string + errs []error +} + +func (l *loader) fail(name, format string, a ...any) { + l.errs = append(l.errs, fmt.Errorf("%s: "+format, append([]any{name}, a...)...)) +} + +func (l *loader) str(name, def string) string { + if v := strings.TrimSpace(l.getenv(name)); v != "" { + return v + } + return def +} + +func (l *loader) required(name string) string { + v := l.str(name, "") + if v == "" { + l.fail(name, "required") + } + return v +} + +func (l *loader) secret(name string) string { + v := l.getenv(name) + if len(v) < minSecretLen { + l.fail(name, "required, at least %d bytes", minSecretLen) + } + return v +} + +func (l *loader) enum(name, def string, allowed ...string) string { + v := strings.ToLower(l.str(name, def)) + for _, a := range allowed { + if v == a { + return v + } + } + l.fail(name, "must be one of %s", strings.Join(allowed, ", ")) + return def +} + +func (l *loader) int(name string, def, min, max int) int { + s := l.str(name, "") + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil || n < min || n > max { + l.fail(name, "must be an integer in [%d, %d]", min, max) + return def + } + return n +} + +func (l *loader) duration(name, def string, min, max time.Duration) time.Duration { + d, err := time.ParseDuration(l.str(name, def)) + if err != nil || d < min || d > max { + l.fail(name, "must be a duration in [%s, %s]", min, max) + } + return d +} + +func (l *loader) level(name string) slog.Level { + var lv slog.Level + if err := lv.UnmarshalText([]byte(l.str(name, "info"))); err != nil { + l.fail(name, "must be one of debug, info, warn, error") + } + return lv +} + +func (l *loader) url(name string, required bool, schemes ...string) *url.URL { + s := l.str(name, "") + if s == "" { + if required { + l.fail(name, "required") + } + return nil + } + u, err := url.Parse(strings.TrimRight(s, "/")) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + l.fail(name, "must be an absolute URL without credentials, query or fragment") + return nil + } + for _, sc := range schemes { + if u.Scheme == sc { + return u + } + } + l.fail(name, "scheme must be %s", strings.Join(schemes, " or ")) + return nil +} + +func (l *loader) optionalURL(name string) string { + if u := l.url(name, false, "https"); u != nil { + return u.String() + } + return "" +} + +func (l *loader) rate(name, def string) Rate { + r, err := ParseRate(l.str(name, def)) + if err != nil { + l.fail(name, "%v", err) + } + return r +} + +func (l *loader) prefixes(name, def string) []netip.Prefix { + p, err := ParsePrefixes(l.str(name, def)) + if err != nil { + l.fail(name, "%v", err) + } + return p +} + +// ParseRate parses "N/duration", e.g. "10/1m". +func ParseRate(s string) (Rate, error) { + n, per, ok := strings.Cut(s, "/") + if !ok { + return Rate{}, errors.New(`must look like "10/1m"`) + } + count, err := strconv.Atoi(n) + if err != nil || count < 1 || count > 1_000_000 { + return Rate{}, errors.New("count must be in [1, 1000000]") + } + d, err := time.ParseDuration(per) + if err != nil || d < time.Second || d > 24*time.Hour { + return Rate{}, errors.New("period must be in [1s, 24h]") + } + return Rate{N: count, Per: d}, nil +} + +// ParsePrefixes parses a comma-separated list of CIDRs or bare IPs. Empty input gives nil. +func ParsePrefixes(s string) ([]netip.Prefix, error) { + var out []netip.Prefix + for f := range strings.SplitSeq(s, ",") { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if !strings.Contains(f, "/") { + a, err := netip.ParseAddr(f) + if err != nil { + return nil, fmt.Errorf("invalid IP %q", f) + } + out = append(out, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen())) + continue + } + p, err := netip.ParsePrefix(f) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q", f) + } + out = append(out, p.Masked()) + } + return out, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dc7b6a0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "net/netip" + "strings" + "testing" + "time" +) + +func validEnv() map[string]string { + return map[string]string{ + "SITE_NAME": "Example", + "SITE_URL": "https://example.com/", + "S3_ENDPOINT": "https://s3.example.com", + "S3_BUCKET": "blog", + "S3_ACCESS_KEY": "access", + "S3_SECRET_KEY": "secret", + "REINDEX_TOKEN": strings.Repeat("r", 32), + "ALTCHA_HMAC_KEY": strings.Repeat("a", 32), + "VISITOR_SECRET": strings.Repeat("v", 32), + } +} + +func load(env map[string]string) (*Config, error) { + return Load(func(k string) string { return env[k] }) +} + +func TestLoadDefaults(t *testing.T) { + c, err := load(validEnv()) + if err != nil { + t.Fatal(err) + } + if c.SiteURL.String() != "https://example.com" { + t.Errorf("SiteURL = %s", c.SiteURL) + } + if c.DefaultLang != "en" || c.LogFormat != "json" || c.PageSize != 10 || c.RSSLimit != 20 { + t.Errorf("bad defaults: %+v", c) + } + if c.RateVote != (Rate{10, time.Minute}) || c.RateSearch != (Rate{30, time.Minute}) { + t.Errorf("bad rate defaults: %+v %+v", c.RateVote, c.RateSearch) + } + if c.MaxPostBytes != 1<<20 || c.MaxObjects != 10000 || c.AltchaExpires != 5*time.Minute { + t.Errorf("bad limit defaults") + } + if !contains(c.MetricsAllow, "127.0.0.1") || !contains(c.ReindexAllow, "192.168.1.5") || contains(c.MetricsAllow, "8.8.8.8") { + t.Errorf("bad allow defaults: %v", c.MetricsAllow) + } + if c.TrustedProxies != nil { + t.Errorf("TrustedProxies default = %v, want none", c.TrustedProxies) + } +} + +func TestLoadErrors(t *testing.T) { + cases := map[string]string{ + "SITE_NAME": "", + "SITE_URL": "ftp://example.com", + "S3_ENDPOINT": "http://s3.example.com", + "REINDEX_TOKEN": "short", + "ALTCHA_HMAC_KEY": "", + "DEFAULT_LANG": "de", + "LOG_LEVEL": "loud", + "LOG_FORMAT": "xml", + "POLL_INTERVAL": "1s", + "PAGE_SIZE": "0", + "RATE_LIMIT_VOTE": "10", + "METRICS_ALLOW": "10.0.0.0/33", + "GITHUB_URL": "javascript:alert(1)", + } + for name, val := range cases { + t.Run(name, func(t *testing.T) { + env := validEnv() + env[name] = val + _, err := load(env) + if err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("err = %v, want error naming %s", err, name) + } + }) + } +} + +func TestErrorsHideSecrets(t *testing.T) { + env := validEnv() + env["REINDEX_TOKEN"] = "tooshort-secret-value" + _, err := load(env) + if err == nil || strings.Contains(err.Error(), "tooshort-secret-value") { + t.Fatalf("err = %v", err) + } +} + +func TestParsePrefixes(t *testing.T) { + p, err := ParsePrefixes(" 10.0.0.1, 2001:db8::/32 ,::ffff:192.0.2.1,") + if err != nil { + t.Fatal(err) + } + want := []string{"10.0.0.1/32", "2001:db8::/32", "192.0.2.1/32"} + if len(p) != len(want) { + t.Fatalf("got %v", p) + } + for i := range want { + if p[i].String() != want[i] { + t.Errorf("p[%d] = %s, want %s", i, p[i], want[i]) + } + } +} + +func FuzzParseRate(f *testing.F) { + for _, s := range []string{"10/1m", "1/1s", "0/1m", "5/0s", "/", "1e9/1h", "-1/1m"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + r, err := ParseRate(s) + if err == nil && (r.N < 1 || r.Per < time.Second) { + t.Fatalf("ParseRate(%q) = %+v, want error", s, r) + } + }) +} + +func contains(ps []netip.Prefix, ip string) bool { + a := netip.MustParseAddr(ip) + for _, p := range ps { + if p.Contains(a) { + return true + } + } + return false +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..30b79a0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,93 @@ +// Package server holds the HTTP routes and middleware. +package server + +import ( + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "runtime/debug" + "sync/atomic" + "time" +) + +// New returns the root handler. ready gates /healthz. +func New(log *slog.Logger, ready *atomic.Bool) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if !ready.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("not ready\n")) + return + } + _, _ = w.Write([]byte("ok\n")) + }) + return middleware(log, mux) +} + +// middleware sets security headers, recovers panics and logs each request on DEBUG. +func middleware(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + id := requestID() + sw := &statusWriter{ResponseWriter: w} + defer func() { + if v := recover(); v != nil { + if v == http.ErrAbortHandler { + panic(v) + } + log.Error("panic in handler", "request_id", id, "method", r.Method, "path", r.URL.Path, "panic", v, "stack", string(debug.Stack())) + if sw.status == 0 { + http.Error(sw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + log.Debug("request", "request_id", id, "method", r.Method, "path", r.URL.Path, "status", sw.code(), "duration", time.Since(start)) + }() + securityHeaders(w.Header()) + next.ServeHTTP(sw, r) + }) +} + +func securityHeaders(h http.Header) { + h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Cross-Origin-Opener-Policy", "same-origin") + h.Set("X-Frame-Options", "DENY") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), browsing-topics=()") +} + +func requestID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) // crypto/rand.Read never returns an error + return hex.EncodeToString(b) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + if w.status == 0 { + w.status = code + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return w.ResponseWriter.Write(b) +} + +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *statusWriter) code() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..7224748 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,53 @@ +package server + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func do(h http.Handler, method, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func TestRoutes(t *testing.T) { + var ready atomic.Bool + h := New(slog.New(slog.DiscardHandler), &ready) + + if got := do(h, "GET", "/healthz").Code; got != http.StatusServiceUnavailable { + t.Errorf("healthz before ready = %d, want 503", got) + } + ready.Store(true) + rec := do(h, "GET", "/healthz") + if rec.Code != http.StatusOK || rec.Body.String() != "ok\n" { + t.Errorf("healthz = %d %q", rec.Code, rec.Body) + } + if got := do(h, "POST", "/healthz").Code; got != http.StatusMethodNotAllowed { + t.Errorf("POST /healthz = %d, want 405", got) + } + rec = do(h, "GET", "/nope") + if rec.Code != http.StatusNotFound { + t.Errorf("unknown path = %d, want 404", rec.Code) + } + for _, k := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "Cross-Origin-Opener-Policy", "X-Frame-Options", "Permissions-Policy"} { + if rec.Header().Get(k) == "" { + t.Errorf("missing header %s on 404", k) + } + } +} + +func TestPanicRecovery(t *testing.T) { + h := middleware(slog.New(slog.DiscardHandler), http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("secret internal detail") + })) + rec := do(h, "GET", "/") + body, _ := io.ReadAll(rec.Body) + if rec.Code != http.StatusInternalServerError || string(body) != "Internal Server Error\n" { + t.Errorf("got %d %q", rec.Code, body) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bd31027 --- /dev/null +++ b/main.go @@ -0,0 +1,101 @@ +// Command hyperhack is a lightweight blog server for one author. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "sync/atomic" + "syscall" + "time" + + "github.com/Mimic890/hyperhack/internal/config" + "github.com/Mimic890/hyperhack/internal/server" +) + +// addr is fixed: the container port is mapped in docker-compose.yml. +const addr = ":8080" + +func main() { + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(healthcheck()) + } + os.Exit(run()) +} + +func run() int { + cfg, err := config.Load(os.Getenv) + if err != nil { + slog.New(slog.NewJSONHandler(os.Stdout, nil)).Error("invalid config", "component", "main", "err", err) + return 1 + } + log := newLogger(cfg) + mainLog := log.With("component", "main") + mainLog.Info("starting", cfg.Summary()...) + + var ready atomic.Bool + // shortcut: no content index yet, so ready at once; set it after the first sync once sync exists. + ready.Store(true) + + httpLog := log.With("component", "http") + srv := &http.Server{ + Addr: addr, + Handler: server.New(httpLog, &ready), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 16 << 10, + ErrorLog: slog.NewLogLogger(httpLog.Handler(), slog.LevelWarn), + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + errc := make(chan error, 1) + go func() { errc <- srv.ListenAndServe() }() + mainLog.Info("listening", "addr", addr) + + select { + case err := <-errc: + mainLog.Error("server failed", "err", err) + return 1 + case <-ctx.Done(): + } + mainLog.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + mainLog.Error("shutdown", "err", err) + return 1 + } + if err := <-errc; !errors.Is(err, http.ErrServerClosed) { + mainLog.Error("server failed", "err", err) + return 1 + } + return 0 +} + +func newLogger(cfg *config.Config) *slog.Logger { + opts := &slog.HandlerOptions{Level: cfg.LogLevel} + if cfg.LogFormat == "text" { + return slog.New(slog.NewTextHandler(os.Stdout, opts)) + } + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) +} + +// healthcheck is for the Compose healthcheck: the image has no curl. +func healthcheck() int { + c := http.Client{Timeout: 3 * time.Second} + resp, err := c.Get("http://127.0.0.1" + addr + "/healthz") + if err != nil { + return 1 + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} From 6ac0079ee859069ae52f1cfdc829b515bd1f12c7 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:43:01 +0300 Subject: [PATCH 2/7] docs: add project spec and contributor rules --- CLAUDE.md | 72 +++++++ SPEC.md | 598 +++++++++++++++++++++++++++++++++++++++++++++++++++++ SPEC.ru.md | 597 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1267 insertions(+) create mode 100644 CLAUDE.md create mode 100644 SPEC.md create mode 100644 SPEC.ru.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..420648a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +## Source of truth + +- `SPEC.md` is the source of truth for this project. Read it before every task. +- `SPEC.ru.md` is the Russian copy. Keep both files identical in content. +- A change to any decision goes into both SPEC files and their Decision log, in the same PR. +- If the SPEC is unclear or contradicts itself, stop and ask. Do not guess. +- Do not add features that are not in the SPEC. Propose them instead. + +## Language + +- Everything in English: code, comments, commit messages, PR text, docs, UI strings. +- Every doc has a Russian copy: `README.md` + `README.ru.md`, `SPEC.md` + `SPEC.ru.md`. A doc change updates both. +- Russian UI strings live in the i18n files next to the English ones. + +## Stack + +- Go 1.27 (`go 1.27`, `toolchain go1.27.3` in `go.mod`). Check go.dev/dl for a newer patch before starting. +- Standard library first. Add a dependency only when the SPEC names it or the stdlib cannot do the job in a reasonable amount of code. +- Pin every dependency version. Verify that a package exists and is maintained before adding it. + +## Work in stages + +One stage = one branch = one PR. Do not start the next stage before the current PR is merged. + +1. Skeleton: config from env, `log/slog` logging, HTTP server with timeouts, `/healthz`, Dockerfile, `docker-compose.yml`, `.env.example`, `.gitignore`, CI. +2. S3 sync: polling, `/reindex`, local mirror via `os.Root`, frontmatter parsing, Markdown render with sanitizing. +3. Pages: main page, post page, top bar, themes, languages, templates, styles. +4. Search, tags, RSS, SEO files. +5. Metrics: beacon, views, votes, ALTCHA, rate limits, `/metrics`. +6. Grafana dashboard, `Caddyfile.example`, full README in both languages. + +## Git + +- Branches: `feat/…`, `fix/…`, `refactor/…`, `docs/…`, `chore/…`. Never commit to `main` directly. +- Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`, `ci:`. +- Small commits, one logical change each. +- Never delete branches or commits. Use `git revert`. No rebase or force push on pushed branches. +- Never mention AI, Claude or any assistant in commits, PRs, docs or code comments. + +## Security rules + +- Follow SPEC → Security for every change. +- Never commit secrets, real domains, bucket names or keys. Real values live only in `.env`. Examples use `example.com`. +- Never log secrets, raw IPs or visitor hashes. +- Validate all input at trust boundaries: HTTP requests, bucket objects, frontmatter, cookies. +- New endpoint → add it to the SPEC endpoint table with its access and protection. + +## Quality checks + +Run before every commit. Do not say a task is done until all pass: + +``` +go vet ./... +staticcheck ./... +govulncheck ./... +go test -race ./... +``` + +- New non-trivial logic gets tests. Parsers and decoders also get Go fuzz tests. +- Security code (sanitizer, path checks, rate limits, ALTCHA, Origin check) always gets tests. + +## Docker + +- Bind mounts to subfolders only (`./data`, `./vault`). Never named volumes. +- Static binary, distroless or `scratch` runtime image, non-root user. See SPEC → Container. + +## Communication + +- Short, direct answers. No filler. +- When a task is done: what changed, what was not checked, any risk. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..6f2b092 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,598 @@ +# hyperhack — project spec + +Russian version: [SPEC.ru.md](SPEC.ru.md). Keep both files in sync. + +Name: `hyperhack`. +A lightweight replacement for Ghost. Ghost is too heavy for one author. + +## Goal + +A light, fast blog for one author. +Posts are Markdown files in S3. +The author writes in Obsidian and publishes by syncing the vault to the bucket. + +## Scope + +### In scope (MVP) + +- Main page. +- Post pages rendered from Markdown. +- Search over posts. +- "Newest" and "Popular" lists. +- Per-post metrics: view count (public), time on page (metric only). +- Estimated reading time on each post. +- Anonymous likes and dislikes. +- RSS feed. +- UI in English and Russian. Default language from `.env`, visitor switch in the top bar. +- `/metrics` endpoint and a Grafana dashboard. +- Structured logging with levels. + +### Out of scope + +- Multiple users, registration, roles. +- Web editor or admin panel for posts. +- WebDAV. S3 is the only storage backend. +- Site-wide traffic analytics. Reverse proxy logs and Grafana cover it. +- Migration of old Ghost posts. + +## Decisions + +| Area | Decision | +|---|---| +| Users | One author. No registration. Public side is read-only. | +| Backend | Go 1.27, latest stable (go1.27.3 on 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Builder image: `golang:1.27`. Check go.dev/dl before starting; Dependabot keeps it current. | +| Frontend | Server-side rendering with `html/template`. No framework. Minimal vanilla JS: votes, ALTCHA, view and time beacon. | +| Themes | Light and dark, toggle in the top bar. System theme by default until the visitor picks one. The choice is stored in a cookie and rendered on the server (no inline JS). | +| Design | Beautiful, minimal, responsive. Designed during implementation. | +| Languages | Code, docs, UI: English. UI also in Russian. Default: `DEFAULT_LANG` (`en` / `ru`) in `.env`. Visitor switch in the top bar; the choice is stored in a cookie (the server must know it to render the page). Post text is not translated. | +| Storage | Garage (S3 API), path-style addressing. | +| Post format | One `.md` = one post. Metadata: Obsidian properties (YAML frontmatter). | +| Metrics | Prometheus text format on `/metrics`, scraped by VictoriaMetrics, Grafana dashboard. | +| Logs | `log/slog`, levels DEBUG / INFO / WARN / ERROR. | +| Deployment | Docker + Docker Compose. Image built by GitHub Actions. | +| Repository | Public, GitHub. License AGPL-3.0 (the author adds it when creating the repo). | + +## Pages + +### Main page + +- Top bar, left to right: + - Logo, then site name. + - Center: empty. + - Right: theme toggle, language switch (`en` / `ru`), RSS link, author's GitHub link (URL from `.env`). +- Search. +- Newest posts. +- Popular posts (by view count). + +### Post page + +- Markdown from S3 rendered to HTML. +- Cover (`cover`), if set. +- Estimated reading time. +- View count. +- Likes and dislikes. + +## Configuration + +All settings and secrets come from environment variables (`.env`). +The repo has `.env.example` only. Real values never go into the repo. + +Proposed variables (final names set during implementation): + +| Variable | Purpose | +|---|---| +| `SITE_NAME`, `SITE_URL` | Site name and public URL. | +| `DEFAULT_LANG` | UI language and default post language: `en` or `ru`. Default `en`. | +| `GITHUB_URL` | Link in the top bar. | +| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Bucket access. | +| `POLL_INTERVAL` | Bucket polling interval. | +| `REINDEX_TOKEN` | Token for `POST /reindex`. | +| `ALTCHA_HMAC_KEY` | ALTCHA secret. | +| `READING_WPM` | Words per minute for estimated reading time. | +| `LOG_LEVEL`, `LOG_FORMAT` | Log level and format (`json` / `text`). | +| `DATA_DIR` | Directory for SQLite. | +| `VAULT_DIR` | Directory for the local bucket mirror. | +| `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_REINDEX` | Per-client limits on write endpoints. Defaults: `10/1m`, `30/1m`, `5/1m`. | +| `POPULAR_DAYS`, `POPULAR_LIMIT` | "Popular" window and size. Defaults: `30`, `5`. | +| `PAGE_SIZE` | Posts per page in "Newest" and tag pages. Default: `10`. | +| `RSS_LIMIT` | Posts in the RSS feed. Default: `20`. | +| `DEFAULT_OG_IMAGE` | Preview image for posts without `cover`. | +| `INDEXNOW_KEY` | Optional. Enables IndexNow pings. | +| `RATE_LIMIT_SEARCH` | Search limit per client. Default: `30/1m`. | +| `VISITOR_SECRET` | HMAC key for the daily visitor hash, ≥ 32 bytes. | +| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Challenge lifetime and difficulty. Default expiry: `5m`. | +| `METRICS_ALLOW`, `REINDEX_ALLOW` | CIDRs allowed to call `/metrics` and `/reindex`. Default: loopback and private ranges. | +| `MAX_POST_BYTES`, `MAX_ASSET_BYTES`, `MAX_OBJECTS` | Bucket content limits. Defaults: 1 MiB, 50 MiB, 10 000. | +| `TRUSTED_PROXIES` | Proxy IPs/CIDRs allowed to set `X-Forwarded-For`. Without it the client IP is the TCP peer. Needed for correct limits and visitor hashes behind Caddy. | + +## Deployment + +- The service runs in Docker and starts with Docker Compose. +- GitHub Actions builds the image. Proposed: push to GHCR (`ghcr.io`). +- Compose uses bind mounts to subfolders (`./data`, …). No named volumes. +- The service port is not published to the internet: localhost, Docker network or mesh IP only. The reverse proxy (Caddy) handles TLS. +- The agent writes `.gitignore` (at least: `.env`, `data/`, build output). +- Domain: from `SITE_URL`. +- The repo has `Caddyfile.example` for this service with domain `example.com`: reverse proxy to the service, `/metrics` and `/reindex` blocked, `/assets/*` served from `vault/assets/` with the headers and allowlist from Security → Perimeter. README shows the same block. + +## Bucket layout + +- `assets/` — all assets: covers, images in posts. Decided. +- Proposed: the service ignores keys where any path segment starts with `.` or `_` (same rule as Remotely Save). + +### Posts + +- Every `.md` file in the vault is a post candidate, at any depth, except `assets/` and ignored paths (`.` / `_`). +- Folders are for the author only. They do not affect the URL. +- URL: `/posts/`. The slug comes from frontmatter or the file name, so moving a file does not break links. +- No numbering. Order comes from `date`. +- A file without valid frontmatter (`title` + `date`) is skipped with WARN. Templates and helper notes go to `_templates/` or another `_` folder, so they are not synced. + +### Local vault mirror and assets + +- During sync the service mirrors the bucket to `VAULT_DIR` on disk: downloads new and changed objects, deletes removed ones. The bucket stays the source of truth. +- Compose layout (bind mounts only, no Docker volumes): + ``` + hyperhack/ + ├── docker-compose.yml + ├── .env + ├── data/ # DATA_DIR: SQLite + └── vault/ # VAULT_DIR: bucket mirror + ``` +- Caddy (on the host) serves `/assets/*` straight from `vault/assets/` with `file_server`. The service does not serve assets. +- Caddy serves only `/assets/*`. `.md` files and other vault paths are not exposed. +- The Caddy user needs read access to `vault/assets/`. +- Pages and images keep working when S3 is down; only new changes wait for the next successful sync. + +Rejected: Redis. One instance, few posts: in-memory index, Go maps for rate limits, SQLite for counters, disk + OS page cache for files are enough. + +## Post format + +Metadata: Obsidian properties — a YAML block between `---` at the top of the file. + +```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: post list, RSS, meta tags, link previews in Telegram and search engines. | +| `date` | date | yes | Publish date. Sort key for "Newest". | +| `updated` | date | no | Last update date. Shown on the post and in RSS. | +| `tags` | list | no | Tags. | +| `cover` | string | no | Cover path inside `assets/`. | +| `slug` | string | no | URL part. Default: from the file name. | +| `lang` | string | no | Post language, ISO 639-1 (`en`, `ru`). Default: `DEFAULT_LANG`. Used in ``, RSS, meta tags. | +| `draft` | bool | no | `true` — not published. Default `false`. | + +Rules: + +- `date` and `updated`: Obsidian "date" (`2026-11-03`) or "date & time" (`2026-11-03T20:59`). The parser accepts both. +- No `title` or `date`, or invalid YAML → post skipped, WARN log. +- Two posts with one `slug` → the first by object key wins, the second is skipped, ERROR log. +- Unknown fields are ignored. Obsidian can add its own. +- README describes every field in this table. + +## Publishing from Obsidian + +- The blog has its own Obsidian vault. Personal notes never reach the blog bucket. +- Obsidian syncs files to Garage with the Remotely Save plugin. +- Vault layout = bucket layout (plus the Remotely Save prefix, if set). +- Remotely Save encryption is off for this bucket. Otherwise the service cannot read the files. +- Remotely Save does not sync files and folders whose name starts with `.` or `_`. +- Remotely Save does not call `/reindex`. A new post appears after the next poll. + +### Obsidian settings ("Files and links") + +| Setting | Value | +|---|---| +| Use [[Wikilinks]] | off | +| New link format | absolute path in vault | +| Default location for new attachments | folder specified below → `assets` | + +With these settings an image is inserted as standard Markdown: `![](assets/abstract-5.jpg)`. +The renderer supports standard Markdown links only. Wikilinks (`![[…]]`) are not supported. +A path `assets/…` in a post maps to `/assets/…` on the site. +README describes these settings. + +Proposed: if a post contains `![[`, log WARN so the author sees the wrong link. + +## Content sync + +Garage does not send bucket notifications (no S3 event API). +The service finds changes in two ways: + +1. **Polling.** Every `POLL_INTERVAL` (30–60 s), call `ListObjectsV2` on the bucket. + Compare each object's `ETag` with the cache. + Fetch and re-render only new or changed objects. + Remove posts whose objects are gone. +2. **Manual trigger.** `POST /reindex` with a token. + Called over the private network (blocked on the public domain), so a post appears at once. + +Polling is the fallback. The endpoint is the fast path. + +## Post metrics + +Site-wide traffic is already in reverse proxy logs and Grafana. The service does not duplicate it. +The service stores per-post metrics only. Storage: SQLite. + +### View count + +- Counted by the client script, not on page render. The same script sends time on page. +- Clients without JS (most bots) are not counted. Readers with JS off are not counted either. Accepted. +- Public: shown on the post. +- Feeds the "Popular" list. +- Repeat views from one visitor are not counted (proposed: hash of IP + User-Agent per day; raw IP is not stored). + +### Time on page + +- The server cannot measure it alone. It needs the client script. +- The script sends active reading time when the page is hidden or closed (`visibilitychange` + `navigator.sendBeacon`). +- Only time with the tab visible counts. +- Stored per post as an aggregate (e.g. average or median). +- Private. Not shown on the site. Metric only (see Monitoring). + +### Estimated reading time + +- Public: shown on the post (e.g. "5 min read"). +- Computed from the post text at render time: word count / `READING_WPM`. +- Proposed: code blocks and images are counted separately. +- Not related to measured time on page. + +### Likes and dislikes + +- Anonymous. No account. +- One vote per `vid` cookie per post (see Security → Votes and visitor identity). A visitor can change or remove the vote. +- Counts are public on the post. +- Each vote must pass ALTCHA (see Security → ALTCHA). + +## Content features + +### Code highlighting + +- Server-side at render time: `github.com/yuin/goldmark` + `github.com/yuin/goldmark-highlighting/v2` (chroma v2). No JS. +- CSS classes, not inline styles (`chromahtml.WithClasses`), so light and dark themes each get their own palette. + +### Search + +Goal: the best search possible without an extra service. + +- Embedded full-text index: Bleve v2 (`github.com/blevesearch/bleve/v2`), in memory, rebuilt on sync. +- Language-aware analysis by post `lang`: English and Russian analyzers (Russian: Snowball stemmer + stop words, package `analysis/lang/ru`). "Постов" finds "пост". +- Fields and weight: `title` > `tags` > `description` > body. +- Typo tolerance (fuzzy match), prefix match for search-as-you-type. +- Result snippets with highlighted matches. +- Works without JS: `GET /search?q=…` renders results. With JS: results update while typing. +- Filter by tag. + +Rejected: Meilisearch and similar. Better typo handling, but a separate service. + +### Popular and Newest + +- "Popular": most views in the last `POPULAR_DAYS` days, top `POPULAR_LIMIT`. +- "Newest": by `date`, `PAGE_SIZE` per page, with pagination. + +### Tags + +- Page `/tags/`: posts with this tag, paginated. +- Each tag has its own color. The color is computed from a hash of the tag name, so it is stable without config. +- Colors are tuned per theme to keep text contrast at WCAG AA. + +### RSS + +- RSS 2.0 at `/rss.xml`, latest `RSS_LIMIT` posts. +- Full post HTML in `content:encoded`, `description` as summary. All URLs absolute. +- Auto-discovery `` on every page. + +### SEO and discoverability + +- Server-rendered semantic HTML. Fast pages: no render-blocking JS, image `width`/`height`, `loading="lazy"`, `alt` from Markdown. +- ``, meta description from `description`, canonical URL from `SITE_URL`, `<html lang>` from post `lang`. +- Open Graph and Twitter Card (`summary_large_image`): image from `cover`, else `DEFAULT_OG_IMAGE`. +- JSON-LD `BlogPosting`: headline, dates, author, image, language, keywords. +- `sitemap.xml` with `lastmod` from `updated` or `date`. +- `robots.txt`: allow all crawlers, including AI crawlers; link to the sitemap. +- Raw Markdown for agents: `/posts/<slug>.md`. +- IndexNow ping on new or changed posts, if `INDEXNOW_KEY` is set (Bing, Yandex; Google does not use IndexNow). +- `/llms.txt`: generated, because it is cheap. Real use by AI assistants is near zero today. +- Real `404` status for missing pages. + +### Healthcheck + +- `GET /healthz`: `200` when the index is loaded, `503` otherwise. +- The image has no curl, so the binary has a `healthcheck` subcommand for the Compose healthcheck. + +### Additional post metrics + +All private: metrics only, not shown on the site. Collected by the same client script and the server. No tracking cookies, no fingerprinting beyond the daily visitor hash. + +| Metric | Source | Purpose | +|---|---|---| +| Views per day per post | script | Trends; base for "Popular". | +| Read depth: share of readers who reach 25 / 50 / 75 / 100 % of the text | script (`IntersectionObserver` on markers) | Do readers finish the post? | +| Traffic source per post: `telegram`, `google`, `yandex`, `bing`, `github`, `direct`, `other` | script (`document.referrer` domain, mapped to a fixed list) | Where readers come from. | +| Outbound link clicks per post (target domain) | script | Which links readers use. | +| Code copies per post | script (`copy` event inside code blocks) | Which code is useful. | +| Device class: `mobile` / `tablet` / `desktop` | script (viewport width bucket) | Layout priorities. | +| UI language and theme chosen | script | Which language and theme readers use. | +| Votes per day per post | server | Vote trends. | +| Search: query count, zero-result count, top queries, top zero-result queries | server | What readers look for and do not find. | + +Rules: + +- Prometheus labels must have bounded values: post slug, fixed source list, fixed device list. Never raw URLs, referrers or queries as labels. +- Search queries are stored in SQLite (normalized, lower case). `/metrics` exposes only the top `N` as a gauge, refreshed periodically. +- One beacon request per page view carries all script metrics, sent on `visibilitychange` / `pagehide`. It is rate-limited by `RATE_LIMIT_BEACON`. + +## Monitoring + +- 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. +- Metrics: all post metrics (see Post metrics); sync status (last poll, errors, object count); search latency; HTTP requests, errors, latency. + +## Logging + +- Library: `log/slog` from the Go standard library. +- Levels: `DEBUG`, `INFO`, `WARN`, `ERROR`. Set by `LOG_LEVEL`. +- Format: JSON to stdout (for Docker and Loki). Text for local development. Set by `LOG_FORMAT`. +- Fields in every record: `time`, `level`, `msg`, `component` (`sync`, `http`, `render`, `votes`, `metrics`, `store`). +- HTTP records: `request_id`, method, path, status, duration. +- Startup error (config, S3, SQLite) → `ERROR` log and exit code 1. + +| Level | What is logged | +|---|---| +| `DEBUG` | Sync details (each object, `ETag` compare), post render, SQLite queries, each HTTP request. | +| `INFO` | Startup and config summary (no secrets), poll result (added / updated / removed), `/reindex` call, shutdown. | +| `WARN` | Post skipped (no `title` or `date`, invalid YAML), rate limit hit, ALTCHA failed, temporary S3 error with retry. | +| `ERROR` | `slug` conflict, S3 unavailable after retries, SQLite write error, recovered panic in a handler. | + +Never log: `/reindex` token, S3 keys, ALTCHA secret, raw IP, visitor hash. + +Proposed: each HTTP request on `DEBUG` only. The reverse proxy already logs requests. + +## Security + +### Threat model + +| Asset | Threat | Main control | +|---|---|---| +| Pages and readers | XSS, defacement, clickjacking | Escaping, sanitizer, strict CSP, security headers | +| Votes and views | Bots, vote stuffing, CSRF | ALTCHA, rate limits, vote cookie, Origin check | +| Host and service | DoS, resource exhaustion, container escape | Limits, timeouts, hardened container | +| Bucket content | A compromised author device writes bad files | Read-only key, size limits, sanitizer, path checks | +| Secrets | Leak via repo, logs, image | `.env` only, never logged, secret scanning | +| Supply chain | Malicious dependency or action, image tampering | Pinned actions, Dependabot, `govulncheck`, attestations | +| Reader privacy | Tracking, IP leaks | No raw IP, daily HMAC hash, no third-party requests | + +Trust boundaries: internet → Caddy → service; bucket → service (semi-trusted: only the author writes, but the author's devices can be compromised); CI → GHCR → host. + +### Perimeter (Caddy, outside the service) + +- `caddy-ratelimit` + CrowdSec bouncer. +- Blocked on the public domain: `/metrics`, `/reindex`. +- `/assets/*`: + - Extension allowlist: `png jpg jpeg webp avif gif svg mp4 webm`. Others → `404`. + - Headers: `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox`. An SVG with a script cannot run. +- Request body limit for `/api/*`: 16 KB. +- `Strict-Transport-Security: max-age=31536000` (no `includeSubDomains`, no preload by default). +- `Server` header removed. +- No Cloudflare: it is throttled in Russia since June 2025, and most readers are in Russia. No upstream DDoS protection: accepted risk. +- Rejected: a challenge on site entry (e.g. Anubis). It can block search engines and link-preview bots. + +### Endpoints + +| Endpoint | Method | Access | Protection | +|---|---|---|---| +| Pages, `/search`, `/tags/*`, `/rss.xml`, `/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/posts/<slug>.md` | GET | public | `RATE_LIMIT_SEARCH` on `/search` | +| `/api/altcha` | GET | public | `RATE_LIMIT_VOTE` | +| `/api/vote` | POST | public | ALTCHA, vote cookie, `RATE_LIMIT_VOTE`, Origin check | +| `/api/beacon` | POST | public | `RATE_LIMIT_BEACON`, Origin check, strict schema | +| `/reindex` | POST | private network | Caddy block, `REINDEX_ALLOW`, `REINDEX_TOKEN` | +| `/metrics` | GET | private network | Caddy block, `METRICS_ALLOW` | +| `/healthz` | GET | public | constant body, no data | + +Other methods → `405`. Unknown paths → `404`. + +### Input validation + +- Body size: `http.MaxBytesReader`, 4 KB for `/api/*`. +- JSON: `Content-Type: application/json` required, `DisallowUnknownFields`, one object only. +- Slug must exist in the current index. Unknown slug → `404`, nothing stored. This also bounds metric labels. +- Numbers are clamped: time on page 0–4 h; read depth only 0 / 25 / 50 / 75 / 100. +- Enums only: vote (`like` / `dislike` / `none`), source, device, theme, lang. Other values → `400`. +- Search query: max 200 chars, max 10 terms. The query is built in code (match + fuzzy + prefix), never from Bleve query-string syntax: this blocks regex and wildcard DoS. Fuzziness ≤ 2, prefix min 2 chars. Page number bounded. +- Cookies `lang` and `theme`: enum check; invalid → default. + +### Output and XSS + +- All HTML through `html/template` (context-aware escaping). Templates parsed at startup. +- Markdown: goldmark without `WithUnsafe`. By default goldmark does not render raw HTML or dangerous URLs. +- Defense in depth: post HTML passes `bluemonday` (UGC policy + chroma classes + heading ids). +- External links get `rel="noopener noreferrer"`. +- Images only from `assets/`. External images are blocked by CSP (also keeps reader IPs private). +- JSON-LD from `json.Marshal` (escapes `<`, `>`, `&`). +- RSS and sitemap via `encoding/xml`. +- `/posts/<slug>.md`: `Content-Type: text/markdown; charset=utf-8`, `nosniff`. +- Error pages show no internals. + +### Security headers (set by the service) + +- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests`. Add `blob:` to `worker-src` only if the ALTCHA build needs it. +- No inline scripts or styles. Theme and language come from cookies and are rendered on the server, so no inline script is needed against a theme flash. +- `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, `X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), browsing-topics=()`. +- All JS, CSS, fonts and the ALTCHA widget are self-hosted. No CDN, no third-party requests. + +### CSRF and CORS + +- No CORS headers. +- `/api/*` requires `Content-Type: application/json` (a cross-site form cannot send it without a preflight) and same-origin `Origin` / `Sec-Fetch-Site`. + +### Votes and visitor identity + +- Vote identity: anonymous random cookie `vid` (128 bit, `HttpOnly; Secure; SameSite=Lax; Max-Age=1 year`). No personal data in it. +- One vote per `vid` per post. Clearing the cookie allows a new vote, but each vote costs an ALTCHA solution and hits `RATE_LIMIT_VOTE`. +- Why not an IP hash: many readers share one IP (mobile CGNAT), and a daily hash would allow one vote per day. +- View dedup: `HMAC-SHA256(daily_key, ip_prefix + user_agent)`, `daily_key = HMAC(VISITOR_SECRET, date)`. Old daily keys are not stored, so days cannot be linked. Raw IPs are never stored or logged. + +### ALTCHA + +- Library: `github.com/altcha-org/altcha-lib-go`. Invisible mode: the browser solves the challenge on click. +- `ALTCHA_HMAC_KEY` ≥ 32 random bytes. +- Challenge expiry `ALTCHA_EXPIRES` (default 5 min). Expired → reject. +- Replay protection in the service: used challenges stay in memory until expiry; reuse → reject. The library does not track this. +- Difficulty: `ALTCHA_MAX_NUMBER`. + +### Rate limiting (service) + +- Key: client IP via `TRUSTED_PROXIES`; IPv6 grouped by `/64`. +- Token bucket in memory with a hard cap on tracked clients (oldest evicted), so a flood of IPs cannot exhaust RAM. +- Limits: `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_SEARCH`, `RATE_LIMIT_REINDEX`. Over limit → `429` + `Retry-After`, WARN log. + +### Private endpoints + +- `/metrics` and `/reindex`: blocked in Caddy, and accepted only from `METRICS_ALLOW` / `REINDEX_ALLOW` CIDRs (default: loopback and private ranges). Two independent layers. +- `REINDEX_TOKEN` ≥ 32 bytes, sent as `Authorization: Bearer`, compared with `crypto/subtle.ConstantTimeCompare`. +- Only one reindex runs at a time. Extra calls wait for the running one. + +### Bucket and local mirror + +- The service has its own Garage key with **read** permission only. Remotely Save uses a separate key with read + write. +- S3 over HTTPS with certificate verification. +- Size limits: `MAX_POST_BYTES` (default 1 MiB), `MAX_ASSET_BYTES` (50 MiB), `MAX_OBJECTS` (10 000). Over limit → skip + WARN. +- Mirror writes go through `os.Root` (Go ≥ 1.24): no escape via `..` or symlinks. Keys with an absolute path, `\`, NUL or control characters are rejected. +- Assets: same extension allowlist as Caddy. +- Atomic writes (temp file + rename), so Caddy never serves a partial file. +- Modes: dirs `0755`, files `0644` (Caddy needs read). SQLite file `0600`. +- Frontmatter size is checked before YAML parsing. `cover` must resolve inside `assets/`. +- Slug: `^[a-z0-9-]{1,100}$`. A file name with other characters (e.g. Cyrillic) is transliterated. If the result is empty and there is no `slug` → skip + WARN. + +### HTTP server + +- `ReadHeaderTimeout` 5 s, `ReadTimeout` 10 s, `WriteTimeout` 30 s, `IdleTimeout` 120 s, `MaxHeaderBytes` 16 KB. +- Context timeouts on S3 calls. +- Panic recovery middleware → `500` + ERROR log. +- Graceful shutdown on SIGTERM. +- No pprof or debug endpoints in release builds. + +### Data + +- SQLite: parameterized queries only; WAL; `busy_timeout`. +- Bounded growth: per-day aggregates, not raw events; search query table capped, with TTL. +- `data/` is backed up by the host (outside the service). + +### Container + +- Static binary (`CGO_ENABLED=0`, `-trimpath`), pure-Go SQLite driver (e.g. `modernc.org/sqlite`). +- Runtime image: distroless static non-root, or `scratch`. No shell, no package manager. +- Compose example: + ```yaml + services: + hyperhack: + image: ghcr.io/<owner>/hyperhack:<version> + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + ports: ["127.0.0.1:8080:8080"] + volumes: + - ./data:/data + - ./vault:/vault + env_file: .env + mem_limit: 256m + pids_limit: 128 + logging: + options: { max-size: "10m", max-file: "3" } + healthcheck: + test: ["CMD", "/hyperhack", "healthcheck"] + restart: unless-stopped + ``` +- `.env` mode `0600`. + +### Secrets + +- Only in `.env`. `.env.example` has placeholders. +- Startup check: required secrets present and long enough, else exit 1. +- Never logged, never in error messages, never in the image. +- README documents rotation: `REINDEX_TOKEN`, `ALTCHA_HMAC_KEY`, `VISITOR_SECRET`, S3 keys. + +### Supply chain and CI + +- GitHub Actions pinned to a full commit SHA. +- Minimal `permissions:` per job: `contents: read` by default; `packages: write`, `id-token: write`, `attestations: write` only in the release job. +- No `pull_request_target` with checkout of PR code. Fork PRs get no secrets. +- Dependabot: `gomod`, `github-actions`, `docker`. +- CI checks: `go vet`, `staticcheck`, `govulncheck`, tests with `-race`, CodeQL (Go). +- Release: build image, push to GHCR, attest with `actions/attest` (`push-to-registry: true`). Verify on the host: `gh attestation verify oci://ghcr.io/<owner>/hyperhack:<version> -R <owner>/hyperhack`. +- Image tags: semver, never overwritten. Compose pins a version, not `latest`. +- Repo settings: branch protection on `main` (PR and checks required, no force push), secret scanning + push protection, private vulnerability reporting. `SECURITY.md` explains how to report. + +### Security tests + +- XSS payload corpus through the Markdown → HTML pipeline. +- Path traversal keys for the mirror. +- Go fuzz tests: frontmatter parser, Markdown render, beacon decoder. +- Rate limiter, ALTCHA replay, Origin check, private endpoint CIDR checks. + +## Proposed (not confirmed) + +- Image registry: GHCR. + +## Open questions + + +## Development rules + +- Everything in English: code, comments, commits, docs, UI. +- Docs have a Russian copy: `README.md` + `README.ru.md`, `SPEC.md` + `SPEC.ru.md`. Every doc change updates both. +- Commits: Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `chore:`). +- Never push to `main`. Branches (`feat/…`, `fix/…`, `refactor/…`, `dev`) and PRs. +- Never delete branches or commits. Revert instead. No rebase or force-push on published branches. +- No author-specific values in code or docs (domains, bucket names, keys). They go to `.env`. +- Confirm the path for service data before using it. +- Every decision change goes into this file and the Decision log. + +## Decision log + +- 2026-11-03 — One author, no registration. +- 2026-11-03 — S3 (Garage) only; WebDAV dropped. +- 2026-11-03 — Sync: `ListObjectsV2` polling + `POST /reindex` with token. +- 2026-11-03 — No site-wide analytics in the service. Per-post metrics only. +- 2026-11-03 — Anonymous likes and dislikes with a bot check. +- 2026-11-03 — Security: Caddy rate limit + CrowdSec, service write limits, ALTCHA on vote. No upstream DDoS protection. No challenge on entry. +- 2026-11-03 — Frontend: `html/template`, no framework. Light and dark themes. +- 2026-11-03 — Top bar: logo + name left, empty center, theme + RSS + GitHub right. RSS in MVP. +- 2026-11-03 — Views counted by the client script. +- 2026-11-03 — Public estimated reading time. Measured time on page is metric only. `/metrics` for VictoriaMetrics + Grafana dashboard. +- 2026-11-03 — `/metrics` on the main port, blocked in the reverse proxy. +- 2026-11-03 — Logs: `log/slog`, DEBUG / INFO / WARN / ERROR, JSON to stdout. +- 2026-11-03 — Post format: Obsidian properties `title`, `description`, `date`, `updated`, `tags`, `cover`, `slug`, `lang`, `draft`. Assets in `assets/`. +- 2026-11-03 — Separate Obsidian vault for the blog, synced by Remotely Save, no encryption. +- 2026-11-03 — Images: standard Markdown links; Obsidian wikilinks off, absolute paths, attachments in `assets`. +- 2026-11-03 — Project name: `hyperhack`. +- 2026-11-03 — Docker + Compose. Settings and secrets in `.env`. +- 2026-11-03 — Open source on GitHub, AGPL-3.0, GitHub Actions. Everything in English, Russian copy of docs, Russian UI via `DEFAULT_LANG`. +- 2026-11-03 — `DATA_DIR`, domain (`SITE_URL`) and rate limits in `.env`. `Caddyfile.example` with `example.com`. Visitor language switch next to the theme toggle. Ghost migration out of scope. +- 2026-11-03 — Posts: any `.md` at any depth except `assets/`, `.` and `_`; URL `/posts/<slug>`. Service mirrors the bucket to `./vault`; Caddy serves `/assets/*` from it. No Redis. +- 2026-11-03 — Server-side code highlighting (chroma). Bleve search (ru/en, fuzzy, prefix, boosts). Popular over `POPULAR_DAYS`. Pagination. Tag pages with hash colors. Full-text RSS. Full SEO set. `/healthz`. Limits and sizes in `.env`. +- 2026-11-03 — Extra private post metrics: daily views, read depth, traffic source, outbound clicks, code copies, device class, language/theme, daily votes, search stats. +- 2026-11-03 — Everything is one Go binary. External: Garage, Caddy, VictoriaMetrics, Grafana (already running). +- 2026-11-03 — Full security design (see Security). Votes keyed by anonymous `vid` cookie, not IP. `/reindex` private like `/metrics`. Theme in a cookie, no inline JS. Images only from `assets/`. +- 2026-11-03 — Go 1.27 (go1.27.3). diff --git a/SPEC.ru.md b/SPEC.ru.md new file mode 100644 index 0000000..12cfd3e --- /dev/null +++ b/SPEC.ru.md @@ -0,0 +1,597 @@ +# hyperhack — спецификация проекта + +Английская версия (основная): [SPEC.md](SPEC.md). Оба файла держать одинаковыми. + +Название: `hyperhack`. +Лёгкая замена Ghost. Ghost слишком тяжёлый для одного автора. + +## Цель + +Лёгкий и быстрый блог одного автора. +Посты — Markdown-файлы в S3. +Автор пишет в Obsidian и публикует синхронизацией vault с бакетом. + +## Объём + +### В MVP + +- Главная страница. +- Страницы постов, рендер из Markdown. +- Поиск по постам. +- Списки «Новые» и «Популярные». +- Метрики поста: просмотры (публично), время на странице (только метрика). +- Расчётное время чтения на каждом посте. +- Анонимные лайки и дизлайки. +- RSS-лента. +- Интерфейс на английском и русском. Язык по умолчанию из `.env`, переключатель для читателя в верхнем баре. +- Эндпоинт `/metrics` и дашборд Grafana. +- Структурированное логирование с уровнями. + +### Вне объёма + +- Несколько пользователей, регистрация, роли. +- Веб-редактор и админ-панель для постов. +- WebDAV. Хранилище только S3. +- Общая аналитика трафика. Её дают логи reverse proxy и Grafana. +- Перенос старых постов из Ghost. + +## Решения + +| Область | Решение | +|---|---| +| Пользователи | Один автор. Регистрации нет. Публичная часть только читает. | +| Backend | Go 1.27, последняя стабильная (go1.27.3 на 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Образ сборки: `golang:1.27`. Перед стартом проверить go.dev/dl; дальше версию обновляет Dependabot. | +| Frontend | Серверный рендер через `html/template`. Без фреймворка. Минимум vanilla JS: голоса, ALTCHA, beacon просмотров и времени. | +| Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). | +| Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. | +| Языки | Код, доки, интерфейс — английский. Интерфейс также на русском. По умолчанию — `DEFAULT_LANG` (`en` / `ru`) в `.env`. Переключатель для читателя в верхнем баре; выбор хранится в cookie (сервер должен знать язык для рендера). Текст постов не переводится. | +| Хранилище | Garage (S3 API), path-style адресация. | +| Формат поста | Один `.md` = один пост. Метаданные — свойства Obsidian (YAML frontmatter). | +| Метрики | Формат Prometheus на `/metrics`, сбор через VictoriaMetrics, дашборд Grafana. | +| Логи | `log/slog`, уровни DEBUG / INFO / WARN / ERROR. | +| Развёртывание | Docker + Docker Compose. Образ собирает GitHub Actions. | +| Репозиторий | Публичный, GitHub. Лицензия AGPL-3.0 (автор добавит при создании репозитория). | + +## Страницы + +### Главная + +- Верхний бар, слева направо: + - Лого, затем название сайта. + - Центр: пусто. + - Справа: переключатель темы, переключатель языка (`en` / `ru`), ссылка на RSS, ссылка на GitHub автора (URL из `.env`). +- Поиск. +- Новые посты. +- Популярные посты (по просмотрам). + +### Пост + +- Рендер Markdown из S3. +- Обложка (`cover`), если задана. +- Расчётное время чтения. +- Счётчик просмотров. +- Лайки и дизлайки. + +## Конфигурация + +Все настройки и секреты — переменные окружения (`.env`). +В репозитории только `.env.example`. Реальные значения в репозиторий не попадают. + +Предложенные переменные (точные имена — при реализации): + +| Переменная | Назначение | +|---|---| +| `SITE_NAME`, `SITE_URL` | Название и публичный URL сайта. | +| `DEFAULT_LANG` | Язык интерфейса и язык поста по умолчанию: `en` или `ru`. По умолчанию `en`. | +| `GITHUB_URL` | Ссылка в верхнем баре. | +| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Доступ к бакету. | +| `POLL_INTERVAL` | Интервал опроса бакета. | +| `REINDEX_TOKEN` | Токен для `POST /reindex`. | +| `ALTCHA_HMAC_KEY` | Секрет ALTCHA. | +| `READING_WPM` | Слов в минуту для расчётного времени чтения. | +| `LOG_LEVEL`, `LOG_FORMAT` | Уровень и формат логов (`json` / `text`). | +| `DATA_DIR` | Папка для SQLite. | +| `VAULT_DIR` | Папка для локального зеркала бакета. | +| `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_REINDEX` | Лимиты на клиента для эндпоинтов записи. По умолчанию: `10/1m`, `30/1m`, `5/1m`. | +| `POPULAR_DAYS`, `POPULAR_LIMIT` | Окно и размер «Популярных». По умолчанию: `30`, `5`. | +| `PAGE_SIZE` | Постов на страницу в «Новых» и на страницах тегов. По умолчанию: `10`. | +| `RSS_LIMIT` | Постов в RSS. По умолчанию: `20`. | +| `DEFAULT_OG_IMAGE` | Картинка превью для постов без `cover`. | +| `INDEXNOW_KEY` | Необязательно. Включает пинги IndexNow. | +| `RATE_LIMIT_SEARCH` | Лимит поиска на клиента. По умолчанию: `30/1m`. | +| `VISITOR_SECRET` | HMAC-ключ для суточного хеша посетителя, ≥ 32 байт. | +| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Срок жизни и сложность задачи. Срок по умолчанию: `5m`. | +| `METRICS_ALLOW`, `REINDEX_ALLOW` | CIDR, с которых можно вызывать `/metrics` и `/reindex`. По умолчанию: loopback и частные диапазоны. | +| `MAX_POST_BYTES`, `MAX_ASSET_BYTES`, `MAX_OBJECTS` | Лимиты контента бакета. По умолчанию: 1 МиБ, 50 МиБ, 10 000. | +| `TRUSTED_PROXIES` | IP/CIDR прокси, которым можно задавать `X-Forwarded-For`. Без этого IP клиента — адрес TCP-соединения. Нужно для правильных лимитов и хеша посетителя за Caddy. | + +## Развёртывание + +- Сервис работает в Docker, запускается через Docker Compose. +- Образ собирает GitHub Actions. Предложено: публикация в GHCR (`ghcr.io`). +- Compose: bind mount в подпапки (`./data`, …). Без именованных томов. +- Порт сервиса не публикуется наружу: только localhost, Docker-сеть или mesh-IP. TLS делает reverse proxy (Caddy). +- `.gitignore` пишет агент (минимум: `.env`, `data/`, результаты сборки). +- Домен: из `SITE_URL`. +- В репозитории есть `Caddyfile.example` для сервиса с доменом `example.com`: reverse proxy на сервис, `/metrics` и `/reindex` закрыты, `/assets/*` отдаётся из `vault/assets/` с заголовками и списком расширений из «Безопасность → Периметр». Тот же блок есть в README. + +## Структура бакета + +- `assets/` — все ассеты: обложки, картинки в постах. Решено. +- Предложено: сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save). + +### Посты + +- Каждый `.md` в vault — кандидат в посты, на любой глубине, кроме `assets/` и игнорируемых путей (`.` / `_`). +- Папки нужны только автору. На URL они не влияют. +- URL: `/posts/<slug>`. Slug берётся из frontmatter или имени файла, поэтому перенос файла не ломает ссылки. +- Нумерации нет. Порядок задаёт `date`. +- Файл без корректного frontmatter (`title` + `date`) пропускается с WARN. Шаблоны и служебные заметки — в `_templates/` или другую папку на `_`, тогда они не синхронизируются. + +### Локальное зеркало vault и ассеты + +- При синхронизации сервис зеркалит бакет в `VAULT_DIR` на диске: скачивает новые и изменённые объекты, удаляет исчезнувшие. Источник правды — бакет. +- Структура рядом с Compose (только bind mount, без Docker-томов): + ``` + hyperhack/ + ├── docker-compose.yml + ├── .env + ├── data/ # DATA_DIR: SQLite + └── vault/ # VAULT_DIR: зеркало бакета + ``` +- Caddy (на хосте) отдаёт `/assets/*` прямо из `vault/assets/` через `file_server`. Сервис ассеты не отдаёт. +- Caddy отдаёт только `/assets/*`. Файлы `.md` и другие пути vault наружу не видны. +- Пользователю Caddy нужно право чтения `vault/assets/`. +- Если S3 недоступен, страницы и картинки работают; новые изменения ждут следующей успешной синхронизации. + +Отклонено: Redis. Один экземпляр, мало постов: хватает индекса в памяти, Go map для лимитов, SQLite для счётчиков, диска и кэша ОС для файлов. + +## Формат поста + +Метаданные — свойства Obsidian: YAML-блок между `---` в начале файла. + +```markdown +--- +title: Как я ушёл с Ghost +description: Почему Ghost тяжёлый и что я написал вместо него +date: 2026-11-03 +updated: 2026-11-05 +tags: + - go + - selfhost +cover: assets/covers/ghost.webp +slug: leaving-ghost +lang: ru +draft: false +--- + +Текст поста… +``` + +| Поле | Тип | Обязательно | Назначение | +|---|---|---|---| +| `title` | строка | да | Заголовок поста. | +| `description` | строка | нет | Краткое описание: список постов, RSS, мета-теги, превью ссылки в Telegram и поисковиках. | +| `date` | дата | да | Дата публикации. Сортировка «Новые». | +| `updated` | дата | нет | Дата обновления. Показывается на посте и в RSS. | +| `tags` | список | нет | Теги. | +| `cover` | строка | нет | Путь к обложке внутри `assets/`. | +| `slug` | строка | нет | Часть URL. По умолчанию — из имени файла. | +| `lang` | строка | нет | Язык поста, ISO 639-1 (`en`, `ru`). По умолчанию — `DEFAULT_LANG`. Идёт в `<html lang>`, RSS, мета-теги. | +| `draft` | bool | нет | `true` — пост не публикуется. По умолчанию `false`. | + +Правила: + +- `date` и `updated`: тип «дата» Obsidian (`2026-11-03`) или «дата и время» (`2026-11-03T20:59`). Парсер принимает оба. +- Нет `title` или `date`, или YAML не парсится → пост пропускается, лог WARN. +- Два поста с одним `slug` → публикуется первый по ключу объекта, второй пропускается, лог ERROR. +- Неизвестные поля игнорируются. Obsidian может добавлять свои. +- README описывает все поля этой таблицы. + +## Публикация из Obsidian + +- Для блога отдельный vault. Личные заметки в бакет блога не попадают. +- Obsidian синхронизирует файлы с Garage плагином Remotely Save. +- Структура vault = структура бакета (плюс префикс Remotely Save, если задан). +- Шифрование Remotely Save для этого бакета выключено. Иначе сервис не прочитает файлы. +- Remotely Save не синхронизирует файлы и папки, имя которых начинается с `.` или `_`. +- Remotely Save не вызывает `/reindex`. Новый пост появляется после ближайшего опроса. + +### Настройки Obsidian («Файлы и ссылки») + +| Настройка | Значение | +|---|---| +| Использовать [[Wiki-ссылки]] | выкл. | +| Формат новой ссылки | абсолютный путь от корня хранилища | +| Место для вложенных файлов по умолчанию | папка, указанная ниже → `assets` | + +С этими настройками картинка вставляется как стандартный Markdown: `![](assets/abstract-5.jpg)`. +Рендер поддерживает только стандартные Markdown-ссылки. Wikilinks (`![[…]]`) не поддерживаются. +Путь `assets/…` в посте ведёт на `/assets/…` сайта. +README описывает эти настройки. + +Предложено: если в посте есть `![[`, лог WARN — так автор заметит неверную ссылку. + +## Синхронизация контента + +Garage не шлёт уведомления об изменениях в бакете (нет S3 event API). +Сервис узнаёт об изменениях двумя способами: + +1. **Опрос.** Каждые `POLL_INTERVAL` (30–60 с) — `ListObjectsV2` по бакету. + Сравнить `ETag` каждого объекта с кэшем. + Скачать и перерендерить только новые и изменённые объекты. + Удалить посты, объектов которых больше нет. +2. **Ручной запуск.** `POST /reindex` с токеном. + Вызывается через приватную сеть (на публичном домене закрыт), и пост появляется сразу. + +Опрос — запасной путь. Эндпоинт — быстрый путь. + +## Метрики постов + +Общий трафик уже есть в логах reverse proxy и Grafana. Сервис его не дублирует. +Сервис хранит только метрики постов. Хранилище: SQLite. + +### Просмотры + +- Считает клиентский скрипт, не рендер страницы. Тот же скрипт шлёт время на странице. +- Клиенты без JS (почти все боты) не считаются. Читатели с выключенным JS тоже. Принято. +- Публично: показывается на посте. +- Источник для списка «Популярные». +- Повторные просмотры одного посетителя не считаются (предложено: хеш IP + User-Agent за сутки; сырой IP не хранится). + +### Время на странице + +- Сервер сам его не измерит. Нужен клиентский скрипт. +- Скрипт шлёт активное время чтения, когда страницу скрывают или закрывают (`visibilitychange` + `navigator.sendBeacon`). +- Считается только время, когда вкладка видима. +- Хранится по посту как агрегат (например, среднее или медиана). +- Не публично. На сайте не показывается. Только метрика (см. «Мониторинг»). + +### Расчётное время чтения + +- Публично: показывается на посте (например, «5 мин чтения»). +- Считается из текста поста при рендере: число слов / `READING_WPM`. +- Предложено: блоки кода и картинки учитываются отдельно. +- Не связано с измеренным временем на странице. + +### Лайки и дизлайки + +- Анонимно. Без аккаунта. +- Один голос на cookie `vid` на пост (см. «Безопасность → Голоса и личность посетителя»). Голос можно сменить или снять. +- Счётчики публичны на посте. +- Каждый голос проходит проверку ALTCHA (см. «Безопасность → ALTCHA»). + +## Функции контента + +### Подсветка кода + +- На сервере при рендере: `github.com/yuin/goldmark` + `github.com/yuin/goldmark-highlighting/v2` (chroma v2). Без JS. +- CSS-классы, не inline-стили (`chromahtml.WithClasses`), чтобы у светлой и тёмной темы была своя палитра. + +### Поиск + +Цель: лучший поиск, какой возможен без отдельного сервиса. + +- Встроенный полнотекстовый индекс: Bleve v2 (`github.com/blevesearch/bleve/v2`), в памяти, перестраивается при синхронизации. +- Анализ по языку поста (`lang`): английский и русский анализаторы (русский: стеммер Snowball + стоп-слова, пакет `analysis/lang/ru`). «Постов» находит «пост». +- Поля и вес: `title` > `tags` > `description` > текст. +- Устойчивость к опечаткам (fuzzy), поиск по префиксу для поиска при наборе. +- Сниппеты с подсветкой совпадений. +- Работает без JS: `GET /search?q=…` рендерит результаты. С JS: результаты обновляются при наборе. +- Фильтр по тегу. + +Отклонено: Meilisearch и подобные. Опечатки ловит лучше, но это отдельный сервис. + +### Популярные и новые + +- «Популярные»: больше всего просмотров за последние `POPULAR_DAYS` дней, топ `POPULAR_LIMIT`. +- «Новые»: по `date`, `PAGE_SIZE` на страницу, с пагинацией. + +### Теги + +- Страница `/tags/<tag>`: посты с этим тегом, с пагинацией. +- У каждого тега свой цвет. Цвет считается из хеша имени тега, поэтому он стабилен без настроек. +- Цвета подобраны под каждую тему, контраст текста — WCAG AA. + +### RSS + +- RSS 2.0 на `/rss.xml`, последние `RSS_LIMIT` постов. +- Полный HTML поста в `content:encoded`, `description` как краткое описание. Все URL абсолютные. +- `<link rel="alternate">` для автообнаружения на каждой странице. + +### SEO и находимость + +- Серверный семантический HTML. Быстрые страницы: без блокирующего JS, `width`/`height` у картинок, `loading="lazy"`, `alt` из Markdown. +- `<title>`, meta description из `description`, canonical URL из `SITE_URL`, `<html lang>` из `lang` поста. +- Open Graph и Twitter Card (`summary_large_image`): картинка из `cover`, иначе `DEFAULT_OG_IMAGE`. +- JSON-LD `BlogPosting`: заголовок, даты, автор, картинка, язык, ключевые слова. +- `sitemap.xml` с `lastmod` из `updated` или `date`. +- `robots.txt`: разрешить всех краулеров, включая ИИ-краулеров; ссылка на sitemap. +- Исходный Markdown для агентов: `/posts/<slug>.md`. +- Пинг IndexNow при новом или изменённом посте, если задан `INDEXNOW_KEY` (Bing, Яндекс; Google IndexNow не использует). +- `/llms.txt`: генерируется, потому что это дёшево. Реально ИИ-ассистенты его сейчас почти не читают. +- Настоящий статус `404` для отсутствующих страниц. + +### Healthcheck + +- `GET /healthz`: `200`, когда индекс загружен, иначе `503`. +- В образе нет curl, поэтому у бинарника есть подкоманда `healthcheck` для healthcheck в Compose. + +### Дополнительные метрики поста + +Все не публичные: только метрики, на сайте не показываются. Собирает тот же клиентский скрипт и сервер. Без трекинговых cookie, без фингерпринтинга сверх суточного хеша посетителя. + +| Метрика | Источник | Зачем | +|---|---|---| +| Просмотры по дням по посту | скрипт | Тренды; основа «Популярных». | +| Глубина чтения: доля читателей, дошедших до 25 / 50 / 75 / 100 % текста | скрипт (`IntersectionObserver` на метках) | Дочитывают ли пост. | +| Источник трафика по посту: `telegram`, `google`, `yandex`, `bing`, `github`, `direct`, `other` | скрипт (домен `document.referrer`, сведённый к фиксированному списку) | Откуда приходят читатели. | +| Клики по внешним ссылкам по посту (домен цели) | скрипт | Какими ссылками пользуются. | +| Копирования кода по посту | скрипт (событие `copy` внутри блоков кода) | Какой код полезен. | +| Класс устройства: `mobile` / `tablet` / `desktop` | скрипт (диапазон ширины viewport) | Приоритеты вёрстки. | +| Выбранные язык интерфейса и тема | скрипт | Какие язык и тему выбирают. | +| Голоса по дням по посту | сервер | Тренды голосов. | +| Поиск: число запросов, число запросов без результатов, топ запросов, топ запросов без результатов | сервер | Что ищут и не находят. | + +Правила: + +- Значения меток Prometheus ограничены: slug поста, фиксированный список источников, фиксированный список устройств. Никогда не делать метками сырые URL, рефереры и запросы. +- Поисковые запросы хранятся в SQLite (нормализованные, в нижнем регистре). `/metrics` отдаёт только топ `N` как gauge, обновляется периодически. +- Все метрики скрипта уходят одним beacon-запросом на просмотр, по `visibilitychange` / `pagehide`. Лимит — `RATE_LIMIT_BEACON`. + +## Мониторинг + +- Сервис отдаёт формат Prometheus на `/metrics`, на основном порту. +- Reverse proxy закрывает `/metrics` на публичном домене. +- VictoriaMetrics собирает метрики напрямую с сервиса, мимо proxy. Prometheus-сервера нет. +- Дашборд Grafana (JSON) лежит в репозитории. +- Метрики: все метрики постов (см. «Метрики постов»); статус синхронизации (последний опрос, ошибки, число объектов); время поиска; HTTP-запросы, ошибки, задержки. + +## Логирование + +- Библиотека: `log/slog` из стандартной библиотеки Go. +- Уровни: `DEBUG`, `INFO`, `WARN`, `ERROR`. Задаётся `LOG_LEVEL`. +- Формат: JSON в stdout (для Docker и Loki). Текстовый — для локальной разработки. Задаётся `LOG_FORMAT`. +- Поля каждой записи: `time`, `level`, `msg`, `component` (`sync`, `http`, `render`, `votes`, `metrics`, `store`). +- HTTP-записи: `request_id`, метод, путь, статус, длительность. +- Ошибка при старте (конфиг, S3, SQLite) → лог `ERROR` и выход с кодом 1. + +| Уровень | Что пишется | +|---|---| +| `DEBUG` | Детали синхронизации (каждый объект, сравнение `ETag`), рендер поста, запросы к SQLite, каждый HTTP-запрос. | +| `INFO` | Старт и сводка конфига (без секретов), итог опроса (добавлено / обновлено / удалено), вызов `/reindex`, остановка. | +| `WARN` | Пост пропущен (нет `title` или `date`, битый YAML), сработал лимит запросов, ALTCHA не прошла, временная ошибка S3 с повтором. | +| `ERROR` | Конфликт `slug`, S3 недоступен после повторов, ошибка записи в SQLite, перехваченная паника в обработчике. | + +Никогда не логировать: токен `/reindex`, ключи S3, секрет ALTCHA, сырой IP, хеш посетителя. + +Предложено: каждый HTTP-запрос только на `DEBUG`. Журнал запросов уже пишет reverse proxy. + +## Безопасность + +### Модель угроз + +| Что защищаем | Угроза | Главная мера | +|---|---|---| +| Страницы и читатели | XSS, подмена контента, clickjacking | Экранирование, санитайзер, строгий CSP, заголовки безопасности | +| Голоса и просмотры | Боты, накрутка, CSRF | ALTCHA, лимиты, cookie голоса, проверка Origin | +| Хост и сервис | DoS, исчерпание ресурсов, выход из контейнера | Лимиты, таймауты, защищённый контейнер | +| Контент бакета | Взломанное устройство автора пишет плохие файлы | Ключие, лимиты размеров, санитайзер, проверка путей | +| Секреты | Утечка через репозиторий, логи, образ | Только `.env`, никогда не логируются, secret scanning | +| Цепочка поставок | Вредная зависимость или action, подмена образа | Закреплённые actions, Dependabot, `govulncheck`, attestations | +| Приватность читателей | СлежкÐ Без сырого IP, суточный HMAC-хеш, без сторонних запросов | + +Границы доверия: интернет → Caddy → сервис; бакет → сервис (доверие частичное: пишет только автор, но его устройства могут взломать); CI → GHCR → хост. + +### Периметр (Caddy, вне сервиса) + +- `caddy-ratelimit` + CrowdSec bouncer. +- На публичном домене закрыт `/metrics`, `/reindex`. +- `/assets/*`: + - Разрешённые расширения: `png jpg jpeg webp avif gif svg mp4 webm`. Остальное → `404`. + - Заголовки: `X-Content-Type-Options: nosniff` и `Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox`. SVG со скриптом не выполнится. +- Лимит тела запроса для `/api/*`: 16 КБ. +- `Strict-Transport-Security: max-age=31536000` (без `includeSubDomainsoad по умолчанию). +- Заголовок `Server` убран. +- Без Cloudflare: его замедляют в России с июня 2025, а большинство читателей в России. Защиты от DDoS выше по сети нет: риск принят. +- Отклонено: проверка при входе на сайт (например, Anubis). Она может блокировать поисковики и ботов превью ссылок. + +### Эндпоинты + +| Эндпоинт | Метод | Доступ | Защита | +|---|---|---|---| +| Страницы, `/search`, `/tags/*`, `/rss.xml`, `/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/posts/<slug>.md` | GET | публичный | `RATE_LIMIT_SEARCH` на `/search` | +| `/api/altcha` | GET | публичный | `RATE_LIMIT_VOTE` | +| `/api/vote` | POST | публичный | ALTCHA, cookie голоса, `RATE_LIMIT_VOTE`, проверка Origin | +| `/api/beacon` | POST | публичный | `RACON`, проверка Origin, строгая схема | +| `/reindex` | POST | приватная сеть | блок в Caddy, `REINDEX_ALLOW`, `REINDEX_TOKEN` | +| `/metrics` | GET | приватная сеть | блок в Caddy, `METRICS_ALLOW` | +| `/healthz` | GET | публичный | постоянный ответ, без данных | + +Другие методы → `405`. Неизвестные пути → `404`. + +### Проверка входных данных + +- Размер тела: `htttesReader`, 4 КБ для `/api/*`. +- JSON: обязателен `Content-Type: application/json`, `DisallowUnknownFields`, только один объект. +- Slug должен быть в текущем индексе. Неизвестный slug → `404`, ничего не сохраняется. Это же ограничивает метки метрик. +- Числа ограничены: время на странице 0–4 ч; глубина чтения только 0 / 25 / 50 / 75 / 100. +- Ѐечисления: голос (`like` / `dislike` / `none`), источник, устройство, тема, язык. Другие значения → `400`. +- Поисковый запрос: максимум 200 символов, максимум 10 слов. Запрос строится в коде (match + fuzzy + prefix), никогда не из синтаксиса query-string Bleve: это закрывает DoS через regex и wildcard. Опечатки ≤ 2, префикс от 2 символов. Номер страницы ограничен. +- Cookie `lang` и `theme`: проверка по перечислению; неверное значение → по умолчанию. + +### Вывод и XSS + +- Весь HTML через `html/template` (экранирование по контексту). Шаблоны парсятся при старте. +- Markdown: goldmark без `WithUnsafe`. По умолчанию goldmark не рендерит сырой HTML ные URL. +- Второй слой: HTML поста проходит `bluemonday` (политика UGC + классы chroma + id заголовков). +- Внешние ссылки получают `rel="noopener noreferrer"`. +- Картинки только из `assets/`. Внешние картинки блокирует CSP (заодно IP читателей не уходят наружу). +- JSON-LD через `json.Marshal` (экранирует `<`, `>`, `&`). +- RSS и sitemap через `encodig/xml`. +- `/posts/<slug>.md`: `Content-Type: text/markdown; charset=utf-8`, `nosniff`. +- Страницы ошибок не показывают внутренние детали. + +### Заголовки безопасности (ставит сервис) + +- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests`. worker-src` добавлять, только если этого требует сборка ALTCHA. +- Нет inline-скриптов и inline-стилей. Тема и язык берутся из cookie и рендерятся на сервере, поэтому inline-скрипт против мигания темы не нужен. +- `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, `X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), browsing-topics=()`. +- Все JS, CSS, шрифты и виджет ALTCHA лежат на своём сервере. Без CDN, без сторонних запросов. + +### CSRF и CORS + +- Заголовков CORS нет. +- `/api/*` требует `Content-Type: application/json` (форма с другого сайта не отправит его без preflight) и same-origin `Origin` / `Sec-Fetch-Site`. + +### Голоса и личнЏ + +- Личность для голоса: анонимная случайная cookie `vid` (128 бит, `HttpOnly; Secure; SameSite=Lax; Max-Age=1 год`). Личных данных в ней нет. +- Один голос на `vid` на пост. Если удалить cookie, можно проголосовать снова, но каждый голос стоит решения ALTCHA и упирается в `RATE_LIMIT_VOTE`. +- Почему не хеш IP: многие читатели делятдин IP (мобильный CGNAT), а суточный хеш разрешил бы голос раз в день. +- Дедупликация просмотров: `HMAC-SHA256(daily_key, ip_prefix + user_agent)`, `daily_key = HMAC(VISITOR_SECRET, date)`. Старые суточные ключи не хранятся, поэтому дни нельзя связать. Сырые IP никогда не хранятся и не логируются. + +### ALTCHA + +- Библиотека: `github.com/altcha-ocha-lib-go`. Невидимый режим: браузер решает задачу при клике. +- `ALTCHA_HMAC_KEY` ≥ 32 случайных байт. +- Срок жизни задачи `ALTCHA_EXPIRES` (по умолчанию 5 мин). Истекла → отказ. +- Защита от повтора в сервисе: использованные задачи хранятся в памяти до истечения; повтор → отказ. Библиотека этого не делает. +- Сложность: `ALTCHA_MAX_NUMBER`. + +### Лимиты запросов (сервис) + +- Ключ: IP клиента через `TRUSTED_PROXIES`; IPv6 группируется по `/64`. +- Token bucket в памяти с жёстким потолком числа клиентов (старые вытесняются), поэтому поток IP не съест RAM. +- Лимиты: `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_SEARCH`, `RATE_LIMIT_REINDEX`. Превышение → `Retry-After`, лог WARN. + +### Приватные эндпоинты + +- `/metrics` и `/reindex`: закрыты в Caddy и принимаются только с CIDR из `METRICS_ALLOW` / `REINDEX_ALLOW` (по умолчанию: loopback и частные диапазоны). Два независимых слоя. +- `REINDEX_TOKEN` ≥ 32 байт, передаётся как `Authorization: Bearer`, сравнивается через `crypto/subtle.ConstantTimeCompare`. +- Одновременно Бт только одна переиндексация. Лишние вызовы ждут текущую. + +### Бакет и локальное зеркало + +- У сервиса свой ключ Garage только с правом **read**. Remotely Save использует отдельный ключ с read + write. +- S3 по HTTPS с проверкой сертификата. +- Лимиты размеров: `MAX_POST_BYTES` (по умолчанию 1 МиБ), `MAX_ASSET_BYTES` (50 МиБ), `MAX_OBCTS` (10 000). Превышение → пропуск + WARN. +- Запись в зеркало через `os.Root` (Go ≥ 1.24): выйти через `..` или symlink нельзя. Ключи с абсолютным путём, `\`, NUL или управляющими символами отклоняются. +- Ассеты: тот же список расширений, что в Caddy. +- Атомарная запись (временный файл + rename), поэтому Caddy не отдаст недописанный файл. +- Права: папки `0755`, файлы `0644` (Caddy нужно чтение). Файл SQLite `0600`. +- Размер frontmatter проверяется до парсинга YAML. `cover` должен указывать внутрь `assets/`. +- Slug: `^[a-z0-9-]{1,100}$`. Имя файла с другими символами (например, кириллицей) транслитерируется. Если результат пустой и `slug` нет â¿уск + WARN. + +### HTTP-сервер + +- `ReadHeaderTimeout` 5 с, `ReadTimeout` 10 с, `WriteTimeout` 30 с, `IdleTimeout` 120 с, `MaxHeaderBytes` 16 КБ. +- Таймауты через context на вызовы S3. +- Middleware восстановления после паники → `500` + лог ERROR. +- Плавная остановка по SIGTERM. +- Без pprof и отладочных эндпоинтов в релизных сборках. + +### Данные + +- SQLite: только парамеросы; WAL; `busy_timeout`. +- Ограниченный рост: агрегаты по дням, не сырые события; таблица поисковых запросов с потолком и TTL. +- `data/` бэкапит хост (вне сервиса). + +### Контейнер + +- Статический бинарник (`CGO_ENABLED=0`, `-trimpath`), драйвер SQLite на чистом Go (например, `modernc.org/sqlite`). +- Образ: distroless static non-root или `scratch`.ез shell и пакетного менеджера. +- Пример Compose: + ```yaml + services: + hyperhack: + image: ghcr.io/<owner>/hyperhack:<version> + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + ports: ["127.0.0.1:8080:8080"] + volumes: + - ./data:/data + - ./vault:/vault + env_file: .env + mem_limit: 256m + pids_limit: 128 + logging: + options: { max-size: "10m" "3" } + healthcheck: + test: ["CMD", "/hyperhack", "healthcheck"] + restart: unless-stopped + ``` +- Права на `.env`: `0600`. + +### Секреты + +- Только в `.env`. В `.env.example` — заглушки. +- Проверка при старте: обязательные секреты есть и достаточно длинные, иначе выход с кодом 1. +- Никогда не логируются, не попадают в сообщения об ошибках и README описывает ротацию: `REINDEX_TOKEN`, `ALTCHA_HMAC_KEY`, `VISITOR_SECRET`, ключи S3. + +### Цепочка поставок и CI + +- GitHub Actions закреплены по полному SHA коммита. +- Минимальные `permissions:` на job: по умолчанию `contents: read`; `packages: write`, `id-token: write`, `attestations: write` — только в release job. +- Без `pull_request_target` с checkout кода PR. PR из форков не получетов. +- Dependabot: `gomod`, `github-actions`, `docker`. +- Проверки в CI: `go vet`, `staticcheck`, `govulncheck`, тесты с `-race`, CodeQL (Go). +- Релиз: сборка образа, push в GHCR, attestation через `actions/attest` (`push-to-registry: true`). Проверка на хосте: `gh attestation verify oci://ghcr.io/<owner>/hyperhack:<version> -R <owner>/hyperhack`. +- Теги образа: semver, никогда не перезаписываются. В Compose закреплена версия, не `latest`. +- Настройки репозитория: защита ветки `main` (обязательны PR и проверки, без force push), secret scanning + push protection, private vulnerability reporting. `SECURITY.md` объясняет, как сообщить об уязвимости. + +### Тесты безопасности + +- Набор XSS-нагрузок через конвейер Markdown → HTML. +- Ключи с path traversal для зеркала. +- Fuzz-тесты Go: парсер frontmatter, рендер Markdown, декодер beacon. +- Лимитер, повтор ALTCHA, проверка Origin, проверка CIDR приватных эндпоинтов. + +## Предложено (не подтверждено) + +- Реестр образов: GHCR. + +## Открытые вопросы + + +## Правила разработки + +- Всё на английском: код, комментарии, коммиты, доки, интерфейс. +- У доков есть русская копия: `README.md` + `README.ru.md`, `SPEC.md` + `SPEC.ru.md`. Любое изменение дока обновляет обе версии. +- Коммиты: Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `chore:`). +- Не пушить в `main`. Ветки (`feat/…`, `fix/…`, `refactor/…`, `dev`) и PR. +- Не удалять ветки и коммиты. Вместо этого revert. Без rebase и force-push в опубликованках. +- В коде и доках нет значений автора (домены, имена бакетов, ключи). Они в `.env`. +- Перед выбором пути для данных сервиса — согласовать его. +- Изменения решений вносить в этот файл и в «Журнал решений». + +## Журнал решений + +- 2026-11-03 — Один автор, без регистрации. +- 2026-11-03 — Только S3 (Garage); Webшен. +- 2026-11-03 — Синхронизация: опрос `ListObjectsV2` + `POST /reindex` с токеном. +- 2026-11-03 — Общей аналитики в сервисе нет. Только метрики постов. +- 2026-11-03 — Анонимные лайки и дизлайки с проверкой на бота. +- 2026-11-03 — Безопасность: rate limit Caddy + CrowdSec, лимиты записи в сервисе, ALTCHA на голос. Без защиты от DDoS выѵз проверки при входе. +- 2026-11-03 — Frontend: `html/template`, без фреймворка. Светлая и тёмная темы. +- 2026-11-03 — Верхний бар: лого + название слева, центр пуст, тема + RSS + GitHub справа. RSS в MVP. +- 2026-11-03 — Просмотры считает клиентский скрипт. +- 2026-11-03 — Расчётное время чтения публично. Измеренное время на стÑтолько метрика. `/metrics` для VictoriaMetrics + дашборд Grafana. +- 2026-11-03 — `/metrics` на основном порту, закрыт в reverse proxy. +- 2026-11-03 — Логи: `log/slog`, DEBUG / INFO / WARN / ERROR, JSON в stdout. +- 2026-11-03 — Формат поста: свойства Obsidian `title`, `description`, `date`, `updated`, `tags`, `cover`, `slug`, `lang`, `draft`. Ассеты в `assets/`. +- 2026-11-03 — Отдельный vault для блога, сиация Remotely Save, без шифрования. +- 2026-11-03 — Картинки: стандартные Markdown-ссылки; в Obsidian wikilinks выкл., абсолютные пути, вложения в `assets`. +- 2026-11-03 — Название проекта: `hyperhack`. +- 2026-11-03 — Docker + Compose. Настройки и секреты в `.env`. +- 2026-11-03 — Open source на GitHub, AGPL-3.0, GitHub Actions. Всё на английском, русская копия доков русский интерфейс через `DEFAULT_LANG`. +- 2026-11-03 — `DATA_DIR`, домен (`SITE_URL`) и лимиты в `.env`. `Caddyfile.example` с `example.com`. Переключатель языка для читателя рядом с темой. Перенос из Ghost вне объёма. +- 2026-11-03 — Посты: любой `.md` на любой глубине, кроме `assets/`, `.` и `_`; URL `/posts/<slug>`. Сервис зеркалит бакет в `./vault`; Caddy оÑиз него `/assets/*`. Без Redis. +- 2026-11-03 — Подсветка кода на сервере (chroma). Поиск Bleve (ru/en, опечатки, префикс, веса полей). «Популярные» за `POPULAR_DAYS`. Пагинация. Страницы тегов с цветами из хеша. RSS с полным текстом. Полный набор SEO. `/healthz`. Лимиты и размеры в `.env`. +- 2026-11-03 — Дополнительные скрытые метѸ: просмотры по дням, глубина чтения, источник трафика, клики по внешним ссылкам, копирования кода, класс устройства, язык и тема, голоса по дням, статистика поиска. +- 2026-11-03 — Всё — один Go-бинарник. Внешнее: Garage, Caddy, VictoriaMetrics, Grafana (уже работают). +- 2026-11-03 — Полный дизайн безопасности (смзопасность»). Голоса по анонимной cookie `vid`, не по IP. `/reindex` приватный, как `/metrics`. Тема в cookie, без inline JS. Картинки только из `assets/`. +- 2026-11-03 — Go 1.27 (go1.27.3). From 84669fdf2711d91f71c148b23604128684714d89 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:45:48 +0300 Subject: [PATCH 3/7] fix: log poll interval as a readable duration --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index fbb07a2..aeb424b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -141,7 +141,7 @@ func (c *Config) Summary() []any { "site_url", c.SiteURL.String(), "default_lang", c.DefaultLang, "s3_endpoint", c.S3Endpoint.String(), - "poll_interval", c.PollInterval, + "poll_interval", c.PollInterval.String(), "log_level", c.LogLevel.String(), "log_format", c.LogFormat, "data_dir", c.DataDir, From 6d49ea10ffff49bb9c6485c13c5a3a5f92dab1ff Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:45:48 +0300 Subject: [PATCH 4/7] chore: add Dockerfile, compose file, env example and gitignore --- .dockerignore | 5 ++++ .env.example | 59 ++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 5 ++++ Dockerfile | 11 +++++++++ docker-compose.yml | 24 +++++++++++++++++++ 5 files changed, 104 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..331b06a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +.env +data +vault diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39b765f --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Copy to .env and set real values. Keep .env at mode 0600: chmod 600 .env +# Generate secrets with: openssl rand -base64 48 + +# Site +SITE_NAME=Example Blog +SITE_URL=https://example.com +DEFAULT_LANG=en +GITHUB_URL=https://github.com/example + +# S3 (Garage). Read-only key. HTTPS only. +S3_ENDPOINT=https://s3.example.com +S3_REGION=garage +S3_BUCKET=example-blog +S3_ACCESS_KEY=change-me +S3_SECRET_KEY=change-me +POLL_INTERVAL=60s + +# Secrets, at least 32 bytes each +REINDEX_TOKEN=change-me-to-at-least-32-random-bytes +ALTCHA_HMAC_KEY=change-me-to-at-least-32-random-bytes +VISITOR_SECRET=change-me-to-at-least-32-random-bytes + +# ALTCHA +ALTCHA_EXPIRES=5m +ALTCHA_MAX_NUMBER=100000 + +# Content +READING_WPM=200 +POPULAR_DAYS=30 +POPULAR_LIMIT=5 +PAGE_SIZE=10 +RSS_LIMIT=20 +DEFAULT_OG_IMAGE= +INDEXNOW_KEY= + +# Logs: debug | info | warn | error; json | text +LOG_LEVEL=info +LOG_FORMAT=json + +# Paths inside the container +DATA_DIR=/data +VAULT_DIR=/vault + +# Rate limits per client: count/period +RATE_LIMIT_VOTE=10/1m +RATE_LIMIT_BEACON=30/1m +RATE_LIMIT_SEARCH=30/1m +RATE_LIMIT_REINDEX=5/1m + +# Comma-separated CIDRs. Empty = loopback and private ranges. +METRICS_ALLOW= +REINDEX_ALLOW= +# Proxy IPs/CIDRs allowed to set X-Forwarded-For. Empty = trust no proxy. +TRUSTED_PROXIES= + +# Bucket limits +MAX_POST_BYTES=1048576 +MAX_ASSET_BYTES=52428800 +MAX_OBJECTS=10000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ff9027 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +vault/ +/hyperhack +/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..529a342 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.27.3@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hyperhack . + +FROM gcr.io/distroless/static-debian13:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329 +COPY --from=build /out/hyperhack /hyperhack +USER 10001:10001 +EXPOSE 8080 +ENTRYPOINT ["/hyperhack"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f348e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + hyperhack: + image: ghcr.io/mimic890/hyperhack:0.1.0 + build: . + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + ports: ["127.0.0.1:8080:8080"] + volumes: + - ./data:/data + - ./vault:/vault + env_file: .env + mem_limit: 256m + pids_limit: 128 + logging: + options: { max-size: "10m", max-file: "3" } + healthcheck: + test: ["CMD", "/hyperhack", "healthcheck"] + interval: 30s + timeout: 5s + start_period: 10s + restart: unless-stopped From 471a6b7596478399899ff83f832e9fada35b86c8 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:45:48 +0300 Subject: [PATCH 5/7] ci: add checks, CodeQL, release with attestation and Dependabot --- .github/dependabot.yml | 14 ++++++++++ .github/workflows/ci.yml | 33 +++++++++++++++++++++++ .github/workflows/codeql.yml | 31 ++++++++++++++++++++++ .github/workflows/release.yml | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d321086 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: docker + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d8eb67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./... + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + - run: go test -race ./... + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: docker build -t hyperhack:ci . diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4928cb8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,31 @@ +name: codeql + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: github/codeql-action/init@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 + with: + languages: go + build-mode: manual + - run: go build ./... + - uses: github/codeql-action/analyze@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8f321fe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Image name + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + # Semver tags only. No latest, so a tag is never overwritten. + flavor: latest=false + tags: type=semver,pattern={{version}} + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + # Storage records need an organization-owned repo. + create-storage-record: false From fff481431c1a943ea326f90cdc1bd4732735ad25 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:45:48 +0300 Subject: [PATCH 6/7] docs: record config defaults and main log component --- SPEC.md | 17 +++++++++-------- SPEC.ru.md | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index 6f2b092..de7e90a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -84,14 +84,14 @@ Proposed variables (final names set during implementation): | `SITE_NAME`, `SITE_URL` | Site name and public URL. | | `DEFAULT_LANG` | UI language and default post language: `en` or `ru`. Default `en`. | | `GITHUB_URL` | Link in the top bar. | -| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Bucket access. | -| `POLL_INTERVAL` | Bucket polling interval. | +| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Bucket access. `S3_ENDPOINT` must be `https://`. `S3_REGION` default: `garage`. | +| `POLL_INTERVAL` | Bucket polling interval. Default: `60s`, range 10s–24h. | | `REINDEX_TOKEN` | Token for `POST /reindex`. | | `ALTCHA_HMAC_KEY` | ALTCHA secret. | -| `READING_WPM` | Words per minute for estimated reading time. | -| `LOG_LEVEL`, `LOG_FORMAT` | Log level and format (`json` / `text`). | -| `DATA_DIR` | Directory for SQLite. | -| `VAULT_DIR` | Directory for the local bucket mirror. | +| `READING_WPM` | Words per minute for estimated reading time. Default: `200`. | +| `LOG_LEVEL`, `LOG_FORMAT` | Log level and format (`json` / `text`). Defaults: `info`, `json`. | +| `DATA_DIR` | Directory for SQLite. Default: `/data`. | +| `VAULT_DIR` | Directory for the local bucket mirror. Default: `/vault`. | | `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_REINDEX` | Per-client limits on write endpoints. Defaults: `10/1m`, `30/1m`, `5/1m`. | | `POPULAR_DAYS`, `POPULAR_LIMIT` | "Popular" window and size. Defaults: `30`, `5`. | | `PAGE_SIZE` | Posts per page in "Newest" and tag pages. Default: `10`. | @@ -100,7 +100,7 @@ Proposed variables (final names set during implementation): | `INDEXNOW_KEY` | Optional. Enables IndexNow pings. | | `RATE_LIMIT_SEARCH` | Search limit per client. Default: `30/1m`. | | `VISITOR_SECRET` | HMAC key for the daily visitor hash, ≥ 32 bytes. | -| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Challenge lifetime and difficulty. Default expiry: `5m`. | +| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Challenge lifetime and difficulty. Defaults: `5m`, `100000`. | | `METRICS_ALLOW`, `REINDEX_ALLOW` | CIDRs allowed to call `/metrics` and `/reindex`. Default: loopback and private ranges. | | `MAX_POST_BYTES`, `MAX_ASSET_BYTES`, `MAX_OBJECTS` | Bucket content limits. Defaults: 1 MiB, 50 MiB, 10 000. | | `TRUSTED_PROXIES` | Proxy IPs/CIDRs allowed to set `X-Forwarded-For`. Without it the client IP is the TCP peer. Needed for correct limits and visitor hashes behind Caddy. | @@ -352,7 +352,7 @@ Rules: - Library: `log/slog` from the Go standard library. - Levels: `DEBUG`, `INFO`, `WARN`, `ERROR`. Set by `LOG_LEVEL`. - Format: JSON to stdout (for Docker and Loki). Text for local development. Set by `LOG_FORMAT`. -- Fields in every record: `time`, `level`, `msg`, `component` (`sync`, `http`, `render`, `votes`, `metrics`, `store`). +- Fields in every record: `time`, `level`, `msg`, `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `main`: startup, config and shutdown. - HTTP records: `request_id`, method, path, status, duration. - Startup error (config, S3, SQLite) → `ERROR` log and exit code 1. @@ -596,3 +596,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-03 — Everything is one Go binary. External: Garage, Caddy, VictoriaMetrics, Grafana (already running). - 2026-11-03 — Full security design (see Security). Votes keyed by anonymous `vid` cookie, not IP. `/reindex` private like `/metrics`. Theme in a cookie, no inline JS. Images only from `assets/`. - 2026-11-03 — Go 1.27 (go1.27.3). +- 2026-11-03 — Config defaults: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Log component `main`. The service listens on fixed port 8080. diff --git a/SPEC.ru.md b/SPEC.ru.md index 12cfd3e..48af416 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -84,14 +84,14 @@ | `SITE_NAME`, `SITE_URL` | Название и публичный URL сайта. | | `DEFAULT_LANG` | Язык интерфейса и язык поста по умолчанию: `en` или `ru`. По умолчанию `en`. | | `GITHUB_URL` | Ссылка в верхнем баре. | -| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Доступ к бакету. | -| `POLL_INTERVAL` | Интервал опроса бакета. | +| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Доступ к бакету. `S3_ENDPOINT` только `https://`. `S3_REGION` по умолчанию: `garage`. | +| `POLL_INTERVAL` | Интервал опроса бакета. По умолчанию: `60s`, диапазон 10 с – 24 ч. | | `REINDEX_TOKEN` | Токен для `POST /reindex`. | | `ALTCHA_HMAC_KEY` | Секрет ALTCHA. | -| `READING_WPM` | Слов в минуту для расчётного времени чтения. | -| `LOG_LEVEL`, `LOG_FORMAT` | Уровень и формат логов (`json` / `text`). | -| `DATA_DIR` | Папка для SQLite. | -| `VAULT_DIR` | Папка для локального зеркала бакета. | +| `READING_WPM` | Слов в минуту для расчётного времени чтения. По умолчанию: `200`. | +| `LOG_LEVEL`, `LOG_FORMAT` | Уровень и формат логов (`json` / `text`). По умолчанию: `info`, `json`. | +| `DATA_DIR` | Папка для SQLite. По умолчанию: `/data`. | +| `VAULT_DIR` | Папка для локального зеркала бакета. По умолчанию: `/vault`. | | `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_REINDEX` | Лимиты на клиента для эндпоинтов записи. По умолчанию: `10/1m`, `30/1m`, `5/1m`. | | `POPULAR_DAYS`, `POPULAR_LIMIT` | Окно и размер «Популярных». По умолчанию: `30`, `5`. | | `PAGE_SIZE` | Постов на страницу в «Новых» и на страницах тегов. По умолчанию: `10`. | @@ -100,7 +100,7 @@ | `INDEXNOW_KEY` | Необязательно. Включает пинги IndexNow. | | `RATE_LIMIT_SEARCH` | Лимит поиска на клиента. По умолчанию: `30/1m`. | | `VISITOR_SECRET` | HMAC-ключ для суточного хеша посетителя, ≥ 32 байт. | -| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Срок жизни и сложность задачи. Срок по умолчанию: `5m`. | +| `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Срок жизни и сложность задачи. По умолчанию: `5m`, `100000`. | | `METRICS_ALLOW`, `REINDEX_ALLOW` | CIDR, с которых можно вызывать `/metrics` и `/reindex`. По умолчанию: loopback и частные диапазоны. | | `MAX_POST_BYTES`, `MAX_ASSET_BYTES`, `MAX_OBJECTS` | Лимиты контента бакета. По умолчанию: 1 МиБ, 50 МиБ, 10 000. | | `TRUSTED_PROXIES` | IP/CIDR прокси, которым можно задавать `X-Forwarded-For`. Без этого IP клиента — адрес TCP-соединения. Нужно для правильных лимитов и хеша посетителя за Caddy. | @@ -352,7 +352,7 @@ Garage не шлёт уведомления об изменениях в бак - Библиотека: `log/slog` из стандартной библиотеки Go. - Уровни: `DEBUG`, `INFO`, `WARN`, `ERROR`. Задаётся `LOG_LEVEL`. - Формат: JSON в stdout (для Docker и Loki). Текстовый — для локальной разработки. Задаётся `LOG_FORMAT`. -- Поля каждой записи: `time`, `level`, `msg`, `component` (`sync`, `http`, `render`, `votes`, `metrics`, `store`). +- Поля каждой записи: `time`, `level`, `msg`, `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `main`: запуск, конфиг и остановка. - HTTP-записи: `request_id`, метод, путь, статус, длительность. - Ошибка при старте (конфиг, S3, SQLite) → лог `ERROR` и выход с кодом 1. @@ -595,3 +595,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-03 — Всё — один Go-бинарник. Внешнее: Garage, Caddy, VictoriaMetrics, Grafana (уже работают). - 2026-11-03 — Полный дизайн безопасности (смзопасность»). Голоса по анонимной cookie `vid`, не по IP. `/reindex` приватный, как `/metrics`. Тема в cookie, без inline JS. Картинки только из `assets/`. - 2026-11-03 — Go 1.27 (go1.27.3). +- 2026-11-03 — Значения по умолчанию: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Компонент логов `main`. Сервис слушает фиксированный порт 8080. From 83316353f7f8a9c3398a422a041f77f6be17a197 Mon Sep 17 00:00:00 2001 From: Mimic890 <144267059+Mimic890@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:51:54 +0300 Subject: [PATCH 7/7] docs: add security policy with private vulnerability reporting --- SECURITY.md | 36 ++++++++++++++++++++++++++++++++++++ SECURITY.ru.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 SECURITY.md create mode 100644 SECURITY.ru.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5e8fa53 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security policy + +Russian version: [SECURITY.ru.md](SECURITY.ru.md). + +## Reporting a vulnerability + +Please report vulnerabilities privately through GitHub private vulnerability reporting: + +1. Open the repository's **Security** tab. +2. Click **Report a vulnerability**. + +Direct link: <https://github.com/Mimic890/hyperhack/security/advisories/new> + +Do not open a public issue, pull request or discussion for a vulnerability. + +Please include: + +- what is affected and the impact; +- steps to reproduce or a proof of concept; +- the version (image tag or commit). + +## What happens next + +- I reply within 7 days. +- I confirm the issue, prepare a fix and publish a new release. +- The advisory is published after the fix is released. You are credited unless you ask not to be. + +## Supported versions + +Only the latest release gets security fixes. + +## Scope + +In scope: the `hyperhack` service, its container image and this repository's CI. + +Out of scope: Caddy, Garage, VictoriaMetrics, Grafana and other external services, unless the issue is in the configuration this repository ships (`docker-compose.yml`, `Caddyfile.example`). diff --git a/SECURITY.ru.md b/SECURITY.ru.md new file mode 100644 index 0000000..e98f20d --- /dev/null +++ b/SECURITY.ru.md @@ -0,0 +1,36 @@ +# Политика безопасности + +English version: [SECURITY.md](SECURITY.md). + +## Как сообщить об уязвимости + +Сообщайте об уязвимостях закрыто, через приватные отчёты об уязвимостях GitHub: + +1. Откройте вкладку **Security** в репозитории. +2. Нажмите **Report a vulnerability**. + +Прямая ссылка: <https://github.com/Mimic890/hyperhack/security/advisories/new> + +Не открывайте публичный issue, pull request или обсуждение об уязвимости. + +Укажите: + +- что затронуто и чем это опасно; +- шаги воспроизведения или proof of concept; +- версию (тег образа или коммит). + +## Что дальше + +- Я отвечу в течение 7 дней. +- Я подтвержу проблему, подготовлю исправление и выпущу новый релиз. +- Advisory публикуется после выхода исправления. Вас укажут в благодарностях, если вы не попросите иначе. + +## Поддерживаемые версии + +Исправления безопасности получает только последний релиз. + +## Область + +Входит: сервис `hyperhack`, его образ контейнера и CI этого репозитория. + +Не входит: Caddy, Garage, VictoriaMetrics, Grafana и другие внешние сервисы, если проблема не в конфигурации из этого репозитория (`docker-compose.yml`, `Caddyfile.example`).