webupdate
This commit is contained in:
@@ -15,14 +15,20 @@ const MAGIC_BYTES = {
|
||||
png: [0x89, 0x50, 0x4e, 0x47],
|
||||
gif: [0x47, 0x49, 0x46],
|
||||
webp: [0x52, 0x49, 0x46, 0x46],
|
||||
bmp: [0x42, 0x4d],
|
||||
tiff_le: [0x49, 0x49, 0x2a, 0x00],
|
||||
tiff_be: [0x4d, 0x4d, 0x00, 0x2a],
|
||||
ico: [0x00, 0x00, 0x01, 0x00],
|
||||
avif: [0x00, 0x00, 0x00], // AVIF starts with ftyp box
|
||||
heic: [0x00, 0x00, 0x00], // HEIC starts with ftyp box
|
||||
};
|
||||
|
||||
// Validate file content by checking magic bytes
|
||||
const validateFileContent = async (filePath, mimetype) => {
|
||||
try {
|
||||
const buffer = Buffer.alloc(8);
|
||||
const buffer = Buffer.alloc(12);
|
||||
const fd = await fs.open(filePath, "r");
|
||||
await fd.read(buffer, 0, 8, 0);
|
||||
await fd.read(buffer, 0, 12, 0);
|
||||
await fd.close();
|
||||
|
||||
// Check JPEG
|
||||
@@ -51,18 +57,73 @@ const validateFileContent = async (filePath, mimetype) => {
|
||||
buffer[3] === 0x46
|
||||
);
|
||||
}
|
||||
return false;
|
||||
// Check BMP
|
||||
if (mimetype === "image/bmp") {
|
||||
return buffer[0] === 0x42 && buffer[1] === 0x4d;
|
||||
}
|
||||
// Check TIFF (both little-endian and big-endian)
|
||||
if (mimetype === "image/tiff") {
|
||||
return (
|
||||
(buffer[0] === 0x49 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x2a &&
|
||||
buffer[3] === 0x00) ||
|
||||
(buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x4d &&
|
||||
buffer[2] === 0x00 &&
|
||||
buffer[3] === 0x2a)
|
||||
);
|
||||
}
|
||||
// Check ICO
|
||||
if (
|
||||
mimetype === "image/x-icon" ||
|
||||
mimetype === "image/vnd.microsoft.icon" ||
|
||||
mimetype === "image/ico"
|
||||
) {
|
||||
return (
|
||||
buffer[0] === 0x00 &&
|
||||
buffer[1] === 0x00 &&
|
||||
buffer[2] === 0x01 &&
|
||||
buffer[3] === 0x00
|
||||
);
|
||||
}
|
||||
// Check SVG (text-based, starts with < or whitespace then <)
|
||||
if (mimetype === "image/svg+xml") {
|
||||
const text = buffer.toString("utf8").trim();
|
||||
return text.startsWith("<") || text.startsWith("<?xml");
|
||||
}
|
||||
// Check AVIF/HEIC/HEIF (ftyp box based formats - more relaxed check)
|
||||
if (
|
||||
mimetype === "image/avif" ||
|
||||
mimetype === "image/heic" ||
|
||||
mimetype === "image/heif"
|
||||
) {
|
||||
// These formats have "ftyp" at offset 4
|
||||
return (
|
||||
buffer[4] === 0x66 &&
|
||||
buffer[5] === 0x74 &&
|
||||
buffer[6] === 0x79 &&
|
||||
buffer[7] === 0x70
|
||||
);
|
||||
}
|
||||
// Check video files (MP4, WebM, MOV, AVI, MKV - allow based on MIME type)
|
||||
if (mimetype.startsWith("video/")) {
|
||||
return true; // Trust MIME type for video files
|
||||
}
|
||||
// For unknown types, allow them through (rely on MIME type check)
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Magic byte validation error:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Allowed file types
|
||||
// Allowed file types - extended to support more image formats and video
|
||||
const ALLOWED_MIME_TYPES = (
|
||||
process.env.ALLOWED_FILE_TYPES || "image/jpeg,image/png,image/gif,image/webp"
|
||||
process.env.ALLOWED_FILE_TYPES ||
|
||||
"image/jpeg,image/jpg,image/png,image/gif,image/webp,image/bmp,image/tiff,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,image/ico,image/avif,image/heic,image/heif,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
|
||||
).split(",");
|
||||
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024; // 5MB default
|
||||
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 100 * 1024 * 1024; // 100MB default for video support
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
@@ -105,16 +166,35 @@ const upload = multer({
|
||||
return cb(
|
||||
new Error(
|
||||
`File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(
|
||||
", "
|
||||
)}`
|
||||
", ",
|
||||
)}`,
|
||||
),
|
||||
false
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
const allowedExtensions = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".svg",
|
||||
".ico",
|
||||
".avif",
|
||||
".heic",
|
||||
".heif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
];
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
logger.warn("File upload rejected - invalid extension", {
|
||||
extension: ext,
|
||||
@@ -159,7 +239,7 @@ router.post(
|
||||
await fs
|
||||
.unlink(file.path)
|
||||
.catch((err) =>
|
||||
logger.error("Failed to clean up invalid file:", err)
|
||||
logger.error("Failed to clean up invalid file:", err),
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -184,7 +264,7 @@ router.post(
|
||||
file.mimetype,
|
||||
uploadedBy,
|
||||
folderId,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
files.push({
|
||||
@@ -242,7 +322,7 @@ router.post(
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get all uploaded files
|
||||
@@ -250,35 +330,40 @@ router.get("/uploads", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const folderId = req.query.folder_id;
|
||||
|
||||
let query = `SELECT
|
||||
id,
|
||||
filename,
|
||||
original_name,
|
||||
file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
uploaded_by,
|
||||
folder_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
used_in_type,
|
||||
used_in_id
|
||||
FROM uploads`;
|
||||
|
||||
// SECURITY: Use parameterized queries for all conditions
|
||||
let queryText;
|
||||
const params = [];
|
||||
|
||||
if (folderId !== undefined) {
|
||||
if (folderId === "null" || folderId === "") {
|
||||
query += ` WHERE folder_id IS NULL`;
|
||||
} else {
|
||||
query += ` WHERE folder_id = $1`;
|
||||
params.push(parseInt(folderId));
|
||||
if (folderId === undefined) {
|
||||
queryText = `SELECT
|
||||
id, filename, original_name, file_path, file_size,
|
||||
mime_type, uploaded_by, folder_id, created_at,
|
||||
updated_at, used_in_type, used_in_id
|
||||
FROM uploads ORDER BY created_at DESC`;
|
||||
} else if (folderId === "null" || folderId === "") {
|
||||
queryText = `SELECT
|
||||
id, filename, original_name, file_path, file_size,
|
||||
mime_type, uploaded_by, folder_id, created_at,
|
||||
updated_at, used_in_type, used_in_id
|
||||
FROM uploads WHERE folder_id IS NULL ORDER BY created_at DESC`;
|
||||
} else {
|
||||
// SECURITY: Validate folder_id is a valid integer
|
||||
const parsedFolderId = parseInt(folderId, 10);
|
||||
if (isNaN(parsedFolderId) || parsedFolderId < 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid folder ID",
|
||||
});
|
||||
}
|
||||
queryText = `SELECT
|
||||
id, filename, original_name, file_path, file_size,
|
||||
mime_type, uploaded_by, folder_id, created_at,
|
||||
updated_at, used_in_type, used_in_id
|
||||
FROM uploads WHERE folder_id = $1 ORDER BY created_at DESC`;
|
||||
params.push(parsedFolderId);
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
const result = await pool.query(queryText, params);
|
||||
|
||||
const files = result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -312,10 +397,30 @@ router.delete("/uploads/:filename", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const filename = req.params.filename;
|
||||
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
|
||||
// Security check: ensure file is within uploads directory
|
||||
if (!filePath.startsWith(uploadDir)) {
|
||||
// SECURITY: Sanitize filename - remove any path traversal attempts
|
||||
const sanitizedFilename = path
|
||||
.basename(filename)
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
if (!sanitizedFilename || sanitizedFilename !== filename) {
|
||||
logger.warn("Path traversal attempt detected", { filename, ip: req.ip });
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid filename",
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, sanitizedFilename);
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedUploadDir = path.resolve(uploadDir);
|
||||
|
||||
// SECURITY: Double-check path is within uploads directory after resolution
|
||||
if (!resolvedPath.startsWith(resolvedUploadDir + path.sep)) {
|
||||
logger.warn("Path traversal attempt blocked", {
|
||||
filename,
|
||||
resolvedPath,
|
||||
ip: req.ip,
|
||||
});
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid file path",
|
||||
@@ -325,7 +430,7 @@ router.delete("/uploads/:filename", requireAuth, async (req, res) => {
|
||||
// Start transaction: delete from database first
|
||||
const result = await pool.query(
|
||||
"DELETE FROM uploads WHERE filename = $1 RETURNING id",
|
||||
[filename]
|
||||
[filename],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
@@ -364,7 +469,7 @@ router.delete("/uploads/id/:id", requireAuth, async (req, res) => {
|
||||
// Get file info first
|
||||
const fileResult = await pool.query(
|
||||
"SELECT filename FROM uploads WHERE id = $1",
|
||||
[fileId]
|
||||
[fileId],
|
||||
);
|
||||
|
||||
if (fileResult.rows.length === 0) {
|
||||
@@ -423,7 +528,7 @@ router.post("/folders", requireAuth, async (req, res) => {
|
||||
if (parent_id) {
|
||||
const parentResult = await pool.query(
|
||||
"SELECT path FROM media_folders WHERE id = $1",
|
||||
[parent_id]
|
||||
[parent_id],
|
||||
);
|
||||
|
||||
if (parentResult.rows.length === 0) {
|
||||
@@ -442,7 +547,7 @@ router.post("/folders", requireAuth, async (req, res) => {
|
||||
`INSERT INTO media_folders (name, parent_id, path, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, parent_id, path, created_at`,
|
||||
[sanitizedName, parent_id || null, path, createdBy]
|
||||
[sanitizedName, parent_id || null, path, createdBy],
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -484,7 +589,7 @@ router.get("/folders", requireAuth, async (req, res) => {
|
||||
(SELECT COUNT(*) FROM uploads WHERE folder_id = f.id) as file_count,
|
||||
(SELECT COUNT(*) FROM media_folders WHERE parent_id = f.id) as subfolder_count
|
||||
FROM media_folders f
|
||||
ORDER BY f.path ASC`
|
||||
ORDER BY f.path ASC`,
|
||||
);
|
||||
|
||||
const folders = result.rows.map((row) => ({
|
||||
@@ -519,7 +624,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
|
||||
// Check if folder exists
|
||||
const folderResult = await pool.query(
|
||||
"SELECT id, name FROM media_folders WHERE id = $1",
|
||||
[folderId]
|
||||
[folderId],
|
||||
);
|
||||
|
||||
if (folderResult.rows.length === 0) {
|
||||
@@ -538,7 +643,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
|
||||
SELECT path || '%' FROM media_folders WHERE id = $1
|
||||
)
|
||||
)`,
|
||||
[folderId]
|
||||
[folderId],
|
||||
);
|
||||
|
||||
// Delete physical files
|
||||
@@ -559,7 +664,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM uploads WHERE folder_id = $1) as file_count,
|
||||
(SELECT COUNT(*) FROM media_folders WHERE parent_id = $1) as subfolder_count`,
|
||||
[folderId]
|
||||
[folderId],
|
||||
);
|
||||
|
||||
const fileCount = parseInt(contentsCheck.rows[0].file_count);
|
||||
@@ -606,7 +711,7 @@ router.patch("/uploads/move", requireAuth, async (req, res) => {
|
||||
if (targetFolderId) {
|
||||
const folderCheck = await pool.query(
|
||||
"SELECT id FROM media_folders WHERE id = $1",
|
||||
[targetFolderId]
|
||||
[targetFolderId],
|
||||
);
|
||||
|
||||
if (folderCheck.rows.length === 0) {
|
||||
@@ -623,7 +728,7 @@ router.patch("/uploads/move", requireAuth, async (req, res) => {
|
||||
SET folder_id = $1, updated_at = NOW()
|
||||
WHERE id = ANY($2::int[])
|
||||
RETURNING id`,
|
||||
[targetFolderId, file_ids]
|
||||
[targetFolderId, file_ids],
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -655,13 +760,13 @@ router.post("/uploads/bulk-delete", requireAuth, async (req, res) => {
|
||||
// Get filenames first
|
||||
const filesResult = await pool.query(
|
||||
"SELECT filename FROM uploads WHERE id = ANY($1::int[])",
|
||||
[file_ids]
|
||||
[file_ids],
|
||||
);
|
||||
|
||||
// Delete from database
|
||||
const result = await pool.query(
|
||||
"DELETE FROM uploads WHERE id = ANY($1::int[])",
|
||||
[file_ids]
|
||||
[file_ids],
|
||||
);
|
||||
|
||||
// Delete physical files
|
||||
@@ -688,4 +793,165 @@ router.post("/uploads/bulk-delete", requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a file
|
||||
router.patch("/uploads/:id/rename", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const fileId = parseInt(req.params.id);
|
||||
const { newName } = req.body;
|
||||
|
||||
if (!newName || newName.trim() === "") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "New name is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current file info
|
||||
const fileResult = await pool.query(
|
||||
"SELECT filename, original_name FROM uploads WHERE id = $1",
|
||||
[fileId],
|
||||
);
|
||||
|
||||
if (fileResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "File not found",
|
||||
});
|
||||
}
|
||||
|
||||
const currentFile = fileResult.rows[0];
|
||||
const ext = path.extname(currentFile.filename);
|
||||
|
||||
// Sanitize new name and keep extension
|
||||
const sanitizedName = newName
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s\-_]/gi, "-")
|
||||
.toLowerCase()
|
||||
.substring(0, 100);
|
||||
|
||||
const newFilename = sanitizedName + "-" + Date.now() + ext;
|
||||
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
||||
const oldPath = path.join(uploadDir, currentFile.filename);
|
||||
const newPath = path.join(uploadDir, newFilename);
|
||||
|
||||
// Rename physical file
|
||||
try {
|
||||
await fs.rename(oldPath, newPath);
|
||||
} catch (fileError) {
|
||||
logger.error("Error renaming physical file:", fileError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to rename file on disk",
|
||||
});
|
||||
}
|
||||
|
||||
// Update database
|
||||
const result = await pool.query(
|
||||
`UPDATE uploads
|
||||
SET filename = $1, original_name = $2, file_path = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id, filename, original_name, file_path`,
|
||||
[newFilename, newName.trim() + ext, `/uploads/${newFilename}`, fileId],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "File renamed successfully",
|
||||
file: {
|
||||
id: result.rows[0].id,
|
||||
filename: result.rows[0].filename,
|
||||
originalName: result.rows[0].original_name,
|
||||
path: result.rows[0].file_path,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error renaming file:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a folder
|
||||
router.patch("/folders/:id/rename", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const folderId = parseInt(req.params.id);
|
||||
const { newName } = req.body;
|
||||
|
||||
if (!newName || newName.trim() === "") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "New name is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current folder info
|
||||
const folderResult = await pool.query(
|
||||
"SELECT id, name, parent_id, path FROM media_folders WHERE id = $1",
|
||||
[folderId],
|
||||
);
|
||||
|
||||
if (folderResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Folder not found",
|
||||
});
|
||||
}
|
||||
|
||||
const currentFolder = folderResult.rows[0];
|
||||
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9\s\-_]/g, "");
|
||||
|
||||
// Build new path
|
||||
const oldPath = currentFolder.path;
|
||||
const pathParts = oldPath.split("/");
|
||||
pathParts[pathParts.length - 1] = sanitizedName;
|
||||
const newPath = pathParts.join("/");
|
||||
|
||||
// Check for duplicate name in same parent
|
||||
const duplicateCheck = await pool.query(
|
||||
`SELECT id FROM media_folders
|
||||
WHERE name = $1 AND parent_id IS NOT DISTINCT FROM $2 AND id != $3`,
|
||||
[sanitizedName, currentFolder.parent_id, folderId],
|
||||
);
|
||||
|
||||
if (duplicateCheck.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "A folder with this name already exists in this location",
|
||||
});
|
||||
}
|
||||
|
||||
// Update folder and all subfolders paths
|
||||
await pool.query(
|
||||
`UPDATE media_folders SET name = $1, path = $2, updated_at = NOW() WHERE id = $3`,
|
||||
[sanitizedName, newPath, folderId],
|
||||
);
|
||||
|
||||
// Update subfolders paths
|
||||
await pool.query(
|
||||
`UPDATE media_folders
|
||||
SET path = REPLACE(path, $1, $2), updated_at = NOW()
|
||||
WHERE path LIKE $3 AND id != $4`,
|
||||
[oldPath, newPath, oldPath + "/%", folderId],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Folder renamed successfully",
|
||||
folder: {
|
||||
id: folderId,
|
||||
name: sanitizedName,
|
||||
path: newPath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error renaming folder:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user