mirror of
https://github.com/DumbWareio/DumbDrop.git
synced 2025-11-04 22:13:27 +00:00
Compare commits
3 Commits
main
...
feat/depen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6d8e15b0 | ||
|
|
dd5e943dd9 | ||
|
|
c87cca9b1d |
@@ -45,12 +45,14 @@ README.md
|
|||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
docs
|
docs
|
||||||
|
|
||||||
# Keep test files and configs for development builds
|
# Testing
|
||||||
# __tests__
|
test/
|
||||||
# jest.config.js
|
*.test.js
|
||||||
# *.test.js
|
*.spec.js
|
||||||
# *.spec.js
|
coverage/
|
||||||
# .eslintrc*
|
|
||||||
# .prettierrc*
|
# Development configs
|
||||||
.editorconfig
|
.editorconfig
|
||||||
nodemon.json
|
nodemon.json
|
||||||
|
eslint.config.js
|
||||||
|
.prettierrc
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Upload directories
|
|
||||||
local_uploads/
|
|
||||||
uploads/
|
|
||||||
test_uploads/
|
|
||||||
|
|
||||||
# Build directories
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Coverage directory
|
|
||||||
coverage/
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"node": true,
|
|
||||||
"es2022": true
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:node/recommended",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2022
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"node/exports-style": ["error", "module.exports"],
|
|
||||||
"node/file-extension-in-import": ["error", "always"],
|
|
||||||
"node/prefer-global/buffer": ["error", "always"],
|
|
||||||
"node/prefer-global/console": ["error", "always"],
|
|
||||||
"node/prefer-global/process": ["error", "always"],
|
|
||||||
"node/prefer-global/url-search-params": ["error", "always"],
|
|
||||||
"node/prefer-global/url": ["error", "always"],
|
|
||||||
"node/prefer-promises/dns": "error",
|
|
||||||
"node/prefer-promises/fs": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
77
eslint.config.js
Normal file
77
eslint.config.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const js = require('@eslint/js');
|
||||||
|
const prettierConfig = require('eslint-config-prettier');
|
||||||
|
const nodePlugin = require('eslint-plugin-n');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'uploads/**',
|
||||||
|
'local_uploads/**',
|
||||||
|
'dist/**',
|
||||||
|
'build/**',
|
||||||
|
'.metadata/**',
|
||||||
|
'test/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
prettierConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.js'],
|
||||||
|
ignores: ['public/service-worker.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
__dirname: 'readonly',
|
||||||
|
__filename: 'readonly',
|
||||||
|
module: 'readonly',
|
||||||
|
require: 'readonly',
|
||||||
|
exports: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
n: nodePlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...nodePlugin.configs.recommended.rules,
|
||||||
|
'n/exports-style': ['error', 'module.exports'],
|
||||||
|
'n/file-extension-in-import': ['error', 'always'],
|
||||||
|
'n/prefer-global/buffer': ['error', 'always'],
|
||||||
|
'n/prefer-global/console': ['error', 'always'],
|
||||||
|
'n/prefer-global/process': ['error', 'always'],
|
||||||
|
'n/prefer-global/url-search-params': ['error', 'always'],
|
||||||
|
'n/prefer-global/url': ['error', 'always'],
|
||||||
|
'n/prefer-promises/dns': 'error',
|
||||||
|
'n/prefer-promises/fs': 'error',
|
||||||
|
'n/no-extraneous-require': 'off',
|
||||||
|
'n/no-unpublished-require': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['public/service-worker.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: {
|
||||||
|
self: 'readonly',
|
||||||
|
caches: 'readonly',
|
||||||
|
clients: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-undef': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
907
package-lock.json
generated
907
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"test": "node --test test/**/*.test.js",
|
||||||
"predev": "node -e \"const v=process.versions.node.split('.');if(v[0]<20) {console.error('Node.js >=20.0.0 required');process.exit(1)}\""
|
"predev": "node -e \"const v=process.versions.node.split('.');if(v[0]<20) {console.error('Node.js >=20.0.0 required');process.exit(1)}\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -22,13 +23,13 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.2",
|
||||||
"toastify-js": "^1.12.0"
|
"toastify-js": "^1.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-n": "^17.0.0",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
"prettier": "^3.2.5"
|
"prettier": "^3.2.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ app.use((req, res, next) => {
|
|||||||
html = html.replace(/{{BASE_URL}}/g, baseUrlWithSlash);
|
html = html.replace(/{{BASE_URL}}/g, baseUrlWithSlash);
|
||||||
html = injectDemoBanner(html);
|
html = injectDemoBanner(html);
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} catch (err) {
|
} catch {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -138,7 +138,9 @@ app.use(express.static('public'));
|
|||||||
app.use('/toastify', express.static(path.join(__dirname, '../node_modules/toastify-js/src')));
|
app.use('/toastify', express.static(path.join(__dirname, '../node_modules/toastify-js/src')));
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
// Express requires all 4 parameters for error handling middleware
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
logger.error(`Unhandled error: ${err.message}`);
|
logger.error(`Unhandled error: ${err.message}`);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
message: 'Internal server error',
|
message: 'Internal server error',
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const { validatePin } = require('../utils/security');
|
const { validatePin } = require('../utils/security');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const fs = require('fs');
|
const fs = require('fs'); // Get version from package.json
|
||||||
const path = require('path');
|
|
||||||
const { version } = require('../../package.json'); // Get version from package.json
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment Variables Reference
|
* Environment Variables Reference
|
||||||
@@ -32,7 +30,6 @@ const logConfig = (message, level = 'info') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Default configurations
|
// Default configurations
|
||||||
const DEFAULT_CHUNK_SIZE = 1024 * 1024 * 100; // 100MB
|
|
||||||
const DEFAULT_SITE_TITLE = 'DumbDrop';
|
const DEFAULT_SITE_TITLE = 'DumbDrop';
|
||||||
const NODE_ENV = process.env.NODE_ENV || 'production';
|
const NODE_ENV = process.env.NODE_ENV || 'production';
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -255,7 +252,9 @@ function validateConfig() {
|
|||||||
config.baseUrl = config.baseUrl + '/';
|
config.baseUrl = config.baseUrl + '/';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push('BASE_URL must be a valid URL');
|
const errorMsg = `BASE_URL must be a valid URL: ${err.message || err}`;
|
||||||
|
logger.error(errorMsg);
|
||||||
|
errors.push(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.nodeEnv === 'production') {
|
if (config.nodeEnv === 'production') {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createSafeContentDisposition(filename) {
|
|||||||
|
|
||||||
// Remove or replace characters that could cause issues
|
// Remove or replace characters that could cause issues
|
||||||
// Remove control characters (0x00-0x1F, 0x7F) and quotes
|
// Remove control characters (0x00-0x1F, 0x7F) and quotes
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const sanitized = basename.replace(/[\u0000-\u001F\u007F"\\]/g, '_');
|
const sanitized = basename.replace(/[\u0000-\u001F\u007F"\\]/g, '_');
|
||||||
|
|
||||||
// For ASCII-only filenames, use simple format
|
// For ASCII-only filenames, use simple format
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const fs = require('fs').promises; // Use promise-based fs
|
|||||||
const fsSync = require('fs'); // For sync checks like existsSync
|
const fsSync = require('fs'); // For sync checks like existsSync
|
||||||
const { config } = require('../config');
|
const { config } = require('../config');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getUniqueFilePath, getUniqueFolderPath, sanitizeFilename, sanitizePathPreserveDirs, sanitizeFilenameSafe, sanitizePathPreserveDirsSafe, isValidBatchId } = require('../utils/fileUtils');
|
const { getUniqueFolderPath, sanitizePathPreserveDirsSafe, isValidBatchId } = require('../utils/fileUtils');
|
||||||
const { sendNotification } = require('../services/notifications');
|
const { sendNotification } = require('../services/notifications');
|
||||||
const { isDemoMode } = require('../utils/demoMode');
|
const { isDemoMode } = require('../utils/demoMode');
|
||||||
|
|
||||||
@@ -54,15 +54,15 @@ async function writeUploadMetadata(uploadId, metadata) {
|
|||||||
}
|
}
|
||||||
const metaFilePath = path.join(METADATA_DIR, `${uploadId}.meta`);
|
const metaFilePath = path.join(METADATA_DIR, `${uploadId}.meta`);
|
||||||
metadata.lastActivity = Date.now(); // Update timestamp on every write
|
metadata.lastActivity = Date.now(); // Update timestamp on every write
|
||||||
|
const tempMetaPath = `${metaFilePath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||||
try {
|
try {
|
||||||
// Write atomically if possible (write to temp then rename) for more safety
|
// Write atomically if possible (write to temp then rename) for more safety
|
||||||
const tempMetaPath = `${metaFilePath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
||||||
await fs.writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
|
await fs.writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
|
||||||
await fs.rename(tempMetaPath, metaFilePath);
|
await fs.rename(tempMetaPath, metaFilePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Error writing metadata for ${uploadId}: ${err.message}`);
|
logger.error(`Error writing metadata for ${uploadId}: ${err.message}`);
|
||||||
// Attempt to clean up temp file if rename failed
|
// Attempt to clean up temp file if rename failed
|
||||||
try { await fs.unlink(tempMetaPath); } catch (unlinkErr) {/* ignore */}
|
try { await fs.unlink(tempMetaPath); } catch {/* ignore */}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ router.post('/chunk/:uploadId', express.raw({
|
|||||||
// await fs.access(potentialFinalPath);
|
// await fs.access(potentialFinalPath);
|
||||||
// return res.json({ bytesReceived: fileSizeGuess, progress: 100 });
|
// return res.json({ bytesReceived: fileSizeGuess, progress: 100 });
|
||||||
return res.status(404).json({ error: 'Upload session not found or already completed' });
|
return res.status(404).json({ error: 'Upload session not found or already completed' });
|
||||||
} catch (finalCheckErr) {
|
} catch {
|
||||||
return res.status(404).json({ error: 'Upload session not found or already completed' });
|
return res.status(404).json({ error: 'Upload session not found or already completed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,7 +329,7 @@ router.post('/chunk/:uploadId', express.raw({
|
|||||||
try {
|
try {
|
||||||
await fs.access(metadata.filePath); // Check if final file exists
|
await fs.access(metadata.filePath); // Check if final file exists
|
||||||
logger.info(`Upload ${uploadId} already finalized at ${metadata.filePath}.`);
|
logger.info(`Upload ${uploadId} already finalized at ${metadata.filePath}.`);
|
||||||
} catch (accessErr) {
|
} catch {
|
||||||
// Final file doesn't exist, attempt rename
|
// Final file doesn't exist, attempt rename
|
||||||
try {
|
try {
|
||||||
await fs.rename(metadata.partialFilePath, metadata.filePath);
|
await fs.rename(metadata.partialFilePath, metadata.filePath);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ async function startServer() {
|
|||||||
// Start a shorter force shutdown timer
|
// Start a shorter force shutdown timer
|
||||||
const forceShutdownTimer = setTimeout(() => {
|
const forceShutdownTimer = setTimeout(() => {
|
||||||
logger.error('Force shutdown initiated');
|
logger.error('Force shutdown initiated');
|
||||||
|
// eslint-disable-next-line n/no-process-exit
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 3000); // 3 seconds maximum for total shutdown
|
}, 3000); // 3 seconds maximum for total shutdown
|
||||||
|
|
||||||
@@ -96,9 +97,11 @@ async function startServer() {
|
|||||||
// Clear the force shutdown timer since we completed gracefully
|
// Clear the force shutdown timer since we completed gracefully
|
||||||
clearTimeout(forceShutdownTimer);
|
clearTimeout(forceShutdownTimer);
|
||||||
process.exitCode = 0;
|
process.exitCode = 0;
|
||||||
|
// eslint-disable-next-line n/no-process-exit
|
||||||
process.exit(0); // Ensure immediate exit
|
process.exit(0); // Ensure immediate exit
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error during shutdown: ${error.message}`);
|
logger.error(`Error during shutdown: ${error.message}`);
|
||||||
|
// eslint-disable-next-line n/no-process-exit
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ async function cleanupIncompleteMetadataUploads() {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.meta')) {
|
if (file.endsWith('.meta')) {
|
||||||
checkedCount++;
|
checkedCount++;
|
||||||
const uploadId = file.replace('.meta', '');
|
|
||||||
const metaFilePath = path.join(METADATA_DIR, file);
|
const metaFilePath = path.join(METADATA_DIR, file);
|
||||||
let metadata;
|
let metadata;
|
||||||
|
|
||||||
@@ -238,11 +237,15 @@ async function cleanupIncompleteMetadataUploads() {
|
|||||||
|
|
||||||
// Schedule the new cleanup function
|
// Schedule the new cleanup function
|
||||||
const METADATA_CLEANUP_INTERVAL = 15 * 60 * 1000; // e.g., every 15 minutes
|
const METADATA_CLEANUP_INTERVAL = 15 * 60 * 1000; // e.g., every 15 minutes
|
||||||
let metadataCleanupTimer = setInterval(cleanupIncompleteMetadataUploads, METADATA_CLEANUP_INTERVAL);
|
let metadataCleanupTimer;
|
||||||
metadataCleanupTimer.unref(); // Allow process to exit if this is the only timer
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => clearInterval(metadataCleanupTimer));
|
if (!process.env.DISABLE_BATCH_CLEANUP) {
|
||||||
process.on('SIGINT', () => clearInterval(metadataCleanupTimer));
|
metadataCleanupTimer = setInterval(cleanupIncompleteMetadataUploads, METADATA_CLEANUP_INTERVAL);
|
||||||
|
metadataCleanupTimer.unref(); // Allow process to exit if this is the only timer
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => clearInterval(metadataCleanupTimer));
|
||||||
|
process.on('SIGINT', () => clearInterval(metadataCleanupTimer));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively remove empty folders
|
* Recursively remove empty folders
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
const { config } = require('../config');
|
const { config } = require('../config');
|
||||||
|
|
||||||
@@ -42,9 +41,9 @@ const injectDemoBanner = (html) => {
|
|||||||
const demoFiles = new Map();
|
const demoFiles = new Map();
|
||||||
const demoUploads = new Map();
|
const demoUploads = new Map();
|
||||||
|
|
||||||
// Configure demo upload handling
|
// Configure demo upload handling (storage configured for multer but not directly used)
|
||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
const upload = multer({ storage });
|
multer({ storage });
|
||||||
|
|
||||||
// Create demo routes with exact path matching
|
// Create demo routes with exact path matching
|
||||||
const demoRouter = express.Router();
|
const demoRouter = express.Router();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
const { config } = require('../config');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format file size to human readable format
|
* Format file size to human readable format
|
||||||
@@ -171,6 +170,7 @@ function sanitizeFilenameSafe(fileName) {
|
|||||||
baseName = baseName
|
baseName = baseName
|
||||||
.normalize('NFD') // Decompose Unicode characters
|
.normalize('NFD') // Decompose Unicode characters
|
||||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/[^\x00-\x7F]/g, ''); // Remove non-ASCII characters
|
.replace(/[^\x00-\x7F]/g, ''); // Remove non-ASCII characters
|
||||||
|
|
||||||
// Step 2: Replace spaces and common separators with underscores
|
// Step 2: Replace spaces and common separators with underscores
|
||||||
@@ -182,7 +182,7 @@ function sanitizeFilenameSafe(fileName) {
|
|||||||
baseName = baseName
|
baseName = baseName
|
||||||
.replace(/[<>:"/\\|?*]/g, '') // Remove filesystem reserved characters
|
.replace(/[<>:"/\\|?*]/g, '') // Remove filesystem reserved characters
|
||||||
.replace(/[`"'$|;&<>(){}[\]]/g, '') // Remove shell/command problematic chars
|
.replace(/[`"'$|;&<>(){}[\]]/g, '') // Remove shell/command problematic chars
|
||||||
.replace(/[~#%&*{}\\:<>?\/+|"']/g, '') // Remove additional problematic chars
|
.replace(/[~#%&*{}\\:<>?/+|"']/g, '') // Remove additional problematic chars
|
||||||
.replace(/[^\w\-_.]/g, '') // Keep only word chars, hyphens, underscores, dots
|
.replace(/[^\w\-_.]/g, '') // Keep only word chars, hyphens, underscores, dots
|
||||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
||||||
.replace(/^[._-]+/, '') // Remove leading dots, underscores, hyphens
|
.replace(/^[._-]+/, '') // Remove leading dots, underscores, hyphens
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function stopCleanupInterval() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start cleanup interval unless disabled
|
// Start cleanup interval unless disabled
|
||||||
if (!process.env.DISABLE_SECURITY_CLEANUP) {
|
if (!process.env.DISABLE_BATCH_CLEANUP && !process.env.DISABLE_SECURITY_CLEANUP) {
|
||||||
startCleanupInterval();
|
startCleanupInterval();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
217
test/auth.test.js
Normal file
217
test/auth.test.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Authentication tests
|
||||||
|
* Tests PIN protection and authentication middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable batch cleanup for tests
|
||||||
|
process.env.DISABLE_BATCH_CLEANUP = 'true';
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const http = require('node:http');
|
||||||
|
|
||||||
|
// Import the app
|
||||||
|
const { app, initialize } = require('../src/app');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
const originalPin = process.env.PIN;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Set PIN for testing
|
||||||
|
process.env.PIN = '1234';
|
||||||
|
|
||||||
|
// Initialize app
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
// Start server on random port
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
baseUrl = `http://localhost:${port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Restore original PIN
|
||||||
|
if (originalPin) {
|
||||||
|
process.env.PIN = originalPin;
|
||||||
|
} else {
|
||||||
|
delete process.env.PIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close server
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to make HTTP requests
|
||||||
|
*/
|
||||||
|
async function makeRequest(options, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = data ? JSON.parse(data) : {};
|
||||||
|
resolve({ status: res.statusCode, data: parsed, headers: res.headers, cookies: res.headers['set-cookie'] });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, data, headers: res.headers, cookies: res.headers['set-cookie'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Authentication API Tests', () => {
|
||||||
|
describe('GET /api/auth/pin-required', () => {
|
||||||
|
it('should indicate if PIN is required', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/pin-required',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.strictEqual(typeof response.data.required, 'boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/verify-pin', () => {
|
||||||
|
it('should accept correct PIN', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/verify-pin',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
pin: '1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.ok(response.cookies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject incorrect PIN', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/verify-pin',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
pin: 'wrong',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty PIN', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/verify-pin',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
pin: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Protected Routes', () => {
|
||||||
|
it('should require PIN for upload init', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'test.txt',
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be redirected or unauthorized without PIN
|
||||||
|
assert.ok(response.status === 401 || response.status === 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow upload with valid PIN cookie', async () => {
|
||||||
|
// First, get PIN cookie
|
||||||
|
const authResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/verify-pin',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
pin: '1234',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract cookie
|
||||||
|
const cookies = authResponse.cookies;
|
||||||
|
const cookie = cookies ? cookies[0].split(';')[0] : '';
|
||||||
|
|
||||||
|
// Try upload with cookie
|
||||||
|
const uploadResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cookie': cookie,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'test.txt',
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(uploadResponse.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/logout', () => {
|
||||||
|
it('should clear authentication cookie', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/auth/logout',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
291
test/files.test.js
Normal file
291
test/files.test.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* File management tests
|
||||||
|
* Tests file listing, downloading, deletion, and renaming operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable batch cleanup for tests
|
||||||
|
process.env.DISABLE_BATCH_CLEANUP = 'true';
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const http = require('node:http');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Import the app
|
||||||
|
const { app, initialize, config } = require('../src/app');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
let testFilePath;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Initialize app
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
// Create a test file
|
||||||
|
testFilePath = path.join(config.uploadDir, 'test-file.txt');
|
||||||
|
await fs.writeFile(testFilePath, 'Test content');
|
||||||
|
|
||||||
|
// Start server on random port
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
baseUrl = `http://localhost:${port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Close server
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up test files
|
||||||
|
try {
|
||||||
|
const testFiles = await fs.readdir(config.uploadDir);
|
||||||
|
for (const file of testFiles) {
|
||||||
|
if (file !== '.metadata') {
|
||||||
|
const filePath = path.join(config.uploadDir, file);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to make HTTP requests
|
||||||
|
*/
|
||||||
|
async function makeRequest(options, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = data ? JSON.parse(data) : {};
|
||||||
|
resolve({ status: res.statusCode, data: parsed, headers: res.headers });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, data, headers: res.headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('File Management API Tests', () => {
|
||||||
|
describe('GET /api/files', () => {
|
||||||
|
it('should list all files', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.ok(Array.isArray(response.data.items));
|
||||||
|
assert.ok(response.data.totalFiles >= 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/files/info/*', () => {
|
||||||
|
it('should return file info for existing file', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/info/test-file.txt',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.strictEqual(response.data.filename, 'test-file.txt');
|
||||||
|
assert.ok(response.data.size >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent file', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/info/nonexistent.txt',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent path traversal attacks', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/info/../../../etc/passwd',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/files/download/*', () => {
|
||||||
|
it('should download existing file', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/download/test-file.txt',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.ok(response.headers['content-disposition']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent file', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/download/nonexistent.txt',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent path traversal in download', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/download/../../../etc/passwd',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/files/*', () => {
|
||||||
|
it('should delete existing file', async () => {
|
||||||
|
// Create a file to delete
|
||||||
|
const deleteTestPath = path.join(config.uploadDir, 'delete-test.txt');
|
||||||
|
await fs.writeFile(deleteTestPath, 'To be deleted');
|
||||||
|
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/delete-test.txt',
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
// Verify file is deleted
|
||||||
|
try {
|
||||||
|
await fs.access(deleteTestPath);
|
||||||
|
assert.fail('File should have been deleted');
|
||||||
|
} catch (err) {
|
||||||
|
assert.strictEqual(err.code, 'ENOENT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent file', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/nonexistent.txt',
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent path traversal in deletion', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/../../../etc/passwd',
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/files/rename/*', () => {
|
||||||
|
it('should rename existing file', async () => {
|
||||||
|
// Create a file to rename
|
||||||
|
const renameTestPath = path.join(config.uploadDir, 'rename-test.txt');
|
||||||
|
await fs.writeFile(renameTestPath, 'To be renamed');
|
||||||
|
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/rename/rename-test.txt',
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
newName: 'renamed-file.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
|
||||||
|
// Verify new file exists
|
||||||
|
const newPath = path.join(config.uploadDir, 'renamed-file.txt');
|
||||||
|
await fs.access(newPath);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(newPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty new name', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/rename/test-file.txt',
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
newName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent path traversal in rename', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/rename/../../../etc/passwd',
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
newName: 'hacked.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
308
test/security.test.js
Normal file
308
test/security.test.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Security tests
|
||||||
|
* Tests path traversal protection, file extension validation, and other security features
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable batch cleanup for tests
|
||||||
|
process.env.DISABLE_BATCH_CLEANUP = 'true';
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const http = require('node:http');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Import the app and utilities
|
||||||
|
const { app, initialize, config } = require('../src/app');
|
||||||
|
const { sanitizeFilenameSafe, sanitizePathPreserveDirsSafe } = require('../src/utils/fileUtils');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Initialize app
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
// Start server on random port
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
baseUrl = `http://localhost:${port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Close server
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up test files
|
||||||
|
try {
|
||||||
|
const testFiles = await fs.readdir(config.uploadDir);
|
||||||
|
for (const file of testFiles) {
|
||||||
|
if (file !== '.metadata') {
|
||||||
|
const filePath = path.join(config.uploadDir, file);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to make HTTP requests
|
||||||
|
*/
|
||||||
|
async function makeRequest(options, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = data ? JSON.parse(data) : {};
|
||||||
|
resolve({ status: res.statusCode, data: parsed, headers: res.headers });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, data, headers: res.headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Security Tests', () => {
|
||||||
|
describe('Path Traversal Protection', () => {
|
||||||
|
it('should block path traversal in file download', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/download/../../../etc/passwd',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block path traversal in file info', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/info/../../package.json',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block path traversal in file deletion', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files/../../../important-file.txt',
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block absolute paths in upload', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: '/etc/passwd',
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should either succeed with sanitized name or reject
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Verify it was sanitized
|
||||||
|
assert.ok(!response.data.uploadId.includes('/etc'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filename Sanitization', () => {
|
||||||
|
it('should sanitize dangerous characters', () => {
|
||||||
|
const dangerous = '../../../etc/passwd';
|
||||||
|
const sanitized = sanitizeFilenameSafe(dangerous);
|
||||||
|
|
||||||
|
assert.ok(!sanitized.includes('..'));
|
||||||
|
assert.ok(!sanitized.includes('/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null bytes', () => {
|
||||||
|
const nullByte = 'file\x00.txt';
|
||||||
|
const sanitized = sanitizeFilenameSafe(nullByte);
|
||||||
|
|
||||||
|
assert.ok(!sanitized.includes('\x00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve safe filenames', () => {
|
||||||
|
const safe = 'my-file_123.txt';
|
||||||
|
const sanitized = sanitizeFilenameSafe(safe);
|
||||||
|
|
||||||
|
assert.strictEqual(sanitized, safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Unicode characters', () => {
|
||||||
|
const unicode = 'файл.txt';
|
||||||
|
const sanitized = sanitizeFilenameSafe(unicode);
|
||||||
|
|
||||||
|
// Should be sanitized to ASCII-safe format
|
||||||
|
assert.ok(sanitized.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Size Limits', () => {
|
||||||
|
it('should reject files exceeding size limit', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'huge-file.bin',
|
||||||
|
fileSize: config.maxFileSize + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept files within size limit', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'small-file.txt',
|
||||||
|
fileSize: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Type Validation', () => {
|
||||||
|
it('should handle various content types safely', async () => {
|
||||||
|
const contentTypes = [
|
||||||
|
'text/plain',
|
||||||
|
'application/json',
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const contentType of contentTypes) {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: `test.${contentType.split('/')[1]}`,
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should handle all content types (unless restricted by config)
|
||||||
|
assert.ok(response.status === 200 || response.status === 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should enforce rate limits on repeated requests', async () => {
|
||||||
|
// Make multiple rapid requests
|
||||||
|
const requests = [];
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
requests.push(
|
||||||
|
makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: `test-${i}.txt`,
|
||||||
|
fileSize: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
|
||||||
|
// At least some should be rate limited (429)
|
||||||
|
const rateLimited = responses.filter((r) => r.status === 429);
|
||||||
|
|
||||||
|
// Rate limiting should kick in for excessive requests
|
||||||
|
assert.ok(rateLimited.length > 0 || responses[0].status === 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CORS Protection', () => {
|
||||||
|
it('should include CORS headers', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/files',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS headers should be present
|
||||||
|
assert.ok(response.headers['access-control-allow-origin'] !== undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Path Sanitization Functions', () => {
|
||||||
|
it('should sanitize paths while preserving directories', () => {
|
||||||
|
const dirPath = 'folder/subfolder/file.txt';
|
||||||
|
const sanitized = sanitizePathPreserveDirsSafe(dirPath);
|
||||||
|
|
||||||
|
// Should preserve structure but sanitize dangerous chars
|
||||||
|
assert.ok(!sanitized.includes('..'));
|
||||||
|
assert.ok(sanitized.includes('/') || sanitized.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block directory traversal attempts', () => {
|
||||||
|
const malicious = '../../etc/passwd';
|
||||||
|
const sanitized = sanitizePathPreserveDirsSafe(malicious);
|
||||||
|
|
||||||
|
// Should not allow traversal
|
||||||
|
assert.ok(!sanitized.startsWith('..'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
291
test/upload.test.js
Normal file
291
test/upload.test.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* Upload functionality tests
|
||||||
|
* Tests file upload initialization, chunked uploads, and batch operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable batch cleanup for tests
|
||||||
|
process.env.DISABLE_BATCH_CLEANUP = 'true';
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const http = require('node:http');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Import the app
|
||||||
|
const { app, initialize, config } = require('../src/app');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Initialize app
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
// Start server on random port
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
baseUrl = `http://localhost:${port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Close server
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up test uploads
|
||||||
|
try {
|
||||||
|
const testFiles = await fs.readdir(config.uploadDir);
|
||||||
|
for (const file of testFiles) {
|
||||||
|
if (file !== '.metadata') {
|
||||||
|
const filePath = path.join(config.uploadDir, file);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to make HTTP requests
|
||||||
|
*/
|
||||||
|
async function makeRequest(options, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = data ? JSON.parse(data) : {};
|
||||||
|
resolve({ status: res.statusCode, data: parsed, headers: res.headers });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, data, headers: res.headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
req.write(body);
|
||||||
|
} else {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Upload API Tests', () => {
|
||||||
|
describe('POST /api/upload/init', () => {
|
||||||
|
it('should initialize a new upload', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'test.txt',
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.ok(response.data.uploadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject uploads without filename', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 400);
|
||||||
|
assert.ok(response.data.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject uploads without fileSize', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'test.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 400);
|
||||||
|
assert.ok(response.data.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero-byte files', async () => {
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'empty.txt',
|
||||||
|
fileSize: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.ok(response.data.uploadId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/upload/chunk/:uploadId', () => {
|
||||||
|
it('should accept chunks for a valid upload', async () => {
|
||||||
|
// Initialize upload first
|
||||||
|
const initResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'chunk-test.txt',
|
||||||
|
fileSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { uploadId } = initResponse.data;
|
||||||
|
|
||||||
|
// Send chunk
|
||||||
|
const chunk = Buffer.from('Hello, World!');
|
||||||
|
const chunkResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: `/api/upload/chunk/${uploadId}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
}, chunk);
|
||||||
|
|
||||||
|
assert.strictEqual(chunkResponse.status, 200);
|
||||||
|
assert.ok(chunkResponse.data.bytesReceived > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject chunks for invalid uploadId', async () => {
|
||||||
|
const chunk = Buffer.from('Test data');
|
||||||
|
const response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/chunk/invalid-id',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
}, chunk);
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/upload/cancel/:uploadId', () => {
|
||||||
|
it('should cancel an active upload', async () => {
|
||||||
|
// Initialize upload
|
||||||
|
const initResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'cancel-test.txt',
|
||||||
|
fileSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { uploadId } = initResponse.data;
|
||||||
|
|
||||||
|
// Cancel upload
|
||||||
|
const cancelResponse = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: `/api/upload/cancel/${uploadId}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(cancelResponse.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Batch uploads', () => {
|
||||||
|
it('should handle multiple files with same batch ID', async () => {
|
||||||
|
const batchId = `batch-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
|
|
||||||
|
// Initialize first file
|
||||||
|
const file1Response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Batch-Id': batchId,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'batch-file1.txt',
|
||||||
|
fileSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(file1Response.status, 200);
|
||||||
|
|
||||||
|
// Initialize second file
|
||||||
|
const file2Response = await makeRequest({
|
||||||
|
host: 'localhost',
|
||||||
|
port: server.address().port,
|
||||||
|
path: '/api/upload/init',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Batch-Id': batchId,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
filename: 'batch-file2.txt',
|
||||||
|
fileSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(file2Response.status, 200);
|
||||||
|
assert.notStrictEqual(file1Response.data.uploadId, file2Response.data.uploadId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user