feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI
This commit is contained in:
+9
-1
@@ -13,9 +13,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/server"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
|
||||
@@ -129,7 +131,13 @@ func run() error {
|
||||
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
|
||||
defer stopMaintenance()
|
||||
|
||||
srv := server.New(cfg, db, logger)
|
||||
st := store.New(db)
|
||||
authService, err := auth.New(ctx, cfg, st, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize authentication: %w", err)
|
||||
}
|
||||
|
||||
srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("http server listening",
|
||||
|
||||
@@ -3,6 +3,8 @@ module glchat
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.28.0
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func newRequestID() string {
|
||||
@@ -12,3 +14,32 @@ func newRequestID() string {
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
type requestInfoKey struct{}
|
||||
|
||||
// RequestInfo — данные исходного запроса, нужные сервисам (IP, User-Agent).
|
||||
type RequestInfo struct {
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// WithRequestInfo кладёт данные запроса в контекст (используется HTTP-слоем).
|
||||
func WithRequestInfo(ctx context.Context, r *http.Request) context.Context {
|
||||
return context.WithValue(ctx, requestInfoKey{}, RequestInfo{
|
||||
IP: ClientIP(r, nil),
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
})
|
||||
}
|
||||
|
||||
func requestInfo(ctx context.Context) RequestInfo {
|
||||
if info, ok := ctx.Value(requestInfoKey{}).(RequestInfo); ok {
|
||||
return info
|
||||
}
|
||||
return RequestInfo{}
|
||||
}
|
||||
|
||||
// ClientIPFromContext возвращает IP клиента для контекста huma.
|
||||
func ClientIPFromContext(ctx context.Context) string { return requestInfo(ctx).IP }
|
||||
|
||||
// UserAgentFromContext возвращает User-Agent для контекста huma.
|
||||
func UserAgentFromContext(ctx context.Context) string { return requestInfo(ctx).UserAgent }
|
||||
|
||||
@@ -44,6 +44,14 @@ func (r *statusRecorder) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestInfoMiddleware кладёт IP и User-Agent запроса в контекст: huma-хендлеры
|
||||
// не получают *http.Request, а сервисам эти данные нужны (аудит, безопасность).
|
||||
func RequestInfoMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r)))
|
||||
})
|
||||
}
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-Id")
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
|
||||
const sessionCookieName = "__Host-session"
|
||||
|
||||
const sessionCookiePath = "/"
|
||||
|
||||
// sessionCookie собирает cookie сессии: HttpOnly, SameSite=Lax и Secure при TLS.
|
||||
// Secure выключается только для установок без TLS (--skip-tls, стенд за туннелем):
|
||||
// в этом режиме браузер не принимает Secure-cookie по http.
|
||||
func (s *Server) sessionCookie(token string, expires time.Time) *http.Cookie {
|
||||
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expires.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie() *http.Cookie {
|
||||
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки
|
||||
// выставляются напрямую, а контракт описан в OpenAPI (docs.go).
|
||||
func (s *Server) registerAuthRoutes(router chi.Router) {
|
||||
router.Post("/auth/register", s.handleRegister)
|
||||
router.Post("/auth/login", s.handleLogin)
|
||||
router.Post("/auth/logout", s.handleLogout)
|
||||
router.Post("/auth/logout-all", s.handleLogoutAll)
|
||||
router.Get("/auth/sessions", s.handleListSessions)
|
||||
router.Post("/auth/step-up", s.handleStepUp)
|
||||
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
|
||||
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
|
||||
router.Get("/users/@me", s.handleGetMe)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type currentUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
CustomStatus string `json:"custom_status"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
func userPayload(user *store.User) currentUserPayload {
|
||||
payload := currentUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
Locale: user.Locale,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if user.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
|
||||
}
|
||||
if user.BannerFileID != nil {
|
||||
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
var request registerRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
user, token, session, err := s.auth.Register(r.Context(), auth.RegisterInput{
|
||||
Username: request.Username,
|
||||
DisplayName: request.DisplayName,
|
||||
Email: request.Email,
|
||||
Password: request.Password,
|
||||
Locale: request.Locale,
|
||||
IP: httpx.ClientIPFromContext(r.Context()),
|
||||
UserAgent: httpx.UserAgentFromContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
var request loginRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
user, token, session, err := s.auth.Login(r.Context(), auth.LoginInput{
|
||||
Email: request.Email,
|
||||
Password: request.Password,
|
||||
TOTPCode: request.TOTPCode,
|
||||
IP: httpx.ClientIPFromContext(r.Context()),
|
||||
UserAgent: httpx.UserAgentFromContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1).
|
||||
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return nil, nil, false
|
||||
}
|
||||
token := ""
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
if token == "" {
|
||||
token = normalizeBearer(r.Header.Get("Authorization"))
|
||||
}
|
||||
if token == "" {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return nil, nil, false
|
||||
}
|
||||
user, session, err := s.auth.ResolveSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
return user, session, true
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_, session, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
// Выход без валидной сессии не ошибка: cookie всё равно очищаем.
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
if err := s.auth.Logout(r.Context(), session.ID); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.auth.LogoutAll(r.Context(), user.ID); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
ID string `json:"id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Current bool `json:"current"`
|
||||
SteppedUp bool `json:"stepped_up"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user, current, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sessions, err := s.store.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
payload := make([]sessionPayload, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
payload = append(payload, sessionPayload{
|
||||
ID: formatSnowflake(session.ID),
|
||||
UserAgent: session.UserAgent,
|
||||
IP: session.IP,
|
||||
CreatedAt: session.CreatedAt.Format(time.RFC3339),
|
||||
LastSeen: session.LastSeen.Format(time.RFC3339),
|
||||
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
|
||||
Current: session.ID == current.ID,
|
||||
SteppedUp: session.SteppedUp(now),
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"sessions": payload})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type totpEnableRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setup, err := s.auth.SetupTOTP(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"secret": setup.Secret,
|
||||
"url": setup.URL,
|
||||
"issuer": setup.IssuerName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request totpEnableRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
codes, err := s.auth.EnableTOTP(r.Context(), user.ID, request.Code)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
type stepUpRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request stepUpRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// formatSnowflake сериализует идентификатор строкой: JS не хранит uint64
|
||||
// без потери точности (AGENT.md 8.1).
|
||||
func formatSnowflake(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for id > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + id%10)
|
||||
id /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func doJSON(t *testing.T, srv *Server, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRegisterEndpointCreatesSession(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"api_user","email":"api@example.com","password":"correct-horse-battery"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if payload.User.Username != "api_user" || payload.User.ID == "" {
|
||||
t.Fatalf("unexpected user payload: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
|
||||
t.Fatalf("session cookie is missing: %v", cookies)
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Fatal("session cookie must be HttpOnly")
|
||||
}
|
||||
|
||||
// С полученной cookie доступен профиль.
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0])
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEndpointErrors(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"login_user","email":"login@example.com","password":"correct-horse-battery"}`)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||
`{"email":"login@example.com","password":"wrong-password"}`)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong password status = %d, want 401", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "invalid credentials") {
|
||||
t.Fatalf("unexpected error body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||
`{"email":"login@example.com","password":"correct-horse-battery"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedEndpointsRequireSession(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
for _, path := range []string{"/api/v1/users/@me", "/api/v1/auth/sessions"} {
|
||||
rec := doJSON(t, srv, http.MethodGet, path, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("GET %s without session = %d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsShortPassword(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"short_user","email":"short@example.com","password":"short"}`)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("short password accepted: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/openapi.json", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("openapi status = %d", rec.Code)
|
||||
}
|
||||
var doc struct {
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode openapi: %v", err)
|
||||
}
|
||||
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
|
||||
if _, ok := doc.Paths[path]; !ok {
|
||||
t.Errorf("openapi is missing %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5).
|
||||
type apiError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e apiError) Error() string { return e.Code + ": " + e.Message }
|
||||
func (e apiError) Unwrap() error { return e.cause }
|
||||
|
||||
// newAPIError подбирает код и статус по доменной ошибке.
|
||||
func newAPIError(err error) apiError {
|
||||
candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err}
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials"
|
||||
case errors.Is(err, auth.ErrTOTPRequired):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required"
|
||||
case errors.Is(err, auth.ErrTOTPInvalid):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
|
||||
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
|
||||
candidate.Message = "instance administrators must enable two-factor authentication"
|
||||
case errors.Is(err, auth.ErrSessionExpired):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
||||
case errors.Is(err, auth.ErrStepUpRequired):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required"
|
||||
candidate.Message = "step-up authentication required"
|
||||
case errors.Is(err, auth.ErrUsernameTaken):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken"
|
||||
case errors.Is(err, auth.ErrEmailTaken):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered"
|
||||
case errors.Is(err, auth.ErrRegistrationOff):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled"
|
||||
candidate.Message = "registration is disabled"
|
||||
case errors.Is(err, auth.ErrWeakPassword):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error()
|
||||
case errors.Is(err, auth.ErrInvalidUsername):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error()
|
||||
case errors.Is(err, auth.ErrTOTPAlreadyEnabled):
|
||||
candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled"
|
||||
candidate.Message = "two-factor authentication is already enabled"
|
||||
case errors.Is(err, auth.ErrNoTOTPSecret):
|
||||
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured"
|
||||
candidate.Message = "two-factor authentication is not configured"
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists"
|
||||
case errors.Is(err, permissions.ErrDenied):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied"
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом.
|
||||
func writeAPIError(w http.ResponseWriter, err error) {
|
||||
apiErr := newAPIError(err)
|
||||
if apiErr.Status >= http.StatusInternalServerError {
|
||||
apiErr.Message = "internal error"
|
||||
}
|
||||
httpx.WriteJSON(w, apiErr.Status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": apiErr.Code,
|
||||
"message": apiErr.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSON пишет успешный ответ (все текущие ручки возвращают 200).
|
||||
func writeJSON(w http.ResponseWriter, body any) {
|
||||
httpx.WriteJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// decodeBody читает JSON-тело с ограничением размера.
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if r.Body == nil {
|
||||
writeAPIError(w, errors.New("empty request body"))
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": map[string]any{"code": "request.bad", "message": "malformed json body"},
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -7,31 +7,6 @@ import (
|
||||
"glchat/internal/httpx"
|
||||
)
|
||||
|
||||
// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta"
|
||||
// into its method and path parts. Patterns without a method apply to all.
|
||||
func methodOfPattern(pattern string) (method, path string) {
|
||||
if i := strings.IndexByte(pattern, ' '); i > 0 {
|
||||
return pattern[:i], pattern[i+1:]
|
||||
}
|
||||
return "", pattern
|
||||
}
|
||||
|
||||
func (s *Server) routeExists(method, path string) bool {
|
||||
for _, pattern := range s.patterns {
|
||||
patternMethod, patternPath := methodOfPattern(pattern)
|
||||
if patternMethod == "" || patternMethod == method {
|
||||
continue
|
||||
}
|
||||
if patternPath == path {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
|
||||
// unknown path under them is a real 404 and must not receive the SPA shell.
|
||||
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
|
||||
@@ -46,10 +21,6 @@ func isReservedPath(path string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.routeExists(r.Method, r.URL.Path) {
|
||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||
return
|
||||
}
|
||||
if isReservedPath(r.URL.Path) {
|
||||
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
|
||||
return
|
||||
|
||||
+265
-69
@@ -1,81 +1,277 @@
|
||||
package server
|
||||
|
||||
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints
|
||||
// implemented so far. Phase 1 replaces it with a schema generated from typed
|
||||
// handler definitions (AGENT.md 8.1).
|
||||
var openAPIDocument = []byte(`{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "glchat API",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.",
|
||||
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" }
|
||||
},
|
||||
"servers": [{ "url": "/api/v1" }],
|
||||
"paths": {
|
||||
"/meta": {
|
||||
"get": {
|
||||
"operationId": "getMeta",
|
||||
"summary": "Instance metadata, API version and feature flags",
|
||||
"tags": ["Meta"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Instance metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Meta" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleOpenAPI отдаёт объединённый документ OpenAPI 3.1: пути, описанные
|
||||
// huma (meta и служебные ручки), плюс контракт auth-ручек, которые живут
|
||||
// на chi и описаны в authPathsJSON (AGENT.md 8.1: единый источник типов).
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
document := map[string]any{}
|
||||
if body, err := json.Marshal(s.api.OpenAPI()); err == nil {
|
||||
if err := json.Unmarshal(body, &document); err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "decode generated openapi", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
if document == nil {
|
||||
document = map[string]any{}
|
||||
}
|
||||
|
||||
paths, _ := document["paths"].(map[string]any)
|
||||
if paths == nil {
|
||||
paths = map[string]any{}
|
||||
}
|
||||
var extra map[string]any
|
||||
if err := json.Unmarshal([]byte(authPathsJSON), &extra); err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "decode auth openapi paths", slog.Any("error", err))
|
||||
}
|
||||
for path, item := range extra {
|
||||
paths[path] = item
|
||||
}
|
||||
document["paths"] = paths
|
||||
if components, ok := document["components"].(map[string]any); ok {
|
||||
if schemas, ok := components["schemas"].(map[string]any); ok {
|
||||
var extraSchemas map[string]any
|
||||
if err := json.Unmarshal([]byte(authSchemasJSON), &extraSchemas); err == nil {
|
||||
for name, schema := range extraSchemas {
|
||||
schemas[name] = schema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
httpxWriteInternalError(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
func httpxWriteInternalError(w http.ResponseWriter) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// authPathsJSON — контракт ручек аутентификации (chi-обработчики).
|
||||
const authPathsJSON = `{
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"operationId": "register",
|
||||
"summary": "Регистрация по email и паролю",
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Аккаунт создан, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
|
||||
"403": { "description": "Регистрация выключена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"409": { "description": "Email или username заняты", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"422": { "description": "Пароль или username не проходят политику", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Meta": {
|
||||
"type": "object",
|
||||
"required": ["name", "version", "api_version", "base_url", "features"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"commit": { "type": "string" },
|
||||
"build_date": { "type": "string" },
|
||||
"api_version": { "type": "string", "enum": ["v1"] },
|
||||
"base_url": { "type": "string" },
|
||||
"files_url": { "type": "string" },
|
||||
"gateway_url": { "type": "string" },
|
||||
"rtc_path": { "type": "string" },
|
||||
"max_upload_size": { "type": "integer", "format": "int64" },
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registration_enabled": { "type": "boolean" },
|
||||
"anti_bot_enabled": { "type": "boolean" },
|
||||
"voice_enabled": { "type": "boolean" },
|
||||
"web_push_enabled": { "type": "boolean" },
|
||||
"oauth_enabled": { "type": "boolean" },
|
||||
"passkeys_enabled": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"operationId": "login",
|
||||
"summary": "Вход по email и паролю (с TOTP при включённой 2FA)",
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
|
||||
},
|
||||
"Error": {
|
||||
"responses": {
|
||||
"200": { "description": "Вход выполнен, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
|
||||
"401": { "description": "Неверные данные или требуется код 2FA (auth.2fa_required)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"operationId": "logout",
|
||||
"summary": "Выход: отзывает текущую сессию",
|
||||
"tags": ["Auth"],
|
||||
"responses": { "200": { "description": "Сессия отозвана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
|
||||
}
|
||||
},
|
||||
"/auth/logout-all": {
|
||||
"post": {
|
||||
"operationId": "logoutAll",
|
||||
"summary": "Выйти везде: отзывает все сессии пользователя",
|
||||
"tags": ["Auth"],
|
||||
"responses": { "200": { "description": "Все сессии отозваны", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
|
||||
}
|
||||
},
|
||||
"/auth/sessions": {
|
||||
"get": {
|
||||
"operationId": "listSessions",
|
||||
"summary": "Активные сессии пользователя",
|
||||
"tags": ["Auth"],
|
||||
"responses": { "200": { "description": "Список сессий", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionsResponse" } } } } }
|
||||
}
|
||||
},
|
||||
"/auth/2fa/setup": {
|
||||
"post": {
|
||||
"operationId": "setupTOTP",
|
||||
"summary": "Создать секрет TOTP (до подтверждения кодом)",
|
||||
"tags": ["Auth"],
|
||||
"responses": { "200": { "description": "Секрет и otpauth-URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPSetup" } } } } }
|
||||
}
|
||||
},
|
||||
"/auth/2fa/enable": {
|
||||
"post": {
|
||||
"operationId": "enableTOTP",
|
||||
"summary": "Включить 2FA, подтвердив код; возвращает резервные коды",
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPEnableRequest" } } }
|
||||
},
|
||||
"responses": { "200": { "description": "2FA включена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } } }
|
||||
}
|
||||
},
|
||||
"/auth/step-up": {
|
||||
"post": {
|
||||
"operationId": "stepUp",
|
||||
"summary": "Подтвердить пароль (и 2FA) для чувствительных действий",
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StepUpRequest" } } }
|
||||
},
|
||||
"responses": { "200": { "description": "Аутентификация подтверждена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
|
||||
}
|
||||
},
|
||||
"/users/@me": {
|
||||
"get": {
|
||||
"operationId": "getMe",
|
||||
"summary": "Текущий пользователь",
|
||||
"tags": ["Users"],
|
||||
"responses": { "200": { "description": "Профиль пользователя", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserResponse" } } } } }
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// authSchemasJSON — схемы запросов и ответов auth-ручек.
|
||||
const authSchemasJSON = `{
|
||||
"RegisterRequest": {
|
||||
"type": "object",
|
||||
"required": ["username", "email", "password"],
|
||||
"properties": {
|
||||
"username": { "type": "string", "minLength": 2, "maxLength": 32 },
|
||||
"display_name": { "type": "string", "maxLength": 64 },
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"password": { "type": "string", "minLength": 10 },
|
||||
"locale": { "type": "string", "enum": ["ru", "en"] }
|
||||
}
|
||||
},
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"password": { "type": "string" },
|
||||
"totp_code": { "type": "string", "description": "Код TOTP или резервный код" }
|
||||
}
|
||||
},
|
||||
"TOTPEnableRequest": {
|
||||
"type": "object",
|
||||
"required": ["code"],
|
||||
"properties": { "code": { "type": "string", "minLength": 6 } }
|
||||
},
|
||||
"StepUpRequest": {
|
||||
"type": "object",
|
||||
"required": ["password"],
|
||||
"properties": {
|
||||
"password": { "type": "string" },
|
||||
"totp_code": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"type": "object",
|
||||
"required": ["id", "username", "display_name", "status", "is_instance_admin", "badges", "locale"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Snowflake строкой" },
|
||||
"username": { "type": "string" },
|
||||
"display_name": { "type": "string" },
|
||||
"bio": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["online", "idle", "dnd", "invisible"] },
|
||||
"custom_status": { "type": "string" },
|
||||
"avatar_file_id": { "type": "string" },
|
||||
"banner_file_id": { "type": "string" },
|
||||
"is_instance_admin": { "type": "boolean" },
|
||||
"badges": { "type": "array", "items": { "type": "string" } },
|
||||
"locale": { "type": "string", "enum": ["ru", "en"] }
|
||||
}
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"required": ["user"],
|
||||
"properties": { "user": { "$ref": "#/components/schemas/User" } }
|
||||
},
|
||||
"UserResponse": {
|
||||
"type": "object",
|
||||
"required": ["user"],
|
||||
"properties": { "user": { "$ref": "#/components/schemas/User" } }
|
||||
},
|
||||
"Session": {
|
||||
"type": "object",
|
||||
"required": ["id", "created_at", "last_seen", "expires_at", "current", "stepped_up"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"user_agent": { "type": "string" },
|
||||
"ip": { "type": "string" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"last_seen": { "type": "string", "format": "date-time" },
|
||||
"expires_at": { "type": "string", "format": "date-time" },
|
||||
"current": { "type": "boolean" },
|
||||
"stepped_up": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"SessionsResponse": {
|
||||
"type": "object",
|
||||
"required": ["sessions"],
|
||||
"properties": { "sessions": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } } }
|
||||
},
|
||||
"TOTPSetup": {
|
||||
"type": "object",
|
||||
"required": ["secret", "url"],
|
||||
"properties": {
|
||||
"secret": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"issuer": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"RecoveryCodes": {
|
||||
"type": "object",
|
||||
"required": ["recovery_codes"],
|
||||
"properties": { "recovery_codes": { "type": "array", "items": { "type": "string" } } }
|
||||
},
|
||||
"OkResponse": {
|
||||
"type": "object",
|
||||
"required": ["ok"],
|
||||
"properties": { "ok": { "type": "boolean" } }
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"details": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"details": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
}`
|
||||
|
||||
+82
-35
@@ -4,36 +4,62 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
patterns []string
|
||||
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
||||
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
|
||||
type Deps struct {
|
||||
Store *store.Store
|
||||
Auth *auth.Service
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
store *store.Store
|
||||
auth *auth.Service
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
api huma.API
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
logger: logger,
|
||||
patterns: append([]string(nil), routePatterns...),
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
store: deps.Store,
|
||||
auth: deps.Auth,
|
||||
logger: logger,
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
handler := httpx.Chain(mux,
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||
s.api = s.registerAPI(apiRouter)
|
||||
s.registerMetaRoutes(s.api)
|
||||
s.registerAuthRoutes(apiRouter)
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
s.registerRoutes(router)
|
||||
|
||||
handler := httpx.Chain(router,
|
||||
httpx.SecurityHeaders(cfg.FilesDomain),
|
||||
httpx.RequestID,
|
||||
httpx.RequestInfoMiddleware,
|
||||
httpx.Logger(logger),
|
||||
httpx.Recoverer(logger),
|
||||
httpx.JSONBodyLimit(1<<20),
|
||||
@@ -50,20 +76,30 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
var routePatterns = []string{
|
||||
"GET /healthz",
|
||||
"GET /readyz",
|
||||
"GET /api/v1/meta",
|
||||
"GET /api/v1/openapi.json",
|
||||
"/",
|
||||
// registerAPI создаёт huma-API: OpenAPI 3.1 и TS-типы выводятся из Go-типов
|
||||
// (AGENT.md 8.1, решение D-006).
|
||||
func (s *Server) registerAPI(router chi.Router) huma.API {
|
||||
cfg := huma.DefaultConfig("glchat API", s.cfg.Version)
|
||||
cfg.Info.Description = "Self-hosted платформа общения: REST /api/v1 и WebSocket Gateway."
|
||||
cfg.Info.License = &huma.License{Name: "AGPL-3.0-or-later", Identifier: "AGPL-3.0-or-later"}
|
||||
cfg.Servers = []*huma.Server{{URL: "/api/v1"}}
|
||||
cfg.OpenAPIPath = "/openapi"
|
||||
cfg.DocsPath = "/docs"
|
||||
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"sessionCookie": {Type: "apiKey", In: "cookie", Name: sessionCookieName},
|
||||
"bearerAuth": {Type: "http", Scheme: "bearer"},
|
||||
}
|
||||
return humachi.New(router, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /api/v1/meta", s.handleMeta)
|
||||
mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI)
|
||||
mux.HandleFunc("/", s.handleFallback)
|
||||
func (s *Server) registerRoutes(router chi.Router) {
|
||||
router.Get("/healthz", s.handleHealthz)
|
||||
router.Get("/readyz", s.handleReadyz)
|
||||
|
||||
router.NotFound(s.handleFallback)
|
||||
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
||||
@@ -104,16 +140,27 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg))
|
||||
type metaOutput struct {
|
||||
Body meta.Response
|
||||
}
|
||||
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(openAPIDocument); err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
|
||||
func (s *Server) registerMetaRoutes(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getMeta",
|
||||
Method: http.MethodGet,
|
||||
Path: "/meta",
|
||||
Summary: "Метаданные инстанса, версия API и флаги функций",
|
||||
Tags: []string{"Meta"},
|
||||
}, func(_ context.Context, _ *struct{}) (*metaOutput, error) {
|
||||
return &metaOutput{Body: meta.New(s.cfg)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeBearer(value string) string {
|
||||
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||||
return strings.TrimSpace(value[7:])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
var startedAt = time.Now()
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
@@ -32,21 +34,31 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
})
|
||||
|
||||
cfg := config.Config{
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
TLSEnabled: true,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
TLSEnabled: true,
|
||||
SessionPepper: "test-pepper",
|
||||
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
||||
Argon2MemoryKiB: 1024,
|
||||
Argon2Iterations: 1,
|
||||
Argon2Parallelism: 1,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
return New(cfg, db, logger), db
|
||||
st := store.New(db)
|
||||
authService, err := auth.New(context.Background(), cfg, st, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize authentication: %v", err)
|
||||
}
|
||||
return New(cfg, db, logger, Deps{Store: st, Auth: authService}), db
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
@@ -221,7 +233,7 @@ func TestServeWebClientReportsMissingBundle(t *testing.T) {
|
||||
|
||||
cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
srv := New(cfg, db, logger)
|
||||
srv := New(cfg, db, logger, Deps{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||
|
||||
Reference in New Issue
Block a user