Files
glchat/internal/server/api_files.go
T

549 lines
20 KiB
Go
Raw Normal View History

package server
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/permissions"
"glchat/internal/store"
)
const (
// maxMultipartOverhead — запас на служебные поля multipart поверх лимита файла.
maxMultipartOverhead = 1 << 20
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
maxMultipartMemory = 8 << 20
)
// uploadPayload — результат загрузки файла: метаданные для вложения.
type uploadPayload struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
URL string `json:"url"`
}
type uploadOutput struct {
Body struct {
File uploadPayload `json:"file"`
}
}
// registerFileRoutes описывает метаданные файла (huma) и загрузку/выдачу
// содержимого (chi: multipart и бинарный ответ вне контракта JSON-API, AGENT.md 8.2).
func (s *Server) registerFileRoutes(api huma.API, router chi.Router) {
huma.Register(api, huma.Operation{
OperationID: "getFileMeta",
Method: http.MethodGet,
Path: "/files/{file_id}",
Summary: "Метаданные файла",
Tags: []string{"Files"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
FileID string `path:"file_id"`
},
) (*uploadOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
file, err := s.requireFileAccess(ctx, input.FileID, user)
if err != nil {
return nil, err
}
output := &uploadOutput{}
output.Body.File = s.uploadPayload(file)
return output, nil
})
router.Post("/channels/{channel_id}/files", s.handleFileUpload)
router.Post("/users/@me/avatar", s.handleAvatarUpload)
router.Delete("/users/@me/avatar", s.handleAvatarDelete)
}
// handleAvatarUpload принимает аватар пользователя (AGENT.md 7.2).
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
currentUser, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
if err := s.checkUploadLimit(currentUser); err != nil {
writeHumaAPIError(w, err)
return
}
limit := s.mediaLimit(ctx, mediaAvatar)
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
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 > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
return
}
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar 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", "avatar must be an image")
return
}
// Старый аватар удаляем: файлы не должны копиться (AGENT.md 7.7).
if currentUser.AvatarFileID != nil {
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &currentUser.ID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "avatar",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
fileID := stored.ID
updated, err := s.store.UpdateUser(ctx, currentUser.ID, store.UpdateUserParams{AvatarFileID: &fileID})
if err != nil {
writeHumaAPIError(w, err)
return
}
s.dispatchUserUpdate(updated)
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
}
// handleAvatarDelete убирает аватар пользователя.
func (s *Server) handleAvatarDelete(w http.ResponseWriter, r *http.Request) {
currentUser, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
if currentUser.AvatarFileID != nil {
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
if err := s.store.ClearAvatar(ctx, currentUser.ID); err != nil {
writeHumaAPIError(w, humaError(err))
return
}
}
updated, err := s.store.GetUser(ctx, currentUser.ID)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
s.dispatchUserUpdate(updated)
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
}
// deleteStoredFile убирает запись и файл с диска, не ломая основную операцию.
func (s *Server) deleteStoredFile(ctx context.Context, fileID uint64) {
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return
}
if file.StoragePath != "" {
// Путь собран из идентификатора файла и каталога данных (saveUpload),
// имя от клиента в него не попадает (AGENT.md 9.2).
if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) { //nolint:gosec // путь формирует сам инстанс
s.logger.WarnContext(ctx, "failed to remove file from disk",
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
}
}
if err := s.store.DeleteFile(ctx, fileID); err != nil {
s.logger.WarnContext(ctx, "failed to delete file record",
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
}
}
// registerFileDownload вешает выдачу содержимого файла на корневой роутер:
// ссылки ведут на files.<domain>/files/{id} (AGENT.md 7.7).
func (s *Server) registerFileDownload(router chi.Router) {
router.Get("/files/{file_id}", s.handleFileDownload)
router.Head("/files/{file_id}", s.handleFileDownload)
}
// handleFileUpload принимает файл в комнату (AGENT.md 7.7).
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
currentUser, _, ok := s.authenticate(w, r)
if !ok {
// authenticate уже отдал ошибку в конверте API.
return
}
ctx := r.Context()
// Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6).
if err := s.checkUploadLimit(currentUser); err != nil {
writeHumaAPIError(w, err)
return
}
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), currentUser, permissions.AttachFiles)
if err != nil {
writeHumaAPIError(w, err)
return
}
// Тело ограничено лимитом файла плюс служебные поля multipart: разбор
// ограничен и по объёму (MaxBytesReader), и по памяти (maxMemory).
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
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 > s.cfg.MaxUploadSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large",
fmt.Sprintf("file exceeds %d bytes", s.cfg.MaxUploadSize))
return
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &currentUser.ID,
GuildID: channel.GuildID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: header.Header.Get("Content-Type"),
}, file)
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"file": s.uploadPayload(stored)})
}
// handleFileDownload отдаёт содержимое файла с проверкой прав на комнату.
func (s *Server) handleFileDownload(w http.ResponseWriter, r *http.Request) {
fileID := chi.URLParam(r, "file_id")
user, err := s.optionalUser(r.Context(), r)
if err != nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
if user == nil {
// Оформление сервера показывается на странице приглашения, куда
// приходят и без аккаунта (AGENT.md 7.9): только эти файлы отдаём
// анонимно, остальное требует сессии.
file, err := s.publicPresentationFile(r.Context(), fileID)
if err != nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
s.writeFileContent(w, r, file)
return
}
file, err := s.requireFileAccess(r.Context(), fileID, user)
if err != nil {
writeHumaAPIError(w, err)
return
}
s.writeFileContent(w, r, file)
}
// presentationPurposes — картинки, которые видны на публичной странице
// приглашения: оформление сервера и переопределение самого приглашения.
var presentationPurposes = map[string]bool{
"guild_icon": true,
"guild_banner": true,
"guild_splash": true,
"invite_background": true,
}
// publicPresentationFile отдаёт файл оформления без сессии.
func (s *Server) publicPresentationFile(ctx context.Context, rawFileID string) (*store.File, error) {
fileID, err := parseID("file_id", rawFileID)
if err != nil {
return nil, err
}
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return nil, err
}
if !presentationPurposes[file.Purpose] {
return nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
}
return file, nil
}
// writeFileContent отдаёт содержимое файла с заголовками кэширования.
func (s *Server) writeFileContent(w http.ResponseWriter, r *http.Request, file *store.File) {
path, err := s.storagePath(file)
if err != nil {
s.logger.ErrorContext(r.Context(), "file path is outside the storage",
slog.String("file_id", formatSnowflake(file.ID)), slog.Any("error", err))
writeAPIError(w, store.ErrNotFound)
return
}
handle, err := os.Open(path) //nolint:gosec // путь проверен storagePath: он внутри каталога данных
if err != nil {
s.logger.ErrorContext(r.Context(), "file is missing on disk",
slog.String("file_id", formatSnowflake(file.ID)))
writeAPIError(w, store.ErrNotFound)
return
}
defer func() { _ = handle.Close() }()
if file.ContentType != "" {
w.Header().Set("Content-Type", file.ContentType)
}
// Содержимое неизменяемо: адресуется идентификатором, поэтому кэшируем надолго.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("ETag", `"`+file.SHA256+`"`)
w.Header().Set("Content-Disposition", contentDisposition(file.Filename))
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if _, err := io.Copy(w, handle); err != nil {
s.logger.DebugContext(r.Context(), "file download interrupted", slog.Any("error", err))
}
}
// storagePath проверяет, что файл лежит внутри каталога данных: путь приходит
// из базы, но лишняя проверка не даёт выйти за пределы хранилища (AGENT.md 9.2).
func (s *Server) storagePath(file *store.File) (string, error) {
root := filepath.Join(s.cfg.DataDir, "files")
clean := filepath.Clean(file.StoragePath)
relative, err := filepath.Rel(root, clean)
if err != nil {
return "", err
}
if strings.HasPrefix(relative, "..") || filepath.IsAbs(relative) {
return "", fmt.Errorf("path %q is outside %q", clean, root)
}
return clean, nil
}
// requireFileAccess проверяет, что пользователь вправе видеть файл: он должен
// видеть комнату, к которой файл привязан (AGENT.md 7.7, 9.7).
func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *store.User) (*store.File, error) {
fileID, err := parseID("file_id", rawFileID)
if err != nil {
return nil, err
}
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return nil, humaError(err)
}
// Аватары и баннеры видны всем авторизованным: они показываются в списках
// участников и друзей (AGENT.md 7.2).
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" ||
file.Purpose == "invite_background" {
return file, nil
}
// Иконка групповой беседы видна только её участникам: личные беседы
// приватны, посторонним файл не отдаём (AGENT.md 7.8, 7.19, 9.2).
if file.Purpose == "dm_icon" && file.ChannelID != nil {
participant, err := s.store.IsDMParticipant(ctx, *file.ChannelID, user.ID)
if err != nil {
return nil, humaError(err)
}
if !participant {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "file not found")
}
return file, nil
}
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
if file.ChannelID != nil {
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
return nil, err
}
return file, nil
}
// Файл ещё не привязан к сообщению: доступен только загрузившему.
if file.UploaderID == nil || *file.UploaderID != user.ID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "file not found")
}
return file, nil
}
// checkUploadLimit применяет лимиты загрузок: минута и сутки (AGENT.md 8.6).
// Администратор инстанса лимит обходит — как и в отправке сообщений.
func (s *Server) checkUploadLimit(user *store.User) error {
if user.IsInstanceAdmin {
return nil
}
key := formatSnowflake(user.ID)
if allowed, retryAfter := s.uploadLimiter.Allow("upload:" + key); !allowed {
return rateLimitedError(retryAfter)
}
if allowed, retryAfter := s.uploadDailyLimiter.Allow("upload-day:" + key); !allowed {
return rateLimitedError(retryAfter)
}
return nil
}
// saveUpload пишет файл на диск и регистрирует его в БД.
func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Reader) (*store.File, error) {
if s.cfg.DataDir == "" {
// Без каталога данных файл некуда положить: лучше явная ошибка, чем
// запись в текущий рабочий каталог процесса.
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "data directory is not configured")
}
directory := filepath.Join(s.cfg.DataDir, "files")
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot prepare storage")
}
// Идентификатор известен заранее: имя файла на диске не зависит от того,
// что прислал клиент (AGENT.md 9.2).
id := s.store.NextID()
path := filepath.Join(directory, strconv.FormatUint(id, 10))
// path собирается из идентификатора и каталога данных: путь клиента сюда
// не попадает (AGENT.md 9.2).
handle, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // путь из идентификатора
if err != nil {
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot store file")
}
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(handle, hasher), io.LimitReader(content, s.cfg.MaxUploadSize+1))
closeErr := handle.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(path)
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot store file")
}
if written > s.cfg.MaxUploadSize {
_ = os.Remove(path)
return nil, humaErrorStatus(http.StatusRequestEntityTooLarge, "file.too_large", "file is too large")
}
if file.ContentType == "" {
file.ContentType = "application/octet-stream"
}
stored, err := s.store.CreateFile(ctx, store.CreateFileParams{
ID: id,
UploaderID: *file.UploaderID,
GuildID: file.GuildID,
ChannelID: file.ChannelID,
Filename: file.Filename,
ContentType: file.ContentType,
SizeBytes: written,
StoragePath: path,
SHA256: hex.EncodeToString(hasher.Sum(nil)),
Purpose: file.Purpose,
})
if err != nil {
_ = os.Remove(path)
return nil, humaError(err)
}
return stored, nil
}
// uploadPayload собирает метаданные файла для API.
func (s *Server) uploadPayload(file *store.File) uploadPayload {
return uploadPayload{
FileID: formatSnowflake(file.ID),
Filename: file.Filename,
ContentType: file.ContentType,
SizeBytes: file.SizeBytes,
URL: s.cfg.FilesURL() + "/" + formatSnowflake(file.ID),
}
}
// sanitizeFilename убирает пути и управляющие символы из имени файла.
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
if index := strings.LastIndex(name, "/"); index >= 0 {
name = name[index+1:]
}
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" {
name = "file"
}
if runes := []rune(name); len(runes) > 200 {
name = string(runes[:200])
}
return name
}
// contentDisposition отдаёт безопасный заголовок для скачивания.
func contentDisposition(filename string) string {
ascii := strings.Map(func(r rune) rune {
if r > 0x7f || r == '"' || r == '\\' {
return '_'
}
return r
}, filename)
return `attachment; filename="` + ascii + `"; filename*=UTF-8''` + urlEncode(filename)
}
func urlEncode(value string) string {
var builder strings.Builder
for _, b := range []byte(value) {
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') ||
b == '-' || b == '_' || b == '.' || b == '~' {
builder.WriteByte(b)
continue
}
fmt.Fprintf(&builder, "%%%02X", b)
}
return builder.String()
}
// httpxWriteJSON пишет JSON-ответ из chi-ручки.
func httpxWriteJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Default().Debug("write json response", slog.Any("error", err))
}
}
// httpxWriteJSONError пишет ошибку в конверте API из chi-ручки.
func httpxWriteJSONError(w http.ResponseWriter, status int, code, message string) {
httpxWriteJSON(w, status, map[string]any{"error": map[string]any{"code": code, "message": message}})
}