hdf
This commit is contained in:
@@ -9330,7 +9330,7 @@ input#s_avatar {
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 10000;
|
||||
z-index: 100100;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -19031,7 +19031,7 @@ body.onara-modal-open #sidebar-drag-zone {
|
||||
z-index: 10050 !important;
|
||||
display: none;
|
||||
flex-direction: column !important;
|
||||
background: rgba(0, 0, 0, 0.70) !important;
|
||||
background: rgba(0, 0, 0, 0.35) !important;
|
||||
backdrop-filter: blur(5px) saturate(130%) !important;
|
||||
-webkit-backdrop-filter: blur(5px) saturate(130%) !important;
|
||||
overflow-y: auto !important;
|
||||
@@ -19266,6 +19266,7 @@ body.onara-modal-open #login-modal,
|
||||
body.onara-modal-open #register-modal,
|
||||
body.onara-modal-open #shortcuts-modal,
|
||||
body.onara-modal-open #excluded-tags-overlay,
|
||||
body.onara-modal-open #search-overlay,
|
||||
body.onara-modal-open #upload-drag-modal,
|
||||
body.onara-modal-open #rethumb-capture-modal,
|
||||
body.onara-modal-open #gchat-img-modal {
|
||||
|
||||
@@ -1799,6 +1799,18 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const searchOverlay = document.getElementById('search-overlay');
|
||||
if (searchOverlay && searchOverlay.classList.contains('visible')) {
|
||||
const closeBtn = document.getElementById('search-close');
|
||||
if (closeBtn) closeBtn.click();
|
||||
return;
|
||||
}
|
||||
const excludedTagsOverlay = document.getElementById('excluded-tags-overlay');
|
||||
if (excludedTagsOverlay && excludedTagsOverlay.classList.contains('visible')) {
|
||||
const closeBtn = document.getElementById('excluded-tags-close');
|
||||
if (closeBtn) closeBtn.click();
|
||||
return;
|
||||
}
|
||||
if (document.body.classList.contains('onara-modal-open')) {
|
||||
closeOnaraModal();
|
||||
}
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ const regen = async (item) => {
|
||||
};
|
||||
|
||||
// Shared NOT IN clause for Flash exclusion
|
||||
const flashExclude = db`mime NOT IN (${db(FLASH_MIMES)})`;
|
||||
const flashExclude = db`mime NOT IN ${db(FLASH_MIMES)}`;
|
||||
|
||||
try {
|
||||
let items;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
<div class="item_title">{!! item.title || '' !!}</div>
|
||||
<div class="item_title">
|
||||
{!! item.title || '' !!}
|
||||
@if(can_manage_item)
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="content">
|
||||
|
||||
@@ -74,7 +74,16 @@
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
<div class="item_title">{!! item.title || '' !!}</div>
|
||||
<div class="item_title">
|
||||
{!! item.title || '' !!}
|
||||
@if(can_manage_item)
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="content">
|
||||
|
||||
@@ -24,20 +24,6 @@
|
||||
</div>
|
||||
|
||||
<div class="infobox-cards-grid">
|
||||
@if(enable_item_title)
|
||||
<div class="infobox-card full-width">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-heading"></i> Title</label>
|
||||
@if(can_manage_item)
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
@else
|
||||
<div class="infobox-card-value">{!! item.title || '<span class="text-muted">No title set</span>' !!}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
|
||||
|
||||
@@ -156,7 +156,73 @@
|
||||
<div id="user-pref-show-motd" style="display:none">@if(session.show_motd !== false)true@else false @endif</div>
|
||||
</div>
|
||||
|
||||
@if(session.admin || session._is_impersonating)
|
||||
<!-- Admin Bar -->
|
||||
<div class="admin-bar" id="admin-bar">
|
||||
@if(session._is_impersonating)
|
||||
<!-- Impersonation Banner -->
|
||||
<div class="admin-bar-impersonate-notice">
|
||||
<span class="admin-bar-imp-icon"><i class="fa-solid fa-user-secret"></i></span>
|
||||
<span class="admin-bar-imp-text">Viewing as <strong>{!! session.user !!}</strong></span>
|
||||
<span class="admin-bar-imp-hint">— all actions are performed as this user</span>
|
||||
<button class="admin-bar-exit-btn" id="admin-bar-exit-impersonate" title="Exit impersonation and return to your admin session">
|
||||
<i class="fa-solid fa-door-open"></i> Exit
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<!-- Normal Admin Bar -->
|
||||
<div class="admin-bar-inner">
|
||||
<div class="admin-bar-brand">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
|
||||
<div class="admin-bar-links">
|
||||
<a href="/admin" class="admin-bar-link" title="Admin panel">
|
||||
<i class="fa-solid fa-cog"></i> Admin
|
||||
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
|
||||
<span class="admin-bar-badge">{{ session.pending_count }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<a href="/admin/approve" class="admin-bar-link" title="Pending approvals">
|
||||
<i class="fa-solid fa-clock"></i> Pending
|
||||
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
|
||||
<span class="admin-bar-badge">{{ session.pending_count }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<a href="/mod" class="admin-bar-link" title="Moderator panel">
|
||||
<i class="fa-solid fa-gavel"></i> Mod
|
||||
</a>
|
||||
<a href="/admin/audit" class="admin-bar-link" title="Audit log">
|
||||
<i class="fa-solid fa-scroll"></i> Audit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Preview As role toggle (right-aligned) -->
|
||||
<div class="admin-bar-preview" id="admin-bar-preview-wrap" style="margin-left: auto;">
|
||||
<span class="admin-bar-preview-label"><i class="fa-solid fa-eye" style="opacity:0.6;"></i> Preview:</span>
|
||||
<div class="admin-bar-preview-pills" id="admin-bar-preview-pills">
|
||||
<button class="admin-bar-preview-pill active" data-role="admin" title="View as yourself (admin)">Admin</button>
|
||||
<button class="admin-bar-preview-pill" data-role="mod" title="Simulate moderator view">Mod</button>
|
||||
<button class="admin-bar-preview-pill" data-role="user" title="Simulate regular user view">User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Floating exit-preview pill (visible only during role preview) -->
|
||||
@if(session.admin)
|
||||
<div id="admin-preview-exit-pill" style="display:none;">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
<span id="admin-preview-exit-label">Previewing as User</span>
|
||||
<button id="admin-preview-exit-btn" title="Return to admin view"><i class="fa-solid fa-xmark"></i> Exit</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@else
|
||||
<!-- not logged in -->
|
||||
@if(!private_society)
|
||||
@@ -435,3 +501,569 @@
|
||||
</script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaModalReady&render=explicit" async defer></script>
|
||||
@endif
|
||||
|
||||
@if(session && (session.admin || session._is_impersonating))
|
||||
<style>
|
||||
/* ── Admin Bar ──────────────────────────────────────────────────────────────── */
|
||||
.admin-bar {
|
||||
width: 100%;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
font-size: 0.76rem;
|
||||
z-index: 900;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
border-bottom: 1px solid rgba(255, 180, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Normal admin bar */
|
||||
.admin-bar-inner {
|
||||
background: linear-gradient(90deg, #1a1200 0%, #1e1600 40%, #141000 100%);
|
||||
border-top: 1px solid rgba(255, 190, 0, 0.22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-bar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #f5c400;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.admin-bar-brand i { font-size: 0.78rem; }
|
||||
|
||||
.admin-bar-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-bar-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(255, 210, 80, 0.75) !important;
|
||||
text-decoration: none !important;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.74rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
.admin-bar-link:hover {
|
||||
background: rgba(255, 200, 0, 0.12);
|
||||
color: #ffd844 !important;
|
||||
}
|
||||
.admin-bar-badge {
|
||||
background: #e05020;
|
||||
color: #fff;
|
||||
border-radius: 9px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0 5px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.admin-bar-sep {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
border-left: 1px solid rgba(255, 200, 0, 0.12);
|
||||
margin: 0 4px;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* View-as widget */
|
||||
.admin-bar-viewas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-bar-viewas-input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.admin-bar-input {
|
||||
background: rgba(255, 200, 0, 0.07);
|
||||
border: 1px solid rgba(255, 200, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #ffd844;
|
||||
font-size: 0.73rem;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
padding: 2px 8px;
|
||||
width: 160px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, width 0.2s;
|
||||
}
|
||||
.admin-bar-input::placeholder { color: rgba(255,210,80,0.35); }
|
||||
.admin-bar-input:focus {
|
||||
border-color: rgba(255, 200, 0, 0.55);
|
||||
width: 210px;
|
||||
}
|
||||
.admin-bar-viewas-btn {
|
||||
background: rgba(255, 200, 0, 0.12);
|
||||
border: 1px solid rgba(255, 200, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ffd844;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-bar-viewas-btn:hover {
|
||||
background: rgba(255, 200, 0, 0.22);
|
||||
border-color: rgba(255, 200, 0, 0.55);
|
||||
}
|
||||
.admin-bar-viewas-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Autocomplete suggestions */
|
||||
.admin-viewas-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1a1600;
|
||||
border: 1px solid rgba(255, 200, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
}
|
||||
.admin-viewas-suggestions.open { display: block; }
|
||||
.admin-viewas-suggestion {
|
||||
padding: 5px 10px;
|
||||
color: rgba(255, 210, 80, 0.85);
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.admin-viewas-suggestion:hover,
|
||||
.admin-viewas-suggestion.active {
|
||||
background: rgba(255, 200, 0, 0.14);
|
||||
color: #ffd844;
|
||||
}
|
||||
.admin-viewas-suggestion i { opacity: 0.5; font-size: 0.72rem; }
|
||||
|
||||
/* Impersonation banner */
|
||||
.admin-bar-impersonate-notice {
|
||||
background: linear-gradient(90deg, #1a0800 0%, #200c00 40%, #180a00 100%);
|
||||
border-top: 1px solid rgba(255, 120, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
animation: admin-imp-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes admin-imp-pulse {
|
||||
0%, 100% { border-top-color: rgba(255, 120, 0, 0.35); }
|
||||
50% { border-top-color: rgba(255, 160, 0, 0.7); }
|
||||
}
|
||||
|
||||
.admin-bar-imp-icon {
|
||||
color: #ff8c00;
|
||||
font-size: 0.85rem;
|
||||
animation: admin-imp-wiggle 4s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes admin-imp-wiggle {
|
||||
0%, 90%, 100% { transform: rotate(0deg); }
|
||||
92% { transform: rotate(-8deg); }
|
||||
96% { transform: rotate(8deg); }
|
||||
}
|
||||
|
||||
.admin-bar-imp-text {
|
||||
color: #ffb84d;
|
||||
font-size: 0.77rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-bar-imp-text strong { color: #ffcc00; }
|
||||
.admin-bar-imp-hint {
|
||||
color: rgba(255, 160, 60, 0.55);
|
||||
font-size: 0.71rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-bar-exit-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 80, 0, 0.18);
|
||||
border: 1px solid rgba(255, 100, 0, 0.45);
|
||||
border-radius: 4px;
|
||||
color: #ff8c40;
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.73rem;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 22px;
|
||||
}
|
||||
.admin-bar-exit-btn:hover {
|
||||
background: rgba(255, 80, 0, 0.35);
|
||||
border-color: rgba(255, 120, 0, 0.7);
|
||||
color: #ffaa66;
|
||||
}
|
||||
|
||||
/* Preview As role pills */
|
||||
.admin-bar-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-bar-preview-label {
|
||||
color: rgba(255,210,80,0.55);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-bar-preview-pills {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.admin-bar-preview-pill {
|
||||
background: rgba(255,200,0,0.07);
|
||||
border: 1px solid rgba(255,200,0,0.18);
|
||||
border-radius: 3px;
|
||||
color: rgba(255,210,80,0.55);
|
||||
cursor: pointer;
|
||||
font-size: 0.71rem;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
padding: 1px 7px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-bar-preview-pill:hover {
|
||||
background: rgba(255,200,0,0.16);
|
||||
color: #ffd844;
|
||||
border-color: rgba(255,200,0,0.4);
|
||||
}
|
||||
.admin-bar-preview-pill.active {
|
||||
background: rgba(255,200,0,0.22);
|
||||
color: #ffd844;
|
||||
border-color: rgba(255,200,0,0.55);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Floating exit-preview pill */
|
||||
#admin-preview-exit-pill {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
z-index: 99999;
|
||||
background: #1a1200;
|
||||
border: 1px solid rgba(255,200,0,0.45);
|
||||
border-radius: 20px;
|
||||
color: #ffd844;
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
animation: preview-pill-in 0.2s ease;
|
||||
}
|
||||
@keyframes preview-pill-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
#admin-preview-exit-pill span { font-weight: 600; }
|
||||
#admin-preview-exit-btn {
|
||||
background: rgba(255,200,0,0.15);
|
||||
border: 1px solid rgba(255,200,0,0.35);
|
||||
border-radius: 10px;
|
||||
color: #ffd844;
|
||||
cursor: pointer;
|
||||
font-size: 0.71rem;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
padding: 1px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#admin-preview-exit-btn:hover { background: rgba(255,200,0,0.3); }
|
||||
|
||||
/* Body class rules: simulate role views */
|
||||
body.preview-as-user .admin-bar,
|
||||
body.preview-as-mod .admin-bar { display: none !important; }
|
||||
|
||||
/* Hide all owner/admin/mod actions in user preview */
|
||||
body.preview-as-user #a_delete,
|
||||
body.preview-as-user #a_rethumb,
|
||||
body.preview-as-user .removetag,
|
||||
body.preview-as-user .can-cycle,
|
||||
body.preview-as-user .info-title-edit-wrap,
|
||||
body.preview-as-user #info-title-save,
|
||||
body.preview-as-user #info-title-status,
|
||||
body.preview-as-user #info-visibility-edit-btn,
|
||||
body.preview-as-user #info-rethumb-btn,
|
||||
body.preview-as-user #info-set-expiry-btn,
|
||||
body.preview-as-user .infobox-action-btn,
|
||||
body.preview-as-user .btn-infobox-action,
|
||||
body.preview-as-user .admin-delete-btn,
|
||||
body.preview-as-user .admin-edit-btn,
|
||||
body.preview-as-user .admin-pin-btn,
|
||||
body.preview-as-user .delete-btn,
|
||||
body.preview-as-user .poll-delete-btn,
|
||||
body.preview-as-user [data-is-admin] .admin-only { display: none !important; }
|
||||
|
||||
/* Hide admin-only actions in mod preview (keep delete — mods can delete) */
|
||||
body.preview-as-mod #a_rethumb,
|
||||
body.preview-as-mod .removetag,
|
||||
body.preview-as-mod #info-rethumb-btn,
|
||||
body.preview-as-mod .btn-infobox-action { display: none !important; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-bar-imp-hint { display: none; }
|
||||
.admin-bar-links a span:not(.admin-bar-badge) { display: none; }
|
||||
.admin-bar-input { width: 110px; }
|
||||
.admin-bar-input:focus { width: 140px; }
|
||||
.admin-bar-preview-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/* ── Admin bar: View-As (impersonation) ────────────────────────────────── */
|
||||
const input = document.getElementById('admin-viewas-input');
|
||||
const suggestBox = document.getElementById('admin-viewas-suggestions');
|
||||
const viewAsBtn = document.getElementById('admin-viewas-btn');
|
||||
const exitBtn = document.getElementById('admin-bar-exit-impersonate');
|
||||
|
||||
let _suggestTimer = null;
|
||||
let _suggestions = [];
|
||||
let _activeIdx = -1;
|
||||
|
||||
function closeSuggestions() {
|
||||
if (suggestBox) { suggestBox.innerHTML = ''; suggestBox.classList.remove('open'); }
|
||||
_suggestions = [];
|
||||
_activeIdx = -1;
|
||||
}
|
||||
|
||||
function renderSuggestions(list) {
|
||||
if (!suggestBox) return;
|
||||
suggestBox.innerHTML = '';
|
||||
if (!list || list.length === 0) { suggestBox.classList.remove('open'); return; }
|
||||
list.forEach((u, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'admin-viewas-suggestion';
|
||||
el.innerHTML = '<i class="fa-solid fa-user"></i>' + u.user;
|
||||
el.dataset.username = u.user;
|
||||
el.addEventListener('mousedown', (e) => { e.preventDefault(); selectSuggestion(u.user); });
|
||||
el.addEventListener('mouseenter', () => { setActive(i); });
|
||||
suggestBox.appendChild(el);
|
||||
});
|
||||
_suggestions = list;
|
||||
_activeIdx = -1;
|
||||
suggestBox.classList.add('open');
|
||||
}
|
||||
|
||||
function setActive(idx) {
|
||||
const items = suggestBox ? suggestBox.querySelectorAll('.admin-viewas-suggestion') : [];
|
||||
items.forEach((el, i) => el.classList.toggle('active', i === idx));
|
||||
_activeIdx = idx;
|
||||
}
|
||||
|
||||
function selectSuggestion(username) {
|
||||
if (input) input.value = username;
|
||||
closeSuggestions();
|
||||
}
|
||||
|
||||
function fetchSuggestions(q) {
|
||||
if (!q || q.length < 1) { closeSuggestions(); return; }
|
||||
fetch('/api/v2/admin/users/search?q=' + encodeURIComponent(q), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (Array.isArray(data)) renderSuggestions(data);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(_suggestTimer);
|
||||
const q = input.value.trim();
|
||||
if (!q) { closeSuggestions(); return; }
|
||||
_suggestTimer = setTimeout(() => fetchSuggestions(q), 200);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = suggestBox ? suggestBox.querySelectorAll('.admin-viewas-suggestion') : [];
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(Math.min(_activeIdx + 1, items.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(Math.max(_activeIdx - 1, -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_activeIdx >= 0 && _suggestions[_activeIdx]) {
|
||||
selectSuggestion(_suggestions[_activeIdx].user);
|
||||
} else {
|
||||
startImpersonation();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
closeSuggestions();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(closeSuggestions, 150);
|
||||
});
|
||||
}
|
||||
|
||||
function startImpersonation() {
|
||||
const username = input ? input.value.trim() : '';
|
||||
if (!username) return;
|
||||
if (viewAsBtn) { viewAsBtn.disabled = true; }
|
||||
|
||||
const csrf = (typeof window.f0ckSession !== 'undefined' && window.f0ckSession.csrf_token)
|
||||
? window.f0ckSession.csrf_token
|
||||
: (document.querySelector('meta[name="csrf-token"]')?.content || '');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('username', username);
|
||||
if (csrf) params.append('csrf_token', csrf);
|
||||
|
||||
fetch('/api/v2/admin/impersonate', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Impersonation failed: ' + (data.msg || 'Unknown error'));
|
||||
if (viewAsBtn) viewAsBtn.disabled = false;
|
||||
}
|
||||
}).catch(e => {
|
||||
alert('Network error: ' + e.message);
|
||||
if (viewAsBtn) viewAsBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (viewAsBtn) {
|
||||
viewAsBtn.addEventListener('click', startImpersonation);
|
||||
}
|
||||
|
||||
/* ── Exit impersonation ─────────────────────────────────────────────────── */
|
||||
if (exitBtn) {
|
||||
exitBtn.addEventListener('click', () => {
|
||||
exitBtn.disabled = true;
|
||||
exitBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Exiting…';
|
||||
|
||||
const csrf = (typeof window.f0ckSession !== 'undefined' && window.f0ckSession.csrf_token)
|
||||
? window.f0ckSession.csrf_token
|
||||
: (document.querySelector('meta[name="csrf-token"]')?.content || '');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (csrf) params.append('csrf_token', csrf);
|
||||
|
||||
fetch('/api/v2/admin/stop-impersonate', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
}).then(() => {
|
||||
window.location.reload();
|
||||
}).catch(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Admin bar: Preview As role toggle ──────────────────────────────────── */
|
||||
(function() {
|
||||
const STORAGE_KEY = 'f0ck_preview_role';
|
||||
const pills = document.querySelectorAll('.admin-bar-preview-pill');
|
||||
const exitPill = document.getElementById('admin-preview-exit-pill');
|
||||
const exitLabel = document.getElementById('admin-preview-exit-label');
|
||||
const exitBtn = document.getElementById('admin-preview-exit-btn');
|
||||
|
||||
const roleLabels = { admin: 'Admin', mod: 'Moderator', user: 'User' };
|
||||
|
||||
function applyRole(role) {
|
||||
document.body.classList.remove('preview-as-user', 'preview-as-mod');
|
||||
if (role === 'user') document.body.classList.add('preview-as-user');
|
||||
if (role === 'mod') document.body.classList.add('preview-as-mod');
|
||||
|
||||
// Update active pill
|
||||
pills.forEach(p => p.classList.toggle('active', p.dataset.role === role));
|
||||
|
||||
// Show/hide exit pill
|
||||
if (exitPill) {
|
||||
if (role !== 'admin') {
|
||||
if (exitLabel) exitLabel.textContent = 'Previewing as ' + roleLabels[role];
|
||||
exitPill.style.display = 'flex';
|
||||
} else {
|
||||
exitPill.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Init from localStorage
|
||||
const saved = localStorage.getItem(STORAGE_KEY) || 'admin';
|
||||
applyRole(saved);
|
||||
|
||||
// Pill click
|
||||
pills.forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
const role = pill.dataset.role;
|
||||
localStorage.setItem(STORAGE_KEY, role);
|
||||
applyRole(role);
|
||||
});
|
||||
});
|
||||
|
||||
// Exit button
|
||||
if (exitBtn) {
|
||||
exitBtn.addEventListener('click', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'admin');
|
||||
applyRole('admin');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user