feat(server): скелет Go-сервера с healthz, readyz и api/v1/meta

- internal/config: конфигурация из env с валидацией и дефолтами (§5.1, §9.1)
- internal/httpx: middleware (request id, slog-логи, recover, security-заголовки),
  единый формат ошибок §8.5 и лимит тела запроса
- internal/database: SQLite WAL + busy_timeout + foreign_keys, один writer
  и пул чтения, goose-миграции embedded, PRAGMA optimize по расписанию
- internal/meta: полезная нагрузка /api/v1/meta (версия, фичи, лимиты)
- internal/server: маршруты, SPA-раздача собранного клиента, OpenAPI 3.1
- cmd/glchat: точка входа с graceful shutdown и JSON-логами
- тесты: конфиг, миграции и идемпотентность, HTTP-эндпоинты, статика, 404/405
This commit is contained in:
2026-09-19 18:20:56 +03:00
parent 9839dbeede
commit 69aa98e18c
22 changed files with 1887 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
package httpx
import (
"crypto/rand"
"encoding/hex"
)
func newRequestID() string {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "0000000000000000"
}
return hex.EncodeToString(buf[:])
}
+71
View File
@@ -0,0 +1,71 @@
package httpx
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
)
type ErrorCode string
const (
CodeInternalError ErrorCode = "internal.error"
CodeNotFound ErrorCode = "not_found"
CodeBadRequest ErrorCode = "request.bad"
CodeRateLimited ErrorCode = "ratelimit.hit"
CodeNotReady ErrorCode = "instance.not_ready"
)
type Error struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type errorEnvelope struct {
Error Error `json:"error"`
}
func (e Error) HTTPStatus() int {
switch e.Code {
case CodeNotFound:
return http.StatusNotFound
case CodeBadRequest:
return http.StatusBadRequest
case CodeRateLimited:
return http.StatusTooManyRequests
case CodeNotReady:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
func NewError(code ErrorCode, message string) Error {
return Error{Code: code, Message: message}
}
func WriteJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
if body == nil {
return
}
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Error("write json response", "error", err)
}
}
func WriteError(w http.ResponseWriter, apiErr Error) {
WriteJSON(w, apiErr.HTTPStatus(), errorEnvelope{Error: apiErr})
}
func WriteErrorStatus(w http.ResponseWriter, status int, code ErrorCode, message string) {
WriteJSON(w, status, errorEnvelope{Error: NewError(code, message)})
}
func StatusForCode(code ErrorCode) int { return NewError(code, "").HTTPStatus() }
var errBodyTooLarge = errors.New("request body too large")
+170
View File
@@ -0,0 +1,170 @@
package httpx
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"time"
)
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = newRequestID()
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(withRequestID(r.Context(), id)))
})
}
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
logger.LogAttrs(r.Context(), slog.LevelInfo, "http request",
slog.String("request_id", RequestIDFrom(r.Context())),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Int("bytes", rec.bytes),
slog.String("remote_ip", ClientIP(r, nil)),
slog.Duration("duration", time.Since(start)),
)
})
}
}
func Recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.ErrorContext(r.Context(), "panic recovered",
slog.String("request_id", RequestIDFrom(r.Context())),
slog.String("path", r.URL.Path),
slog.Any("panic", rec),
)
WriteError(w, NewError(CodeInternalError, "internal error"))
}
}()
next.ServeHTTP(w, r)
})
}
}
func SecurityHeaders(filesDomain string) Middleware {
frameAncestors := "'none'"
csp := strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors " + frameAncestors,
"form-action 'self'",
}, "; ")
return func(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", "strict-origin-when-cross-origin")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Cross-Origin-Resource-Policy", "same-site")
if isTLS(r) {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
func isTLS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func ClientIP(r *http.Request, trustedProxies []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
proxyTrusted := false
for _, n := range trustedProxies {
if ip := net.ParseIP(host); ip != nil && n.Contains(ip) {
proxyTrusted = true
break
}
}
if !proxyTrusted {
return host
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
candidate := strings.TrimSpace(parts[len(parts)-1])
if ip := net.ParseIP(candidate); ip != nil {
return ip.String()
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); realIP != "" {
if ip := net.ParseIP(realIP); ip != nil {
return ip.String()
}
}
return host
}
type ctxKey int
const requestIDKey ctxKey = iota
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
+30
View File
@@ -0,0 +1,30 @@
package httpx
import (
"net/http"
)
const defaultJSONBodyLimit = 1 << 20
func JSONBodyLimit(limit int64) Middleware {
if limit <= 0 {
limit = defaultJSONBodyLimit
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}
func MethodNotAllowed(w http.ResponseWriter, _ *http.Request) {
WriteErrorStatus(w, http.StatusMethodNotAllowed, CodeBadRequest, "method not allowed")
}
func NotFound(w http.ResponseWriter, _ *http.Request) {
WriteError(w, NewError(CodeNotFound, "resource not found"))
}