fix(files): аватары и баннеры доступны авторизованным пользователям

Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
2026-09-20 01:39:04 +03:00
parent 20eed227ca
commit d02ef7527a
35 changed files with 2330 additions and 191 deletions
@@ -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;
+7
View File
@@ -130,6 +130,7 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
UploaderID: &currentUser.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)
+19
View File
@@ -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())
+13 -6
View File
@@ -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)
}