updating from dev

This commit is contained in:
2026-05-04 04:24:18 +02:00
parent 46afca976d
commit 2f1e42343b
76 changed files with 5554 additions and 2527 deletions

View File

@@ -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: [] });
}
});

View File

@@ -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;
});

View File

@@ -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' });
}
});

View File

@@ -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',