Files
glchat/internal/store/files.go
T
grendervill d02ef7527a fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
2026-09-20 01:39:04 +03:00

147 lines
4.5 KiB
Go

package store
import (
"context"
"database/sql"
"time"
)
// File — загруженный файл: метаданные в БД, содержимое на диске (AGENT.md 7.7).
type File struct {
ID uint64
UploaderID *uint64
GuildID *uint64
ChannelID *uint64
MessageID *uint64
Filename string
ContentType string
SizeBytes int64
Width int
Height int
StoragePath string
SHA256 string
// Purpose: attachment (по умолчанию) | avatar | banner — от него зависит
// проверка доступа при выдаче файла.
Purpose string
CreatedAt time.Time
}
// CreateFileParams — параметры регистрации файла.
type CreateFileParams struct {
ID uint64
UploaderID uint64
GuildID *uint64
ChannelID *uint64
Filename string
ContentType string
SizeBytes int64
Width int
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, 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, 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.Purpose)
if err != nil {
return nil, mapError(err)
}
return s.GetFile(ctx, params.ID)
}
func (s *Store) GetFile(ctx context.Context, id uint64) (*File, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+fileColumns+` FROM files WHERE id = ?`, int64(id))
return scanFile(row)
}
// AttachFileToMessage связывает файл с сообщением после отправки.
func (s *Store) AttachFileToMessage(ctx context.Context, fileID, messageID uint64) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE files SET message_id = ? WHERE id = ?`, int64(messageID), int64(fileID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// DeleteFile удаляет запись о файле (содержимое чистит вызывающий код).
func (s *Store) DeleteFile(ctx context.Context, id uint64) error {
result, err := s.writer.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ListOrphanFiles возвращает файлы без сообщения старше указанного времени:
// их удаляет обслуживание (AGENT.md 7.7).
func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+fileColumns+` FROM files
WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`,
s.Timestamp(olderThan), limit)
if err != nil {
return nil, err
}
defer rows.Close()
files := make([]File, 0, limit)
for rows.Next() {
file, err := scanFile(rows)
if err != nil {
return nil, err
}
files = append(files, *file)
}
return files, rows.Err()
}
func scanFile(scanner interface{ Scan(...any) error }) (*File, error) {
var (
file File
uploaderID sql.NullInt64
guildID sql.NullInt64
channelID sql.NullInt64
messageID sql.NullInt64
createdAt string
)
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.Purpose)
if err != nil {
return nil, mapError(err)
}
file.UploaderID = optionalID(uploaderID)
file.GuildID = optionalID(guildID)
file.ChannelID = optionalID(channelID)
file.MessageID = optionalID(messageID)
file.CreatedAt = parseTimestamp(createdAt)
return &file, nil
}