This commit is contained in:
2026-09-12 01:44:03 +02:00
parent 3dda406954
commit 155395237b
16 changed files with 1045 additions and 31 deletions
+2
View File
@@ -23,6 +23,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import db from './sql.mjs';
import cfg from './config.mjs';
import { removePrivateItem } from './private_items.mjs';
/**
* Safely remove the media file for a deleted item.
@@ -236,6 +237,7 @@ export async function purgeExpiredUploads() {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
}
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
removePrivateItem(item.id, item.dest);
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
} catch (e) {
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
+143
View File
@@ -0,0 +1,143 @@
import db from "./sql.mjs";
// Maps for fast O(1) in-memory lookups:
// dest (string) -> owner username (lowercase string)
const _privateDests = new Map();
// id (number) -> owner username (lowercase string)
const _privateIds = new Map();
let _initialized = false;
let _initPromise = null;
/**
* Load all active private items into memory cache.
*/
export async function initPrivateItems() {
try {
const rows = await db`
SELECT id, dest, LOWER(username) as username
FROM items
WHERE visibility = 2 AND is_deleted = false
`;
_privateDests.clear();
_privateIds.clear();
for (const r of rows) {
if (r.dest) _privateDests.set(r.dest, r.username || '');
if (r.id) _privateIds.set(Number(r.id), r.username || '');
}
_initialized = true;
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) into memory cache`);
} catch (err) {
console.error('[BOOT] Failed to load private items into cache:', err.message);
}
}
export function ensurePrivateItemsInit() {
if (!_initialized && !_initPromise) {
_initPromise = initPrivateItems().finally(() => { _initPromise = null; });
}
return _initPromise;
}
// Background sync every 30 seconds
setInterval(() => {
initPrivateItems().catch(() => {});
}, 30_000).unref();
export function addPrivateItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _privateDests.set(dest, u);
if (id) _privateIds.set(Number(id), u);
}
export function removePrivateItem(id, dest) {
if (dest) _privateDests.delete(dest);
if (id) _privateIds.delete(Number(id));
}
/**
* Checks if a given pathname (/b/<dest>, /t/<id>..., /ca/<id>...) is a private item.
* Returns { isPrivate: boolean, owner: string } or null if not private.
*/
export function getPrivateItemFromPath(pathname) {
if (!pathname || typeof pathname !== 'string') return null;
if (pathname.startsWith('/b/')) {
let dest;
try {
dest = decodeURIComponent(pathname.slice(3));
} catch {
dest = pathname.slice(3);
}
dest = dest.split('?')[0].split('#')[0];
const owner = _privateDests.get(dest);
if (owner !== undefined) {
return { isPrivate: true, owner };
}
return null;
}
if (pathname.startsWith('/t/')) {
let filename;
try {
filename = decodeURIComponent(pathname.slice(3));
} catch {
filename = pathname.slice(3);
}
filename = filename.split('?')[0].split('#')[0];
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
const owner = _privateIds.get(id);
if (owner !== undefined) {
return { isPrivate: true, owner };
}
}
return null;
}
if (pathname.startsWith('/ca/')) {
let filename;
try {
filename = decodeURIComponent(pathname.slice(4));
} catch {
filename = pathname.slice(4);
}
filename = filename.split('?')[0].split('#')[0];
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
const owner = _privateIds.get(id);
if (owner !== undefined) {
return { isPrivate: true, owner };
}
}
return null;
}
return null;
}
export function isPrivateItemPath(pathname) {
return getPrivateItemFromPath(pathname) !== null;
}
/**
* Render standard 502 Bad Gateway response.
*/
export function render502(req, res) {
if (req.headers && req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(JSON.stringify({ success: false, msg: 'Bad Gateway' }));
} else {
const body = (typeof global._buildGatePage === 'function')
? global._buildGatePage(req)
: (global._nginx502 || `<html>\n<head><title>502 Bad Gateway</title></head>\n<body bgcolor="white">\n<center><h1>502 Bad Gateway</h1></center>\n<hr><center>nginx</center>\n</body>\n</html>`);
res.writeHead(502, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(body);
}
}
+9 -9
View File
@@ -954,15 +954,6 @@ export default {
session.admin || session.is_moderator
);
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
// Block numeric enumeration unless viewer is owner/admin
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
};
}
// If item is Private (visibility === 2):
// Direct link only allowed for owner/admin
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
@@ -973,6 +964,15 @@ export default {
};
}
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted):
// Block numeric enumeration unless viewer is owner/admin
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
};
}
if (user_id) {
db`
insert into user_video_views (user_id, video_id, view_count, last_viewed)
+78
View File
@@ -1755,5 +1755,83 @@ export default (router, tpl) => {
}
});
// ── Admin Bar: User Impersonation ────────────────────────────────────────────
// GET /api/v2/admin/users/search?q= — autocomplete for the admin bar "View as" input
router.get(/^\/api\/v2\/admin\/users\/search\/?$/, lib.adminAuth, async (req, res) => {
try {
const q = (req.url.qs?.q || '').trim();
if (!q || q.length < 1) {
if (res.json) return res.json([]);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end('[]');
}
const escaped = lib.escapeLike(q);
const users = await db`
SELECT id, login as user
FROM "user"
WHERE login ILIKE ${'%' + escaped + '%'}
AND activated = true
AND banned = false
ORDER BY login ASC
LIMIT 10
`;
const result = users.map(u => ({ id: u.id, user: u.user }));
if (res.json) return res.json(result);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
}
});
// POST /api/v2/admin/impersonate — start impersonating a user
router.post(/^\/api\/v2\/admin\/impersonate\/?$/, lib.adminAuth, async (req, res) => {
try {
const { username } = req.post;
if (!username) throw new Error('Username required');
const target = await db`
SELECT id, login as user
FROM "user"
WHERE login = ${username.toLowerCase().trim()}
AND activated = true
LIMIT 1
`;
if (target.length === 0) throw new Error('User not found');
if (target[0].id === req.session.id) throw new Error('Cannot impersonate yourself');
// Build signed payload: base64(JSON) + "." + HMAC
const crypto = (await import('crypto')).default || await import('crypto');
const secret = cfg.main.secret || cfg.main.url.full || 'f0ckm-impersonate-secret';
const payload = Buffer.from(JSON.stringify({
uid: target[0].id,
orig: lib.sha256(req.cookies.session),
ts: Date.now()
})).toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const cookieVal = `${payload}.${sig}`;
const cookieOpts = lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT');
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `impersonate=${cookieVal}; ${cookieOpts}`
}).end(JSON.stringify({ success: true, username: target[0].user }));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
}
});
// POST /api/v2/admin/stop-impersonate — exit impersonation
router.post(/^\/api\/v2\/admin\/stop-impersonate\/?$/, async (req, res) => {
// No auth guard needed — just clear the cookie
const cookieOpts = lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT');
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `impersonate=; ${cookieOpts}`
}).end(JSON.stringify({ success: true }));
});
return router;
}
+9 -2
View File
@@ -11,6 +11,7 @@ import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
import { addPrivateItem, removePrivateItem } from '../../private_items.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => {
@@ -1311,7 +1312,7 @@ export default router => {
});
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
if (cfg.enable_private_uploads === false) {
if (cfg.enable_private_uploads === false && !req.session?.admin) {
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
}
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
@@ -1322,7 +1323,7 @@ export default router => {
const isNumeric = /^\d+$/.test(String(postid));
const item = await db`
SELECT id, slug, username, visibility
SELECT id, slug, username, visibility, dest
FROM items
WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false
LIMIT 1
@@ -1341,6 +1342,12 @@ export default router => {
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
if (visibility === 2) {
addPrivateItem(item[0].id, item[0].dest, item[0].username);
} else {
removePrivateItem(item[0].id, item[0].dest);
}
f0cklib.clearCountCache();
return res.json({
+9
View File
@@ -8,6 +8,7 @@ import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
import f0cklib from "../../routeinc/f0cklib.mjs";
import { addPrivateItem } from "../../private_items.mjs";
// ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string)
@@ -463,6 +464,10 @@ export default router => {
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
// Auto-subscribe uploader
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
@@ -797,6 +802,10 @@ export default router => {
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
+5
View File
@@ -3,6 +3,7 @@ import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -266,6 +267,10 @@ export default (router, tpl) => {
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
if (data.is_private && !req.session && (mode === 'item' || data.message === '403 - private upload')) {
render502(req, res);
return;
}
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
+113 -1
View File
@@ -26,6 +26,7 @@ import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502 } from "./inc/private_items.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -350,6 +351,9 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
? null
: nginx502Fallback;
global._buildGatePage = (req) => (nginx502 ?? buildGatePage(req));
global._nginx502 = nginx502Fallback;
// Custom gate template — resolved once at boot from config
// Set private_society_gate: "custom" and private_society_gate_template: "your-template-name" (no .html)
const _customGateTemplate = (cfg.websrv.private_society && cfg.websrv.private_society_gate === 'custom' && cfg.websrv.private_society_gate_template)
@@ -600,7 +604,7 @@ process.on('uncaughtException', err => {
app.use(async (req, res) => {
const p = req.url?.pathname;
if (!p) return;
if (getProtectFiles()) return; // Protect-files gates these with auth — don't cache
if (getProtectFiles() || isPrivateItemPath(p)) return; // Protect-files or private item — don't cache
if (p.startsWith('/t/') || p.startsWith('/ca/') || p.startsWith('/b/')) {
// Thumbnails, covers, and source blobs: 1-year cache.
// These never change for a given ID (content-addressed by item ID).
@@ -720,6 +724,48 @@ process.on('uncaughtException', err => {
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
return;
if (req.url.pathname.match(/^\/(b|c|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
const privItem = getPrivateItemFromPath(req.url.pathname);
if (privItem) {
// Private item (visibility === 2):
// Direct URLs MUST serve 502 when requested without a session (or by unauthorized users),
// regardless of the protect_files setting.
let isAuthorized = false;
if (req.cookies?.session) {
const _sessionHash = lib.sha256(req.cookies.session);
let user = _scGet(_sessionHash);
if (!user) {
const urows = await db`
select "user".id, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
where "user_sessions".session = ${_sessionHash}
limit 1
`;
if (urows.length > 0) {
user = urows[0];
_scSet(_sessionHash, user);
}
}
if (user && !user.banned) {
const isOwner = user.user && user.user.toLowerCase() === privItem.owner.toLowerCase();
const isAdminOrMod = !!(user.admin || user.is_moderator);
if (isOwner || isAdminOrMod) {
isAuthorized = true;
}
}
}
if (!isAuthorized) {
render502(req, res);
req.url.pathname = '/private_item_bypass';
return;
}
// Authorized: set private cache control so media is never cached publicly
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
return;
}
// protect_files gates raw file URLs behind a session (401 if not logged in).
// private_society also gates file URLs — but only when protect_files is ALSO enabled.
// If private_society is on but protect_files is off, direct file URLs are intentionally
@@ -800,7 +846,70 @@ process.on('uncaughtException', err => {
// but we'll use CSS to hide the content in header.html.
}
// ── Admin impersonation overlay ───────────────────────────────────────────
// If the admin has an `impersonate` cookie set, overlay the target user's
// session data. The admin's real session passes all security checks above,
// then we swap req.session to look like the target user for the rest of the
// request. The admin's identity is preserved in _impersonated_by.
const _impersonateCookie = req.cookies?.impersonate;
if (_impersonateCookie && req.session.admin &&
!req.url.pathname.startsWith('/api/v2/admin/stop-impersonate') &&
!req.url.pathname.startsWith('/api/v2/admin/impersonate')) {
try {
const [impPayload, impSig] = _impersonateCookie.split('.');
if (impPayload && impSig) {
const { createHmac } = await import('crypto');
const _impSecret = cfg.main.secret || cfg.main.url.full || 'f0ckm-impersonate-secret';
const expectedSig = createHmac('sha256', _impSecret).update(impPayload).digest('hex');
if (impSig === expectedSig) {
const impData = JSON.parse(Buffer.from(impPayload, 'base64url').toString('utf8'));
// Validate that the original session matches the current admin cookie
if (impData.orig && impData.orig === lib.sha256(req.cookies.session)) {
const targetRow = await db`
SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change,
"user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file,
"user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color,
"user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge,
"user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score,
"user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments,
"user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung,
"user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb,
"user_options".comment_display_mode, "user_options".force_comment_display_mode
FROM "user"
LEFT JOIN "user_options" ON "user_options".user_id = "user".id
WHERE "user".id = ${+impData.uid}
LIMIT 1
`;
if (targetRow.length > 0) {
const adminUser = req.session.user;
const adminDisplayName = req.session.display_name || req.session.user;
req.session = {
...targetRow[0],
// Preserve CSRF token from the real session for form submissions to still work
csrf_token: user[0].csrf_token,
sess_id: user[0].sess_id,
// Impersonation metadata — used in navbar template
_is_impersonating: true,
_impersonated_by: adminUser,
_impersonated_by_display: adminDisplayName,
// Suppress admin/mod powers in the impersonated view
admin: false,
is_moderator: false,
};
req._original_admin_session = user[0]; // stash for potential future use
}
}
}
}
} catch (_impErr) {
// Silently ignore malformed impersonate cookie
console.error('[IMPERSONATE] Cookie parse error:', _impErr.message);
}
}
// ─────────────────────────────────────────────────────────────────────────
// log last action (Fire-and-Forget)
if (!req.url.pathname.startsWith('/api/notifications')) {
const { getLogUserIps, getHashUserIps } = await import("./inc/settings.mjs");
const currentIp = security.getRealIP(req);
@@ -1339,6 +1448,9 @@ process.on('uncaughtException', err => {
console.log(`[BOOT] File protection ENABLED via config.json — direct file links require login`);
}
// Load active private items into memory cache
await initPrivateItems();
// Load private_messages from config.json (static — not a DB setting)
// Default is true; set to false to fully disable private messaging
setPrivateMessages(cfg.websrv.private_messages !== false);
+9
View File
@@ -10,6 +10,7 @@ import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateChe
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
import { addPrivateItem } from "./inc/private_items.mjs";
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
@@ -361,6 +362,10 @@ export const handleUpload = async (req, res, self) => {
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
@@ -758,6 +763,10 @@ export const handleUpload = async (req, res, self) => {
const itemid = await queue.getItemID(filename);
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
// Automatically subscribe uploader to comment thread
try {
await db`