feat(documents): папки шаблонов, маппинги и поле document_template; fix(batch): атомарное создание задач, idempotency и массовое удаление в реестре
This commit is contained in:
@@ -133,20 +133,40 @@ app.get('/api/files/:key/presigned', authenticateToken, async (req: Authenticate
|
||||
});
|
||||
|
||||
// Helper to set Content-Type and Content-Disposition headers based on file extension.
|
||||
function setFileResponseHeaders(res: Response, fileKey: string, overrideContentType?: string, overrideContentDisposition?: string) {
|
||||
function setFileResponseHeaders(
|
||||
res: Response,
|
||||
fileKey: string,
|
||||
overrideContentType?: string,
|
||||
overrideContentDisposition?: string,
|
||||
originalName?: string | null,
|
||||
) {
|
||||
const ext = getFileExt(fileKey);
|
||||
const contentType = overrideContentType || EXT_TO_MIME[ext]?.[0] || 'application/octet-stream';
|
||||
const isPdf = ext === 'pdf';
|
||||
const utf8Name = originalName ? `filename*=UTF-8''${encodeURIComponent(originalName)}` : '';
|
||||
// PDFs are always shown inline (browser iframe preview).
|
||||
// Other files use S3-provided disposition if available, defaulting to attachment.
|
||||
// Prefer file_uploads.originalName (UTF-8) over raw fileKey to avoid encoding issues.
|
||||
const contentDisposition = isPdf
|
||||
? `inline; filename="${fileKey}"`
|
||||
: (overrideContentDisposition || `attachment; filename="${fileKey}"`);
|
||||
? (utf8Name ? `inline; ${utf8Name}` : `inline; filename="${fileKey}"`)
|
||||
: (overrideContentDisposition || (utf8Name ? `attachment; ${utf8Name}` : `attachment; filename="${fileKey}"`));
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
async function getFileOriginalName(fileKey: string): Promise<string | undefined> {
|
||||
try {
|
||||
const rows = await db.select({ originalName: fileUploads.originalName })
|
||||
.from(fileUploads)
|
||||
.where(eq(fileUploads.fileKey, fileKey))
|
||||
.limit(1);
|
||||
return rows[0]?.originalName || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isS3Enabled) {
|
||||
// S3/MinIO mode: files served via /api/files/:key.
|
||||
app.use('/api/files/:key', tryPresignedOrAuth(), async (req: AuthenticatedRequest, res: Response) => {
|
||||
@@ -160,7 +180,8 @@ if (isS3Enabled) {
|
||||
|
||||
const obj = await streamFromS3(key);
|
||||
if (!obj) return res.status(404).end();
|
||||
setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined);
|
||||
const originalName = await getFileOriginalName(key);
|
||||
setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined, originalName);
|
||||
if (obj.contentLength) res.setHeader('Content-Length', obj.contentLength);
|
||||
(obj.body as any).pipe(res);
|
||||
});
|
||||
@@ -181,7 +202,8 @@ if (isS3Enabled) {
|
||||
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
||||
setFileResponseHeaders(res, filename);
|
||||
const originalName = await getFileOriginalName(filename);
|
||||
setFileResponseHeaders(res, filename, undefined, undefined, originalName);
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
}
|
||||
@@ -1100,10 +1122,12 @@ async function runStartupDataPatches() {
|
||||
// this serves both the API and the client.
|
||||
// It is the only port that is not firewalled.
|
||||
const port = parseInt(process.env.PORT || '5000', 10);
|
||||
// reusePort is not supported on Windows; skip it for native Windows dev
|
||||
const isWindows = process.platform === 'win32';
|
||||
server.listen({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
reusePort: true,
|
||||
...(isWindows ? {} : { reusePort: true }),
|
||||
}, () => {
|
||||
log(`serving on port ${port}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user