958 lines
27 KiB
JavaScript
958 lines
27 KiB
JavaScript
const express = require("express");
|
|
const router = express.Router();
|
|
const multer = require("multer");
|
|
const path = require("path");
|
|
const fs = require("fs").promises;
|
|
const { requireAuth } = require("../middleware/auth");
|
|
const { pool } = require("../config/database");
|
|
const logger = require("../config/logger");
|
|
const { uploadLimiter } = require("../config/rateLimiter");
|
|
require("dotenv").config();
|
|
|
|
// Magic bytes for image file validation
|
|
const MAGIC_BYTES = {
|
|
jpeg: [0xff, 0xd8, 0xff],
|
|
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(12);
|
|
const fd = await fs.open(filePath, "r");
|
|
await fd.read(buffer, 0, 12, 0);
|
|
await fd.close();
|
|
|
|
// Check JPEG
|
|
if (mimetype === "image/jpeg" || mimetype === "image/jpg") {
|
|
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
|
}
|
|
// Check PNG
|
|
if (mimetype === "image/png") {
|
|
return (
|
|
buffer[0] === 0x89 &&
|
|
buffer[1] === 0x50 &&
|
|
buffer[2] === 0x4e &&
|
|
buffer[3] === 0x47
|
|
);
|
|
}
|
|
// Check GIF
|
|
if (mimetype === "image/gif") {
|
|
return buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46;
|
|
}
|
|
// Check WebP
|
|
if (mimetype === "image/webp") {
|
|
return (
|
|
buffer[0] === 0x52 &&
|
|
buffer[1] === 0x49 &&
|
|
buffer[2] === 0x46 &&
|
|
buffer[3] === 0x46
|
|
);
|
|
}
|
|
// 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 - extended to support more image formats and video
|
|
const ALLOWED_MIME_TYPES = (
|
|
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) || 100 * 1024 * 1024; // 100MB default for video support
|
|
|
|
// Configure multer for file uploads
|
|
const storage = multer.diskStorage({
|
|
destination: async function (req, file, cb) {
|
|
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
|
try {
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
cb(null, uploadDir);
|
|
} catch (error) {
|
|
logger.error("Error creating upload directory:", error);
|
|
cb(error);
|
|
}
|
|
},
|
|
filename: function (req, file, cb) {
|
|
// Generate unique filename
|
|
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const name = path
|
|
.basename(file.originalname, ext)
|
|
.replace(/[^a-z0-9]/gi, "-")
|
|
.toLowerCase()
|
|
.substring(0, 50); // Limit filename length
|
|
cb(null, name + "-" + uniqueSuffix + ext);
|
|
},
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: {
|
|
fileSize: MAX_FILE_SIZE,
|
|
files: 10, // Max 10 files per request
|
|
},
|
|
fileFilter: function (req, file, cb) {
|
|
// Validate MIME type
|
|
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
|
logger.warn("File upload rejected - invalid type", {
|
|
mimetype: file.mimetype,
|
|
userId: req.session?.user?.id,
|
|
});
|
|
return cb(
|
|
new Error(
|
|
`File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(
|
|
", ",
|
|
)}`,
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
|
|
// Validate file extension
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
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,
|
|
userId: req.session?.user?.id,
|
|
});
|
|
return cb(new Error("Invalid file extension"), false);
|
|
}
|
|
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
// Upload multiple files
|
|
router.post(
|
|
"/upload",
|
|
requireAuth,
|
|
uploadLimiter,
|
|
upload.array("files", 10),
|
|
async (req, res, next) => {
|
|
try {
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "No files uploaded",
|
|
});
|
|
}
|
|
|
|
const uploadedBy = req.session.user?.id || null;
|
|
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
|
const files = [];
|
|
|
|
// Validate file content with magic bytes
|
|
for (const file of req.files) {
|
|
const isValid = await validateFileContent(file.path, file.mimetype);
|
|
if (!isValid) {
|
|
logger.warn("File upload rejected - magic byte mismatch", {
|
|
filename: file.filename,
|
|
mimetype: file.mimetype,
|
|
userId: uploadedBy,
|
|
});
|
|
// Clean up invalid file
|
|
await fs
|
|
.unlink(file.path)
|
|
.catch((err) =>
|
|
logger.error("Failed to clean up invalid file:", err),
|
|
);
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `File ${file.originalname} failed security validation`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Insert each file into database
|
|
for (const file of req.files) {
|
|
try {
|
|
const result = await pool.query(
|
|
`INSERT INTO uploads
|
|
(filename, original_name, file_path, file_size, mime_type, uploaded_by, folder_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
|
RETURNING id, filename, original_name, file_path, file_size, mime_type, folder_id, created_at`,
|
|
[
|
|
file.filename,
|
|
file.originalname,
|
|
`/uploads/${file.filename}`,
|
|
file.size,
|
|
file.mimetype,
|
|
uploadedBy,
|
|
folderId,
|
|
],
|
|
);
|
|
|
|
files.push({
|
|
id: result.rows[0].id,
|
|
filename: result.rows[0].filename,
|
|
originalName: result.rows[0].original_name,
|
|
size: result.rows[0].file_size,
|
|
mimetype: result.rows[0].mime_type,
|
|
path: result.rows[0].file_path,
|
|
uploadDate: result.rows[0].created_at,
|
|
folderId: result.rows[0].folder_id,
|
|
});
|
|
|
|
logger.info("File uploaded successfully", {
|
|
fileId: result.rows[0].id,
|
|
filename: file.filename,
|
|
userId: uploadedBy,
|
|
});
|
|
} catch (dbError) {
|
|
logger.error("Database insert failed for file:", {
|
|
filename: file.filename,
|
|
error: dbError.message,
|
|
});
|
|
// Clean up this specific file
|
|
await fs
|
|
.unlink(file.path)
|
|
.catch((err) => logger.error("Failed to clean up file:", err));
|
|
}
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "Failed to save uploaded files",
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `${files.length} file(s) uploaded successfully`,
|
|
files: files,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Upload error:", error);
|
|
|
|
// Clean up all uploaded files on error
|
|
if (req.files) {
|
|
for (const file of req.files) {
|
|
try {
|
|
await fs.unlink(file.path);
|
|
} catch (unlinkError) {
|
|
logger.error("Error cleaning up file:", unlinkError);
|
|
}
|
|
}
|
|
}
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get all uploaded files
|
|
router.get("/uploads", requireAuth, async (req, res) => {
|
|
try {
|
|
const folderId = req.query.folder_id;
|
|
|
|
// SECURITY: Use parameterized queries for all conditions
|
|
let queryText;
|
|
const params = [];
|
|
|
|
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);
|
|
}
|
|
|
|
const result = await pool.query(queryText, params);
|
|
|
|
const files = result.rows.map((row) => ({
|
|
id: row.id,
|
|
filename: row.filename,
|
|
originalName: row.original_name,
|
|
size: row.file_size,
|
|
mimetype: row.mime_type,
|
|
path: row.file_path,
|
|
uploadDate: row.created_at,
|
|
uploadedBy: row.uploaded_by,
|
|
folderId: row.folder_id,
|
|
usedInType: row.used_in_type,
|
|
usedInId: row.used_in_id,
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
files: files,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error listing files:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete a file
|
|
router.delete("/uploads/:filename", requireAuth, async (req, res) => {
|
|
try {
|
|
const filename = req.params.filename;
|
|
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
|
|
|
// 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",
|
|
});
|
|
}
|
|
|
|
// Start transaction: delete from database first
|
|
const result = await pool.query(
|
|
"DELETE FROM uploads WHERE filename = $1 RETURNING id",
|
|
[filename],
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "File not found in database",
|
|
});
|
|
}
|
|
|
|
// Then delete physical file
|
|
try {
|
|
await fs.unlink(filePath);
|
|
} catch (fileError) {
|
|
logger.warn("File already deleted from disk:", filename);
|
|
// Continue anyway since database record is deleted
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "File deleted successfully",
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error deleting file:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete file by ID
|
|
router.delete("/uploads/id/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const fileId = parseInt(req.params.id);
|
|
|
|
// Get file info first
|
|
const fileResult = await pool.query(
|
|
"SELECT filename FROM uploads WHERE id = $1",
|
|
[fileId],
|
|
);
|
|
|
|
if (fileResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "File not found",
|
|
});
|
|
}
|
|
|
|
const filename = fileResult.rows[0].filename;
|
|
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
|
const filePath = path.join(uploadDir, filename);
|
|
|
|
// Delete from database
|
|
await pool.query("DELETE FROM uploads WHERE id = $1", [fileId]);
|
|
|
|
// Delete physical file
|
|
try {
|
|
await fs.unlink(filePath);
|
|
} catch (fileError) {
|
|
logger.warn("File already deleted from disk:", filename);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "File deleted successfully",
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error deleting file:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ===== FOLDER MANAGEMENT ROUTES =====
|
|
|
|
// Create a new folder
|
|
router.post("/folders", requireAuth, async (req, res) => {
|
|
try {
|
|
const { name, parent_id } = req.body;
|
|
|
|
if (!name || name.trim() === "") {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Folder name is required",
|
|
});
|
|
}
|
|
|
|
// Sanitize folder name
|
|
const sanitizedName = name.trim().replace(/[^a-zA-Z0-9\s\-_]/g, "");
|
|
|
|
// Build path
|
|
let path = `/${sanitizedName}`;
|
|
if (parent_id) {
|
|
const parentResult = await pool.query(
|
|
"SELECT path FROM media_folders WHERE id = $1",
|
|
[parent_id],
|
|
);
|
|
|
|
if (parentResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Parent folder not found",
|
|
});
|
|
}
|
|
|
|
path = `${parentResult.rows[0].path}/${sanitizedName}`;
|
|
}
|
|
|
|
const createdBy = req.session.user?.id || null;
|
|
|
|
const result = await pool.query(
|
|
`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],
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
folder: {
|
|
id: result.rows[0].id,
|
|
name: result.rows[0].name,
|
|
parentId: result.rows[0].parent_id,
|
|
path: result.rows[0].path,
|
|
createdAt: result.rows[0].created_at,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error.code === "23505") {
|
|
// Unique constraint violation
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "A folder with this name already exists in this location",
|
|
});
|
|
}
|
|
logger.error("Error creating folder:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get all folders
|
|
router.get("/folders", requireAuth, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT
|
|
f.id,
|
|
f.name,
|
|
f.parent_id,
|
|
f.path,
|
|
f.created_at,
|
|
(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`,
|
|
);
|
|
|
|
const folders = result.rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
parentId: row.parent_id,
|
|
path: row.path,
|
|
createdAt: row.created_at,
|
|
fileCount: parseInt(row.file_count),
|
|
subfolderCount: parseInt(row.subfolder_count),
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
folders: folders,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error listing folders:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete a folder (and optionally its contents)
|
|
router.delete("/folders/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const folderId = parseInt(req.params.id);
|
|
const deleteContents = req.query.delete_contents === "true";
|
|
|
|
// Check if folder exists
|
|
const folderResult = await pool.query(
|
|
"SELECT id, name FROM media_folders WHERE id = $1",
|
|
[folderId],
|
|
);
|
|
|
|
if (folderResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Folder not found",
|
|
});
|
|
}
|
|
|
|
if (deleteContents) {
|
|
// Delete all files in this folder and subfolders
|
|
const filesResult = await pool.query(
|
|
`SELECT u.filename FROM uploads u
|
|
WHERE u.folder_id = $1 OR u.folder_id IN (
|
|
SELECT id FROM media_folders WHERE path LIKE (
|
|
SELECT path || '%' FROM media_folders WHERE id = $1
|
|
)
|
|
)`,
|
|
[folderId],
|
|
);
|
|
|
|
// Delete physical files
|
|
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
|
for (const row of filesResult.rows) {
|
|
try {
|
|
await fs.unlink(path.join(uploadDir, row.filename));
|
|
} catch (err) {
|
|
logger.warn(`Could not delete file: ${row.filename}`, err);
|
|
}
|
|
}
|
|
|
|
// Delete folder (cascade will delete subfolders and DB records)
|
|
await pool.query("DELETE FROM media_folders WHERE id = $1", [folderId]);
|
|
} else {
|
|
// Check if folder has contents
|
|
const contentsCheck = await pool.query(
|
|
`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],
|
|
);
|
|
|
|
const fileCount = parseInt(contentsCheck.rows[0].file_count);
|
|
const subfolderCount = parseInt(contentsCheck.rows[0].subfolder_count);
|
|
|
|
if (fileCount > 0 || subfolderCount > 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: `Folder contains ${fileCount} file(s) and ${subfolderCount} subfolder(s). Delete contents first or use delete_contents=true`,
|
|
});
|
|
}
|
|
|
|
await pool.query("DELETE FROM media_folders WHERE id = $1", [folderId]);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Folder deleted successfully",
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error deleting folder:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Move files to a folder
|
|
router.patch("/uploads/move", requireAuth, async (req, res) => {
|
|
try {
|
|
const { file_ids, folder_id } = req.body;
|
|
|
|
if (!Array.isArray(file_ids) || file_ids.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "file_ids array is required",
|
|
});
|
|
}
|
|
|
|
const targetFolderId = folder_id || null;
|
|
|
|
// Verify folder exists if provided
|
|
if (targetFolderId) {
|
|
const folderCheck = await pool.query(
|
|
"SELECT id FROM media_folders WHERE id = $1",
|
|
[targetFolderId],
|
|
);
|
|
|
|
if (folderCheck.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Target folder not found",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Move files
|
|
const result = await pool.query(
|
|
`UPDATE uploads
|
|
SET folder_id = $1, updated_at = NOW()
|
|
WHERE id = ANY($2::int[])
|
|
RETURNING id`,
|
|
[targetFolderId, file_ids],
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `${result.rowCount} file(s) moved successfully`,
|
|
movedCount: result.rowCount,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error moving files:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Bulk delete files
|
|
router.post("/uploads/bulk-delete", requireAuth, async (req, res) => {
|
|
try {
|
|
const { file_ids } = req.body;
|
|
|
|
if (!Array.isArray(file_ids) || file_ids.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "file_ids array is required",
|
|
});
|
|
}
|
|
|
|
// Get filenames first
|
|
const filesResult = await pool.query(
|
|
"SELECT filename FROM uploads WHERE id = ANY($1::int[])",
|
|
[file_ids],
|
|
);
|
|
|
|
// Delete from database
|
|
const result = await pool.query(
|
|
"DELETE FROM uploads WHERE id = ANY($1::int[])",
|
|
[file_ids],
|
|
);
|
|
|
|
// Delete physical files
|
|
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
|
|
for (const row of filesResult.rows) {
|
|
try {
|
|
await fs.unlink(path.join(uploadDir, row.filename));
|
|
} catch (err) {
|
|
logger.warn(`Could not delete file: ${row.filename}`, err);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `${result.rowCount} file(s) deleted successfully`,
|
|
deletedCount: result.rowCount,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error bulk deleting files:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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;
|