feat: remake into katdrop with minimal smooth UI, security optimizations, and 10-minute auto-deletion
This commit is contained in:
parent
a728cf86e3
commit
9fc9709f69
10 changed files with 2032 additions and 138 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
.DS_Store
|
||||
*.log
|
||||
37
README.md
37
README.md
|
|
@ -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
|
||||
|
|
|
|||
106
index.html
106
index.html
|
|
@ -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
1014
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -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
340
public/css/style.css
Normal 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
46
public/index.html
Normal 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
386
public/js/app.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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();
|
||||
})();
|
||||
213
server.js
213
server.js
|
|
@ -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');
|
||||
// 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
1
uploads/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
# KatDrop uploads directory
|
||||
Loading…
Add table
Reference in a new issue