ghost rider

This commit is contained in:
2026-09-12 03:11:29 +02:00
parent 155395237b
commit b55a17dc85
25 changed files with 1012 additions and 204 deletions
+10
View File
@@ -518,5 +518,15 @@ export default new class {
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
isLocalhostRequest(req) {
if (!req) return false;
const rawHost = req.headers?.['x-forwarded-host'] || req.headers?.['host'] || req.headers?.['x-forwarded-server'] || '';
if (!rawHost) return false;
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
const firstHost = hostStr.split(',')[0].trim();
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort === 'localhost' || hostNoPort === '127.0.0.1' || hostNoPort === '::1';
}
};
+2
View File
@@ -643,6 +643,8 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Wird verarbeitet...",
"yes": "Ja",
"no": "Nein",
+2
View File
@@ -647,6 +647,8 @@
"reason_required": "Reason is required.",
"reason_optional": "Reason (optional)",
"reason_required_label": "Reason (required)",
"captcha_required": "Please complete the CAPTCHA.",
"captcha_loading": "CAPTCHA is still loading. Please wait a moment.",
"processing": "Processing...",
"yes": "Yes",
"no": "No",
+2
View File
@@ -641,6 +641,8 @@
"reason_required": "Reden is vereist.",
"reason_optional": "Reden (optioneel)",
"reason_required_label": "Reden (vereist)",
"captcha_required": "Vul alstublieft de CAPTCHA in.",
"captcha_loading": "CAPTCHA is nog aan het laden. Even geduld a.u.b.",
"processing": "Verwerken...",
"yes": "Ja",
"no": "Nee",
+2
View File
@@ -642,6 +642,8 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Verarbeitung wird durchgeführt...",
"yes": "Ja",
"no": "Nein",
+84 -19
View File
@@ -3,32 +3,44 @@ 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();
// Unavailable items (visibility === 3, serves HTTP 451 for non-logged in users):
const _unavailableDests = new Map();
const _unavailableIds = new Map();
let _initialized = false;
let _initPromise = null;
/**
* Load all active private items into memory cache.
* Load all active private and unavailable items into memory cache.
*/
export async function initPrivateItems() {
try {
const rows = await db`
SELECT id, dest, LOWER(username) as username
SELECT id, dest, LOWER(username) as username, visibility
FROM items
WHERE visibility = 2 AND is_deleted = false
WHERE visibility IN (2, 3) AND is_deleted = false
`;
_privateDests.clear();
_privateIds.clear();
_unavailableDests.clear();
_unavailableIds.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 || '');
const u = r.username || '';
if (r.visibility === 2) {
if (r.dest) _privateDests.set(r.dest, u);
if (r.id) _privateIds.set(Number(r.id), u);
} else if (r.visibility === 3) {
if (r.dest) _unavailableDests.set(r.dest, u);
if (r.id) _unavailableIds.set(Number(r.id), u);
}
}
_initialized = true;
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) into memory cache`);
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) and ${_unavailableDests.size} unavailable item(s) into memory cache`);
} catch (err) {
console.error('[BOOT] Failed to load private items into cache:', err.message);
console.error('[BOOT] Failed to load private/unavailable items into cache:', err.message);
}
}
@@ -48,6 +60,8 @@ export function addPrivateItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _privateDests.set(dest, u);
if (id) _privateIds.set(Number(id), u);
// Ensure not in unavailable
removeUnavailableItem(id, dest);
}
export function removePrivateItem(id, dest) {
@@ -55,9 +69,22 @@ export function removePrivateItem(id, dest) {
if (id) _privateIds.delete(Number(id));
}
export function addUnavailableItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _unavailableDests.set(dest, u);
if (id) _unavailableIds.set(Number(id), u);
// Ensure not in private
removePrivateItem(id, dest);
}
export function removeUnavailableItem(id, dest) {
if (dest) _unavailableDests.delete(dest);
if (id) _unavailableIds.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.
* Checks if a given pathname (/b/<dest>, /t/<id>..., /ca/<id>...) is a private or unavailable item.
* Returns { isPrivate: boolean, isUnavailable: boolean, owner: string } or null if normal public.
*/
export function getPrivateItemFromPath(pathname) {
if (!pathname || typeof pathname !== 'string') return null;
@@ -70,9 +97,13 @@ export function getPrivateItemFromPath(pathname) {
dest = pathname.slice(3);
}
dest = dest.split('?')[0].split('#')[0];
const owner = _privateDests.get(dest);
if (owner !== undefined) {
return { isPrivate: true, owner };
const privOwner = _privateDests.get(dest);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableDests.get(dest);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
return null;
}
@@ -88,9 +119,13 @@ export function getPrivateItemFromPath(pathname) {
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 };
const privOwner = _privateIds.get(id);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableIds.get(id);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
@@ -107,9 +142,13 @@ export function getPrivateItemFromPath(pathname) {
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 };
const privOwner = _privateIds.get(id);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableIds.get(id);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
@@ -141,3 +180,29 @@ export function render502(req, res) {
}).end(body);
}
}
/**
* Render HTTP 451 Unavailable For Legal Reasons response.
*/
export function render451(req, res) {
if (req.headers && (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.headers.accept?.includes('application/json'))) {
res.writeHead(451, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(JSON.stringify({ success: false, is_unavailable: true, msg: '451 - Unavailable For Legal Reasons' }));
} else {
const body = `<html>\r
<head><title>451 Unavailable For Legal Reasons</title></head>\r
<body>\r
<center><h1>451 Unavailable For Legal Reasons</h1></center>\r
<hr><center>nginx</center>\r
</body>\r
</html>\r
`;
res.writeHead(451, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(body);
}
}
+17 -6
View File
@@ -371,11 +371,12 @@ const buildFeedFilters = async ({
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
}
const isAdmin = !!session?.admin;
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
const visibilityFilter = isOwnerOrAdmin
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return {
@@ -881,10 +882,11 @@ export default {
// Helper to construct shared filter conditions
const buildConditions = () => {
const visibilityFilter = isOwnerOrAdmin
const isAdmin = !!session?.admin;
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return db`
@@ -964,9 +966,18 @@ export default {
};
}
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted):
// If item is Unavailable (visibility === 3):
// Only viewable by admins, not regular users or mods (renders normal post not found)
if (actitem.visibility === 3 && !session?.admin) {
return {
success: false,
message: "404 - upload not found"
};
}
// 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) {
if (isNumeric && (actitem.visibility === 1 || actitem.visibility === 2) && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
+19
View File
@@ -137,6 +137,25 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
item.author_banner_position = null;
item.author_banner_size = null;
item.author_avatar = null;
item.author_avatar_file = null;
item.author_color = null;
item.author_description = null;
item.author_display_name = null;
item.author_id = null;
if (data.uploader) {
data.uploader.name = 'anonymous';
data.uploader.id = null;
data.uploader.color = null;
}
}
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
+16 -3
View File
@@ -11,7 +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';
import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => {
@@ -1067,7 +1067,11 @@ export default router => {
if (!isOwner && !isMod) return res.json({ success: false, msg: 'Forbidden' }, 403);
// Accept title from JSON or URL-encoded body
let rawTitle = req.post?.title ?? req.body?.title ?? null;
let body = req.post || req.body || {};
if (typeof body === 'string') {
try { body = JSON.parse(body); } catch (_) {}
}
let rawTitle = body?.title ?? null;
if (rawTitle !== null) rawTitle = String(rawTitle).trim();
// Empty string → null (clears the title)
const title = (rawTitle === '' || rawTitle === null) ? null : rawTitle.substring(0, 500);
@@ -1317,7 +1321,7 @@ export default router => {
}
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
if (!postid || isNaN(visibility) || ![0, 1, 2, 3].includes(visibility)) {
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
}
@@ -1340,12 +1344,21 @@ export default router => {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
if (visibility === 3 && !isAdmin) {
return res.json({ success: false, msg: 'Only moderators or administrators can make an item unavailable' }, 403);
}
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);
removeUnavailableItem(item[0].id, item[0].dest);
} else if (visibility === 3) {
addUnavailableItem(item[0].id, item[0].dest, item[0].username);
removePrivateItem(item[0].id, item[0].dest);
} else {
removePrivateItem(item[0].id, item[0].dest);
removeUnavailableItem(item[0].id, item[0].dest);
}
f0cklib.clearCountCache();
+18 -3
View File
@@ -398,9 +398,24 @@ export default (router, tpl) => {
// Hall columns for display
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// When guest anonymization is active, suppress source URL from the info modal
if (cfg.main.guest_anonymize && !req.session && item.src) {
item.src = null;
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
item.author_banner_position = null;
item.author_banner_size = null;
item.author_avatar = null;
item.author_avatar_file = null;
item.author_color = null;
item.author_description = null;
item.author_display_name = null;
item.author_id = null;
if (data.uploader) {
data.uploader.name = 'anonymous';
data.uploader.id = null;
data.uploader.color = null;
}
}
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
+38 -5
View File
@@ -1,11 +1,13 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import cfg from "../config.mjs";
import security from "../security.mjs";
export default (router, tpl) => {
// User: Submit a new report
router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
// Submit a new report (Users & Guests)
router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
@@ -18,10 +20,40 @@ export default (router, tpl) => {
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
}
const ip = security.getRealIP(req);
const isGuest = !req.session;
if (isGuest) {
// CAPTCHA verification for guest reports (bypassed for .onion and localhost)
const isOnion = lib.isOnionRequest(req);
const isLocalhost = lib.isLocalhostRequest(req);
if (!isOnion && !isLocalhost && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) {
return res.json({ success: false, msg: "Please complete the CAPTCHA." }, 400);
}
try {
const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
});
const { success } = await verifyRes.json();
if (!success) {
return res.json({ success: false, msg: "CAPTCHA verification failed. Please try again." }, 400);
}
} catch (e) {
console.error('[REPORT] reCAPTCHA error:', e.message);
return res.json({ success: false, msg: "CAPTCHA verification error. Please try again." }, 500);
}
}
}
const reportRes = await db`
INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason)
VALUES (
${req.session.id},
${req.session ? req.session.id : null},
${ip},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
@@ -86,7 +118,8 @@ export default (router, tpl) => {
c.content AS comment_body,
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
COALESCE(i.mime, ci.mime) AS resolved_item_mime
COALESCE(i.mime, ci.mime) AS resolved_item_mime,
COALESCE(i.visibility, ci.visibility, 0) AS resolved_item_visibility
FROM reports r
LEFT JOIN "user" rep ON r.reporter_id = rep.id
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
+18 -3
View File
@@ -185,9 +185,24 @@ export default (router, tpl) => {
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
// When guest anonymization is active, suppress source URL from the info modal
if (cfg.main.guest_anonymize && !req.session && item.src) {
item.src = null;
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
item.author_banner_position = null;
item.author_banner_size = null;
item.author_avatar = null;
item.author_avatar_file = null;
item.author_color = null;
item.author_description = null;
item.author_display_name = null;
item.author_id = null;
if (data.uploader) {
data.uploader.name = 'anonymous';
data.uploader.id = null;
data.uploader.color = null;
}
}
}
+38 -3
View File
@@ -26,7 +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 { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -87,7 +87,7 @@ const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.si
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
function getGateLoginInjection(req) {
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req) && !lib.isLocalhostRequest(req);
return `
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
@@ -726,6 +726,41 @@ process.on('uncaughtException', err => {
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) {
if (privItem.isUnavailable) {
// Unavailable item (visibility === 3):
// Direct URLs MUST serve 451 when requested by non-admins (or without a session)
let isAdmin = 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 && user.admin) {
isAdmin = true;
}
}
if (!isAdmin) {
render451(req, res);
req.url.pathname = '/unavailable_item_bypass';
return;
}
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
return;
}
// Private item (visibility === 2):
// Direct URLs MUST serve 502 when requested without a session (or by unauthorized users),
// regardless of the protect_files setting.
@@ -1693,7 +1728,7 @@ process.on('uncaughtException', err => {
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
let perRequestRecaptcha = defaultRecaptcha;
if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
if (effectiveReq && (lib.isOnionRequest(effectiveReq) || lib.isLocalhostRequest(effectiveReq))) {
perRequestRecaptcha = false;
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
perRequestRecaptcha = data.recaptcha_enabled;