|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import express from 'express'; |
|
|
import multer from 'multer'; |
|
|
import cors from 'cors'; |
|
|
import path from 'path'; |
|
|
import os from 'os'; |
|
|
import { fileURLToPath } from 'url'; |
|
|
import fs from 'fs/promises'; |
|
|
import { existsSync, mkdirSync } from 'fs'; |
|
|
import dotenv from 'dotenv'; |
|
|
import rateLimit from 'express-rate-limit'; |
|
|
import { v4 as uuidv4 } from 'uuid'; |
|
|
|
|
|
dotenv.config(); |
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url); |
|
|
const __dirname = path.dirname(__filename); |
|
|
|
|
|
const CONFIG = { |
|
|
port: process.env.PORT || 7860, |
|
|
uploadDir: process.env.UPLOAD_DIR || path.join(os.tmpdir(), 'web-uploader'), |
|
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE) || 500 * 1024 * 1024, |
|
|
rateLimitWindow: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, |
|
|
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX) || 100, |
|
|
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL_MS) || 60 * 60 * 1000 |
|
|
}; |
|
|
|
|
|
const METADATA_FILE = path.join(CONFIG.uploadDir, 'metadata.json'); |
|
|
|
|
|
const app = express(); |
|
|
|
|
|
app.set('trust proxy', true); |
|
|
app.use(cors()); |
|
|
app.use(express.static(path.join(__dirname, 'public'))); |
|
|
app.use('/uploads', express.static(CONFIG.uploadDir)); |
|
|
|
|
|
|
|
|
const limiter = rateLimit({ |
|
|
windowMs: CONFIG.rateLimitWindow, |
|
|
max: CONFIG.rateLimitMax, |
|
|
standardHeaders: true, |
|
|
legacyHeaders: false, |
|
|
validate: { trustProxy: false } |
|
|
}); |
|
|
|
|
|
const storage = multer.diskStorage({ |
|
|
destination: (req, file, cb) => { |
|
|
if (!existsSync(CONFIG.uploadDir)) { |
|
|
mkdirSync(CONFIG.uploadDir, { recursive: true }); |
|
|
} |
|
|
cb(null, CONFIG.uploadDir); |
|
|
}, |
|
|
filename: (req, file, cb) => { |
|
|
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; |
|
|
cb(null, `${uniqueSuffix}-${file.originalname}`); |
|
|
} |
|
|
}); |
|
|
|
|
|
const upload = multer({ |
|
|
storage, |
|
|
limits: { fileSize: CONFIG.maxFileSize } |
|
|
}); |
|
|
|
|
|
|
|
|
class MetadataManager { |
|
|
static async ensureMetadataFile() { |
|
|
if (!existsSync(CONFIG.uploadDir)) { |
|
|
mkdirSync(CONFIG.uploadDir, { recursive: true }); |
|
|
} |
|
|
if (!existsSync(METADATA_FILE)) { |
|
|
await fs.writeFile(METADATA_FILE, JSON.stringify({}, null, 2)); |
|
|
} |
|
|
} |
|
|
|
|
|
static async load() { |
|
|
await this.ensureMetadataFile(); |
|
|
try { |
|
|
const data = await fs.readFile(METADATA_FILE, 'utf8'); |
|
|
return JSON.parse(data); |
|
|
} catch (error) { |
|
|
console.error('Error reading metadata:', error); |
|
|
return {}; |
|
|
} |
|
|
} |
|
|
|
|
|
static async save(data) { |
|
|
await fs.writeFile(METADATA_FILE, JSON.stringify(data, null, 2)); |
|
|
} |
|
|
|
|
|
static async addFile(filename, expiration) { |
|
|
const metadata = await this.load(); |
|
|
const expiresAt = expiration === 'permanent' ? null : Date.now() + this.parseExpiration(expiration); |
|
|
|
|
|
metadata[filename] = { |
|
|
filename, |
|
|
uploadedAt: Date.now(), |
|
|
expiresAt |
|
|
}; |
|
|
|
|
|
await this.save(metadata); |
|
|
return metadata[filename]; |
|
|
} |
|
|
|
|
|
static parseExpiration(exp) { |
|
|
const units = { |
|
|
'h': 60 * 60 * 1000, |
|
|
'd': 24 * 60 * 60 * 1000, |
|
|
'w': 7 * 24 * 60 * 60 * 1000 |
|
|
}; |
|
|
const match = exp.match(/^(\d+)([hdw])$/); |
|
|
if (!match) return 24 * 60 * 60 * 1000; |
|
|
return parseInt(match[1]) * units[match[2]]; |
|
|
} |
|
|
|
|
|
static async cleanup() { |
|
|
const metadata = await this.load(); |
|
|
const now = Date.now(); |
|
|
let changed = false; |
|
|
|
|
|
for (const [filename, info] of Object.entries(metadata)) { |
|
|
if (info.expiresAt && info.expiresAt < now) { |
|
|
try { |
|
|
const filePath = path.join(CONFIG.uploadDir, filename); |
|
|
if (existsSync(filePath)) { |
|
|
await fs.unlink(filePath); |
|
|
console.log(`Expired file deleted: ${filename}`); |
|
|
} |
|
|
delete metadata[filename]; |
|
|
changed = true; |
|
|
} catch (err) { |
|
|
console.error(`Failed to delete expired file ${filename}:`, err); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (changed) { |
|
|
await this.save(metadata); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
setInterval(() => { |
|
|
MetadataManager.cleanup(); |
|
|
}, CONFIG.cleanupInterval); |
|
|
|
|
|
|
|
|
MetadataManager.cleanup(); |
|
|
|
|
|
function isValidFilePath(filePath) { |
|
|
const safeUploadDir = path.normalize(CONFIG.uploadDir); |
|
|
const resolvedPath = path.normalize(filePath); |
|
|
return resolvedPath.startsWith(safeUploadDir + path.sep); |
|
|
} |
|
|
|
|
|
function buildFileUrl(req, filename) { |
|
|
return `${req.protocol}://${req.get('host')}/uploads/${filename}`; |
|
|
} |
|
|
|
|
|
app.post('/api/upload', limiter, upload.array('files'), async (req, res) => { |
|
|
if (!req.files || req.files.length === 0) { |
|
|
return res.status(400).json({ |
|
|
status: 'error', |
|
|
message: 'No files uploaded' |
|
|
}); |
|
|
} |
|
|
|
|
|
const expiration = req.body.expiration || '24h'; |
|
|
|
|
|
const uploadedFiles = await Promise.all(req.files.map(async file => { |
|
|
const metadata = await MetadataManager.addFile(file.filename, expiration); |
|
|
return { |
|
|
originalName: file.originalname, |
|
|
filename: file.filename, |
|
|
url: buildFileUrl(req, file.filename), |
|
|
expiresAt: metadata.expiresAt |
|
|
}; |
|
|
})); |
|
|
|
|
|
res.status(200).json({ |
|
|
status: 'success', |
|
|
message: 'Files uploaded successfully', |
|
|
files: uploadedFiles |
|
|
}); |
|
|
}); |
|
|
|
|
|
app.delete('/api/delete/:filename', async (req, res) => { |
|
|
const { filename } = req.params; |
|
|
const filePath = path.join(CONFIG.uploadDir, filename); |
|
|
|
|
|
if (!isValidFilePath(filePath)) { |
|
|
return res.status(403).json({ |
|
|
status: 'error', |
|
|
message: 'Access denied: Invalid file path' |
|
|
}); |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
const metadata = await MetadataManager.load(); |
|
|
if (metadata[filename]) { |
|
|
delete metadata[filename]; |
|
|
await MetadataManager.save(metadata); |
|
|
} |
|
|
|
|
|
await fs.access(filePath); |
|
|
} catch { |
|
|
return res.status(404).json({ |
|
|
status: 'error', |
|
|
message: 'File not found' |
|
|
}); |
|
|
} |
|
|
|
|
|
try { |
|
|
await fs.unlink(filePath); |
|
|
res.status(200).json({ |
|
|
status: 'success', |
|
|
message: 'File deleted successfully' |
|
|
}); |
|
|
} catch (err) { |
|
|
console.error('Delete error:', err); |
|
|
res.status(500).json({ |
|
|
status: 'error', |
|
|
message: 'Failed to delete file' |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.use((err, req, res, next) => { |
|
|
if (err instanceof multer.MulterError) { |
|
|
if (err.code === 'LIMIT_FILE_SIZE') { |
|
|
return res.status(413).json({ |
|
|
status: 'error', |
|
|
message: `File too large. Maximum size is ${CONFIG.maxFileSize / (1024 * 1024)} MB` |
|
|
}); |
|
|
} |
|
|
return res.status(400).json({ |
|
|
status: 'error', |
|
|
message: err.message |
|
|
}); |
|
|
} |
|
|
next(err); |
|
|
}); |
|
|
|
|
|
app.listen(CONFIG.port, () => { |
|
|
console.log(`Server running on http://localhost:${CONFIG.port}`); |
|
|
}); |
|
|
|