webupdate

This commit is contained in:
Local Server
2026-01-18 02:22:05 -06:00
parent 6fc159051a
commit 2a2a3d99e5
135 changed files with 54897 additions and 9825 deletions

View File

@@ -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;