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:
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- Переопределение splash для конкретного приглашения (AGENT.md 7.9): страница
|
||||
-- приглашения показывает свою картинку вместо splash сервера.
|
||||
ALTER TABLE invites ADD COLUMN background_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE invites DROP COLUMN background_file_id;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,9 @@ type Invite struct {
|
||||
MaxAgeSec int
|
||||
ExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
// BackgroundFileID — переопределение splash для этого приглашения
|
||||
// (AGENT.md 7.9): страница приглашения показывает свою картинку.
|
||||
BackgroundFileID *uint64
|
||||
}
|
||||
|
||||
// CreateInviteParams — параметры приглашения.
|
||||
@@ -30,7 +33,7 @@ type CreateInviteParams struct {
|
||||
}
|
||||
|
||||
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
|
||||
max_age_sec, expires_at, created_at`
|
||||
max_age_sec, expires_at, created_at, background_file_id`
|
||||
|
||||
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
|
||||
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
|
||||
@@ -54,6 +57,19 @@ func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
||||
return scanInvite(row)
|
||||
}
|
||||
|
||||
// SetInviteBackground задаёт или снимает переопределение splash приглашения.
|
||||
func (s *Store) SetInviteBackground(ctx context.Context, code string, fileID *uint64) (*Invite, error) {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE invites SET background_file_id = ? WHERE code = ?`, nullableID(fileID), code)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.GetInvite(ctx, code)
|
||||
}
|
||||
|
||||
// ListGuildInvites возвращает приглашения сервера (для панели управления).
|
||||
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
@@ -132,19 +148,21 @@ func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error
|
||||
|
||||
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
|
||||
var (
|
||||
invite Invite
|
||||
channelID sql.NullInt64
|
||||
creatorID sql.NullInt64
|
||||
expiresAt sql.NullString
|
||||
createdAt string
|
||||
invite Invite
|
||||
channelID sql.NullInt64
|
||||
creatorID sql.NullInt64
|
||||
expiresAt sql.NullString
|
||||
createdAt string
|
||||
background sql.NullInt64
|
||||
)
|
||||
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
|
||||
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt)
|
||||
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt, &background)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
invite.ChannelID = optionalID(channelID)
|
||||
invite.CreatorID = optionalID(creatorID)
|
||||
invite.BackgroundFileID = optionalID(background)
|
||||
if expiresAt.Valid {
|
||||
value := parseTimestamp(expiresAt.String)
|
||||
invite.ExpiresAt = &value
|
||||
|
||||
Reference in New Issue
Block a user