diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 6017c9b..092d930 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -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 { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 00f1e7b..b2cb489 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -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(); } diff --git a/scripts/regen.mjs b/scripts/regen.mjs index 0818e1c..21f2302 100644 --- a/scripts/regen.mjs +++ b/scripts/regen.mjs @@ -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; diff --git a/src/inc/lib_delete.mjs b/src/inc/lib_delete.mjs index 79acd02..181fac8 100644 --- a/src/inc/lib_delete.mjs +++ b/src/inc/lib_delete.mjs @@ -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); diff --git a/src/inc/private_items.mjs b/src/inc/private_items.mjs new file mode 100644 index 0000000..e41a457 --- /dev/null +++ b/src/inc/private_items.mjs @@ -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/, /t/..., /ca/...) 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 || `\n502 Bad Gateway\n\n

502 Bad Gateway

\n
nginx
\n\n`); + res.writeHead(502, { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }).end(body); + } +} diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 15aac10..5005265 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -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) diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 7437677..b757a45 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -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; } + diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 1815bcb..c46d434 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -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({ diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 5a708c0..d0a983a 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -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) { } diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index d97a2db..53189e5 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -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({ diff --git a/src/index.mjs b/src/index.mjs index ff196b0..3960115 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -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); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index f8b6be8..8bc384a 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -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` diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 63cd3fe..da1f09f 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -10,7 +10,16 @@
@if(enable_item_title) -
{!! item.title || '' !!}
+
+ {!! item.title || '' !!} + @if(can_manage_item) +
+ + +
+ + @endif +
@endif
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index 15de014..7b59258 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -74,7 +74,16 @@
@if(enable_item_title) -
{!! item.title || '' !!}
+
+ {!! item.title || '' !!} + @if(can_manage_item) +
+ + +
+ + @endif +
@endif
diff --git a/views/snippets/info-modal.html b/views/snippets/info-modal.html index 8035319..9ac55a4 100644 --- a/views/snippets/info-modal.html +++ b/views/snippets/info-modal.html @@ -24,20 +24,6 @@
- @if(enable_item_title) -
- - @if(can_manage_item) -
- - -
- - @else -
{!! item.title || 'No title set' !!}
- @endif -
- @endif
diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index b45a41a..6cc03d5 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -156,7 +156,73 @@
+ @if(session.admin || session._is_impersonating) + +
+ @if(session._is_impersonating) + +
+ + Viewing as {!! session.user !!} + — all actions are performed as this user + +
+ @else + + + @endif +
+ @endif + + + +@if(session.admin) + +@endif + @else @if(!private_society) @@ -435,3 +501,569 @@ @endif + +@if(session && (session.admin || session._is_impersonating)) + + + +@endif