diff --git a/cmd/bare/main.go b/cmd/bare/main.go new file mode 100644 index 0000000..10223ec --- /dev/null +++ b/cmd/bare/main.go @@ -0,0 +1,145 @@ +// Команда bare: сервер чата одним бинарём. +// +// bare serve запустить http-сервер +// bare vapid напечатать пару vapid-ключей +// bare version напечатать ревизию сборки +package main + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "runtime/debug" + "syscall" + "time" + + "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/web" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + var err error + switch os.Args[1] { + case "serve": + err = serve() + case "vapid": + err = vapid() + case "version": + version() + default: + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "bare:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `bare — сервер чата + +использование: + bare serve запустить http-сервер + bare vapid напечатать пару vapid-ключей + bare version напечатать ревизию сборки + +настройка — переменные окружения BARE_*, см. docs/deploy.md +`) +} + +func serve() error { + cfg, err := config.Load() + if err != nil { + return err + } + static, err := web.New() + if err != nil { + return err + } + + srv := &http.Server{ + Handler: api.New(static, os.Stdout), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + // OPTIONS * иначе обслуживает net/http сам, в обход middleware: + // ответ уходил бы без заголовков безопасности (ADR-021). + DisableGeneralOptionsHandler: true, + // WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004). + } + + // Сначала bind, потом сообщение: строка в журнале означает, что порт занят + // нами, а не то, что мы собирались его занять. + ln, err := net.Listen("tcp", cfg.Addr) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + failed := make(chan error, 1) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + failed <- err + } + }() + fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin) + + select { + case err := <-failed: + return err + case <-ctx.Done(): + } + + // Второй сигнал больше не перехватываем: он завершает процесс сразу. + stop() + fmt.Println("bare завершается") + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdown) +} + +// vapid печатает пару ключей P-256 в формате, который ждёт webpush-go: +// приватный — 32 байта скаляра, публичный — 65 байт несжатой точки, +// оба base64url без паддинга. +func vapid() error { + priv, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return err + } + b64 := base64.RawURLEncoding + fmt.Printf("BARE_VAPID_PUBLIC=%s\n", b64.EncodeToString(priv.PublicKey().Bytes())) + fmt.Printf("BARE_VAPID_PRIVATE=%s\n", b64.EncodeToString(priv.Bytes())) + return nil +} + +func version() { + fmt.Println(revision()) +} + +func revision() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + for _, s := range info.Settings { + if s.Key == "vcs.revision" { + return s.Value + } + } + return "unknown" +} diff --git a/docs/decisions/025-embed-static-root.md b/docs/decisions/025-embed-static-root.md new file mode 100644 index 0000000..6024a3f --- /dev/null +++ b/docs/decisions/025-embed-static-root.md @@ -0,0 +1,17 @@ +# ADR-025: Встраивание статики объявляется в корне модуля + +## Контекст + +ADR-022 требует один артефакт деплоя: клиентская статика вкомпилирована в бинарь. Раскладка в `docs/plan.md` кладёт отдачу статики в `internal/web/`, а сам клиент — в `web/` в корне. Директива `//go:embed` встраивает только файлы каталога своего пакета и ниже: из `internal/web/` до корневого `web/` не дотянуться. Варианты — перенести клиент внутрь `internal/web/`, продублировать файлы или объявить встраивание в корне. + +## Решение + +Клиент остаётся в `web/` в корне: путь в репозитории совпадает с путём в URL, и его видно первым в дереве. Встраивание объявляется рядом — файл `embed.go` в корне модуля, `package bare`, `//go:embed web` и `var Web embed.FS`. Логики в пакете нет, только объявление. + +`internal/web/` получает подкаталог через `fs.Sub(bare.Web, "web")` и отвечает за отдачу: ETag, `Cache-Control`, `Content-Type`, 304, `Service-Worker-Allowed`. + +## Следствия + +- В корне модуля появляется пакет `bare` из одного файла — он не растёт: всё, что не объявление `embed.FS`, идёт в `internal/`. +- `internal/web/` импортирует корневой пакет; обратной зависимости нет и не будет. +- Раскладка в `docs/plan.md` дополнена строкой `embed.go`; в остальном не меняется. diff --git a/docs/decisions/026-protocol-error-codes.md b/docs/decisions/026-protocol-error-codes.md new file mode 100644 index 0000000..d12b401 --- /dev/null +++ b/docs/decisions/026-protocol-error-codes.md @@ -0,0 +1,17 @@ +# ADR-026: Код `too_large` и 404 на неподдерживаемый метод + +## Контекст + +Этап 0 обнажил два места, где код знает больше протокола. Первое: `413` описан в общих правилах (`ADR-021`, «тело запроса — до 32 КиБ»), но кода ошибки для него в перечне `protocol.md` нет, а сервер уже отдаёт `{"error": "too_large"}`. Второе: `POST` к известному пути статики отвечает `404 not_found`; в перечне правил есть только «неизвестный путь — `404 not_found`», решение про метод жило комментарием в коде. + +## Решение + +- `413` отдаётся с кодом `too_large`. Код добавлен в перечень «Коды ошибок» `protocol.md`, строка про лимит тела уточнена до `413 too_large`. +- Неподдерживаемый метод на известном пути — тоже `404 not_found`. Кода `405` в протоколе нет и не появится: клиент ходит по фиксированному набору маршрутов, а лишний код — лишняя ветка у обеих сторон. +- Тело ошибки в форме `{"error", "message"}` собирает `internal/api`; остальные пакеты пользуются его хелпером, чтобы коды не расходились между пакетами. + +## Следствия + +- Клиент разбирает `error` по перечню из `protocol.md`, и перечень исчерпывающий. +- Отсутствие `405` означает, что перебор методов не отличается от перебора путей — снаружи виден только `404`. +- `internal/web` импортирует `internal/api`; обратной зависимости нет. diff --git a/docs/plan.md b/docs/plan.md index 07c080c..718627f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -16,6 +16,7 @@ ## Раскладка репозитория ``` +embed.go //go:embed web в корне модуля (ADR-025) cmd/bare/main.go подкоманды: serve, vapid, version internal/config/ переменные BARE_* internal/store/ SQLite, migrations/*.sql (embed), запросы diff --git a/docs/protocol.md b/docs/protocol.md index 89c143b..ff54fc2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -7,9 +7,10 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Аутентификация — cookie `bare_session` (ADR-021). Без неё — `401 unauthenticated`. Публичные: `GET /api/config`, `GET /api/kdf`, `POST /api/register`, `POST /api/login`. - На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`. - Заголовок `X-Device: ` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. -- Тело запроса — до 32 КиБ, иначе `413`. +- Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). ## Типы @@ -126,7 +127,7 @@ event: ready data: {} ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`. ## Статика и служебное diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..60fd2f3 --- /dev/null +++ b/embed.go @@ -0,0 +1,12 @@ +// Package bare встраивает клиентскую статику в бинарь. +// +// Директива go:embed видит только каталог своего пакета и ниже, поэтому +// объявление живёт в корне модуля, а не в internal/web (ADR-025). +package bare + +import "embed" + +// Web — каталог web/ как есть: index.html, app.css, manifest.json, sw.js, icons/. +// +//go:embed web +var Web embed.FS diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cfe0663 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/xmatic-squad/bare + +go 1.27.0 diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..2310195 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,127 @@ +// Package api собирает маршруты и общие для всех ответов правила: +// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout. +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// MaxBody — предел тела запроса, 32 КиБ (ADR-021). +const MaxBody = 32 << 10 + +// maxLogPath — сколько байт пути попадает в строку лога. +const maxLogPath = 256 + +// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет. +const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + +// New собирает обработчик: /healthz, всё остальное — статика. +// log — куда писать строки запросов; nil отключает лог. +func New(static http.Handler, logw io.Writer) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + mux.Handle("/", static) + return logging(logw, headers(limitBody(mux))) +} + +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + io.WriteString(w, "ok") +} + +// Error пишет ошибку в форме протокола: {"error": код, "message": текст}. +// Единственное место, где эта форма собирается, — коды берутся из +// перечня в docs/protocol.md. +func Error(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": code, + "message": message, + }) +} + +// NotFound — ответ на неизвестный путь и на неподдерживаемый метод +// известного пути (ADR-026). +func NotFound(w http.ResponseWriter) { + Error(w, http.StatusNotFound, "not_found", "такого пути нет") +} + +// headers ставит заголовки безопасности на каждый ответ, включая ошибки. +func headers(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + +// limitBody отрезает тело на 32 КиБ. Заявленный размер сверх лимита +// отклоняется сразу, незаявленный — обрывается при чтении. +func limitBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > MaxBody { + Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ") + return + } + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, MaxBody) + } + next.ServeHTTP(w, r) + }) +} + +// logging пишет время, метод, путь, статус и длительность. +// Ни IP, ни ник, ни query в лог не попадают. +func logging(out io.Writer, next http.Handler) http.Handler { + if out == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &recorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + fmt.Fprintf(out, "%s %s %s %d %s\n", + start.Format(time.RFC3339), + r.Method, + logPath(r.URL), + rec.status, + time.Since(start).Round(time.Microsecond)) + }) +} + +// logPath даёт путь в percent-форме: перевод строки, escape-последовательности +// и прочие управляющие байты в журнал не попадают — иначе любой запрос +// подделывал бы строки в journald. Длинный путь обрезается. +func logPath(u *url.URL) string { + p := u.EscapedPath() + if len(p) > maxLogPath { + return p[:maxLogPath] + "…" + } + return p +} + +type recorder struct { + http.ResponseWriter + status int +} + +func (r *recorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +// Unwrap отдаёт исходный ResponseWriter: через него http.ResponseController +// добирается до Flush и Hijack. Без этого SSE (docs/protocol.md, «События») +// буферизовался бы — лог стоит самым внешним слоем и виден всем маршрутам. +func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..1f7f6e8 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,160 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/web" +) + +func handler(t *testing.T) http.Handler { + t.Helper() + static, err := web.New() + if err != nil { + t.Fatalf("web.New: %v", err) + } + return api.New(static, nil) +} + +func TestHealthz(t *testing.T) { + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + res := rec.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("чтение тела: %v", err) + } + if string(body) != "ok" { + t.Errorf("тело: получено %q, ожидалось \"ok\"", body) + } + + want := map[string]string{ + "Content-Security-Policy": "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + } + for header, value := range want { + if got := res.Header.Get(header); got != value { + t.Errorf("%s: получено %q, ожидалось %q", header, got, value) + } + } +} + +func TestStaticNotModified(t *testing.T) { + h := handler(t) + + first := httptest.NewRecorder() + h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil)) + if first.Code != http.StatusOK { + t.Fatalf("статус: получено %d, ожидалось 200", first.Code) + } + etag := first.Header().Get("ETag") + if etag == "" { + t.Fatal("нет ETag") + } + if got := first.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got) + } + + req := httptest.NewRequest(http.MethodGet, "/app.css", nil) + req.Header.Set("If-None-Match", etag) + second := httptest.NewRecorder() + h.ServeHTTP(second, req) + + if second.Code != http.StatusNotModified { + t.Errorf("статус: получено %d, ожидалось 304", second.Code) + } + if second.Body.Len() != 0 { + t.Errorf("тело 304 не пустое: %q", second.Body.String()) + } + if got := second.Header().Get("ETag"); got != etag { + t.Errorf("ETag на 304: получено %q, ожидалось %q", got, etag) + } +} + +func TestBodyTooLarge(t *testing.T) { + body := strings.NewReader(strings.Repeat("a", api.MaxBody+1)) + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body)) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("статус: получено %d, ожидалось 413", rec.Code) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("разбор тела: %v", err) + } + if got["error"] != "too_large" { + t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"]) + } +} + +// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026). +func TestStaticRejectsWrite(t *testing.T) { + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil)) + + if rec.Code != http.StatusNotFound { + t.Fatalf("статус: получено %d, ожидалось 404", rec.Code) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("разбор тела: %v", err) + } + if got["error"] != "not_found" { + t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"]) + } +} + +// Путь из запроса не должен уметь дописать строку в журнал. +func TestLogPathEscaped(t *testing.T) { + static, err := web.New() + if err != nil { + t.Fatalf("web.New: %v", err) + } + var log bytes.Buffer + h := api.New(static, &log) + + target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms" + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil)) + + line := log.String() + if n := strings.Count(line, "\n"); n != 1 { + t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line) + } + if !strings.Contains(line, "%0a") { + t.Errorf("путь не в percent-форме: %q", line) + } + + log.Reset() + long := "/" + strings.Repeat("z", 4096) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil)) + if len(log.String()) > 512 { + t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String())) + } +} + +// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера +// не должна прятать Flush от http.ResponseController. +func TestFlushThroughMiddleware(t *testing.T) { + var flushErr error + h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + flushErr = http.NewResponseController(w).Flush() + }), io.Discard) + + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + if flushErr != nil { + t.Errorf("Flush: %v", flushErr) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c3639df --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,57 @@ +// Package config читает конфигурацию из переменных окружения BARE_* (ADR-022). +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config — всё, что сервер знает о своём окружении. +type Config struct { + Addr string // BARE_ADDR — адрес прослушивания + DB string // BARE_DB — путь к файлу SQLite + Origin string // BARE_ORIGIN — единственный допустимый Origin (ADR-021) + VAPIDPublic string // BARE_VAPID_PUBLIC + VAPIDPrivate string // BARE_VAPID_PRIVATE + VAPIDSubject string // BARE_VAPID_SUBJECT + InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию +} + +// Значения по умолчанию — локальный запуск без окружения. +const ( + defaultAddr = "127.0.0.1:8411" + defaultDB = "bare.db" + defaultOrigin = "http://127.0.0.1:8411" +) + +// Load читает окружение. Незаданная переменная берёт значение по умолчанию; +// заданная пустой — ошибка: пустой адрес, путь к базе или origin неработоспособны. +func Load() (*Config, error) { + c := &Config{ + Addr: env("BARE_ADDR", defaultAddr), + DB: env("BARE_DB", defaultDB), + Origin: env("BARE_ORIGIN", defaultOrigin), + VAPIDPublic: env("BARE_VAPID_PUBLIC", ""), + VAPIDPrivate: env("BARE_VAPID_PRIVATE", ""), + VAPIDSubject: env("BARE_VAPID_SUBJECT", ""), + InviteCode: env("BARE_INVITE_CODE", ""), + } + for _, v := range []struct{ key, value string }{ + {"BARE_ADDR", c.Addr}, + {"BARE_DB", c.DB}, + {"BARE_ORIGIN", c.Origin}, + } { + if v.value == "" { + return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key) + } + } + return c, nil +} + +func env(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok { + return strings.TrimSpace(v) + } + return fallback +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..57cd721 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,134 @@ +// Package web отдаёт клиентскую статику из embed (ADR-025). +// +// Файлы читаются в память один раз при старте: их немного и они неизменны. +// Ни листинга каталогов, ни доступа к файловой системе сервера здесь нет — +// отдаётся только то, что вкомпилировано в бинарь. +package web + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "net/http" + "path" + "strconv" + "strings" + + bare "github.com/xmatic-squad/bare" + "github.com/xmatic-squad/bare/internal/api" +) + +// Handler — карта «путь в URL → файл». +type Handler struct { + files map[string]file +} + +type file struct { + data []byte + etag string // сильный, SHA-256 содержимого, в кавычках + ctype string +} + +// New читает web/ из embed и готовит ответы. +func New() (*Handler, error) { + root, err := fs.Sub(bare.Web, "web") + if err != nil { + return nil, err + } + h := &Handler{files: make(map[string]file)} + err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := fs.ReadFile(root, p) + if err != nil { + return err + } + sum := sha256.Sum256(data) + h.files["/"+p] = file{ + data: data, + etag: `"` + hex.EncodeToString(sum[:]) + `"`, + ctype: contentType(path.Ext(p)), + } + return nil + }) + if err != nil { + return nil, err + } + index, ok := h.files["/index.html"] + if !ok { + return nil, errors.New("web: в embed нет index.html") + } + h.files["/"] = index + return h, nil +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Статика читается только чтением. Для прочих методов путь считается + // неизвестным: кода 405 в протоколе нет (ADR-026). + if r.Method != http.MethodGet && r.Method != http.MethodHead { + api.NotFound(w) + return + } + f, ok := h.files[r.URL.Path] + if !ok { + api.NotFound(w) + return + } + + head := w.Header() + head.Set("ETag", f.etag) + head.Set("Cache-Control", "no-cache") + if r.URL.Path == "/sw.js" { + head.Set("Service-Worker-Allowed", "/") + } + if match(r.Header.Get("If-None-Match"), f.etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + head.Set("Content-Type", f.ctype) + head.Set("Content-Length", strconv.Itoa(len(f.data))) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + w.Write(f.data) +} + +// match разбирает If-None-Match: список тегов, «*» или слабые формы W/"...". +func match(header, etag string) bool { + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "*" || strings.TrimPrefix(part, "W/") == etag { + return true + } + } + return false +} + +func contentType(ext string) string { + switch ext { + case ".html": + return "text/html; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".js": + return "text/javascript; charset=utf-8" + case ".json": + return "application/json; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".png": + return "image/png" + default: + return "application/octet-stream" + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..86f9e58 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/bare ./cmd/bare +scp /tmp/bare xmatic:/tmp/bare +ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sudo systemctl restart bare && sleep 1 && curl -fsS http://127.0.0.1:8411/healthz' diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..46f9bf3 --- /dev/null +++ b/web/app.css @@ -0,0 +1,56 @@ +/* айдентика «скобы» — docs/identity/brief.md, ADR-024 */ + +:root { + --bone: #F7F5F0; + --ink: #1B1917; + --text2: #3C3B38; + --mute: #6E6D68; + --stone: #A9A59D; + --line: #E7E3DA; + --edge: #DEDCD6; + --mark: #C82D40; + --mark: oklch(55% 0.19 20); + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + + /* тема одна, светлая (ADR-024): системная тёмная ничего не перекрашивает */ + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bone); + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.55; + -webkit-text-size-adjust: 100%; +} + +/* заставка: знак и слово, больше пока ничего */ + +.boot { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.boot .mark { + width: 20px; + height: 20px; +} + +.boot .word { + font-size: 15px; + letter-spacing: -0.02em; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..88370b2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + + +bare + + + + + + +
+ + bare +
+ + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..8fdaf54 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "bare", + "short_name": "bare", + "lang": "ru", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#F7F5F0", + "theme_color": "#F7F5F0", + "icons": [ + { "src": "/icons/icon.svg", "type": "image/svg+xml", "sizes": "any" }, + { "src": "/icons/icon-192.png", "type": "image/png", "sizes": "192x192" }, + { "src": "/icons/icon-512.png", "type": "image/png", "sizes": "512x512" } + ] +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..9f2a8df --- /dev/null +++ b/web/sw.js @@ -0,0 +1,8 @@ +// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023). +// версия кэша меняется при релизе. + +const CACHE = "bare-v1"; + +self.addEventListener("install", () => {}); + +self.addEventListener("activate", () => {});