b22db8f048
Тестовый сервер писал файлы вложения в `internal/server/files`, потому что DATA_DIR не был задан: теперь тесты используют временный каталог, а `saveUpload` отказывается работать без настроенного каталога данных вместо записи в текущий рабочий каталог процесса. Лишние файлы удалены.
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"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)
|
|
}
|
|
|
|
// 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()
|
|
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) {
|
|
user, _, ok := s.authenticate(w, r)
|
|
if !ok {
|
|
writeAPIError(w, auth.ErrSessionExpired)
|
|
return
|
|
}
|
|
file, err := s.requireFileAccess(r.Context(), chi.URLParam(r, "file_id"), user)
|
|
if err != nil {
|
|
writeHumaAPIError(w, err)
|
|
return
|
|
}
|
|
handle, err := os.Open(file.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))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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)),
|
|
})
|
|
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}})
|
|
}
|