fix: public_file_shares Tabelle in db.js, google.de Redirect bei Fehler, saubere Router-Trennung
This commit is contained in:
@@ -571,4 +571,22 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xre
|
|||||||
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
||||||
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
||||||
|
|
||||||
|
// Öffentliche Datei/Ordner-Freigabe Links
|
||||||
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get()) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE 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 NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
download_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -1,42 +1,43 @@
|
|||||||
// Öffentliche Endpunkte für Datei/Ordner-Download (kein Login nötig)
|
// Öffentliche Endpunkte – kein Login nötig
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||||
|
|
||||||
function getShare(token) {
|
function getShare(token) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path,
|
SELECT s.*,
|
||||||
fo.name as folder_name
|
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
|
FROM public_file_shares s
|
||||||
LEFT JOIN files f ON f.id = s.file_id
|
LEFT JOIN files f ON f.id = s.file_id
|
||||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||||
WHERE s.token=?
|
WHERE s.token = ?
|
||||||
`).get(token);
|
`).get(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkExpiry(s) {
|
function isExpired(s) {
|
||||||
return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000);
|
return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /:token – Metadaten (Name, Passwort nötig, abgelaufen?)
|
// GET /:token – Metadaten
|
||||||
router.get('/:token', (req, res) => {
|
router.get('/:token', (req, res) => {
|
||||||
const s = getShare(req.params.token);
|
const s = getShare(req.params.token);
|
||||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||||
if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||||
res.json({
|
res.json({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
file_name: s.file_name,
|
file_name: s.file_name,
|
||||||
file_size: s.file_size,
|
file_size: s.file_size,
|
||||||
mime_type: s.mime_type,
|
mime_type: s.mime_type,
|
||||||
folder_name: s.folder_name,
|
folder_name: s.folder_name,
|
||||||
is_folder: !!s.folder_id,
|
is_folder: !!s.folder_id,
|
||||||
needs_password: !!s.password_hash,
|
needs_password: !!s.password_hash,
|
||||||
expires_at: s.expires_at,
|
expires_at: s.expires_at,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,11 +45,9 @@ router.get('/:token', (req, res) => {
|
|||||||
router.post('/:token/download', async (req, res) => {
|
router.post('/:token/download', async (req, res) => {
|
||||||
const s = getShare(req.params.token);
|
const s = getShare(req.params.token);
|
||||||
if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||||
if (s.password_hash) {
|
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
|
||||||
}
|
|
||||||
const filePath = path.join(UPLOAD_DIR, s.file_path);
|
const filePath = path.join(UPLOAD_DIR, s.file_path);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
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);
|
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||||
@@ -57,17 +56,15 @@ router.post('/:token/download', async (req, res) => {
|
|||||||
fs.createReadStream(filePath).pipe(res);
|
fs.createReadStream(filePath).pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:token/folder – Ordnerinhalt abrufen
|
// POST /:token/folder – Ordnerinhalt
|
||||||
router.post('/:token/folder', async (req, res) => {
|
router.post('/:token/folder', async (req, res) => {
|
||||||
const s = getShare(req.params.token);
|
const s = getShare(req.params.token);
|
||||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||||
if (s.password_hash) {
|
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
|
||||||
}
|
|
||||||
const files = db.prepare(`
|
const files = db.prepare(`
|
||||||
SELECT id, name, size, mime_type, created_at FROM files
|
SELECT id, name, size, mime_type FROM files
|
||||||
WHERE folder_id=? AND user_id=? ORDER BY name
|
WHERE folder_id=? AND user_id=? ORDER BY name
|
||||||
`).all(s.folder_id, s.user_id);
|
`).all(s.folder_id, s.user_id);
|
||||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||||
@@ -78,14 +75,12 @@ router.post('/:token/folder', async (req, res) => {
|
|||||||
router.post('/:token/folder/:fileId', async (req, res) => {
|
router.post('/:token/folder/:fileId', async (req, res) => {
|
||||||
const s = getShare(req.params.token);
|
const s = getShare(req.params.token);
|
||||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||||
if (s.password_hash) {
|
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||||
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=?')
|
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);
|
.get(req.params.fileId, s.folder_id, s.user_id);
|
||||||
if (!file) return res.status(404).json({ error: 'Datei nicht im freigegebenen Ordner' });
|
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||||
const filePath = path.join(UPLOAD_DIR, file.path);
|
const filePath = path.join(UPLOAD_DIR, file.path);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
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-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`);
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
const express = require('express');
|
// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links
|
||||||
const bcrypt = require('bcryptjs');
|
const express = require('express');
|
||||||
const crypto = require('crypto');
|
const bcrypt = require('bcryptjs');
|
||||||
const path = require('path');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const db = require('../../db');
|
||||||
const db = require('../../db');
|
|
||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
|
||||||
|
|
||||||
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
|
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
|
||||||
|
|
||||||
// ── Migration: Tabelle erstellen falls nicht vorhanden ─────────────────────
|
// GET / – eigene Shares auflisten
|
||||||
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) => {
|
router.get('/', authenticate, (req, res) => {
|
||||||
const shares = db.prepare(`
|
const shares = db.prepare(`
|
||||||
SELECT s.*,
|
SELECT s.*, f.name as file_name, f.size as file_size,
|
||||||
f.name as file_name, f.size as file_size, f.mime_type,
|
fo.name as folder_name
|
||||||
fo.name as folder_name
|
|
||||||
FROM public_file_shares s
|
FROM public_file_shares s
|
||||||
LEFT JOIN files f ON f.id = s.file_id
|
LEFT JOIN files f ON f.id = s.file_id
|
||||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||||
@@ -44,15 +22,13 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
res.json({ shares });
|
res.json({ shares });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST /api/tools/dateien/public-shares ─────────────────────────────────
|
// POST / – neuen Share erstellen
|
||||||
// Neuen Share erstellen
|
router.post('/', authenticate, async (req, res) => {
|
||||||
router.post('/', authenticate, (req, res) => {
|
|
||||||
const { file_id, folder_id, password, expires_hours, label } = req.body;
|
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' });
|
if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' });
|
||||||
if (!password) return res.status(400).json({ error: 'Passwort ist Pflicht' });
|
if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' });
|
||||||
if (!expires_hours || expires_hours <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' });
|
if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' });
|
||||||
|
|
||||||
// Sicherstellen dass die Datei/Ordner dem User gehört
|
|
||||||
if (file_id) {
|
if (file_id) {
|
||||||
const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.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 (!f) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
@@ -63,8 +39,8 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
const password_hash = password ? bcrypt.hashSync(password, 10) : null;
|
const password_hash = await bcrypt.hash(password.trim(), 10);
|
||||||
const expires_at = expires_hours ? Math.floor(Date.now() / 1000) + expires_hours * 3600 : null;
|
const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600;
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
|
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
|
||||||
@@ -75,7 +51,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
res.json({ share, token });
|
res.json({ share, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /api/tools/dateien/public-shares/:id ───────────────────────────
|
// DELETE /:id – Share löschen
|
||||||
router.delete('/:id', authenticate, (req, res) => {
|
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);
|
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' });
|
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
@@ -83,143 +59,4 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
res.json({ ok: true });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2907,48 +2907,38 @@ function PublicFileShare({ token }) {
|
|||||||
const [dlLoading,setDlLoading]= useState(null);
|
const [dlLoading,setDlLoading]= useState(null);
|
||||||
|
|
||||||
const S2 = {
|
const S2 = {
|
||||||
wrap: { minHeight:'100vh', background:'#0f1117', display:'flex', alignItems:'center', justifyContent:'center', padding:20 },
|
wrap: { minHeight:'100vh', background:'#0f1117', display:'flex', alignItems:'center', justifyContent:'center', padding:20 },
|
||||||
box: { background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:16, padding:32, maxWidth:480, width:'100%' },
|
box: { background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:16, padding:32, maxWidth:480, width:'100%' },
|
||||||
head: { color:'#fff', fontFamily:'monospace', fontSize:18, fontWeight:700, marginBottom:6 },
|
head: { color:'#fff', fontFamily:'monospace', fontSize:18, fontWeight:700, marginBottom:6 },
|
||||||
sub: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:12, marginBottom:24 },
|
sub: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:12, marginBottom:24 },
|
||||||
label: { color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:11, marginBottom:6, display:'block' },
|
inp: { width:'100%', boxSizing:'border-box', background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'10px 14px', color:'#fff', fontFamily:'monospace', fontSize:13, outline:'none' },
|
||||||
inp: { width:'100%', boxSizing:'border-box', background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'10px 14px', color:'#fff', fontFamily:'monospace', fontSize:13, outline:'none' },
|
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'10px 16px', color:c, fontFamily:'monospace', fontSize:13, cursor:'pointer', width:'100%', marginTop:10 }),
|
||||||
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'10px 16px', color:c, fontFamily:'monospace', fontSize:13, cursor:'pointer', width:'100%', marginTop:10 }),
|
fileRow: { display:'flex', alignItems:'center', gap:12, padding:'10px 14px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8, marginBottom:8 },
|
||||||
fileRow:{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8, marginBottom:8 },
|
err: { color:'#f87171', fontFamily:'monospace', fontSize:12, marginTop:8 },
|
||||||
err: { color:'#f87171', fontFamily:'monospace', fontSize:12, marginTop:8 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BASE = '/api/public/file-share/' + token;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/public/file-share/' + token)
|
fetch(BASE)
|
||||||
.then(async r => {
|
.then(async r => {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) { setErrMsg(d.error || 'Link ungültig'); setPhase('error'); return; }
|
if (!r.ok) { setErrMsg(d.error || 'Link ungültig'); setPhase('error'); return; }
|
||||||
setInfo(d);
|
setInfo(d);
|
||||||
setPhase(d.needs_password ? 'pw' : 'ready');
|
setPhase('pw'); // Passwort immer Pflicht
|
||||||
if (!d.needs_password && d.is_folder) loadFolder('');
|
|
||||||
})
|
})
|
||||||
.catch(() => { setErrMsg('Verbindung fehlgeschlagen'); setPhase('error'); });
|
.catch(() => { setErrMsg('Verbindung fehlgeschlagen'); setPhase('error'); });
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
async function loadFolder(pw2) {
|
|
||||||
const res = await fetch('/api/public/file-share/' + token + '/folder', {
|
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ password: pw2 }),
|
|
||||||
});
|
|
||||||
const d = await res.json();
|
|
||||||
if (!res.ok) { setErrMsg(d.error); return; }
|
|
||||||
setFiles(d.files || []);
|
|
||||||
setPhase('ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePw() {
|
async function handlePw() {
|
||||||
if (!pw.trim()) return;
|
if (!pw.trim()) return;
|
||||||
const res = await fetch('/api/public/file-share/' + token + (info?.is_folder ? '/folder' : '/download'), {
|
const url = BASE + (info?.is_folder ? '/folder' : '/download');
|
||||||
|
const res = await fetch(url, {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ password: pw }),
|
body: JSON.stringify({ password: pw }),
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!res.ok) { setErrMsg(d.error); return; }
|
if (!res.ok) { setErrMsg(d.error || 'Falsches Passwort'); return; }
|
||||||
setPassword(pw);
|
setPassword(pw);
|
||||||
setErrMsg('');
|
setErrMsg('');
|
||||||
if (info?.is_folder) { setFiles(d.files || []); setPhase('ready'); }
|
if (info?.is_folder) { setFiles(d.files || []); setPhase('ready'); }
|
||||||
@@ -2958,16 +2948,17 @@ function PublicFileShare({ token }) {
|
|||||||
async function downloadFile(fileId, fileName) {
|
async function downloadFile(fileId, fileName) {
|
||||||
setDlLoading(fileId || 'single');
|
setDlLoading(fileId || 'single');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const url = BASE + (fileId ? '/folder/' + fileId : '/download');
|
||||||
'/api/public/file-share/' + token + (fileId ? '/folder/' + fileId : '/download'),
|
const res = await fetch(url, {
|
||||||
{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password }) }
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
);
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
if (!res.ok) { const d = await res.json(); setErrMsg(d.error); return; }
|
if (!res.ok) { const d = await res.json(); setErrMsg(d.error); return; }
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const objUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a'); a.href = url; a.download = fileName;
|
const a = document.createElement('a'); a.href = objUrl; a.download = fileName;
|
||||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
||||||
} finally { setDlLoading(null); }
|
} finally { setDlLoading(null); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2978,43 +2969,50 @@ function PublicFileShare({ token }) {
|
|||||||
return (b/1024/1024).toFixed(1) + ' MB';
|
return (b/1024/1024).toFixed(1) + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abgelaufen/ungültig → Redirect zu google.de
|
||||||
|
if (phase === 'error') {
|
||||||
|
window.location.replace('https://www.google.de');
|
||||||
|
return <div style={S2.wrap}><div style={{color:'#fff',fontFamily:'monospace'}}>Weiterleitung…</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={S2.wrap}>
|
<div style={S2.wrap}>
|
||||||
<div style={S2.box}>
|
<div style={S2.box}>
|
||||||
<div style={S2.head}>🔗 {info?.is_folder ? '📁 Ordner' : '📄 Datei'} – Freigabe</div>
|
<div style={S2.head}>🔗 {info?.is_folder ? '📁 Ordner' : '📄 Datei'} – Freigabe</div>
|
||||||
<div style={S2.sub}>
|
<div style={S2.sub}>
|
||||||
{info?.folder_name || info?.file_name || info?.label || 'Öffentlicher Link'}
|
{info?.folder_name || info?.file_name || info?.label || 'Öffentlicher Link'}
|
||||||
{info?.expires_at && <span style={{marginLeft:8}}>· Gültig bis {new Date(info.expires_at*1000).toLocaleDateString('de-DE')}</span>}
|
{info?.expires_at && (
|
||||||
|
<span style={{marginLeft:8}}>
|
||||||
|
· Gültig bis {new Date(info.expires_at*1000).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase === 'loading' && <div style={S2.sub}>Lade...</div>}
|
{phase === 'loading' && <div style={S2.sub}>Lade...</div>}
|
||||||
|
|
||||||
{phase === 'error' && <div style={S2.err}>⚠️ {errMsg}</div>}
|
|
||||||
|
|
||||||
{phase === 'pw' && (
|
{phase === 'pw' && (
|
||||||
<>
|
<>
|
||||||
<span style={S2.label}>Passwort</span>
|
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:6,display:'block'}}>Passwort</span>
|
||||||
<input type="password" value={pw} onChange={e=>setPw(e.target.value)}
|
<input value={pw} onChange={e=>setPw(e.target.value)}
|
||||||
onKeyDown={e=>e.key==='Enter'&&handlePw()} autoFocus
|
onKeyDown={e=>e.key==='Enter'&&handlePw()} autoFocus
|
||||||
placeholder="Passwort eingeben" style={S2.inp} />
|
placeholder="Passwort eingeben" style={S2.inp} />
|
||||||
{errMsg && <div style={S2.err}>{errMsg}</div>}
|
{errMsg && <div style={S2.err}>⚠ {errMsg}</div>}
|
||||||
<button onClick={handlePw} style={S2.btn('#4ecdc4')}>🔓 Entsperren</button>
|
<button onClick={handlePw} style={S2.btn('#4ecdc4')}>🔓 Entsperren</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'ready' && !info?.is_folder && (
|
{phase === 'ready' && !info?.is_folder && (
|
||||||
<>
|
<>
|
||||||
<div style={{ ...S2.fileRow, marginBottom:16 }}>
|
<div style={{...S2.fileRow, marginBottom:16}}>
|
||||||
<span style={{fontSize:24}}>📄</span>
|
<span style={{fontSize:24}}>📄</span>
|
||||||
<div>
|
<div>
|
||||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{info.file_name}</div>
|
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{info.file_name}</div>
|
||||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>{fmtSize(info.file_size)}</div>
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>{fmtSize(info.file_size)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => downloadFile(null, info.file_name)}
|
<button onClick={() => downloadFile(null, info.file_name)} disabled={dlLoading==='single'}
|
||||||
disabled={dlLoading === 'single'}
|
|
||||||
style={S2.btn('#4ade80')}>
|
style={S2.btn('#4ade80')}>
|
||||||
{dlLoading === 'single' ? '⏳ Lädt...' : '⬇ Datei herunterladen'}
|
{dlLoading==='single' ? '⏳ Lädt...' : '⬇ Datei herunterladen'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -3031,14 +3029,13 @@ function PublicFileShare({ token }) {
|
|||||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12}}>{f.name}</div>
|
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12}}>{f.name}</div>
|
||||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{fmtSize(f.size)}</div>
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{fmtSize(f.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => downloadFile(f.id, f.name)}
|
<button onClick={()=>downloadFile(f.id, f.name)} disabled={!!dlLoading}
|
||||||
disabled={!!dlLoading}
|
|
||||||
style={{...S2.btn('#4ade80'), width:'auto', marginTop:0, padding:'6px 14px', fontSize:11}}>
|
style={{...S2.btn('#4ade80'), width:'auto', marginTop:0, padding:'6px 14px', fontSize:11}}>
|
||||||
{dlLoading === f.id ? '⏳' : '⬇'}
|
{dlLoading===f.id ? '⏳' : '⬇'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{files.length === 0 && <div style={S2.sub}>Ordner ist leer.</div>}
|
{files.length===0 && <div style={S2.sub}>Ordner ist leer.</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3046,6 +3043,7 @@ function PublicFileShare({ token }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function PublicUpload({ token }) {
|
function PublicUpload({ token }) {
|
||||||
const [phase, setPhase] = useState('loading');
|
const [phase, setPhase] = useState('loading');
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user