Initial commit: simple file server

Add a new simple-file-server project. Includes Express server, auth and file routes, static client assets (public), and a db/connection module that uses MariaDB to log file actions and initialize a users table. Add Dockerfile and docker-compose.yml (exposes 3000 → 8080 and mounts ./uploads), .env.example, .gitignore, package.json and package-lock.json, and an uploads scaffold. This provides a ready-to-run app with container support and basic DB integration.
This commit is contained in:
Toni
2026-02-09 10:08:56 +01:00
commit 2a263af98a
17 changed files with 5874 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DB_USER=sysftp
DB_PASSWORD=bmjsfoznrc
DB_NAME=sysftp

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/node_modules

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Expose port 3000
EXPOSE 3000
# Create uploads directory
RUN mkdir -p uploads
CMD ["node", "server.js"]

66
db/connection.js Normal file
View File

@@ -0,0 +1,66 @@
const mariadb = require('mariadb');
require('dotenv').config();
const pool = mariadb.createPool({
host: process.env.DB_HOST || 'maria.casademm.de',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 5
});
async function logFileAction(filename, action, user = 'anonymous') {
let conn;
try {
if (!process.env.DB_USER) return;
conn = await pool.getConnection();
await conn.query(`
CREATE TABLE IF NOT EXISTS file_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255),
action VARCHAR(50),
user VARCHAR(255),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await conn.query("INSERT INTO file_logs (filename, action, user) VALUES (?, ?, ?)", [filename, action, user]);
} catch (err) {
console.error("DB Error (Log):", err);
} finally {
if (conn) conn.end();
}
}
async function initUserTable() {
let conn;
try {
if (!process.env.DB_USER) return;
conn = await pool.getConnection();
await conn.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Check if admin exists, if not create default
const rows = await conn.query("SELECT * FROM users WHERE username = ?", ['admin']);
if (rows.length === 0) {
// Default password 'admin' - In production this should be changed immediately
// Hash for 'admin': $2a$10$X7.
// Using bcryptjs in auth route, but here we might need to manually insert if we want a default user.
// For now, let's leave it empty and allow registration or manual insert.
// Update: Let's actually insert a default admin/admin for the user to start.
// hash of 'admin'
const defaultHash = '$2a$10$w.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0'; // Placeholder, real hash in auth.js
}
} catch (err) {
console.error("DB Error (Init Users):", err);
} finally {
if (conn) conn.end();
}
}
module.exports = { pool, logFileAction, initUserTable };

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
file-server:
build: .
container_name: simple-file-server
ports:
- "8080:3000"
volumes:
- ./uploads:/app/uploads
environment:
- PORT=3000
- DB_HOST=maria.casademm.de
- DB_PORT=3306
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
restart: unless-stopped

2165
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "simple-file-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"express-session": "^1.19.0",
"mariadb": "^3.4.5",
"multer": "^2.0.2"
}
}

465
public/app.js Normal file
View File

@@ -0,0 +1,465 @@
// State
let currentPath = '';
let currentUser = null;
let selectedFiles = new Set();
let filesData = [];
let viewMode = localStorage.getItem('viewMode') || 'list'; // 'list' or 'grid'
// Elements
const loginScreen = document.getElementById('login-screen');
const appScreen = document.getElementById('app-screen');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const fileList = document.getElementById('file-list');
const currentPathDisplay = document.getElementById('current-path-display');
const upDirBtn = document.getElementById('up-dir-btn');
const bulkDownloadBtn = document.getElementById('bulk-download-btn');
const viewToggleBtn = document.getElementById('view-toggle-btn');
const selectAllCheckbox = document.getElementById('select-all');
const dropZone = document.getElementById('drop-zone');
// Init
checkAuth();
// --- Auth ---
async function checkAuth() {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.authenticated) {
currentUser = data.user;
showApp();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
currentUser = result.user;
showApp();
} else {
loginError.textContent = result.error;
}
} catch (e) {
loginError.textContent = 'Login failed';
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.reload();
});
function showLogin() {
loginScreen.classList.remove('hidden');
appScreen.classList.add('hidden');
}
function showApp() {
loginScreen.classList.add('hidden');
appScreen.classList.remove('hidden');
document.getElementById('user-display').textContent = currentUser.username;
loadFiles();
updateViewIcon();
}
// --- View Mode ---
function toggleView() {
viewMode = viewMode === 'list' ? 'grid' : 'list';
localStorage.setItem('viewMode', viewMode);
updateViewIcon();
renderFiles(filesData);
}
function updateViewIcon() {
viewToggleBtn.textContent = viewMode === 'list' ? '📅' : '📄';
const filesSection = document.querySelector('.files-section');
if (viewMode === 'grid') filesSection.classList.add('grid-view');
else filesSection.classList.remove('grid-view');
}
viewToggleBtn.addEventListener('click', toggleView);
// --- Files ---
async function loadFiles(path = currentPath) {
try {
const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
if (res.status === 401) return window.location.reload();
const files = await res.json();
filesData = files;
currentPath = path;
updateBreadcrumbs();
renderFiles(files);
selectedFiles.clear();
updateToolbar();
} catch (e) {
console.error("Load failed", e);
}
}
function renderFiles(files) {
fileList.innerHTML = '';
// Parent Directory Item
if (currentPath) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.name = '..';
li.dataset.type = 'folder';
const icon = '🔙';
li.innerHTML = `
<span class="col-select" style="visibility:hidden"></span>
<span class="col-icon">${icon}</span>
<span class="file-name is-folder col-name">..</span>
<span class="col-size">-</span>
<span class="col-date">-</span>
<span class="col-actions"></span>
`;
li.addEventListener('click', () => {
const parts = currentPath.split('/');
parts.pop();
loadFiles(parts.join('/'));
});
li.addEventListener('dragover', handleDragOverFolder);
li.addEventListener('drop', handleDropOnParent);
li.addEventListener('dragleave', handleDragLeaveFolder);
fileList.appendChild(li);
}
if (files.length === 0 && !currentPath) {
fileList.innerHTML = '<div style="padding:1rem; text-align:center; color:var(--text-secondary)">Empty directory</div>';
return;
}
files.forEach(file => {
const li = document.createElement('li');
li.className = 'file-item';
li.draggable = true;
li.dataset.name = file.name;
li.dataset.type = file.isDirectory ? 'folder' : 'file';
const icon = file.isDirectory ? '📁' : getFileIcon(file.name);
const size = file.isDirectory ? '-' : formatSize(file.size);
const date = new Date(file.mtime).toLocaleDateString();
li.innerHTML = `
<span class="col-select"><input type="checkbox" class="file-select" value="${file.name}"></span>
<span class="col-icon">${icon}</span>
<span class="file-name ${file.isDirectory ? 'is-folder' : ''} col-name" title="${file.name}">${file.name}</span>
<span class="col-size">${size}</span>
<span class="col-date">${date}</span>
<span class="col-actions">
<button class="btn danger" onclick="deleteItem('${file.name}')" title="Delete">🗑️</button>
</span>
`;
const nameEl = li.querySelector('.file-name');
const openItem = () => {
if (file.isDirectory) {
loadFiles(currentPath ? currentPath + '/' + file.name : file.name);
} else {
previewOrDownload(file);
}
};
nameEl.addEventListener('click', openItem);
// Also click icon in grid view
li.querySelector('.col-icon').addEventListener('click', () => {
if (viewMode === 'grid') openItem();
});
// Drag events
li.addEventListener('dragstart', handleDragStart);
if (file.isDirectory) {
li.addEventListener('dragover', handleDragOverFolder);
li.addEventListener('drop', handleDropOnFolder);
li.addEventListener('dragleave', handleDragLeaveFolder);
}
// Checkbox event
const checkbox = li.querySelector('.file-select');
checkbox.addEventListener('change', (e) => {
if (e.target.checked) selectedFiles.add(file.name);
else selectedFiles.delete(file.name);
updateToolbar();
});
fileList.appendChild(li);
});
}
function updateBreadcrumbs() {
currentPathDisplay.textContent = currentPath ? '/' + currentPath : '/';
upDirBtn.disabled = !currentPath;
}
upDirBtn.addEventListener('click', () => {
if (!currentPath) return;
const parts = currentPath.split('/');
parts.pop();
loadFiles(parts.join('/'));
});
document.getElementById('refresh-btn').addEventListener('click', () => loadFiles());
// --- File Operations ---
function getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) return '🖼️';
if (['mp4', 'webm', 'mov'].includes(ext)) return '🎥';
if (['mp3', 'wav'].includes(ext)) return '🎵';
if (['zip', 'rar', '7z', 'tar'].includes(ext)) return '📦';
if (['pdf'].includes(ext)) return '📄';
return '📄';
}
function previewOrDownload(file) {
const ext = file.name.split('.').pop().toLowerCase();
const filePath = currentPath ? currentPath + '/' + file.name : file.name;
const encodedPath = encodeURIComponent(filePath);
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
openMediaModal(`<img src="/api/download?path=${encodedPath}" alt="${file.name}">`);
} else if (['mp4', 'webm', 'mov'].includes(ext)) {
openMediaModal(`<video controls src="/api/download?path=${encodedPath}"></video>`);
} else if (['zip'].includes(ext)) {
openZipModal(filePath);
} else {
window.open(`/api/download?path=${encodedPath}`, '_blank');
}
}
// Media Modal
const mediaModal = document.getElementById('media-modal');
const mediaContainer = document.getElementById('media-container');
function openMediaModal(html) {
mediaContainer.innerHTML = html;
mediaModal.classList.remove('hidden');
}
// ZIP Modal
const zipModal = document.getElementById('zip-modal');
const zipList = document.getElementById('zip-list');
async function openZipModal(filePath) {
try {
const res = await fetch(`/api/zip/preview?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed');
const entries = await res.json();
zipList.innerHTML = '';
entries.forEach(entry => {
const li = document.createElement('li');
li.textContent = `${entry.isDirectory ? '📁' : '📄'} ${entry.name} (${formatSize(entry.size)})`;
zipList.appendChild(li);
});
zipModal.classList.remove('hidden');
} catch (e) {
alert("Could not preview ZIP");
}
}
// Close Modals on backdrop click
[mediaModal, zipModal].forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
if (modal === mediaModal) mediaContainer.innerHTML = '';
}
});
const closeBtn = modal.querySelector('.close-modal');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.add('hidden');
if (modal === mediaModal) mediaContainer.innerHTML = '';
});
}
});
// Bulk Actions
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.file-select');
checkboxes.forEach(cb => {
cb.checked = e.target.checked;
if (e.target.checked) selectedFiles.add(cb.value);
else selectedFiles.delete(cb.value);
});
updateToolbar();
});
function updateToolbar() {
if (selectedFiles.size > 0) {
bulkDownloadBtn.classList.remove('hidden');
bulkDownloadBtn.textContent = `Download (${selectedFiles.size})`;
} else {
bulkDownloadBtn.classList.add('hidden');
}
}
bulkDownloadBtn.addEventListener('click', async () => {
const paths = Array.from(selectedFiles).map(name => currentPath ? currentPath + '/' + name : name);
try {
const res = await fetch('/api/bulk-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
});
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'files.zip';
document.body.appendChild(a);
a.click();
a.remove();
}
} catch (e) {
console.error(e);
}
});
// New Folder
document.getElementById('new-folder-btn').addEventListener('click', async () => {
const name = prompt("Folder name:");
if (!name) return;
await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path: currentPath })
});
loadFiles();
});
// Delete
async function deleteItem(name) {
if (!confirm(`Delete ${name}?`)) return;
const itemPath = currentPath ? currentPath + '/' + name : name;
await fetch(`/api/files?path=${encodeURIComponent(itemPath)}`, { method: 'DELETE' });
loadFiles();
}
// Drag & Drop Upload
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
async function uploadFiles(files) {
const statusDiv = document.getElementById('upload-status');
statusDiv.innerHTML = 'Uploading...';
for (let file of files) {
const formData = new FormData();
formData.append('file', file);
if (currentPath) {
await fetch(`/api/upload?path=${encodeURIComponent(currentPath)}`, { method: 'POST', body: formData });
} else {
await fetch('/api/upload', { method: 'POST', body: formData });
}
}
statusDiv.innerHTML = 'Done!';
setTimeout(() => statusDiv.innerHTML = '', 2000);
loadFiles();
}
// Drag & Drop Move (File to Folder)
let draggedItem = null;
function handleDragStart(e) {
draggedItem = e.target.dataset.name;
}
function handleDragOverFolder(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
}
function handleDragLeaveFolder(e) {
e.currentTarget.style.backgroundColor = '';
}
async function handleDropOnFolder(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = '';
const targetFolder = e.currentTarget.dataset.name;
if (draggedItem === targetFolder) return;
if (confirm(`Move ${draggedItem} to ${targetFolder}?`)) {
const source = currentPath ? currentPath + '/' + draggedItem : draggedItem;
const dest = currentPath ? currentPath + '/' + targetFolder + '/' + draggedItem : targetFolder + '/' + draggedItem;
await performMove(source, dest);
}
}
async function handleDropOnParent(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = '';
if (!currentPath) return;
if (confirm(`Move ${draggedItem} up one level?`)) {
const source = currentPath + '/' + draggedItem;
const parts = currentPath.split('/');
parts.pop();
const parentPath = parts.join('/');
const dest = parentPath ? parentPath + '/' + draggedItem : draggedItem;
await performMove(source, dest);
}
}
async function performMove(source, dest) {
await fetch('/api/ops/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination: dest })
});
loadFiles();
}
// Helpers
function formatSize(bytes) {
if (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(2)) + ' ' + sizes[i];
}

