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:
@@ -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) {
|
||||
|
||||
+18
-7
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -144,12 +144,16 @@ export default function InvitePage() {
|
||||
|
||||
{guild.inviter === undefined ? null : (
|
||||
<div className="mt-4 flex items-center gap-2" data-testid="invite-inviter">
|
||||
{/* Аватар человека показываем только вошедшим: файлы профилей
|
||||
анонимно не отдаются. */}
|
||||
{guest ? null : (
|
||||
<Avatar
|
||||
name={inviterName}
|
||||
seed={guild.inviter.id}
|
||||
fileId={guild.inviter.avatar_file_id}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-fg-muted">
|
||||
{t('invites.inviter', { name: inviterName })}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user