fix(invites): картинки оформления отдаются без сессии для страницы приглашения

- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
  приглашения (guild_icon, guild_banner, guild_splash, invite_background):
  их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
  принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
This commit is contained in:
2026-09-21 19:30:25 +03:00
parent 566e1cf981
commit 72537e9ff9
4 changed files with 185 additions and 17 deletions
+69 -4
View File
@@ -251,17 +251,67 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
// handleFileDownload отдаёт содержимое файла с проверкой прав на комнату.
func (s *Server) handleFileDownload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
fileID := chi.URLParam(r, "file_id")
user, err := s.optionalUser(r.Context(), r)
if err != nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
file, err := s.requireFileAccess(r.Context(), chi.URLParam(r, "file_id"), user)
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
}
handle, err := os.Open(file.StoragePath)
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)))
@@ -287,6 +337,21 @@ func (s *Server) handleFileDownload(w http.ResponseWriter, r *http.Request) {
}
}
// 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) {