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
+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);