Files
forkmessager/apps/server/src/shared.ts
Халимов Рустам 540ded7009 фикс багов
2026-03-10 23:16:44 +03:00

187 lines
6.4 KiB
TypeScript

import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { Request, Response, NextFunction } from 'express';
import { encryptFileInPlace, isEncryptionEnabled } from './encrypt';
// ─── Prisma select objects ────────────────────────────────────────────
/** Standard user fields to include in API responses (excludes password) */
export const USER_SELECT = {
id: true,
username: true,
displayName: true,
avatar: true,
bio: true,
birthday: true,
isOnline: true,
lastSeen: true,
createdAt: true,
hideStoryViews: true,
} as const;
/** Compact user fields for message sender / forwarded-from */
export const SENDER_SELECT = {
id: true,
username: true,
displayName: true,
avatar: true,
} as const;
/** Full message include for API responses */
export const MESSAGE_INCLUDE = {
sender: { select: SENDER_SELECT },
forwardedFrom: { select: SENDER_SELECT },
replyTo: {
include: { sender: { select: { id: true, username: true, displayName: true } } },
},
media: true,
reactions: {
include: { user: { select: { id: true, username: true, displayName: true } } },
},
readBy: { select: { userId: true } },
} as const;
// ─── File system helpers ──────────────────────────────────────────────
const uploadsRoot = path.join(__dirname, '../uploads');
/** Ensure a directory exists (recursive). */
export function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/** Safely delete a file from the uploads directory given its URL path (e.g. '/uploads/avatars/abc.jpg'). */
export function deleteUploadedFile(urlPath: string): void {
if (!urlPath) return;
try {
const filename = urlPath.replace(/^\/uploads\//, '');
const filePath = path.resolve(uploadsRoot, filename);
// Path containment check — prevent directory traversal
if (!filePath.startsWith(uploadsRoot)) {
console.error('Path traversal attempt blocked:', urlPath);
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
console.error('Failed to delete file:', urlPath, e);
}
}
// ─── Multer configurations ───────────────────────────────────────────
const avatarsDir = path.join(uploadsRoot, 'avatars');
ensureDir(avatarsDir);
ensureDir(uploadsRoot);
/** Allowed image extensions for avatars. */
const ALLOWED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']);
function createAvatarStorage(prefix = '') {
return multer.diskStorage({
destination: (_req, _file, cb) => cb(null, avatarsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${prefix}${uuidv4()}${ext}`);
},
});
}
/** Multer middleware for user avatar uploads (max 5MB, images only). */
export const uploadUserAvatar = multer({
storage: createAvatarStorage(''),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
},
});
/** Multer middleware for group avatar uploads (max 5MB, images only). */
export const uploadGroupAvatar = multer({
storage: createAvatarStorage('group-'),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
},
});
/** Blocked file extensions that could be served as executable content. */
const BLOCKED_EXTENSIONS = new Set([
'.html', '.htm', '.svg', '.xml', '.xhtml',
'.php', '.jsp', '.asp', '.aspx', '.cgi',
'.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif',
'.sh', '.bash', '.ps1', '.psm1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh',
'.dll', '.sys', '.drv',
'.hta', '.cpl', '.inf', '.reg',
]);
/** Multer middleware for general file uploads (max 50MB). */
export const uploadFile = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsRoot),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
},
}),
limits: { fileSize: 200 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
cb(new Error('Этот тип файла не разрешён'));
} else {
cb(null, true);
}
},
});
// ─── Post-upload file encryption middleware ───────────────────────────
/**
* Express middleware that encrypts an uploaded file in-place after multer
* has written it to disk. Use after any multer middleware.
*/
export function encryptUploadedFile(req: Request, _res: Response, next: NextFunction): void {
if (!isEncryptionEnabled()) return next();
try {
// Single file upload (req.file)
if (req.file) {
// Don't encrypt videos and audio as they need range support and are large
if (!req.file.mimetype.startsWith('video/') && !req.file.mimetype.startsWith('audio/')) {
encryptFileInPlace(req.file.path);
}
}
// Multiple files (req.files) — handle both array and field-keyed forms
if (req.files) {
const files = Array.isArray(req.files)
? req.files
: Object.values(req.files).flat() as Express.Multer.File[];
for (const file of files) {
if (!file.mimetype.startsWith('video/') && !file.mimetype.startsWith('audio/')) {
encryptFileInPlace(file.path);
}
}
}
} catch (e) {
console.error('File encryption error:', e);
// Don't block the request — file is already saved, just unencrypted
}
next();
}
/** Absolute path to the uploads root directory. */
export const UPLOADS_ROOT = uploadsRoot;