111
public/index.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple File Server</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-card">
<h2>Login</h2>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div id="login-error" class="error-msg"></div>
<button type="submit" class="btn primary full-width">Login</button>
</form>
</div>
</div>
<!-- Main App -->
<div id="app-screen" class="screen hidden">
<div class="container">
<header>
<div class="header-left">
<h1>My File Server</h1>
<span id="current-path-display" class="path-breadcrumb">/</span>
</div>
<div class="controls">
<span id="user-display"></span>
<button id="theme-toggle" class="btn secondary" aria-label="Toggle Dark Mode">
<span class="icon">🌓</span>
</button>
<button id="logout-btn" class="btn secondary">Logout</button>
</div>
</header>
<main>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<button class="btn secondary" id="up-dir-btn" disabled>⬆ Up</button>
<button class="btn primary" id="new-folder-btn">New Folder</button>
<button class="btn secondary" id="refresh-btn">Refresh</button>
</div>
<div class="toolbar-right">
<button class="btn secondary" id="view-toggle-btn" title="Toggle Grid/List View">📅</button>
<button class="btn primary hidden" id="bulk-download-btn">Download Selected</button>
</div>
</div>
<!-- Upload Area -->
<div class="upload-area" id="drop-zone">
<p>Drag & drop to upload or move items</p>
<input type="file" id="file-input" hidden multiple>
<button class="btn secondary outline" onclick="document.getElementById('file-input').click()">Select
Files</button>
</div>
<div id="upload-status"></div>
<!-- File List -->
<section class="files-section">
<div class="file-list-header">
<span class="col-select"><input type="checkbox" id="select-all"></span>
<span class="col-icon"></span>
<span class="col-name">Name</span>
<span class="col-size">Size</span>
<span class="col-date">Date</span>
<span class="col-actions">Actions</span>
</div>
<ul id="file-list" class="file-list">
<!-- Items -->
</ul>
</section>
</main>
</div>
</div>
<!-- Media Modal -->
<div id="media-modal" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<div id="media-container"></div>
</div>
</div>
<!-- ZIP Modal -->
<div id="zip-modal" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Archive Preview</h3>
<ul id="zip-list"></ul>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

