Upload-Freigabe: öffentlicher Upload-Link mit Passwort, Ablaufzeit, max. Größe; Admin-Verwaltung
This commit is contained in:
@@ -521,4 +521,46 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Upload-Freigaben (externer Upload-Link) ───────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE upload_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 10,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_share_logs'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE upload_share_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id INTEGER NOT NULL REFERENCES upload_shares(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
originalname TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
uploaded_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// allow_upload_share Recht pro User
|
||||
{
|
||||
const userCols = db.pragma('table_info(users)').map(c => c.name);
|
||||
if (!userCols.includes('allow_upload_share'))
|
||||
db.exec('ALTER TABLE users ADD COLUMN allow_upload_share INTEGER DEFAULT 0');
|
||||
}
|
||||
// Upload-Ordner Flag auf Folders-Tabelle
|
||||
{
|
||||
const folderCols = db.pragma('table_info(folders)').map(c => c.name);
|
||||
if (!folderCols.includes('is_upload_folder'))
|
||||
db.exec('ALTER TABLE folders ADD COLUMN is_upload_folder INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -68,6 +68,7 @@ fs.readdirSync(toolsDir, { withFileTypes: true })
|
||||
|
||||
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
|
||||
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
|
||||
app.use('/api/upload-shares', require('./tools/dateien/upload-share'));
|
||||
app.use('/api/search', require('./routes/search'));
|
||||
|
||||
const PUBLIC = path.join(__dirname, '../public');
|
||||
@@ -84,6 +85,13 @@ app.get('/sw.js', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC, 'sw.js'));
|
||||
});
|
||||
|
||||
// Öffentliche Upload-Seite (kein Login erforderlich)
|
||||
const UPLOAD_HTML = path.join(__dirname, '../upload-page.html');
|
||||
app.get('/u/:token', (_req, res) => {
|
||||
if (require('fs').existsSync(UPLOAD_HTML)) res.sendFile(UPLOAD_HTML);
|
||||
else res.status(404).send('Upload-Seite nicht gefunden');
|
||||
});
|
||||
|
||||
app.use(express.static(PUBLIC, {
|
||||
setHeaders: (res, filePath) => {
|
||||
// JS/CSS Assets haben Hash im Namen → lang cachen
|
||||
|
||||
183
backend/src/tools/dateien/upload-share.js
Normal file
183
backend/src/tools/dateien/upload-share.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function generateToken() {
|
||||
return crypto.randomBytes(20).toString('base64url');
|
||||
}
|
||||
|
||||
function getOrCreateUploadFolder(userId) {
|
||||
let folder = db.prepare(
|
||||
"SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1"
|
||||
).get(userId);
|
||||
if (!folder) {
|
||||
const r = db.prepare(
|
||||
"INSERT INTO folders (user_id, name, is_upload_folder, created_at) VALUES (?,?,1,datetime('now','localtime'))"
|
||||
).run(userId, 'Upload-Ordner');
|
||||
folder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
function canUseUploadShare(userId) {
|
||||
const user = db.prepare('SELECT role, allow_upload_share FROM users WHERE id=?').get(userId);
|
||||
return user && (user.role === 'admin' || !!user.allow_upload_share);
|
||||
}
|
||||
|
||||
// ── Eigene Shares verwalten ───────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
WHERE s.user_id=?
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares, folder });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
const { password, expires_hours=24, max_size_mb=10 } = req.body;
|
||||
if (!password?.trim()) return res.status(400).json({ error: 'Passwort erforderlich' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const token = generateToken();
|
||||
const hash = bcrypt.hashSync(password.trim(), 10);
|
||||
const expires = new Date(Date.now() + Number(expires_hours) * 3600000).toISOString();
|
||||
const r = db.prepare(`
|
||||
INSERT INTO upload_shares (user_id, token, password_hash, expires_at, max_size_mb, folder_id, created_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
|
||||
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('UPDATE upload_shares SET is_active=0 WHERE id=?').run(s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/logs', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
WHERE l.user_id=?
|
||||
ORDER BY l.uploaded_at DESC
|
||||
LIMIT 100
|
||||
`).all(req.user.id);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
// ── Admin: alle Logs sehen ────────────────────────────────────────────────────
|
||||
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb, u.username
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
JOIN users u ON u.id=l.user_id
|
||||
ORDER BY l.uploaded_at DESC
|
||||
LIMIT 500
|
||||
`).all();
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, u.username, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s
|
||||
JOIN users u ON u.id=s.user_id
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
res.json(shares);
|
||||
});
|
||||
|
||||
// ── Admin: Berechtigung pro User setzen ──────────────────────────────────────
|
||||
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
|
||||
const { user_id, allow } = req.body;
|
||||
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Öffentlicher Endpunkt: Infos zum Share ────────────────────────────────────
|
||||
router.get('/public/:token', (req, res) => {
|
||||
const s = db.prepare(
|
||||
"SELECT id, expires_at, max_size_mb, is_active FROM upload_shares WHERE token=?"
|
||||
).get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig oder deaktiviert' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlicher Endpunkt: Passwort prüfen ────────────────────────────────────
|
||||
router.post('/public/:token/verify', (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
if (!bcrypt.compareSync(req.body.password || '', s.password_hash))
|
||||
return res.status(401).json({ error: 'Falsches Passwort' });
|
||||
res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlicher Upload ───────────────────────────────────────────────────────
|
||||
router.post('/public/:token/upload', (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
|
||||
// Passwort im Header prüfen
|
||||
const pw = req.headers['x-upload-password'] || '';
|
||||
if (!bcrypt.compareSync(pw, s.password_hash))
|
||||
return res.status(401).json({ error: 'Nicht autorisiert' });
|
||||
|
||||
const maxBytes = s.max_size_mb * 1024 * 1024;
|
||||
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: maxBytes },
|
||||
}).single('file');
|
||||
|
||||
upload(req, res, err => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE')
|
||||
return res.status(413).json({ error: `Datei zu groß (max ${s.max_size_mb} MB)` });
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei' });
|
||||
|
||||
try {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO files (user_id, filename, originalname, mimetype, size, folder_id, created_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(s.user_id, req.file.filename, req.file.originalname, req.file.mimetype, req.file.size, s.folder_id);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO upload_share_logs (share_id, user_id, originalname, size, ip_address, uploaded_at)
|
||||
VALUES (?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(s.id, s.user_id, req.file.originalname, req.file.size, req.ip || '');
|
||||
|
||||
res.json({ ok: true, filename: req.file.originalname });
|
||||
} catch(e) {
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user