diff --git a/internal/database/migrations/00006_files_purpose.sql b/internal/database/migrations/00006_files_purpose.sql new file mode 100644 index 0000000..f5f512f --- /dev/null +++ b/internal/database/migrations/00006_files_purpose.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Назначение файла: вложения видны по правам комнаты, аватары и баннеры — +-- всем авторизованным (AGENT.md 7.2, 7.7). +ALTER TABLE files ADD COLUMN purpose TEXT NOT NULL DEFAULT 'attachment'; + +-- +goose Down +ALTER TABLE files DROP COLUMN purpose; diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 9eb4bfc..8c4b83b 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -130,6 +130,7 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { UploaderID: ¤tUser.ID, Filename: sanitizeFilename(header.Filename), ContentType: contentType, + Purpose: "avatar", }, bytes.NewReader(data)) if err != nil { writeHumaAPIError(w, err) @@ -295,6 +296,11 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user * if err != nil { return nil, humaError(err) } + // Аватары и баннеры видны всем авторизованным: они показываются в списках + // участников и друзей (AGENT.md 7.2). + if file.Purpose == "avatar" || file.Purpose == "banner" { + return file, nil + } if file.ChannelID != nil { if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil { return nil, err @@ -355,6 +361,7 @@ func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Rea SizeBytes: written, StoragePath: path, SHA256: hex.EncodeToString(hasher.Sum(nil)), + Purpose: file.Purpose, }) if err != nil { _ = os.Remove(path) diff --git a/internal/server/social_test.go b/internal/server/social_test.go index 32669ba..d40ed71 100644 --- a/internal/server/social_test.go +++ b/internal/server/social_test.go @@ -254,6 +254,25 @@ func TestTimezoneAndAvatarUpdate(t *testing.T) { t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID) } + // Аватар виден другому авторизованному пользователю (он показывается в + // списках друзей и участников), но не анонимному. + otherCookie := registerAndLogin(t, srv, "tz_other", "tz-other@example.com") + _ = otherCookie + downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + httpServer.URL+"/files/"+fileID, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + downloadReq.AddCookie(otherCookie) + downloadResp, err := httpServer.Client().Do(downloadReq) + if err != nil { + t.Fatalf("avatar download: %v", err) + } + _ = downloadResp.Body.Close() + if downloadResp.StatusCode != http.StatusOK { + t.Fatalf("другой пользователь не видит аватар: %d", downloadResp.StatusCode) + } + removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie) if removed.Code != http.StatusOK { t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String()) diff --git a/internal/store/files.go b/internal/store/files.go index c2967c7..f29b699 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -20,7 +20,10 @@ type File struct { Height int StoragePath string SHA256 string - CreatedAt time.Time + // Purpose: attachment (по умолчанию) | avatar | banner — от него зависит + // проверка доступа при выдаче файла. + Purpose string + CreatedAt time.Time } // CreateFileParams — параметры регистрации файла. @@ -36,23 +39,27 @@ type CreateFileParams struct { Height int StoragePath string SHA256 string + Purpose string } const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename, - content_type, size_bytes, width, height, storage_path, sha256, created_at` + content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose` // CreateFile регистрирует загруженный файл. func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) { if params.ID == 0 { params.ID = s.NextID() } + if params.Purpose == "" { + params.Purpose = "attachment" + } _, err := s.writer.ExecContext(ctx, ` INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename, - content_type, size_bytes, width, height, storage_path, sha256, created_at) - VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose) + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID), params.Filename, params.ContentType, params.SizeBytes, params.Width, params.Height, - params.StoragePath, params.SHA256, s.Now()) + params.StoragePath, params.SHA256, s.Now(), params.Purpose) if err != nil { return nil, mapError(err) } @@ -126,7 +133,7 @@ func scanFile(scanner interface{ Scan(...any) error }) (*File, error) { ) err := scanner.Scan(&file.ID, &uploaderID, &guildID, &channelID, &messageID, &file.Filename, &file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath, - &file.SHA256, &createdAt) + &file.SHA256, &createdAt, &file.Purpose) if err != nil { return nil, mapError(err) } diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts new file mode 100644 index 0000000..19b9344 --- /dev/null +++ b/web/src/api/friends.ts @@ -0,0 +1,245 @@ +import { request } from './client'; +import { userStatuses, type Channel, type UserStatus } from './types'; + +/** + * Друзья, поиск пользователей и личные беседы (AGENT.md §7.8). + * + * Все ответы разбираются вручную: сервер помечает необязательные поля + * `omitempty`, поэтому доверять форме «как есть» нельзя — неизвестные и + * пропущенные поля не должны ломать список. + */ + +/** Тип связи с пользователем. */ +export const relationshipKinds = ['friend', 'incoming', 'outgoing', 'blocked', 'none'] as const; +export type RelationshipKind = (typeof relationshipKinds)[number]; + +/** Пользователь вместе с типом связи — форма `GET /users/@me/relationships`. */ +export interface RelationshipUser { + user_id: string; + username: string; + display_name: string; + avatar_file_id?: string; + /** Статус, выставленный пользователем (может быть `invisible`). */ + status: UserStatus; + /** Статус, который видят другие: «невидимка» приходит как `offline`. */ + visible_status: UserStatus; + custom_status?: string; + is_instance_admin: boolean; + badges: string[]; + last_seen_at?: string; + /** Часовой пояс собеседника (для справки; время показываем в своём). */ + timezone: string; + relationship: RelationshipKind; +} + +export interface Relationships { + friends: RelationshipUser[]; + incoming: RelationshipUser[]; + outgoing: RelationshipUser[]; + blocked: RelationshipUser[]; +} + +export const relationshipsQueryKey = ['users', '@me', 'relationships'] as const; + +export function userSearchQueryKey(query: string) { + return ['users', 'search', query] as const; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +function asStatus(value: unknown): UserStatus | undefined { + return userStatuses.includes(value as UserStatus) ? (value as UserStatus) : undefined; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +/** Разбирает пользователя со связью; `null` — запись не по контракту. */ +export function parseRelationshipUser(value: unknown): RelationshipUser | null { + const record = asRecord(value); + const userId = asString(record?.['user_id']); + if (record === null || userId === undefined) { + return null; + } + const username = asString(record['username']) ?? userId; + const status = asStatus(record['status']) ?? 'offline'; + const user: RelationshipUser = { + user_id: userId, + username, + display_name: asString(record['display_name']) ?? username, + // Видимый статус сервер считает сам; если его нет — берём обычный. + status, + visible_status: asStatus(record['visible_status']) ?? status, + is_instance_admin: record['is_instance_admin'] === true, + badges: asStringArray(record['badges']), + timezone: asString(record['timezone']) ?? 'Europe/Moscow', + relationship: (relationshipKinds as readonly string[]).includes( + record['relationship'] as string, + ) + ? (record['relationship'] as RelationshipKind) + : 'none', + }; + const avatar = asString(record['avatar_file_id']); + const customStatus = asString(record['custom_status']); + const lastSeenAt = asString(record['last_seen_at']); + if (avatar !== undefined) { + user.avatar_file_id = avatar; + } + if (customStatus !== undefined) { + user.custom_status = customStatus; + } + if (lastSeenAt !== undefined) { + user.last_seen_at = lastSeenAt; + } + return user; +} + +function parseList(value: unknown): RelationshipUser[] { + return Array.isArray(value) + ? value.map(parseRelationshipUser).filter((item): item is RelationshipUser => item !== null) + : []; +} + +export function parseRelationships(value: unknown): Relationships { + const record = asRecord(value); + return { + friends: parseList(record?.['friends']), + incoming: parseList(record?.['incoming']), + outgoing: parseList(record?.['outgoing']), + blocked: parseList(record?.['blocked']), + }; +} + +/** `GET /users/@me/relationships` — друзья, входящие и исходящие заявки. */ +export async function fetchRelationships(signal?: AbortSignal): Promise { + const payload = await request( + '/users/@me/relationships', + signal === undefined ? {} : { signal }, + ); + return parseRelationships(payload); +} + +/** `GET /users/search?q=` — поиск по логину (минимум 2 символа). */ +export async function searchUsers( + query: string, + signal?: AbortSignal, +): Promise { + const payload = await request<{ users?: unknown }>( + `/users/search?q=${encodeURIComponent(query)}`, + signal === undefined ? {} : { signal }, + ); + return parseList(payload.users); +} + +/** `POST /users/@me/relationships` — заявка в друзья по логину или id. */ +export async function sendFriendRequest(input: { + username?: string; + user_id?: string; +}): Promise { + await request<{ ok: true }>('/users/@me/relationships', { method: 'POST', body: input }); +} + +/** `POST /users/@me/relationships/{id}/accept` — принять входящую заявку. */ +export async function acceptFriendRequest(userId: string): Promise { + await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}/accept`, { + method: 'POST', + }); +} + +/** + * `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или + * отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка — + * тот же вызов (AGENT.md §7.8). + */ +export async function removeRelationship(userId: string): Promise { + await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); +} + +/** Личная беседа в REST-форме (`GET /users/@me/channels`). */ +export interface DirectChannelPayload { + id?: unknown; + can_send?: unknown; + can_view?: unknown; + recipient?: unknown; + last_message_id?: unknown; + last_message_at?: unknown; +} + +/** + * Приводит личную беседу из REST к общей форме `Channel`: чат и сайдбар + * работают с одним типом, поэтому имя и аватар берём у собеседника. + */ +export function parseDirectChannel(value: unknown): Channel | null { + const record = asRecord(value); + const id = asString(record?.['id']); + if (record === null || id === undefined) { + return null; + } + const recipient = asRecord(record['recipient']); + const username = asString(recipient?.['username']) ?? ''; + const channel: Channel = { + id, + name: asString(recipient?.['display_name']) ?? username, + type: 'dm', + position: 0, + can_send: record['can_send'] !== false, + can_view: record['can_view'] !== false, + }; + const recipientId = asString(recipient?.['user_id']); + const avatar = asString(recipient?.['avatar_file_id']); + // Наружу отдаём видимый статус: «невидимка» выглядит как офлайн. + const status = asStatus(recipient?.['visible_status']) ?? asStatus(recipient?.['status']); + const lastMessageId = asString(record['last_message_id']); + const lastMessageAt = asString(record['last_message_at']); + if (recipientId !== undefined) { + channel.recipient_id = recipientId; + } + if (avatar !== undefined) { + channel.icon_file_id = avatar; + } + if (status !== undefined) { + channel.status = status; + } + if (lastMessageId !== undefined) { + channel.last_message_id = lastMessageId; + } + if (lastMessageAt !== undefined) { + channel.last_message_at = lastMessageAt; + } + return channel; +} + +/** `GET /users/@me/channels` — все личные беседы пользователя. */ +export async function fetchDirectChannels(signal?: AbortSignal): Promise { + const payload = await request<{ channels?: unknown }>( + '/users/@me/channels', + signal === undefined ? {} : { signal }, + ); + return Array.isArray(payload.channels) + ? payload.channels.map(parseDirectChannel).filter((item): item is Channel => item !== null) + : []; +} + +/** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */ +export async function openDirectChannel(recipientId: string): Promise { + const payload = await request<{ channel?: unknown }>('/users/@me/channels', { + method: 'POST', + body: { recipient_id: recipientId }, + }); + const channel = parseDirectChannel(payload.channel); + if (channel === null) { + throw new Error('malformed direct channel payload'); + } + return channel; +} diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 5fcd708..fe3c3d7 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -194,7 +194,7 @@ export function parseChannelPayload(value: unknown): Channel | null { if (id === undefined || name === undefined) { return null; } - if (type !== 'text' && type !== 'voice' && type !== 'category') { + if (type !== 'text' && type !== 'voice' && type !== 'category' && type !== 'dm') { return null; } const channel: Channel = { @@ -226,9 +226,94 @@ export function parseChannelPayload(value: unknown): Channel | null { if (typeof record['can_view'] === 'boolean') { channel.can_view = record['can_view']; } + // Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*). + const recipientId = asString(record['recipient_id']); + const iconFileId = asString(record['icon_file_id']); + const status = asString(record['status']); + const lastMessageId = asString(record['last_message_id']); + const lastMessageAt = asString(record['last_message_at']); + if (recipientId !== undefined) { + channel.recipient_id = recipientId; + } + if (iconFileId !== undefined) { + channel.icon_file_id = iconFileId; + } + if (userStatuses.includes(status as UserStatus)) { + channel.status = status as UserStatus; + } + if (lastMessageId !== undefined) { + channel.last_message_id = lastMessageId; + } + if (lastMessageAt !== undefined) { + channel.last_message_at = lastMessageAt; + } return channel; } +/* ------------------------------------------------------------------ */ +/* Друзья и личные беседы (AGENT.md §8.3 / 7.8) */ +/* ------------------------------------------------------------------ */ + +export interface DmChannelEvent { + channel_id: string; +} + +export interface RelationshipEvent { + user_id: string; +} + +export interface PresenceEvent { + user_id: string; + status?: UserStatus; + visible_status?: UserStatus; + custom_status?: string; + last_seen_at?: string; +} + +/** `DM_CHANNEL_CREATE`: создана личная беседа — перечитываем список бесед. */ +export function parseDmChannelEvent(value: unknown): DmChannelEvent | null { + const record = asRecord(value); + const channelId = asString(record?.['channel_id']); + return record === null || channelId === undefined ? null : { channel_id: channelId }; +} + +/** `RELATIONSHIP_UPDATE`: изменилась связь с пользователем. */ +export function parseRelationshipEvent(value: unknown): RelationshipEvent | null { + const record = asRecord(value); + const userId = asString(record?.['user_id']); + return record === null || userId === undefined ? null : { user_id: userId }; +} + +/** + * `PRESENCE_UPDATE`: статус друга. Поля необязательны — сервер присылает + * только изменившиеся, поэтому разбираем каждое отдельно. + */ +export function parsePresenceEvent(value: unknown): PresenceEvent | null { + const record = asRecord(value); + const userId = asString(record?.['user_id']); + if (record === null || userId === undefined) { + return null; + } + const event: PresenceEvent = { user_id: userId }; + const status = asString(record['status']); + const visibleStatus = asString(record['visible_status']); + const customStatus = asString(record['custom_status']); + const lastSeenAt = asString(record['last_seen_at']); + if (userStatuses.includes(status as UserStatus)) { + event.status = status as UserStatus; + } + if (userStatuses.includes(visibleStatus as UserStatus)) { + event.visible_status = visibleStatus as UserStatus; + } + if (customStatus !== undefined) { + event.custom_status = customStatus; + } + if (lastSeenAt !== undefined) { + event.last_seen_at = lastSeenAt; + } + return event; +} + /* ------------------------------------------------------------------ */ /* События сообщений Фазы 2 (AGENT.md §8.3) */ /* ------------------------------------------------------------------ */ diff --git a/web/src/api/invites.ts b/web/src/api/invites.ts new file mode 100644 index 0000000..7260bfd --- /dev/null +++ b/web/src/api/invites.ts @@ -0,0 +1,107 @@ +import { request } from './client'; + +/** + * Приглашения на сервер (AGENT.md §7.9). + * + * Присоединиться можно только по приглашению, поэтому клиент умеет: + * разобрать код из ссылки `https://<домен>/invite/<код>`, показать + * предпросмотр (`GET /invites/{code}`) и принять его (`POST /invites/{code}`). + */ + +export interface InviteGuildPreview { + id: string; + name: string; + icon_file_id?: string; + description?: string; + member_count: number; + is_member: boolean; +} + +export interface InvitePreview { + code: string; + guild_id: string; + guild: InviteGuildPreview; +} + +export const inviteQueryKey = (code: string) => ['invites', code] as const; + +/** + * Достаёт код приглашения из ссылки или «сырого» кода. + * Возвращает `null`, если строка не похожа ни на то, ни на другое. + */ +export function parseInviteCode(input: string): string | null { + const trimmed = input.trim(); + if (trimmed === '') { + return null; + } + const fromUrl = (value: string): string | null => { + try { + const url = new URL(value); + const segments = url.pathname.split('/').filter((segment) => segment !== ''); + const index = segments.indexOf('invite'); + const code = index === -1 ? undefined : segments[index + 1]; + return code === undefined || code === '' ? null : code; + } catch { + return null; + } + }; + if (/^https?:\/\//iu.test(trimmed)) { + return fromUrl(trimmed); + } + // Сырой код: буквы и цифры без разделителей (алфавит сервера — a-z0-9). + return /^[0-9a-z]+$/iu.test(trimmed) ? trimmed : null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** `GET /invites/{code}` — предпросмотр приглашения. */ +export async function fetchInvite(code: string, signal?: AbortSignal): Promise { + const payload = await request( + `/invites/${encodeURIComponent(code)}`, + signal === undefined ? {} : { signal }, + ); + const record = asRecord(payload); + const invite = asRecord(record?.['invite']); + const guild = asRecord(record?.['guild']); + const guildId = asString(guild?.['id']); + if (guild === null || guildId === undefined) { + throw new Error('malformed invite payload'); + } + const preview: InvitePreview = { + code: asString(invite?.['code']) ?? code, + guild_id: guildId, + guild: { + id: guildId, + name: asString(guild['name']) ?? guildId, + member_count: typeof guild['member_count'] === 'number' ? guild['member_count'] : 0, + is_member: guild['is_member'] === true, + }, + }; + const icon = asString(guild['icon_file_id']); + const description = asString(guild['description']); + if (icon !== undefined) { + preview.guild.icon_file_id = icon; + } + if (description !== undefined) { + preview.guild.description = description; + } + return preview; +} + +/** `POST /invites/{code}` — вступить на сервер; сервер отвечает его id. */ +export async function acceptInvite(code: string): Promise { + const payload = await request<{ guild_id?: unknown }>(`/invites/${encodeURIComponent(code)}`, { + method: 'POST', + }); + const guildId = asString(payload.guild_id); + if (guildId === undefined) { + throw new Error('malformed invite accept payload'); + } + return guildId; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 732c83a..0898ae7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -8,8 +8,8 @@ export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const; export type UserStatus = (typeof userStatuses)[number]; -/** Тип комнаты: текст, голосовой канал или категория. */ -export type ChannelType = 'text' | 'voice' | 'category'; +/** Тип комнаты: текст, голосовой канал, категория или личная беседа. */ +export type ChannelType = 'text' | 'voice' | 'category' | 'dm'; /** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */ export interface User { @@ -75,6 +75,16 @@ export interface Channel { can_view?: boolean; can_send?: boolean; can_connect?: boolean; + /** + * Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый + * статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке + * бесед — внутри `recipient`. + */ + recipient_id?: string; + icon_file_id?: string; + status?: UserStatus; + last_message_id?: string; + last_message_at?: string; } export interface Guild { diff --git a/web/src/components/chat/ChatView.tsx b/web/src/components/chat/ChatView.tsx index 5566d4d..9cdef15 100644 --- a/web/src/components/chat/ChatView.tsx +++ b/web/src/components/chat/ChatView.tsx @@ -97,9 +97,15 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha const currentUserId = sessionUser?.id ?? null; const permissions = guild?.my_permissions ?? []; const isAdmin = sessionUser?.is_instance_admin === true; - const canManage = canManageMessagesPermission(permissions, isAdmin); - const canSend = canSendMessages(permissions, isAdmin, channel.can_send); - const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES)); + // Личная беседа: прав сервера нет, отправку разрешает сам канал (`can_send`), + // а модерация сообщений в беседах не применяется. + const isDirect = channel.type === 'dm'; + const canManage = !isDirect && canManageMessagesPermission(permissions, isAdmin); + const canSend = isDirect + ? channel.can_send !== false + : canSendMessages(permissions, isAdmin, channel.can_send); + const canAttach = + canSend && (isDirect || isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES)); const disabledReason = canSend ? null : t('chat.composer.readOnly'); const windowActive = useWindowActive(); @@ -189,6 +195,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha setReplyTo(null)} disabledReason={disabledReason} diff --git a/web/src/components/chat/Composer.tsx b/web/src/components/chat/Composer.tsx index 2ed8017..dd3eb9c 100644 --- a/web/src/components/chat/Composer.tsx +++ b/web/src/components/chat/Composer.tsx @@ -14,6 +14,8 @@ import { useMessagesStore } from '@/stores/messages'; interface ComposerProps { channel: Channel; authors: AuthorDirectory; + /** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */ + direct?: boolean; /** Сообщение, на которое отвечаем (плашка над полем ввода). */ replyTo: Message | null; onCancelReply: () => void; @@ -34,6 +36,7 @@ const LINE_HEIGHT_PX = 22; export function Composer({ channel, authors, + direct = false, replyTo, onCancelReply, disabledReason, @@ -291,8 +294,12 @@ export function Composer({ />