feat(files): загрузка и выдача вложений

- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
  диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
  очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
  (невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
  Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
  ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
  (writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.

Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
2026-09-19 23:36:16 +03:00
parent 63bf1e63b5
commit 0e39b8e027
25 changed files with 1162 additions and 39 deletions
+147
View File
@@ -1,7 +1,12 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"glchat/internal/permissions"
@@ -371,3 +376,145 @@ func TestTypingAndReadState(t *testing.T) {
t.Fatalf("read states = %+v", states)
}
}
func TestFileUploadAndAccess(t *testing.T) {
f := newMessagingFixture(t)
httpServer := httptest.NewServer(f.srv.Handler())
t.Cleanup(httpServer.Close)
content := []byte("вложение: проверка загрузки")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "документ.txt")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
httpServer.URL+"/api/v1/channels/"+f.openChannel+"/files", bytes.NewReader(body.Bytes()))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(f.memberCookie)
resp, err := httpServer.Client().Do(req)
if err != nil {
t.Fatalf("upload: %v", err)
}
payload, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("upload = %d, body = %s", resp.StatusCode, payload)
}
var decoded struct {
File struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
} `json:"file"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode upload: %v (%s)", err, payload)
}
if decoded.File.FileID == "" || decoded.File.Filename != "документ.txt" || decoded.File.SizeBytes != int64(len(content)) {
t.Fatalf("unexpected upload payload: %s", payload)
}
// Скачивание участником комнаты.
downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+decoded.File.FileID, nil)
if err != nil {
t.Fatalf("new download request: %v", err)
}
downloadReq.AddCookie(f.memberCookie)
downloadResp, err := httpServer.Client().Do(downloadReq)
if err != nil {
t.Fatalf("download: %v", err)
}
downloaded, _ := io.ReadAll(downloadResp.Body)
_ = downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK || !bytes.Equal(downloaded, content) {
t.Fatalf("download = %d, body = %q", downloadResp.StatusCode, downloaded)
}
if downloadResp.Header.Get("ETag") == "" {
t.Error("ETag header is missing")
}
// Файл из скрытой комнаты недоступен тому, кто её не видит: загружаем
// тот же контент владельцем в «тайную» и проверяем участника.
secretBody := &bytes.Buffer{}
secretWriter := multipart.NewWriter(secretBody)
secretPart, err := secretWriter.CreateFormFile("file", "секрет.txt")
if err != nil {
t.Fatalf("CreateFormFile secret: %v", err)
}
if _, err := secretPart.Write(content); err != nil {
t.Fatalf("write secret part: %v", err)
}
if err := secretWriter.Close(); err != nil {
t.Fatalf("close secret writer: %v", err)
}
secretReq, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
httpServer.URL+"/api/v1/channels/"+f.secretID+"/files", bytes.NewReader(secretBody.Bytes()))
if err != nil {
t.Fatalf("new secret request: %v", err)
}
secretReq.Header.Set("Content-Type", secretWriter.FormDataContentType())
secretReq.AddCookie(f.ownerCookie)
secretResp, err := httpServer.Client().Do(secretReq)
if err != nil {
t.Fatalf("secret upload: %v", err)
}
secretPayload, _ := io.ReadAll(secretResp.Body)
_ = secretResp.Body.Close()
if secretResp.StatusCode != http.StatusOK {
t.Fatalf("secret upload = %d, body = %s", secretResp.StatusCode, secretPayload)
}
var secretFile struct {
File struct {
FileID string `json:"file_id"`
} `json:"file"`
}
if err := json.Unmarshal(secretPayload, &secretFile); err != nil {
t.Fatalf("decode secret upload: %v", err)
}
hiddenReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+secretFile.File.FileID, nil)
if err != nil {
t.Fatalf("new hidden request: %v", err)
}
hiddenReq.AddCookie(f.memberCookie)
hiddenResp, err := httpServer.Client().Do(hiddenReq)
if err != nil {
t.Fatalf("hidden download: %v", err)
}
_ = hiddenResp.Body.Close()
if hiddenResp.StatusCode != http.StatusNotFound {
t.Fatalf("hidden channel file = %d, want 404", hiddenResp.StatusCode)
}
// Вложение прикрепляется к сообщению и попадает в ответ API.
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"","attachment_ids":["`+decoded.File.FileID+`"]}`, f.memberCookie)
if sent.Code != http.StatusOK {
t.Fatalf("message with attachment = %d, body = %s", sent.Code, sent.Body.String())
}
message := decodeResponse[struct {
Message struct {
Attachments []struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
} `json:"attachments"`
} `json:"message"`
}](t, sent)
if len(message.Message.Attachments) != 1 || message.Message.Attachments[0].FileID != decoded.File.FileID {
t.Fatalf("attachments = %+v", message.Message.Attachments)
}
}