feat(invites): страница приглашения со splash и переопределением

- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
  сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
  создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
  карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
  возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
  присоединение, недействительная ссылка)
This commit is contained in:
2026-09-21 19:25:06 +03:00
parent ed98c78c9e
commit 566e1cf981
14 changed files with 701 additions and 24 deletions
+2 -1
View File
@@ -301,7 +301,8 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
// Аватары и баннеры видны всем авторизованным: они показываются в списках
// участников и друзей (AGENT.md 7.2).
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
file.Purpose == "sound" || file.Purpose == "webhook_avatar" ||
file.Purpose == "invite_background" {
return file, nil
}
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
+151 -12
View File
@@ -1,14 +1,17 @@
package server
import (
"bytes"
"context"
"crypto/rand"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/permissions"
"glchat/internal/store"
@@ -28,6 +31,9 @@ type invitePayload struct {
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
URL string `json:"url"`
// BackgroundFileID — переопределение splash для страницы приглашения
// (AGENT.md 7.9).
BackgroundFileID string `json:"background_file_id,omitempty"`
}
type inviteListOutput struct {
@@ -49,6 +55,13 @@ type inviteOutput struct {
Description string `json:"description,omitempty"`
MemberCount int `json:"member_count"`
IsMember bool `json:"is_member"`
// Inviter — кто позвал: страница приглашения показывает имя.
Inviter struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
} `json:"inviter"`
} `json:"guild"`
}
}
@@ -212,24 +225,24 @@ func (s *Server) registerInviteRoutes(api huma.API) {
Path: "/invites/{code}",
Summary: "Предпросмотр приглашения",
Tags: []string{"Invites"},
Security: security,
// Карточку приглашения видно и без сессии: по ссылке приходят те, у кого
// аккаунта ещё нет (AGENT.md 7.9). Присоединение требует входа.
}, func(ctx context.Context, input *struct {
Code string `path:"code"`
},
) (*inviteOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
user, _, _ := sessionFromContext(ctx)
invite, err := s.store.GetInvite(ctx, input.Code)
if err != nil {
return nil, humaError(err)
}
// Бан сервера сильнее приглашения: снять его может только модератор.
if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
return nil, humaError(err)
} else if banned {
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
if user != nil {
if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
return nil, humaError(err)
} else if banned {
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
}
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
@@ -362,11 +375,14 @@ func (s *Server) invitePayload(invite *store.Invite) invitePayload {
if invite.ExpiresAt != nil {
payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339)
}
if invite.BackgroundFileID != nil {
payload.BackgroundFileID = formatSnowflake(*invite.BackgroundFileID)
}
return payload
}
// inviteOutput собирает приглашение вместе с карточкой сервера для страницы
// предпросмотра (AGENT.md 7.9).
// предпросмотра (AGENT.md 7.9). `user` может быть nil — гость по ссылке.
func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput {
output := &inviteOutput{}
output.Body.Invite = s.invitePayload(invite)
@@ -390,8 +406,21 @@ func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *sto
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
output.Body.Guild.MemberCount = count
}
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
output.Body.Guild.IsMember = true
if user != nil {
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
output.Body.Guild.IsMember = true
}
}
// Пригласившего показываем по имени: это часть карточки приглашения.
if invite.CreatorID != nil {
if creator, err := s.store.GetUser(ctx, *invite.CreatorID); err == nil {
output.Body.Guild.Inviter.ID = formatSnowflake(creator.ID)
output.Body.Guild.Inviter.Username = creator.Username
output.Body.Guild.Inviter.DisplayName = creator.DisplayName
if creator.AvatarFileID != nil {
output.Body.Guild.Inviter.AvatarFileID = formatSnowflake(*creator.AvatarFileID)
}
}
}
return output
}
@@ -412,3 +441,113 @@ func newInviteCode() (string, error) {
}
return builder.String(), nil
}
// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7).
const maxInviteBackgroundSize = 5 << 20
// registerInviteBackgroundRoutes описывает картинку приглашения: создатель
// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы
// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере.
func (s *Server) registerInviteBackgroundRoutes(router chi.Router) {
router.Post("/invites/{code}/background", s.handleInviteBackgroundUpload)
router.Delete("/invites/{code}/background", s.handleInviteBackgroundDelete)
}
func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user)
if err != nil {
writeHumaAPIError(w, err)
return
}
file, header, err := r.FormFile("file")
if err != nil {
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
return
}
defer func() { _ = file.Close() }()
if header.Size > maxInviteBackgroundSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
return
}
data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1))
if err != nil || int64(len(data)) > maxInviteBackgroundSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
return
}
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invite background must be an image")
return
}
// Старую картинку удаляем: загрузки не должны копиться (AGENT.md 7.7).
if previous := invite.BackgroundFileID; previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
GuildID: &invite.GuildID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "invite_background",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
updated, err := s.store.SetInviteBackground(ctx, invite.Code, &stored.ID)
if err != nil {
s.deleteStoredFile(ctx, stored.ID)
writeHumaAPIError(w, humaError(err))
return
}
s.recordAudit(ctx, user, invite.GuildID, "invite.background", "invite", nil, invite.Code)
httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)})
}
func (s *Server) handleInviteBackgroundDelete(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user)
if err != nil {
writeHumaAPIError(w, err)
return
}
if previous := invite.BackgroundFileID; previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
updated, err := s.store.SetInviteBackground(ctx, invite.Code, nil)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
s.recordAudit(ctx, user, invite.GuildID, "invite.background_remove", "invite", nil, invite.Code)
httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)})
}
// requireInviteManage достаёт приглашение и проверяет право менять его
// оформление: создатель или MANAGE_GUILD сервера (AGENT.md 7.9).
func (s *Server) requireInviteManage(ctx context.Context, code string, user *store.User) (*store.Invite, error) {
invite, err := s.store.GetInvite(ctx, code)
if err != nil {
return nil, humaError(err)
}
if invite.CreatorID != nil && *invite.CreatorID == user.ID {
return invite, nil
}
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil {
return nil, err
}
return invite, nil
}
+95
View File
@@ -602,3 +602,98 @@ func TestBlockUserStopsDMsAndRequests(t *testing.T) {
}
_ = ownerID
}
func TestInviteBackgroundAndPreview(t *testing.T) {
srv, owner, member, guildID, _ := cosmeticsFixture(t)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites",
`{"max_uses":5,"max_age_seconds":600}`, owner)
code := decodeResponse[struct {
Invite struct {
Code string `json:"code"`
} `json:"invite"`
}](t, created).Invite.Code
if code == "" {
t.Fatalf("приглашение не создано: %s", created.Body.String())
}
// Предпросмотр: карточка сервера и пригласивший.
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
body := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
Guild struct {
Name string `json:"name"`
IsMember bool `json:"is_member"`
Inviter struct {
Username string `json:"username"`
} `json:"inviter"`
} `json:"guild"`
}](t, preview)
if body.Guild.Name == "" || body.Guild.Inviter.Username == "" {
t.Fatalf("предпросмотр без карточки: %s", preview.Body.String())
}
// Загрузка переопределения splash: multipart, картинка.
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "invite.png")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/invites/"+code+"/background", &buf)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(owner)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
t.Fatalf("загрузка картинки приглашения = %d (%s)", rec.Code, rec.Body.String())
}
uploaded := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, rec)
if uploaded.Invite.BackgroundFileID == "" {
t.Fatalf("переопределение не сохранилось: %s", rec.Body.String())
}
// Карточка приглашения отдаёт переопределение и без сессии не работает.
after := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
fileID := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, after).Invite.BackgroundFileID
if fileID != uploaded.Invite.BackgroundFileID {
t.Fatalf("переопределение не видно в предпросмотре: %s", after.Body.String())
}
// Посторонний не может менять оформление чужого приглашения.
outsider := registerAndLogin(t, srv, "invite_guest", "invite-guest@example.com")
denied := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", outsider)
if denied.Code != http.StatusForbidden && denied.Code != http.StatusNotFound {
t.Fatalf("снятие чужим = %d, ожидался отказ (%s)", denied.Code, denied.Body.String())
}
// Снятие переопределения создателем.
cleared := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", owner)
if cleared.Code != http.StatusOK {
t.Fatalf("снятие переопределения = %d (%s)", cleared.Code, cleared.Body.String())
}
final := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, cleared)
if final.Invite.BackgroundFileID != "" {
t.Fatalf("переопределение не снято: %s", cleared.Body.String())
}
}
+1
View File
@@ -155,6 +155,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerWebhookRoutes(s.api, apiRouter)
s.registerCosmeticRoutes(s.api, apiRouter)
s.registerChannelBackgroundRoutes(apiRouter)
s.registerInviteBackgroundRoutes(apiRouter)
s.registerVoiceWebhook(apiRouter)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)