Files
SkyArtShop/backend/routes/upload.js

692 lines
19 KiB
JavaScript
Raw Normal View History

2025-12-14 01:54:40 -06:00
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");
2025-12-19 20:44:46 -06:00
const logger = require("../config/logger");
const { uploadLimiter } = require("../config/rateLimiter");
require("dotenv").config();
2026-01-04 17:52:37 -06:00
// 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],
};
// Validate file content by checking magic bytes
const validateFileContent = async (filePath, mimetype) => {
try {
const buffer = Buffer.alloc(8);
const fd = await fs.open(filePath, "r");
await fd.read(buffer, 0, 8, 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
);
}
return false;
} catch (error) {
logger.error("Magic byte validation error:", error);
return false;
}
};
2025-12-19 20:44:46 -06:00
// Allowed file types
const ALLOWED_MIME_TYPES = (
process.env.ALLOWED_FILE_TYPES || "image/jpeg,image/png,image/gif,image/webp"
).split(",");
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024; // 5MB default
2025-12-14 01:54:40 -06:00
// 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) {
2025-12-19 20:44:46 -06:00
logger.error("Error creating upload directory:", error);
2025-12-14 01:54:40 -06:00
cb(error);
}
},
filename: function (req, file, cb) {
// Generate unique filename
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
2025-12-19 20:44:46 -06:00
const ext = path.extname(file.originalname).toLowerCase();
2025-12-14 01:54:40 -06:00
const name = path
.basename(file.originalname, ext)
.replace(/[^a-z0-9]/gi, "-")
2025-12-19 20:44:46 -06:00
.toLowerCase()
.substring(0, 50); // Limit filename length
2025-12-14 01:54:40 -06:00
cb(null, name + "-" + uniqueSuffix + ext);
},
});
const upload = multer({
storage: storage,
limits: {
2025-12-19 20:44:46 -06:00
fileSize: MAX_FILE_SIZE,
files: 10, // Max 10 files per request
2025-12-14 01:54:40 -06:00
},
fileFilter: function (req, file, cb) {
2025-12-19 20:44:46 -06:00
// 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"];
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);
2025-12-14 01:54:40 -06:00
}
2025-12-19 20:44:46 -06:00
2025-12-14 01:54:40 -06:00
cb(null, true);
},
});
// Upload multiple files
router.post(
"/upload",
requireAuth,
2025-12-19 20:44:46 -06:00
uploadLimiter,
2025-12-14 01:54:40 -06:00
upload.array("files", 10),
2025-12-19 20:44:46 -06:00
async (req, res, next) => {
2025-12-14 01:54:40 -06:00
try {
2025-12-19 20:44:46 -06:00
if (!req.files || req.files.length === 0) {
return res.status(400).json({
success: false,
message: "No files uploaded",
});
}
2025-12-14 01:54:40 -06:00
const uploadedBy = req.session.user?.id || null;
2025-12-19 20:44:46 -06:00
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
2025-12-14 01:54:40 -06:00
const files = [];
2026-01-04 17:52:37 -06:00
// 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`,
});
}
}
2025-12-14 01:54:40 -06:00
// Insert each file into database
for (const file of req.files) {
2025-12-19 20:44:46 -06:00
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",
2025-12-14 01:54:40 -06:00
});
}
res.json({
success: true,
message: `${files.length} file(s) uploaded successfully`,
files: files,
});
} catch (error) {
2025-12-19 20:44:46 -06:00
logger.error("Upload error:", error);
2025-12-14 01:54:40 -06:00
2025-12-19 20:44:46 -06:00
// Clean up all uploaded files on error
2025-12-14 01:54:40 -06:00
if (req.files) {
for (const file of req.files) {
try {
await fs.unlink(file.path);
} catch (unlinkError) {
2025-12-19 20:44:46 -06:00
logger.error("Error cleaning up file:", unlinkError);
2025-12-14 01:54:40 -06:00
}
}
}
2025-12-19 20:44:46 -06:00
next(error);
2025-12-14 01:54:40 -06:00
}
}
);
// Get all uploaded files
router.get("/uploads", requireAuth, async (req, res) => {
try {
2025-12-19 20:44:46 -06:00
const folderId = req.query.folder_id;
let query = `SELECT
2025-12-14 01:54:40 -06:00
id,
filename,
original_name,
file_path,
file_size,
mime_type,
uploaded_by,
2025-12-19 20:44:46 -06:00
folder_id,
2025-12-14 01:54:40 -06:00
created_at,
updated_at,
used_in_type,
used_in_id
2025-12-19 20:44:46 -06:00
FROM uploads`;
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));
}
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
2025-12-14 01:54:40 -06:00
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,
2025-12-19 20:44:46 -06:00
folderId: row.folder_id,
2025-12-14 01:54:40 -06:00
usedInType: row.used_in_type,
usedInId: row.used_in_id,
}));
res.json({
success: true,
files: files,
});
} catch (error) {
2025-12-19 20:44:46 -06:00
logger.error("Error listing files:", error);
2025-12-14 01:54:40 -06:00
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");
const filePath = path.join(uploadDir, filename);
// Security check: ensure file is within uploads directory
if (!filePath.startsWith(uploadDir)) {
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) {
2025-12-19 20:44:46 -06:00
logger.warn("File already deleted from disk:", filename);
2025-12-14 01:54:40 -06:00
// Continue anyway since database record is deleted
}
res.json({
success: true,
message: "File deleted successfully",
});
} catch (error) {
2025-12-19 20:44:46 -06:00
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);
2025-12-14 01:54:40 -06:00
res.status(500).json({
success: false,
error: error.message,
});
}
});
module.exports = router;