Files
glchat/internal/server/api_context.go
T
grendervill 33dc1fec96 feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
2026-09-20 01:11:04 +03:00

198 lines
6.9 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"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
}
s.touchPresence(user.ID)
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// touchPresence обновляет время последней активности пользователя, но не чаще
// раза в минуту: значение нужно списку друзей (статус и «был в сети»).
func (s *Server) touchPresence(userID uint64) {
s.presenceMu.Lock()
last, ok := s.presence[userID]
now := time.Now()
if ok && now.Sub(last) < time.Minute {
s.presenceMu.Unlock()
return
}
if len(s.presence) > 8192 {
for id, at := range s.presence {
if now.Sub(at) > time.Hour {
delete(s.presence, id)
}
}
}
s.presence[userID] = now
s.presenceMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.store.TouchLastSeen(ctx, userID); err != nil {
s.logger.DebugContext(ctx, "failed to update last seen", slog.Any("error", err))
}
}
// 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
details map[string]any
}
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
func (e *humaAPIError) GetStatus() int { return e.status }
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
payload := map[string]any{"code": e.code, "message": e.message}
for key, value := range e.details {
payload[key] = value
}
return json.Marshal(map[string]any{"error": payload})
}
// humaErrorStatusDetails добавляет машиночитаемые детали (например,
// retry_after_ms для 429), AGENT.md 8.5.
func humaErrorStatusDetails(status int, code, message string, details map[string]any) huma.StatusError {
return &humaAPIError{status: status, code: code, message: message, details: details}
}
// 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
}