screentinker/server/routes/content.js

401 lines
16 KiB
JavaScript

const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const upload = require('../middleware/upload');
const config = require('../config');
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
const { sanitizeString } = require('../middleware/sanitize');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Multer captures file.originalname directly from the multipart filename header,
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
// `"><img src=x onerror=alert(1)>.jpg` is stored as `&quot;&gt;&lt;img...` and
// renders as text in every UI sink. Umlauts, spaces, dots, and other unicode are
// preserved — sanitizeString only touches `& < > " '`.
function safeFilename(name) {
return sanitizeString(name || '');
}
// SSRF gate for remote_url. Returns null if valid, else { status, error }.
// Used by both POST /remote and PUT /:id so a user can't bypass the check by
// uploading a benign URL and then PUT-updating it to file:///etc/passwd.
function validateRemoteUrl(url) {
let parsed;
try { parsed = new URL(url); }
catch { return { status: 400, error: 'Invalid URL format' }; }
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { status: 400, error: 'URL must use http or https' };
}
const hostname = parsed.hostname.toLowerCase();
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
hostname.startsWith('127.') || hostname.startsWith('10.') ||
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' ||
hostname.endsWith('.local') || hostname.endsWith('.internal');
if (isPrivate) return { status: 400, error: 'Internal URLs are not allowed' };
return null;
}
// List content for current user (admins see all).
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
router.get('/', (req, res) => {
const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const folder = req.query.folder;
const folderId = req.query.folder_id;
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`;
const params = isAdmin ? [] : [req.user.id];
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
if (folderId !== undefined) {
if (folderId === 'root' || folderId === '') {
sql += ' AND folder_id IS NULL';
} else {
sql += ' AND folder_id = ?';
params.push(folderId);
}
}
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
const content = db.prepare(sql).all(...params);
res.json(content);
});
// Get folders list
router.get('/folders', (req, res) => {
const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const folders = db.prepare(
`SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder`
).all(...(isAdmin ? [] : [req.user.id]));
res.json(folders);
});
// Upload content
router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const id = uuidv4();
const filepath = req.file.filename;
let width = null, height = null, durationSec = null, thumbnailPath = null;
// Try to generate thumbnail, get dimensions, and detect duration
try {
if (req.file.mimetype.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(req.file.path).metadata();
width = metadata.width;
height = metadata.height;
// Generate thumbnail
thumbnailPath = `thumb_${filepath}`;
await sharp(req.file.path)
.resize(config.thumbnailWidth)
.jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbnailPath));
} else if (req.file.mimetype.startsWith('video/')) {
// Extract video duration and dimensions with ffprobe
try {
const { execFileSync } = require('child_process');
// Use execFileSync (not execSync) to prevent shell injection - args are NOT passed through shell
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', req.file.path],
{ timeout: 15000 }
).toString();
const info = JSON.parse(probe);
if (info.format?.duration) durationSec = parseFloat(info.format.duration);
const videoStream = info.streams?.find(s => s.codec_type === 'video');
if (videoStream) {
width = videoStream.width;
height = videoStream.height;
}
// Generate video thumbnail at 2 second mark
thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
try {
execFileSync('ffmpeg', ['-y', '-i', req.file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
{ timeout: 15000 }
);
} catch { thumbnailPath = null; }
} catch (e) {
console.warn('ffprobe failed:', e.message);
}
}
} catch (e) {
console.warn('Thumbnail/metadata generation failed:', e.message);
}
db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content);
} catch (err) {
console.error('Upload error:', err);
res.status(500).json({ error: 'Upload failed' });
}
});
// Add remote URL content
router.post('/remote', checkRemoteUrl, (req, res) => {
try {
const { url, name, mime_type } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' });
const urlErr = validateRemoteUrl(url);
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
const id = uuidv4();
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg');
db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url)
VALUES (?, ?, ?, '', ?, 0, ?)
`).run(id, req.user.id, safeFilename(filename), mimeType, url);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content);
} catch (err) {
console.error('Remote URL add error:', err);
res.status(500).json({ error: 'Failed to add remote URL' });
}
});
// Add YouTube content (available to all plans - no storage used)
router.post('/youtube', async (req, res) => {
try {
const { url, name } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' });
// Extract YouTube video ID from various URL formats
const videoId = extractYoutubeId(url);
if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' });
// Fetch video title from YouTube oEmbed if no name provided
let filename = name;
if (!filename) {
try {
const oembedRes = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
if (oembedRes.ok) {
const oembed = await oembedRes.json();
filename = oembed.title;
}
} catch {}
}
if (!filename) filename = `YouTube: ${videoId}`;
const id = uuidv4();
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=${videoId}&enablejsapi=1`;
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?)
`).run(id, req.user.id, safeFilename(filename), embedUrl, thumbnailUrl);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content);
} catch (err) {
console.error('YouTube add error:', err);
res.status(500).json({ error: 'Failed to add YouTube video' });
}
});
function extractYoutubeId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/ // bare video ID
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
// Helper: check content ownership
function checkContentAccess(req, res) {
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
if (!ELEVATED_ROLES.includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null;
}
return content;
}
// Get content metadata
router.get('/:id', (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
res.json(content);
});
// Update content metadata
router.put('/:id', (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
const { filename, mime_type, remote_url, folder, folder_id } = req.body;
const updates = [];
const values = [];
if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
if (remote_url !== undefined) {
if (remote_url) {
const urlErr = validateRemoteUrl(remote_url);
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
}
updates.push('remote_url = ?');
values.push(remote_url || null);
}
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
if (folder_id !== undefined) {
// Verify the destination folder belongs to the same user. Only superadmin gets
// cross-user access — matches the policy in routes/folders.js so a plain "admin"
// can't move content into a folder they can't see in GET /api/folders.
if (folder_id) {
const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
if (!isSuperadmin && target.user_id !== req.user.id) {
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
}
}
updates.push('folder_id = ?');
values.push(folder_id || null);
}
if (updates.length > 0) {
values.push(req.params.id);
db.prepare(`UPDATE content SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
});
// Replace content file
router.put('/:id/replace', upload.single('file'), async (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
if (!req.file) return res.status(400).json({ error: 'No file provided' });
// Delete old file
if (content.filepath) {
const oldPath = path.join(config.contentDir, content.filepath);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
// Delete old thumbnail
if (content.thumbnail_path) {
const oldThumb = path.join(config.contentDir, content.thumbnail_path);
if (fs.existsSync(oldThumb)) fs.unlinkSync(oldThumb);
}
const filepath = req.file.filename;
let width = null, height = null, thumbnailPath = null;
// Generate new thumbnail for images
try {
if (req.file.mimetype.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(req.file.path).metadata();
width = metadata.width;
height = metadata.height;
thumbnailPath = `thumb_${filepath}`;
await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbnailPath));
}
} catch (e) {
console.warn('Thumbnail generation failed:', e.message);
}
db.prepare(`UPDATE content SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ? WHERE id = ?`)
.run(filepath, req.file.mimetype, req.file.size, thumbnailPath, width, height, req.params.id);
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
});
// Serve content file
router.get('/:id/file', (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
// Prevent path traversal
const safePath = path.resolve(config.contentDir, path.basename(content.filepath));
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
res.sendFile(safePath);
});
// Serve thumbnail
router.get('/:id/thumbnail', (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
res.sendFile(safePath);
});
// Delete content
router.delete('/:id', (req, res) => {
const content = checkContentAccess(req, res);
if (!content) return;
// Delete file from disk (skip for remote URL content)
if (content.filepath) {
const filePath = path.join(config.contentDir, content.filepath);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
// Delete thumbnail
if (content.thumbnail_path) {
const thumbPath = path.join(config.contentDir, content.thumbnail_path);
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
}
// Get devices that have this content in their playlist (via playlist_items)
const affectedDevices = db.prepare(`
SELECT DISTINCT d.id as device_id FROM devices d
JOIN playlists p ON d.playlist_id = p.id
JOIN playlist_items pi ON pi.playlist_id = p.id
WHERE pi.content_id = ?
`).all(req.params.id);
// Scrub published snapshots that reference this content
// Validate UUID format to prevent LIKE wildcard injection
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
const snapshotPlaylists = db.prepare(
"SELECT id, published_snapshot FROM playlists WHERE user_id = ? AND published_snapshot LIKE ?"
).all(content.user_id, `%${req.params.id}%`);
for (const pl of snapshotPlaylists) {
try {
const items = JSON.parse(pl.published_snapshot);
const filtered = items.filter(item => item.content_id !== req.params.id);
if (filtered.length !== items.length) {
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
.run(JSON.stringify(filtered), pl.id);
}
} catch (e) { /* corrupt snapshot, skip */ }
}
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
// Push updated snapshots to affected devices
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
for (const d of affectedDevices) {
io.of('/device').to(d.device_id).emit('device:playlist-update', buildPlaylistPayload(d.device_id));
}
}
} catch (e) { /* silent */ }
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
});
module.exports = router;