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: ¤tUser.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./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: ¤tUser.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 } // Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу). 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}}) }