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({