diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 6d2b8e3..1b01b14 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -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) { diff --git a/internal/server/auth.go b/internal/server/auth.go index 18f3a72..53ed000 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -187,18 +187,29 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } // authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1). +// optionalUser резолвит сессию, не отвечая ошибкой: `nil` — анонимный запрос. +// Нужен ручкам, которые часть данных отдают и без входа (страница приглашения). +func (s *Server) optionalUser(ctx context.Context, r *http.Request) (*store.User, error) { + if s.auth == nil { + return nil, nil + } + token := sessionToken(r) + if token == "" { + return nil, nil + } + user, _, err := s.auth.ResolveSession(ctx, token) + if err != nil { + return nil, err + } + return user, nil +} + func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) { if s.auth == nil { writeAPIError(w, auth.ErrSessionExpired) return nil, nil, false } - token := "" - if cookie, err := r.Cookie(sessionCookieName); err == nil { - token = cookie.Value - } - if token == "" { - token = normalizeBearer(r.Header.Get("Authorization")) - } + token := sessionToken(r) if token == "" { writeAPIError(w, auth.ErrSessionExpired) return nil, nil, false diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 0f8b28e..912f5a4 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -697,3 +697,91 @@ func TestInviteBackgroundAndPreview(t *testing.T) { t.Fatalf("переопределение не снято: %s", cleared.Body.String()) } } + +func TestPresentationFilesArePublic(t *testing.T) { + srv, owner, _, guildID, _ := cosmeticsFixture(t) + + // Оформление сервера: splash загружается как обычно. + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", "splash.png") + if err != nil { + t.Fatalf("multipart: %v", err) + } + if _, err := part.Write(pngBytes()); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/guilds/"+guildID+"/appearance/splash", &buf) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.AddCookie(owner) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, request) + if rec.Code != http.StatusOK { + t.Fatalf("загрузка splash = %d (%s)", rec.Code, rec.Body.String()) + } + splashID := decodeResponse[struct { + Guild struct { + SplashFileID string `json:"splash_file_id"` + } `json:"guild"` + }](t, rec).Guild.SplashFileID + + // Без сессии картинка оформления отдаётся: её показывают до входа. + anonymous := doJSON(t, srv, http.MethodGet, "/files/"+splashID, "") + if anonymous.Code != http.StatusOK { + t.Fatalf("splash без сессии = %d, ожидался 200", anonymous.Code) + } + + // Обычное вложение без сессии по-прежнему закрыто. + var fileBuf bytes.Buffer + fileWriter := multipart.NewWriter(&fileBuf) + filePart, err := fileWriter.CreateFormFile("file", "secret.txt") + if err != nil { + t.Fatalf("multipart: %v", err) + } + if _, err := filePart.Write([]byte("секрет")); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := fileWriter.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + uploadReq := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/channels/"+guildChannelID(t, srv, guildID, owner)+"/files", &fileBuf) + uploadReq.Header.Set("Content-Type", fileWriter.FormDataContentType()) + uploadReq.AddCookie(owner) + uploadRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(uploadRec, uploadReq) + if uploadRec.Code != http.StatusOK { + t.Fatalf("загрузка вложения = %d (%s)", uploadRec.Code, uploadRec.Body.String()) + } + attachmentID := decodeResponse[struct { + File struct { + FileID string `json:"file_id"` + } `json:"file"` + }](t, uploadRec).File.FileID + if rec := doJSON(t, srv, http.MethodGet, "/files/"+attachmentID, ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("вложение без сессии = %d, ожидался 401", rec.Code) + } +} + +// guildChannelID возвращает первую текстовую комнату сервера. +func guildChannelID(t *testing.T, srv *Server, guildID string, cookie *http.Cookie) string { + t.Helper() + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", cookie) + channels := decodeResponse[struct { + Channels []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"channels"` + }](t, list) + for _, channel := range channels.Channels { + if channel.Type == "text" { + return channel.ID + } + } + t.Fatal("текстовая комната не найдена") + return "" +} diff --git a/web/src/pages/InvitePage.tsx b/web/src/pages/InvitePage.tsx index 6e02ab3..91e6c3b 100644 --- a/web/src/pages/InvitePage.tsx +++ b/web/src/pages/InvitePage.tsx @@ -144,12 +144,16 @@ export default function InvitePage() { {guild.inviter === undefined ? null : (