feat: öffentliche Datei/Ordner-Freigabe mit Link, Ablauf & Passwort; Paywall-Killer in Suche
This commit is contained in:
@@ -70,6 +70,8 @@ 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/tools/dateien/public-shares', require('./tools/dateien/public-share'));
|
||||
app.use('/api/public/file-share', require('./tools/dateien/public-share'));
|
||||
app.use('/api/search', require('./routes/search'));
|
||||
|
||||
// Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst
|
||||
@@ -78,6 +80,12 @@ app.get('/u/:token', (_req, res) => {
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
// Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst
|
||||
app.get('/s/:token', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache');
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
const PUBLIC = path.join(__dirname, '../public');
|
||||
|
||||
app.get('/manifest.json', (_req, res) => {
|
||||
|
||||
223
backend/src/tools/dateien/public-share.js
Normal file
223
backend/src/tools/dateien/public-share.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
|
||||
|
||||
// ── Migration: Tabelle erstellen falls nicht vorhanden ─────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS public_file_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
password_hash TEXT,
|
||||
expires_at INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
download_count INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK (file_id IS NOT NULL OR folder_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── GET /api/tools/dateien/public-shares ──────────────────────────────────
|
||||
// Alle eigenen Shares auflisten
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*,
|
||||
f.name as file_name, f.size as file_size, f.mime_type,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares });
|
||||
});
|
||||
|
||||
// ── POST /api/tools/dateien/public-shares ─────────────────────────────────
|
||||
// Neuen Share erstellen
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { file_id, folder_id, password, expires_hours, label } = req.body;
|
||||
if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' });
|
||||
|
||||
// Sicherstellen dass die Datei/Ordner dem User gehört
|
||||
if (file_id) {
|
||||
const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id);
|
||||
if (!f) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
if (folder_id) {
|
||||
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id);
|
||||
if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const password_hash = password ? bcrypt.hashSync(password, 10) : null;
|
||||
const expires_at = expires_hours ? Math.floor(Date.now() / 1000) + expires_hours * 3600 : null;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at);
|
||||
|
||||
const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid);
|
||||
res.json({ share, token });
|
||||
});
|
||||
|
||||
// ── DELETE /api/tools/dateien/public-shares/:id ───────────────────────────
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT id FROM public_file_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('DELETE FROM public_file_shares WHERE id=?').run(s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── GET /api/tools/dateien/public-shares/:id/info ─────────────────────────
|
||||
// Eigene Share-Infos (für Management)
|
||||
router.get('/:id/info', authenticate, (req, res) => {
|
||||
const s = db.prepare(`
|
||||
SELECT s.*, f.name as file_name, fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.id=? AND s.user_id=?
|
||||
`).get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ share: s });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ÖFFENTLICHE ENDPUNKTE (kein Login nötig)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── GET /api/public/file-share/:token ─────────────────────────────────────
|
||||
// Share-Metadaten abrufen (Name, ob Passwort nötig, abgelaufen?)
|
||||
router.get('/public/:token', (req, res) => {
|
||||
const s = db.prepare(`
|
||||
SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.token=?
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
label: s.label,
|
||||
file_name: s.file_name,
|
||||
file_size: s.file_size,
|
||||
mime_type: s.mime_type,
|
||||
folder_name: s.folder_name,
|
||||
is_folder: !!s.folder_id,
|
||||
needs_password: !!s.password_hash,
|
||||
expires_at: s.expires_at,
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/public/file-share/:token/download ───────────────────────────
|
||||
// Datei herunterladen (mit optionalem Passwort)
|
||||
router.post('/public/:token/download', async (req, res) => {
|
||||
const s = db.prepare(`
|
||||
SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
WHERE s.token=? AND s.file_id IS NOT NULL
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden oder kein Datei-Link' });
|
||||
if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
}
|
||||
if (s.password_hash) {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||
const ok = await bcrypt.compare(password, s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, s.file_path);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`);
|
||||
res.setHeader('Content-Type', s.mime_type || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// ── POST /api/public/file-share/:token/folder ─────────────────────────────
|
||||
// Ordnerinhalt abrufen (mit optionalem Passwort)
|
||||
router.post('/public/:token/folder', async (req, res) => {
|
||||
const s = db.prepare(`
|
||||
SELECT s.*, fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.token=? AND s.folder_id IS NOT NULL
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden oder kein Ordner-Link' });
|
||||
if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
}
|
||||
if (s.password_hash) {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||
const ok = await bcrypt.compare(password, s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
|
||||
const files = db.prepare(`
|
||||
SELECT id, name, size, mime_type, created_at FROM files
|
||||
WHERE folder_id=? AND user_id=?
|
||||
ORDER BY name
|
||||
`).all(s.folder_id, s.user_id);
|
||||
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
res.json({ folder_name: s.folder_name, files, token: s.token });
|
||||
});
|
||||
|
||||
// ── POST /api/public/file-share/:token/folder/:fileId ─────────────────────
|
||||
// Einzelne Datei aus freigegebenem Ordner herunterladen
|
||||
router.post('/public/:token/folder/:fileId', async (req, res) => {
|
||||
const s = db.prepare(`
|
||||
SELECT s.* FROM public_file_shares s WHERE s.token=? AND s.folder_id IS NOT NULL
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
}
|
||||
if (s.password_hash) {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||
const ok = await bcrypt.compare(password, s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?')
|
||||
.get(req.params.fileId, s.folder_id, s.user_id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht im freigegebenen Ordner' });
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, file.path);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`);
|
||||
res.setHeader('Content-Type', file.mime_type || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user