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
+3 -2
View File
@@ -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 {
+12
View File
@@ -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
View File
@@ -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;
+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`
+10 -1
View File
@@ -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">
+10 -1
View File
@@ -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">
-14
View File
@@ -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>
+632
View File
@@ -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