feat: improve attachment headers (#13709)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Safer, consistent file downloads with automatic attachment headers and
filenames.
- Smarter MIME detection for uploads (avatars, workspace blobs, Copilot
files/transcripts).
  - Sensible default buffer limit when reading uploads.

- **Bug Fixes**
- Prevents risky content from rendering inline by forcing downloads and
adding no‑sniff protection.
- More accurate content types when original metadata is missing or
incorrect.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-10-09 16:04:18 +08:00
committed by GitHub
parent bf72833f05
commit 1b859a37c5
18 changed files with 143 additions and 33 deletions
@@ -1,7 +1,11 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { ActionForbidden, UserAvatarNotFound } from '../../base';
import {
ActionForbidden,
applyAttachHeaders,
UserAvatarNotFound,
} from '../../base';
import { Public } from '../auth/guard';
import { AvatarStorage } from '../storage';
@@ -30,6 +34,10 @@ export class UserAvatarController {
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('content-length', metadata.contentLength);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: `${id}`,
});
body.pipe(res);
}
@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
import {
CannotDeleteOwnAccount,
type FileUpload,
readBufferWithLimit,
sniffMime,
Throttle,
UserNotFound,
} from '../../base';
@@ -98,20 +100,20 @@ export class UserResolver {
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
if (!avatar.mimetype.startsWith('image/')) {
throw new Error('Invalid file type');
}
if (!user) {
throw new UserNotFound();
}
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
}
const avatarUrl = await this.storage.put(
`${user.id}-avatar-${Date.now()}`,
avatar.createReadStream(),
{
contentType: avatar.mimetype,
}
avatarBuffer,
{ contentType }
);
if (user.avatarUrl) {
@@ -2,6 +2,7 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import {
applyAttachHeaders,
BlobNotFound,
CallMetric,
CommentAttachmentNotFound,
@@ -83,6 +84,10 @@ export class WorkspacesController {
} else {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: name,
});
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);
@@ -215,6 +220,10 @@ export class WorkspacesController {
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: key,
});
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
body.pipe(res);