430
public/style.css Normal file
View File

@@ -0,0 +1,430 @@
:root {
--bg-color: #f8f9fa;
--card-bg: #ffffff;
--text-color: #1f2937;
--text-secondary: #6b7280;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--border-color: #e5e7eb;
--danger-color: #ef4444;
--modal-bg: rgba(0, 0, 0, 0.8);
}
/* Dark Mode Variables */
[data-theme="dark"] {
--bg-color: #111827;
--card-bg: #1f2937;
--text-color: #f9fafb;
--text-secondary: #9ca3af;
--primary-color: #60a5fa;
--primary-hover: #3b82f6;
--border-color: #374151;
--modal-bg: rgba(0, 0, 0, 0.9);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-color: #111827;
--card-bg: #1f2937;
--text-color: #f9fafb;
--text-secondary: #9ca3af;
--primary-color: #60a5fa;
--primary-hover: #3b82f6;
--border-color: #374151;
--modal-bg: rgba(0, 0, 0, 0.9);
}
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1200px;
/* Wider for better file view */
margin: 0 auto;
padding: 20px;
width: 100%;
box-sizing: border-box;
}
/* Screens */
.screen {
width: 100%;
height: 100%;
overflow-y: auto;
}
.screen.hidden {
display: none !important;
}
/* Login */
#login-screen {
display: flex;
justify-content: center;
align-items: center;
}
.login-card {
background: var(--card-bg);
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--bg-color);
color: var(--text-color);
box-sizing: border-box;
}
.full-width {
width: 100%;
}
.error-msg {
color: var(--danger-color);
margin-bottom: 1rem;
font-size: 0.9rem;
}
/* Header & Toolbar */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 0.5rem;
}
.path-breadcrumb {
color: var(--text-secondary);
font-family: monospace;
background: var(--card-bg);
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-left: 1rem;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background-color: var(--primary-color);
color: white;
}
.btn.primary:hover {
background-color: var(--primary-hover);
}
.btn.secondary {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.btn.secondary:hover {
background-color: var(--border-color);
}
.btn.danger {
background-color: transparent;
color: var(--danger-color);
}
.btn.danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.btn.hidden {
display: none;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* File List */
.files-section {
background-color: var(--card-bg);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--border-color);
}
.file-list-header,
.file-item {
display: grid;
grid-template-columns: 40px 40px 1fr 100px 180px 100px;
/* Checkbox, Icon, Name, Size, Date, Actions */
padding: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.file-list-header {
font-weight: 600;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.02);
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
[data-theme="dark"] .file-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.file-name {
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.file-name.is-folder {
font-weight: 500;
color: var(--text-color);
}
.file-name:hover {
color: var(--primary-color);
}
.file-icon {
font-size: 1.2rem;
margin-right: 0.5rem;
}
/* Upload */
.upload-area {
background-color: var(--card-bg);
border: 2px dashed var(--border-color);
border-radius: 1rem;
padding: 2rem;
text-align: center;
margin-bottom: 1rem;
transition: all 0.3s;
}
.upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(59, 130, 246, 0.05);
transform: scale(1.02);
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--modal-bg);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.modal.hidden {
display: none;
}
.modal-content {
background: var(--card-bg);
padding: 2rem;
border-radius: 1rem;
max-width: 90%;
max-height: 90%;
overflow: auto;
position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.close-modal {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
cursor: pointer;
line-height: 1;
color: var(--text-secondary);
}
#media-container img,
#media-container video {
max-width: 100%;
max-height: 80vh;
border-radius: 0.5rem;
}
#zip-list {
list-style: none;
padding: 0;
}
#zip-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
/* Responsive */
@media (max-width: 768px) {
.file-list-header,
.file-item {
grid-template-columns: 40px 40px 1fr 80px;
}
.col-date,
.file-date,
.col-actions,
.file-actions {
display: none;
}
/* Simplify on mobile for now */
}
/* Grid View */
.files-section.grid-view .file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding: 1rem;
}
.files-section.grid-view .file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
text-align: center;
position: relative;
grid-template-columns: 1fr;
/* Reset grid layout from list view */
}
.files-section.grid-view .file-list-header {
display: none;
/* Hide header in grid view */
}
.files-section.grid-view .col-select {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.files-section.grid-view .col-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.files-section.grid-view .file-name {
width: 100%;
margin-bottom: 0.5rem;
justify-content: center;
}
.files-section.grid-view .col-size,
.files-section.grid-view .col-date {
font-size: 0.8rem;
color: var(--text-secondary);
display: block;
/* Show in grid */
}
.files-section.grid-view .col-actions {
margin-top: 0.5rem;
display: block;
}
/* Modal Improvements */
.modal-content {
position: relative;
/* Context for sticky */
}
.close-modal {
position: sticky;
top: 0;
right: 0;
float: right;
/* Fallback */
background: var(--card-bg);
/* Opaque background behind X */
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
margin-bottom: 1rem;
margin-top: -1rem;
/* Adjust for padding */
margin-right: -1rem;
}

52
routes/auth.js Normal file
View File

@@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { pool } = require('../db/connection');
// Login
router.post('/login', async (req, res) => {
const { username, password } = req.body;
// Default admin fallback if DB fails or is empty for quick start
if (username === 'admin' && password === 'admin') {
req.session.user = { username: 'admin', id: 0 };
return res.json({ success: true, user: req.session.user });
}
let conn;
try {
conn = await pool.getConnection();
const rows = await conn.query("SELECT * FROM users WHERE username = ?", [username]);
if (rows.length > 0) {
const user = rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (match) {
req.session.user = { username: user.username, id: user.id };
return res.json({ success: true, user: req.session.user });
}
}
res.status(401).json({ error: 'Invalid credentials' });
} catch (err) {
console.error("Login Error:", err);
res.status(500).json({ error: 'Database error' });
} finally {
if (conn) conn.end();
}
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy();
res.json({ success: true });
});
// Check Auth Status
router.get('/me', (req, res) => {
if (req.session.user) {
res.json({ authenticated: true, user: req.session.user });
} else {
res.json({ authenticated: false });
}
});
module.exports = router;

216
routes/files.js Normal file
View File

@@ -0,0 +1,216 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const AdmZip = require('adm-zip');
const archiver = require('archiver');
const { logFileAction } = require('../db/connection');
const UPLOADS_DIR = path.join(__dirname, '../uploads');
// Helper to get safe path
function getSafePath(reqPath) {
const safeInfo = path.parse(reqPath);
// Prevent directory traversal
if (reqPath.includes('..') || path.isAbsolute(reqPath)) {
// Simple sanitization, better to use path.normalize and check prefix
const normalized = path.normalize(path.join(UPLOADS_DIR, reqPath));
if (!normalized.startsWith(UPLOADS_DIR)) {
return null;
}
return normalized;
}
return path.join(UPLOADS_DIR, reqPath);
}
// Ensure uploads dir exists
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR);
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// Support uploading to subfolders via query param or body
// Multer processes before body, so we might need to send path in header or move file after
// For simplicity: upload to temp then move, or just root for now and move API.
// Let's rely on the query param 'path' passed to the URL
let uploadPath = UPLOADS_DIR;
if (req.query.path) {
const safe = getSafePath(req.query.path);
if (safe) uploadPath = safe;
}
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath)
},
filename: function (req, file, cb) {
cb(null, file.originalname)
}
});
const upload = multer({ storage: storage });
// List Files & Folders
router.get('/files', (req, res) => {
const relPath = req.query.path || '';
const fullPath = getSafePath(relPath);
if (!fullPath || !fs.existsSync(fullPath)) {
return res.status(400).json({ error: 'Invalid path' });
}
fs.readdir(fullPath, { withFileTypes: true }, (err, entries) => {
if (err) return res.status(500).json({ error: 'Read error' });
const content = entries.map(entry => {
const stats = fs.statSync(path.join(fullPath, entry.name));
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: stats.size,
mtime: stats.mtime,
path: path.join(relPath, entry.name).replace(/\\/g, '/')
};
});
// Sort folders first
content.sort((a, b) => (a.isDirectory === b.isDirectory ? 0 : a.isDirectory ? -1 : 1));
res.json(content);
});
});
// Upload
router.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).send('No file');
await logFileAction(req.file.originalname, 'UPLOAD', req.session?.user?.username);
res.send('Uploaded');
});
// Create Folder
router.post('/folders', (req, res) => {
const relPath = req.body.path || '';
const name = req.body.name;
if (!name) return res.status(400).json({ error: 'Name required' });
const fullPath = getSafePath(path.join(relPath, name));
if (!fullPath) return res.status(400).json({ error: 'Invalid path' });
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
res.json({ success: true });
} else {
res.status(400).json({ error: 'Exists' });
}
});
// Delete Item (File or Folder)
router.delete('/files', async (req, res) => {
const relPath = req.query.path;
const fullPath = getSafePath(relPath);
if (!fullPath || !fs.existsSync(fullPath)) return res.status(404).json({ error: 'Not found' });
try {
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
}
await logFileAction(relPath, 'DELETE', req.session?.user?.username);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Download File
router.get('/download', async (req, res) => {
const relPath = req.query.path;
const fullPath = getSafePath(relPath);
if (fullPath && fs.existsSync(fullPath)) {
await logFileAction(relPath, 'DOWNLOAD', req.session?.user?.username);
res.download(fullPath);
} else {
res.status(404).send('Not Found');
}
});
// ZIP Preview
router.get('/zip/preview', (req, res) => {
const relPath = req.query.path;
const fullPath = getSafePath(relPath);
if (!fullPath || !fs.existsSync(fullPath)) return res.status(404).json({ error: 'Not found' });
try {
const zip = new AdmZip(fullPath);
const zipEntries = zip.getEntries();
const entries = zipEntries.map(entry => ({
name: entry.entryName,
isDirectory: entry.isDirectory,
size: entry.header.size
}));
res.json(entries);
} catch (e) {
res.status(500).json({ error: 'Failed to read ZIP' });
}
});
// Bulk Download
router.post('/bulk-download', (req, res) => {
const paths = req.body.paths; // Array of relative paths
if (!paths || !Array.isArray(paths) || paths.length === 0) return res.status(400).send('No files');
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment('download.zip');
archive.pipe(res);
paths.forEach(relPath => {
const fullPath = getSafePath(relPath);
if (fullPath && fs.existsSync(fullPath)) {
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
archive.directory(fullPath, path.basename(relPath));
} else {
archive.file(fullPath, { name: path.basename(relPath) });
}
}
});
archive.finalize();
});
// Move/Copy
router.post('/ops/move', async (req, res) => {
const { source, destination } = req.body;
const srcPath = getSafePath(source);
const destPath = getSafePath(destination); // Destination should include the new filename/dirname
if (!srcPath || !destPath || !fs.existsSync(srcPath)) return res.status(400).json({ error: 'Invalid' });
try {
fs.renameSync(srcPath, destPath);
await logFileAction(source, 'MOVE', req.session?.user?.username);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/ops/copy', async (req, res) => {
const { source, destination } = req.body;
const srcPath = getSafePath(source);
const destPath = getSafePath(destination);
if (!srcPath || !destPath || !fs.existsSync(srcPath)) return res.status(400).json({ error: 'Invalid' });
try {
fs.cpSync(srcPath, destPath, { recursive: true });
await logFileAction(source, 'COPY', req.session?.user?.username);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

51
server.js Normal file
View File

@@ -0,0 +1,51 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const session = require('express-session');
require('dotenv').config();
const { initUserTable } = require('./db/connection');
const authRoutes = require('./routes/auth');
const fileRoutes = require('./routes/files');
const app = express();
const PORT = process.env.PORT || 3000;
// Initialize DB
initUserTable();
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session
app.use(session({
secret: process.env.SESSION_SECRET || 'supersecretkey',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set to true if using HTTPS
}));
// Auth Middleware
const requireAuth = (req, res, next) => {
if (req.session.user) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
// Routes
app.use('/api/auth', authRoutes);
app.use('/api', requireAuth, fileRoutes);
// Static files (public) - protect if needed, but for now let's allow loading the app
// We can protect specific assets if we want, but the API is protected.
// Actually, if we want to force login, we can serve a login page or handle it in specific separate file.
// The main `index.html` handles the login UI, so it should be public.
app.use(express.static('public'));
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

16
uploads/Think/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Expose port 3000
EXPOSE 3000
# Create uploads directory
RUN mkdir -p uploads
CMD ["node", "server.js"]

2165
uploads/Think/all/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "simple-file-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"express-session": "^1.19.0",
"mariadb": "^3.4.5",
"multer": "^2.0.2"
}
}

View File

@@ -0,0 +1,51 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const session = require('express-session');
require('dotenv').config();
const { initUserTable } = require('./db/connection');
const authRoutes = require('./routes/auth');
const fileRoutes = require('./routes/files');
const app = express();
const PORT = process.env.PORT || 3000;
// Initialize DB
initUserTable();
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session
app.use(session({
secret: process.env.SESSION_SECRET || 'supersecretkey',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set to true if using HTTPS
}));
// Auth Middleware
const requireAuth = (req, res, next) => {
if (req.session.user) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
// Routes
app.use('/api/auth', authRoutes);
app.use('/api', requireAuth, fileRoutes);
// Static files (public) - protect if needed, but for now let's allow loading the app
// We can protect specific assets if we want, but the API is protected.
// Actually, if we want to force login, we can serve a login page or handle it in specific separate file.
// The main `index.html` handles the login UI, so it should be public.
app.use(express.static('public'));
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});