159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
|
|
package server
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
|
||
|
|
"github.com/danielgtaylor/huma/v2"
|
||
|
|
|
||
|
|
"glchat/internal/permissions"
|
||
|
|
"glchat/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена.
|
||
|
|
type sessionContextValue struct {
|
||
|
|
User *store.User
|
||
|
|
Session *store.Session
|
||
|
|
}
|
||
|
|
|
||
|
|
type sessionContextKey struct{}
|
||
|
|
|
||
|
|
// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст.
|
||
|
|
// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI).
|
||
|
|
func (s *Server) sessionContext(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if s.auth == nil {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
token := sessionToken(r)
|
||
|
|
if token == "" {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
user, session, err := s.auth.ResolveSession(r.Context(), token)
|
||
|
|
if err != nil {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
|
||
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
|
||
|
|
func sessionToken(r *http.Request) string {
|
||
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||
|
|
return cookie.Value
|
||
|
|
}
|
||
|
|
return normalizeBearer(r.Header.Get("Authorization"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) {
|
||
|
|
value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue)
|
||
|
|
if !ok || value.User == nil || value.Session == nil {
|
||
|
|
return nil, nil, false
|
||
|
|
}
|
||
|
|
return value.User, value.Session, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// requireUser достаёт текущего пользователя или возвращает 401 в конверте API.
|
||
|
|
func requireUser(ctx context.Context) (*store.User, *store.Session, error) {
|
||
|
|
user, session, ok := sessionFromContext(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
|
||
|
|
}
|
||
|
|
return user, session, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5).
|
||
|
|
func requireInstanceAdmin(ctx context.Context) (*store.User, error) {
|
||
|
|
user, _, err := requireUser(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !user.IsInstanceAdmin {
|
||
|
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||
|
|
}
|
||
|
|
return user, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}}
|
||
|
|
// (AGENT.md 8.5): клиент разбирает код, а не текст.
|
||
|
|
type humaAPIError struct {
|
||
|
|
status int
|
||
|
|
code string
|
||
|
|
message string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
|
||
|
|
func (e *humaAPIError) GetStatus() int { return e.status }
|
||
|
|
|
||
|
|
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
|
||
|
|
return json.Marshal(map[string]any{
|
||
|
|
"error": map[string]any{"code": e.code, "message": e.message},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// humaErrorStatus создаёт ошибку с явным кодом.
|
||
|
|
func humaErrorStatus(status int, code, message string) huma.StatusError {
|
||
|
|
return &humaAPIError{status: status, code: code, message: message}
|
||
|
|
}
|
||
|
|
|
||
|
|
// humaError переводит доменную ошибку в ответ API с тем же кодом, что и
|
||
|
|
// chi-ручки (единая таблица в errors.go).
|
||
|
|
func humaError(err error) huma.StatusError {
|
||
|
|
if err == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
converted := newAPIError(err)
|
||
|
|
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4).
|
||
|
|
func parseID(name, value string) (uint64, error) {
|
||
|
|
if value == "" {
|
||
|
|
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required")
|
||
|
|
}
|
||
|
|
parsed, err := strconv.ParseUint(value, 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id")
|
||
|
|
}
|
||
|
|
return parsed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// guildPermissions считает права пользователя на сервере.
|
||
|
|
func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) {
|
||
|
|
resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, store.ErrNotFound) {
|
||
|
|
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||
|
|
}
|
||
|
|
return permissions.Resolved{}, humaError(err)
|
||
|
|
}
|
||
|
|
if !resolved.Has(permissions.ViewGuild) {
|
||
|
|
// Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7).
|
||
|
|
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||
|
|
}
|
||
|
|
return resolved, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// requireGuildPermission считает права и требует конкретное разрешение.
|
||
|
|
func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) {
|
||
|
|
id, err := parseID("guild_id", guildID)
|
||
|
|
if err != nil {
|
||
|
|
return 0, permissions.Resolved{}, err
|
||
|
|
}
|
||
|
|
resolved, err := s.guildPermissions(ctx, id, user)
|
||
|
|
if err != nil {
|
||
|
|
return 0, permissions.Resolved{}, err
|
||
|
|
}
|
||
|
|
if !resolved.Has(permission) {
|
||
|
|
return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||
|
|
}
|
||
|
|
return id, resolved, nil
|
||
|
|
}
|