Этап 0: скелет сервера, статика из embed, деплой на bare.xmatic.team
Один бинарь на stdlib, ноль зависимостей: подкоманды serve, vapid, version; статика из embed с ETag и Cache-Control: no-cache; /healthz; заголовки безопасности ADR-021 на всех ответах, включая ошибки; лимит тела 32 КиБ. Путь в логе — в percent-форме: подделать строку журнала запросом нельзя. Ни IP, ни ника, ни query в логах нет. Клиент — знак «Скобы» и слово bare: без script-тегов, inline-стилей и внешних ресурсов. ADR-025: go:embed не выходит за каталог пакета, поэтому объявление статики живёт в корне модуля, а клиент остаётся в web/. ADR-026: 413 отдаётся кодом too_large, неподдерживаемый метод — 404; оба добавлены в перечень protocol.md. Сервер: пользователь bare, /opt/bare, /var/lib/bare, /etc/bare/env 0600, systemd-юнит, nginx с сертификатом certbot (плагин nginx, как у соседей). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
@@ -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`; в остальном не меняется.
|
||||||
@@ -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`; обратной зависимости нет.
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
## Раскладка репозитория
|
## Раскладка репозитория
|
||||||
|
|
||||||
```
|
```
|
||||||
|
embed.go //go:embed web в корне модуля (ADR-025)
|
||||||
cmd/bare/main.go подкоманды: serve, vapid, version
|
cmd/bare/main.go подкоманды: serve, vapid, version
|
||||||
internal/config/ переменные BARE_*
|
internal/config/ переменные BARE_*
|
||||||
internal/store/ SQLite, migrations/*.sql (embed), запросы
|
internal/store/ SQLite, migrations/*.sql (embed), запросы
|
||||||
|
|||||||
+3
-2
@@ -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`.
|
- Аутентификация — 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`.
|
- На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`.
|
||||||
- Заголовок `X-Device: <deviceId>` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`.
|
- Заголовок `X-Device: <deviceId>` обязателен на `/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` (секунды).
|
- Rate limiting — `429` с `Retry-After` (секунды).
|
||||||
- Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`.
|
- Неизвестный путь — `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`.
|
||||||
|
|
||||||
## Статика и служебное
|
## Статика и служебное
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+5
@@ -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'
|
||||||
+56
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#F7F5F0">
|
||||||
|
<title>bare</title>
|
||||||
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="boot">
|
||||||
|
<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="7" aria-hidden="true">
|
||||||
|
<path d="M10 26V10h16"/>
|
||||||
|
<path d="M38 10h16v16"/>
|
||||||
|
<path d="M54 38v16H38"/>
|
||||||
|
<path d="M26 54H10V38"/>
|
||||||
|
</svg>
|
||||||
|
<span class="word">bare</span>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user