updating from dev
This commit is contained in:
@@ -11,8 +11,17 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
export default router => {
|
||||
// Ensure cache table exists
|
||||
db`CREATE TABLE IF NOT EXISTS meta_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
data JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`.catch(err => console.error('[META-CACHE] Table creation failed:', err));
|
||||
|
||||
router.group(/^\/api\/v2/, group => {
|
||||
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
@@ -282,6 +291,8 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
|
||||
// Guests use cached entries from DB (populated by authenticated user requests).
|
||||
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
|
||||
if (!cfg.websrv.web_meta_extraction) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
||||
@@ -290,6 +301,38 @@ export default router => {
|
||||
const url = req.url.qs.url;
|
||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
||||
|
||||
if (metaCache.has(url)) {
|
||||
return res.json({ success: true, meta: metaCache.get(url) });
|
||||
}
|
||||
|
||||
// Check DB cache for persistence across restarts
|
||||
try {
|
||||
const cached = await db`SELECT data FROM meta_cache WHERE url = ${url} LIMIT 1`;
|
||||
if (cached.length > 0) {
|
||||
const meta = cached[0].data;
|
||||
metaCache.set(url, meta); // update in-memory cache
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB lookup failed:', err);
|
||||
}
|
||||
|
||||
const setCache = async (u, m) => {
|
||||
if (!m || !m.title) return;
|
||||
metaCache.set(u, m);
|
||||
if (metaCache.size > MAX_META_CACHE) {
|
||||
const first = metaCache.keys().next().value;
|
||||
metaCache.delete(first);
|
||||
}
|
||||
// Persist to DB
|
||||
try {
|
||||
await db`INSERT INTO meta_cache (url, data) VALUES (${u}, ${m})
|
||||
ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (/\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(url)) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction skipped for direct media URLs' }, 400);
|
||||
}
|
||||
@@ -314,13 +357,15 @@ export default router => {
|
||||
if (oembedOut && oembedOut.trim()) {
|
||||
const data = JSON.parse(oembedOut);
|
||||
if (data.title) {
|
||||
const meta = {
|
||||
title: data.title,
|
||||
site_name: 'youtube.com',
|
||||
author: data.author_name || 'Unknown'
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
title: data.title,
|
||||
site_name: 'youtube.com',
|
||||
author: data.author_name || 'Unknown'
|
||||
}
|
||||
meta
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -354,13 +399,15 @@ export default router => {
|
||||
}
|
||||
|
||||
if (title) {
|
||||
const meta = {
|
||||
title: title,
|
||||
site_name: lines[2] ? lines[2].trim() : 'Media Site',
|
||||
author: lines[1] ? lines[1].trim() : 'Unknown'
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
title: title,
|
||||
site_name: lines[2] ? lines[2].trim() : 'Media Site',
|
||||
author: lines[1] ? lines[1].trim() : 'Unknown'
|
||||
}
|
||||
meta
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -402,6 +449,7 @@ export default router => {
|
||||
return res.json({ success: false, msg: 'Reddit bot protection encountered' }, 403);
|
||||
}
|
||||
|
||||
await setCache(url, meta);
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -663,7 +711,7 @@ export default router => {
|
||||
reply.success = true;
|
||||
reply.suggestions = search(q, searchString);
|
||||
} catch (err) {
|
||||
reply.error = err.msg;
|
||||
reply.error = 'Tag suggestion error';
|
||||
}
|
||||
|
||||
return res.json(reply);
|
||||
@@ -688,7 +736,7 @@ export default router => {
|
||||
`;
|
||||
return res.json({ success: true, suggestions: users });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, error: err.message, suggestions: [] });
|
||||
return res.json({ success: false, error: 'User suggestion error', suggestions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
@@ -443,6 +445,20 @@ export default router => {
|
||||
group.put(/\/font/, lib.loggedin, async (req, res) => {
|
||||
const { font } = req.post;
|
||||
|
||||
// F-023 Security: Validate font against actual files on disk
|
||||
// The font value is rendered into CSS url() in header.html, so it must be a real filename
|
||||
if (font) {
|
||||
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
|
||||
try {
|
||||
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
|
||||
if (!available.includes(font)) {
|
||||
return res.json({ success: false, msg: 'Invalid font selection' }, 400);
|
||||
}
|
||||
} catch {
|
||||
return res.json({ success: false, msg: 'Font directory unavailable' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
@@ -517,23 +533,25 @@ export default router => {
|
||||
|
||||
// Update Ruffle (Flash) preferences
|
||||
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
|
||||
const ruffle_volume = parseFloat(req.post.ruffle_volume);
|
||||
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
|
||||
const ruffle_volume = req.post.ruffle_volume !== undefined ? parseFloat(req.post.ruffle_volume) : undefined;
|
||||
|
||||
if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
|
||||
if (ruffle_volume !== undefined && (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = { ruffle_background };
|
||||
if (ruffle_volume !== undefined) updateData.ruffle_volume = ruffle_volume;
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set ruffle_volume = ${ruffle_volume},
|
||||
ruffle_background = ${ruffle_background}
|
||||
set ${db(updateData)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) {
|
||||
req.session.ruffle_volume = ruffle_volume;
|
||||
req.session.ruffle_background = ruffle_background;
|
||||
if (ruffle_volume !== undefined) req.session.ruffle_volume = ruffle_volume;
|
||||
}
|
||||
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
|
||||
} catch (e) {
|
||||
@@ -639,6 +657,62 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update comment display mode preference
|
||||
group.put(/\/comment_display_mode/, lib.loggedin, async (req, res) => {
|
||||
const mode = parseInt(req.post.mode, 10);
|
||||
if (isNaN(mode) || (mode !== 0 && mode !== 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid mode' }, 400);
|
||||
}
|
||||
|
||||
// Check if mode is forced
|
||||
const forced = (await db`select force_comment_display_mode from user_options where user_id = ${+req.session.id}`)[0]?.force_comment_display_mode;
|
||||
if (forced) {
|
||||
return res.json({ success: false, msg: 'Comment layout is locked for your account.' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set comment_display_mode = ${mode}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.comment_display_mode = mode;
|
||||
return res.json({ success: true, mode }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update comment_display_mode error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification preferences (Consolidated Endpoint)
|
||||
group.post('/notifications', lib.loggedin, async (req, res) => {
|
||||
const { key, value } = req.post;
|
||||
const allowedKeys = ['receive_system_notifications', 'receive_user_notifications', 'do_not_disturb'];
|
||||
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return res.json({ success: false, msg: 'Invalid preference key' }, 400);
|
||||
}
|
||||
|
||||
const boolValue = value === true || value === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set ${db({ [key]: boolValue }, key)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
if (req.session) req.session[key] = boolValue;
|
||||
|
||||
await db`SELECT pg_notify('profile_update', ${JSON.stringify({ user_id: req.session.id, [key]: boolValue })})`;
|
||||
|
||||
return res.json({ success: true, [key]: boolValue }, 200);
|
||||
} catch (e) {
|
||||
console.error(`Update notification preference (${key}) error:`, e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export default router => {
|
||||
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: isDuplicate ? 'Tag already exists' : err.message,
|
||||
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export default router => {
|
||||
|
||||
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
return res.json({ success: false, msg: 'Failed to update rating' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,14 +54,13 @@ import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../se
|
||||
// Collect request body as buffer with debug logging
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[UPLOAD DEBUG] collectBody started');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] collectBody started');
|
||||
const chunks = [];
|
||||
req.on('data', chunk => {
|
||||
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
if (cfg.main.development) console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on('error', err => {
|
||||
@@ -71,7 +70,7 @@ const collectBody = (req) => {
|
||||
|
||||
// Ensure stream is flowing
|
||||
if (req.isPaused()) {
|
||||
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
req.resume();
|
||||
}
|
||||
});
|
||||
@@ -230,16 +229,11 @@ export default router => {
|
||||
|
||||
// Download YouTube thumbnail as our thumbnail
|
||||
try {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
|
||||
await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
|
||||
await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
|
||||
await fs.unlink(tmpThumb).catch(() => {});
|
||||
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, isApprovalRequired);
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign rating tag
|
||||
@@ -317,8 +311,8 @@ export default router => {
|
||||
|
||||
// Priority 2: Extract HTTP codes
|
||||
const httpCode = msg.match(/HTTP Error (\d+)/i)?.[1]
|
||||
|| msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
||||
|| null;
|
||||
|| msg.match(/status code (\d{3})/i)?.[1]
|
||||
|| (msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] !== '537' ? msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] : null);
|
||||
if (httpCode) return `Download/Process failed (HTTP ${httpCode})`;
|
||||
|
||||
// Priority 3: Sanitize raw queue.spawn errors
|
||||
@@ -355,7 +349,7 @@ export default router => {
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||
if (isInstagram) throw new Error(sanitizeError(err));
|
||||
@@ -367,9 +361,10 @@ export default router => {
|
||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err2) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
let referer = url;
|
||||
try {
|
||||
@@ -380,7 +375,7 @@ export default router => {
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'-s', '-S', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'--max-filesize', `${maxfilesize}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
|
||||
Reference in New Issue
Block a user