feat: remake into katdrop with minimal smooth UI, security optimizations, and 10-minute auto-deletion

This commit is contained in:
katR 2026-09-12 09:32:11 +05:30
parent a728cf86e3
commit 9fc9709f69
10 changed files with 2032 additions and 138 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
uploads/*
!uploads/.gitkeep
.DS_Store
*.log

View file

@ -1,2 +1,35 @@
hi
this was supposed to be like a website but :(
# katdrop
a minimal, smooth file transfer service.
## features
- minimal dark interface with smooth interactive particle background
- drag & drop files anywhere to upload instantly
- 10-minute auto-deletion timer per file with live countdown
- sleek upload progress bar
- safe path verification and collision-free file naming
- zero external CDN dependencies (works completely offline)
## usage
```bash
# install dependencies
npm install
# start server
npm start
```
Runs by default on `http://localhost:3000` (or falls back to an available port if 3000 is in use).
## endpoints
- `POST /upload` - upload files (`file`)
- `GET /files` - list files
- `GET /uploads/:filename` - download file
- `DELETE /delete/:filename` - delete file
## license
MIT

View file

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>File Share</title>
<script src="https://cdn.jsdelivr.net/npm/particles.js"></script>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #0d0d0d;
color: #fff;
text-align: center;
}
#particles-js {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: -1;
}
.container {
margin-top: 100px;
}
button, input[type="file"] {
margin: 10px;
padding: 10px 20px;
background: #1a1a1a;
color: #fff;
border: 1px solid #333;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background: #333;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 10px 0;
}
a {
color: #4db8ff;
text-decoration: none;
margin-right: 10px;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div id="particles-js"></div>
<div class="container">
<h1>File Share</h1>
<form id="uploadForm" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">Upload</button>
</form>
<h2>Available Files</h2>
<ul id="fileList"></ul>
</div>
<script>
// Particle background
particlesJS.load('particles-js', 'https://cdn.jsdelivr.net/gh/VincentGarreau/particles.js/demo/particles.json');
// Fetch file list
async function fetchFiles() {
const res = await fetch('/files');
const files = await res.json();
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
files.forEach(file => {
const li = document.createElement('li');
li.innerHTML = `
<a href="/uploads/${file}" download>${file}</a>
<button onclick="deleteFile('${file}')">Delete</button>
`;
fileList.appendChild(li);
});
}
// Upload file
document.getElementById('uploadForm').addEventListener('submit', async e => {
e.preventDefault();
const formData = new FormData(e.target);
await fetch('/upload', {
method: 'POST',
body: formData
});
e.target.reset();
fetchFiles();
});
// Delete file
async function deleteFile(filename) {
await fetch(`/delete/${filename}`, { method: 'DELETE' });
fetchFiles();
}
fetchFiles();
</script>
</body>
</html>

1014
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,22 @@
{
"name": "file-server",
"version": "1.0.0",
"name": "katdrop",
"version": "2.0.0",
"description": "Fast, sleek, local & web file transfer service",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [
"file-transfer",
"file-drop",
"lan-share",
"express",
"katdrop"
],
"author": "oiupoyt",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"multer": "1.4.5-lts.1"
"express": "^4.21.2",
"multer": "^1.4.5-lts.1"
}
}

340
public/css/style.css Normal file
View file

@ -0,0 +1,340 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
border-radius: 0 !important; /* Square everything out */
}
body {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: #0b0b0b;
color: #e5e5e5;
min-height: 100vh;
position: relative;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
#particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
max-width: 620px;
margin: 75px auto 60px;
padding: 0 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
h1 {
font-size: 2.2rem;
font-weight: 600;
letter-spacing: -0.04em;
color: #fff;
margin-bottom: 4px;
}
.subtitle {
font-size: 0.8rem;
color: #555;
letter-spacing: 0.05em;
text-transform: lowercase;
}
/* Upload Drop Zone */
.drop-zone {
border: 1px dashed #2a2a2a;
padding: 26px 20px;
background: rgba(15, 15, 15, 0.7);
backdrop-filter: blur(6px);
text-align: center;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.drop-zone.dragover {
border-color: #555;
background: rgba(24, 24, 24, 0.85);
}
.drop-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 18px;
background: #161616;
color: #ddd;
border: 1px solid #303030;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
user-select: none;
text-decoration: none;
letter-spacing: 0.02em;
transition: all 0.15s ease;
}
.btn:hover:not(:disabled) {
background: #242424;
border-color: #555;
color: #fff;
}
.btn:active:not(:disabled) {
background: #fff;
color: #000;
border-color: #fff;
}
.btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-upload {
background: #1f1f1f;
border-color: #383838;
}
.file-selection {
display: none;
margin-top: 14px;
font-size: 0.74rem;
color: #777;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
animation: fadeIn 0.15s ease;
}
.file-selection.active {
display: block;
}
/* Minimal Square Progress Bar */
.progress-wrap {
display: none;
width: 100%;
height: 2px;
background: #1c1c1c;
margin-top: 12px;
overflow: hidden;
}
.progress-wrap.active {
display: block;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
/* Files Section */
.files-section {
margin-top: 42px;
}
.files-header {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid #1c1c1c;
padding-bottom: 10px;
margin-bottom: 12px;
}
h2 {
font-size: 0.76rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #666;
}
.file-count {
font-size: 0.72rem;
color: #444;
}
ul {
list-style: none;
padding: 0;
}
/* File Card Items */
li.file-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
margin-bottom: 6px;
background: rgba(17, 17, 17, 0.75);
border: 1px solid #202020;
transition: border-color 0.15s ease, background-color 0.15s ease, opacity 0.2s ease;
}
li.file-card:hover {
background: rgba(24, 24, 24, 0.9);
border-color: #333;
}
li.file-card.deleting {
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
}
.file-info {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
flex: 1;
}
.file-name {
color: #e2e2e2;
font-size: 0.84rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 360px;
}
.file-meta {
display: flex;
align-items: baseline;
gap: 10px;
flex-shrink: 0;
}
.file-size {
font-size: 0.7rem;
color: #555;
white-space: nowrap;
}
.file-timer {
font-size: 0.7rem;
color: #666;
white-space: nowrap;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.file-timer.warning {
color: #f59e0b;
}
.file-timer.critical {
color: #ff5555;
}
.file-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.action-btn {
padding: 4px 10px;
font-size: 0.72rem;
font-family: inherit;
text-decoration: none;
background: #141414;
border: 1px solid #262626;
cursor: pointer;
letter-spacing: 0.02em;
transition: all 0.15s ease;
}
.action-download {
color: #4db8ff;
}
.action-download:hover {
background: #4db8ff;
color: #000;
border-color: #4db8ff;
}
.action-delete {
color: #666;
}
.action-delete:hover {
background: #ff5555;
color: #000;
border-color: #ff5555;
}
.empty-state {
text-align: center;
color: #3a3a3a;
font-size: 0.78rem;
padding: 30px 0;
border: 1px dashed #1a1a1a;
margin-top: 4px;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: #141414;
border: 1px solid #2f2f2f;
color: #ccc;
font-size: 0.75rem;
padding: 6px 14px;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
z-index: 10;
letter-spacing: 0.02em;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 520px) {
.container {
margin-top: 40px;
padding: 0 14px;
}
.file-name {
max-width: 170px;
}
.file-info {
flex-direction: column;
gap: 2px;
}
}

46
public/index.html Normal file
View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>katdrop</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<canvas id="particles"></canvas>
<main class="container">
<header class="header">
<h1>katdrop</h1>
<p class="subtitle">// minimal file transfer</p>
</header>
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" multiple hidden>
<div class="drop-actions">
<label for="fileInput" class="btn">choose files</label>
<button id="uploadBtn" class="btn btn-upload" disabled>upload</button>
</div>
<div id="fileSelection" class="file-selection"></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar" id="progressBar"></div>
</div>
</div>
<section class="files-section">
<div class="files-header">
<h2>available files</h2>
<span id="fileCount" class="file-count">0 files</span>
</div>
<ul id="fileList"></ul>
</section>
</main>
<div id="toast" class="toast"></div>
<script src="/js/app.js"></script>
</body>
</html>

386
public/js/app.js Normal file
View file

@ -0,0 +1,386 @@
/**
* katdrop - minimal, smooth, and interactive
*/
(function () {
'use strict';
// DOM Elements
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const fileSelection = document.getElementById('fileSelection');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const fileList = document.getElementById('fileList');
const fileCount = document.getElementById('fileCount');
const toastEl = document.getElementById('toast');
let selectedFiles = [];
// Toast
let toastTimeout;
function showToast(msg) {
clearTimeout(toastTimeout);
toastEl.textContent = `// ${msg}`;
toastEl.classList.add('show');
toastTimeout = setTimeout(() => {
toastEl.classList.remove('show');
}, 2200);
}
// Interactive Particle Background
function initInteractiveParticles() {
const canvas = document.getElementById('particles');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let width = (canvas.width = window.innerWidth);
let height = (canvas.height = window.innerHeight);
window.addEventListener('resize', () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
});
// Mouse tracking for interactivity
const mouse = {
x: null,
y: null,
radius: 140
};
window.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
window.addEventListener('mouseleave', () => {
mouse.x = null;
mouse.y = null;
});
// Click to push particles slightly
window.addEventListener('click', e => {
for (const p of particles) {
const dx = p.x - e.clientX;
const dy = p.y - e.clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 180 && dist > 0) {
const force = (180 - dist) / 180;
p.vx += (dx / dist) * force * 3;
p.vy += (dy / dist) * force * 3;
}
}
});
const particles = [];
const count = Math.min(50, Math.floor(width / 26));
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.45,
vy: (Math.random() - 0.5) * 0.45,
radius: Math.random() * 1.5 + 1
});
}
function animate() {
ctx.clearRect(0, 0, width, height);
// Connect particles to each other
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 115) {
ctx.strokeStyle = `rgba(255, 255, 255, ${0.06 * (1 - dist / 115)})`;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
// Update & draw particles + connect to mouse
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
// Dampen velocity back to normal
p.vx *= 0.985;
p.vy *= 0.985;
// Boundary bounce
if (p.x < 0) { p.x = 0; p.vx *= -1; }
if (p.x > width) { p.x = width; p.vx *= -1; }
if (p.y < 0) { p.y = 0; p.vy *= -1; }
if (p.y > height) { p.y = height; p.vy *= -1; }
// Interaction with mouse cursor
if (mouse.x !== null && mouse.y !== null) {
const mdx = mouse.x - p.x;
const mdy = mouse.y - p.y;
const mDist = Math.sqrt(mdx * mdx + mdy * mdy);
if (mDist < mouse.radius) {
// Connect line to mouse
ctx.strokeStyle = `rgba(77, 184, 255, ${0.18 * (1 - mDist / mouse.radius)})`;
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
// Repel gently if very close
if (mDist < 70 && mDist > 0) {
const repel = (70 - mDist) / 70;
p.x -= (mdx / mDist) * repel * 1.5;
p.y -= (mdy / mDist) * repel * 1.5;
}
}
}
// Draw particle dot
ctx.fillStyle = 'rgba(255, 255, 255, 0.28)';
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(animate);
}
animate();
}
// Safe HTML helper
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Countdown Timer Management
let countdownTimer = null;
function formatTimeLeft(seconds) {
if (seconds <= 0) return '00:00';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
function tickCountdowns() {
const timerEls = document.querySelectorAll('.file-timer');
if (timerEls.length === 0) return;
const now = Date.now();
let hasExpired = false;
timerEls.forEach(el => {
const expires = parseInt(el.dataset.expiresMs, 10);
if (isNaN(expires)) return;
const remainingSec = Math.max(0, Math.ceil((expires - now) / 1000));
el.textContent = formatTimeLeft(remainingSec);
if (remainingSec <= 30) {
el.className = 'file-timer critical';
} else if (remainingSec <= 120) {
el.className = 'file-timer warning';
} else {
el.className = 'file-timer';
}
if (remainingSec === 0) {
hasExpired = true;
const card = el.closest('li.file-card');
if (card) card.classList.add('deleting');
}
});
if (hasExpired) {
setTimeout(fetchFiles, 400);
}
}
function startCountdownLoop() {
if (countdownTimer) clearInterval(countdownTimer);
tickCountdowns();
countdownTimer = setInterval(tickCountdowns, 1000);
}
// Fetch and display files
async function fetchFiles() {
try {
const res = await fetch('/files');
const files = await res.json();
fileList.innerHTML = '';
if (!files || files.length === 0) {
fileList.innerHTML = '<li class="empty-state">// no files uploaded yet</li>';
if (fileCount) fileCount.textContent = '0 files';
return;
}
if (fileCount) {
fileCount.textContent = `${files.length} file${files.length === 1 ? '' : 's'}`;
}
files.forEach(file => {
const li = document.createElement('li');
li.className = 'file-card';
const name = typeof file === 'string' ? file : file.name;
const size = typeof file === 'object' && file.sizeFormatted ? file.sizeFormatted : '';
const expiresMs = file.expiresAt ? new Date(file.expiresAt).getTime() : (Date.now() + 600000);
const remainingSec = Math.max(0, Math.ceil((expiresMs - Date.now()) / 1000));
li.innerHTML = `
<div class="file-info">
<span class="file-name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
<div class="file-meta">
${size ? `<span class="file-size">${escapeHtml(size)}</span>` : ''}
<span class="file-timer" data-expires-ms="${expiresMs}" title="Auto-deletes in">${formatTimeLeft(remainingSec)}</span>
</div>
</div>
<div class="file-actions">
<a href="/uploads/${encodeURIComponent(name)}" class="action-btn action-download" download>download</a>
<button class="action-btn action-delete" onclick="deleteFile('${escapeHtml(name)}', this.closest('li'))">delete</button>
</div>
`;
fileList.appendChild(li);
});
startCountdownLoop();
} catch (err) {
console.error(err);
fileList.innerHTML = '<li class="empty-state">// error reading files</li>';
}
}
// Delete file with smooth animation
window.deleteFile = async function (filename, cardElement) {
if (cardElement) cardElement.classList.add('deleting');
try {
const res = await fetch(`/delete/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.ok) {
showToast('file deleted');
setTimeout(() => {
fetchFiles();
}, 180);
} else {
if (cardElement) cardElement.classList.remove('deleting');
showToast('failed to delete');
}
} catch (err) {
if (cardElement) cardElement.classList.remove('deleting');
showToast('delete error');
}
};
// Update selected files
function updateFileSelection(files) {
selectedFiles = Array.from(files);
if (selectedFiles.length === 0) {
fileSelection.textContent = '';
fileSelection.classList.remove('active');
uploadBtn.disabled = true;
} else if (selectedFiles.length === 1) {
fileSelection.textContent = `> ${selectedFiles[0].name}`;
fileSelection.classList.add('active');
uploadBtn.disabled = false;
} else {
fileSelection.textContent = `> ${selectedFiles.length} files selected`;
fileSelection.classList.add('active');
uploadBtn.disabled = false;
}
}
// Upload files with smooth progress bar
function uploadSelectedFiles() {
if (selectedFiles.length === 0) return;
const formData = new FormData();
selectedFiles.forEach(f => formData.append('file', f));
progressWrap.classList.add('active');
progressBar.style.width = '0%';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = `${pct}%`;
}
});
xhr.onload = () => {
progressBar.style.width = '100%';
setTimeout(() => {
progressWrap.classList.remove('active');
progressBar.style.width = '0%';
fileInput.value = '';
updateFileSelection([]);
fetchFiles();
showToast('upload complete');
}, 250);
};
xhr.onerror = () => {
progressWrap.classList.remove('active');
uploadBtn.disabled = false;
showToast('upload failed');
};
xhr.open('POST', '/upload');
xhr.send(formData);
}
// Listeners
fileInput.addEventListener('change', e => {
updateFileSelection(e.target.files);
});
uploadBtn.addEventListener('click', () => {
uploadSelectedFiles();
});
// Drag and drop
['dragenter', 'dragover'].forEach(name => {
dropZone.addEventListener(name, e => {
e.preventDefault();
dropZone.classList.add('dragover');
});
window.addEventListener(name, e => e.preventDefault());
});
['dragleave', 'drop'].forEach(name => {
dropZone.addEventListener(name, e => {
e.preventDefault();
dropZone.classList.remove('dragover');
});
window.addEventListener(name, e => e.preventDefault());
});
dropZone.addEventListener('drop', e => {
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
updateFileSelection(e.dataTransfer.files);
uploadSelectedFiles();
}
});
// Init
initInteractiveParticles();
fetchFiles();
})();

215
server.js
View file

@ -4,43 +4,208 @@ const fs = require('fs');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
let currentPort = parseInt(process.env.PORT, 10) || 3000;
// Directories
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
const publicDir = path.join(__dirname, 'public');
// File storage setup
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 10-Minute File Expiration (in milliseconds)
const FILE_TTL_MS = 10 * 60 * 1000;
// Utility: format file size
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Utility: Safe unique filename (prevent overwrites and directory traversal)
function getSafeUniqueName(destination, originalName) {
const base = path.basename(originalName).replace(/[^\w\s.-]/gi, '_').trim() || 'file';
const ext = path.extname(base);
const nameOnly = path.basename(base, ext) || 'file';
let candidate = `${nameOnly}${ext}`;
let counter = 1;
while (fs.existsSync(path.join(destination, candidate))) {
candidate = `${nameOnly} (${counter})${ext}`;
counter++;
}
return candidate;
}
// Utility: Validate path and prevent directory traversal
function getSafeFilePath(filename) {
if (!filename || typeof filename !== 'string') return null;
const safeName = path.basename(filename);
const resolvedPath = path.resolve(uploadDir, safeName);
if (!resolvedPath.startsWith(uploadDir + path.sep) && resolvedPath !== uploadDir) {
return null;
}
return { resolvedPath, safeName };
}
// Automatic cleanup of expired files (older than 10 minutes)
async function cleanupExpiredFiles() {
try {
const entries = await fs.promises.readdir(uploadDir);
const now = Date.now();
for (const name of entries) {
if (name.startsWith('.')) continue; // Preserve .gitkeep and hidden files
const fullPath = path.join(uploadDir, name);
try {
const stats = await fs.promises.stat(fullPath);
if (stats.isFile() && (now - stats.mtimeMs) >= FILE_TTL_MS) {
await fs.promises.unlink(fullPath);
}
} catch (err) {
// Ignore files already deleted or locked
}
}
} catch (err) {
// Ignore read errors during sweep
}
}
// Run cleanup sweep every 10 seconds (unref'd to prevent blocking process exit)
const cleanupInterval = setInterval(cleanupExpiredFiles, 10 * 1000);
cleanupInterval.unref();
// Multer storage
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => cb(null, file.originalname)
filename: (req, file, cb) => {
cb(null, getSafeUniqueName(uploadDir, file.originalname));
}
});
const upload = multer({ storage });
// Middleware
app.use(express.static(__dirname)); // serve index.html
app.use('/uploads', express.static(uploadDir));
// Upload endpoint
app.post('/upload', upload.single('file'), (req, res) => {
res.send('File uploaded!');
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 * 1024 } // 2GB
});
// List files
app.get('/files', (req, res) => {
fs.readdir(uploadDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.json(files);
// Middlewares
app.use(express.json());
app.use(express.static(publicDir));
// Upload endpoint (supports single or multiple files)
app.post('/upload', upload.array('file', 20), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const now = Date.now();
const uploaded = req.files.map(f => ({
name: f.filename,
size: f.size,
sizeFormatted: formatBytes(f.size),
expiresAt: new Date(now + FILE_TTL_MS).toISOString(),
remainingSeconds: Math.floor(FILE_TTL_MS / 1000)
}));
res.json({ success: true, files: uploaded });
});
// List files with metadata, remaining TTL, and on-demand expiration check
app.get('/files', async (req, res) => {
try {
const entries = await fs.promises.readdir(uploadDir);
const files = [];
const now = Date.now();
for (const name of entries) {
if (name.startsWith('.')) continue;
const fullPath = path.join(uploadDir, name);
try {
const stats = await fs.promises.stat(fullPath);
if (!stats.isFile()) continue;
const ageMs = now - stats.mtimeMs;
// Immediate purge if expired
if (ageMs >= FILE_TTL_MS) {
fs.promises.unlink(fullPath).catch(() => {});
continue;
}
const remainingMs = Math.max(0, FILE_TTL_MS - ageMs);
const remainingSeconds = Math.ceil(remainingMs / 1000);
files.push({
name,
size: stats.size,
sizeFormatted: formatBytes(stats.size),
modifiedAt: stats.mtime,
expiresAt: new Date(stats.mtimeMs + FILE_TTL_MS).toISOString(),
remainingSeconds
});
} catch (e) {
continue;
}
}
// Sort newest first
files.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
res.json(files);
} catch (err) {
res.status(500).json({ error: 'Error reading files' });
}
});
// Download / direct link with on-demand expiration check
app.get('/uploads/:filename', (req, res) => {
const safe = getSafeFilePath(req.params.filename);
if (!safe || !fs.existsSync(safe.resolvedPath)) {
return res.status(404).send('File not found');
}
try {
const stats = fs.statSync(safe.resolvedPath);
if ((Date.now() - stats.mtimeMs) >= FILE_TTL_MS) {
fs.unlink(safe.resolvedPath, () => {});
return res.status(410).send('File expired');
}
} catch (err) {
return res.status(404).send('File not found');
}
res.download(safe.resolvedPath, safe.safeName);
});
// Delete file
app.delete('/delete/:filename', (req, res) => {
const filePath = path.join(uploadDir, req.params.filename);
fs.unlink(filePath, err => {
if (err) return res.status(500).send('Error deleting file');
app.delete('/delete/:filename', async (req, res) => {
const safe = getSafeFilePath(req.params.filename);
if (!safe) return res.status(400).send('Invalid filename');
try {
await fs.promises.unlink(safe.resolvedPath);
res.send('File deleted');
});
} catch (err) {
res.status(500).send('Error deleting file');
}
});
// Start server
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
// Start Server with fallback port handling
function startServer(port, maxAttempts = 10) {
const server = app.listen(port, '0.0.0.0', () => {
currentPort = port;
console.log(`\nkatdrop running on http://localhost:${port}\n`);
cleanupExpiredFiles(); // Run initial cleanup on startup
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE' && !process.env.PORT && maxAttempts > 0) {
startServer(port + 1, maxAttempts - 1);
} else {
console.error(`Failed to start server on port ${port}:`, err.message);
process.exit(1);
}
});
}
startServer(currentPort);

1
uploads/.gitkeep Normal file
View file

@ -0,0 +1 @@
# KatDrop uploads directory