187 lines
6.4 KiB
TypeScript
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;
|