esifawafwu

This commit is contained in:
2026-09-28 19:03:31 +02:00
parent 5eb33f97c8
commit dde0a2d271
62 changed files with 3760 additions and 1865 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads).
*
* Every call gets a randomized browser identity:
* - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random
* profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers,
* so we must NOT override the User-Agent (a mismatch would defeat the point).
* - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language.
*
* config: main.curl_impersonate
* (unset) / true → auto-detect wrappers on PATH
* false → never use curl-impersonate
* "<dir>" → look for wrappers in that directory (in addition to PATH)
*/
import fs from 'fs';
import path from 'path';
import cfg from './config.mjs';
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Browser majors derived from the date so the pool never goes stale.
// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks.
const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000));
const randomUserAgent = () => {
const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind
const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3);
const templates = [
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`
];
return pick(templates);
};
const ACCEPT_LANGUAGES = [
'en-US,en;q=0.9',
'en-US,en;q=0.8',
'en-GB,en;q=0.9,en-US;q=0.8',
'en-US,en;q=0.9,de;q=0.8',
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
];
// ── curl-impersonate detection (cached after first lookup) ──────────────────
// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android.
const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i;
// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl.
const PROFILES_PER_BROWSER = 3;
let _impersonateBins = null;
const findImpersonateBins = () => {
if (_impersonateBins) return _impersonateBins;
const setting = cfg.main?.curl_impersonate;
if (setting === false) return (_impersonateBins = []);
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim());
const byBrowser = new Map(); // family → [{ version, full }]
const seen = new Set();
for (const dir of dirs) {
let entries;
try { entries = fs.readdirSync(dir); } catch { continue; }
for (const name of entries) {
const m = name.match(IMPERSONATE_RE);
if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants
const full = path.join(dir, name);
try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; }
seen.add(name);
const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase();
if (!byBrowser.has(family)) byBrowser.set(family, []);
byBrowser.get(family).push({ version: parseInt(m[2], 10), full });
}
}
// Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome
// (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient).
const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version));
if (newestChrome && byBrowser.has('edge')) {
byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10));
}
_impersonateBins = [];
for (const list of byBrowser.values()) {
list.sort((a, b) => b.version - a.version);
_impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full));
}
console.log(_impersonateBins.length
? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})`
: '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent');
return _impersonateBins;
};
// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros
// (openSUSE, Fedora, macOS) point it at the local bundle explicitly.
const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt';
const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'];
let _caArgs = null;
const caArgs = () => {
if (_caArgs) return _caArgs;
if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []);
const found = CA_FALLBACKS.find(f => fs.existsSync(f));
return (_caArgs = found ? ['--cacert', found] : []);
};
const socksArgs = () => {
const socks = cfg.main?.socks;
if (!socks || socks === 'undefined') return [];
const host = socks.includes('://') ? socks.split('://')[1] : socks;
return ['--socks5-hostname', host];
};
/**
* Build a curl command for a 4chan URL with a randomized browser identity.
* @param {string} url
* @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300'])
* @returns {{ bin: string, args: string[] }}
*/
export const chanCurl = (url, extraArgs = []) => {
const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()];
const impersonate = findImpersonateBins();
if (impersonate.length) {
// Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint
return { bin: pick(impersonate), args: [...base, ...caArgs(), url] };
}
return {
bin: 'curl',
args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url]
};
};
export default { chanCurl };
+2 -2
View File
@@ -4,7 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
@@ -552,7 +552,7 @@ export default new class {
}
return res.redirect('/login');
}
const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'));
const hasGroup = canUseChan(req.session);
if (!hasGroup) {
if (isApi) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
+16 -7
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs";
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession, getSessionOwnerName } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
@@ -129,6 +129,13 @@ const computeBaseMode = (mode, ratings, session) => {
if (!allowedModes.includes('sfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
}
// Uploaders always see their own untagged items, even when 'untagged' isn't an allowed anon mode.
// The name is interpolated into raw SQL, so only accept the plain shadow-login charset.
// Only `items.id` may reference the outer row: some callers alias items (e.g. comments.mjs rewrites items.id → i.id).
const ownerName = getSessionOwnerName(session);
if (!allowedModes.includes('untagged') && ownerName && /^[a-z0-9_]+$/i.test(ownerName)) {
baseMode = `((${baseMode}) or items.id in (select own.id from items own where lower(own.username) = '${ownerName.toLowerCase()}' and not exists (select 1 from tags_assign ota where ota.item_id = own.id and ota.tag_id in (1, 2, ${nsflId}))))`;
}
}
return baseMode;
};
@@ -477,11 +484,11 @@ const buildFeedFilters = async ({
}
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 ownerName = getSessionOwnerName(session);
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: (ownerName
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return {
@@ -1217,10 +1224,11 @@ const f0cklib = {
// Helper to construct shared filter conditions
const buildConditions = () => {
const isAdmin = !!session?.admin;
const ownerName = getSessionOwnerName(session);
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: (ownerName
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return db`
@@ -1284,8 +1292,9 @@ const f0cklib = {
const itemid = actitem.id;
// Check visibility permissions:
const ownerName = getSessionOwnerName(session);
const isOwnerOrAdmin = session && (
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
(ownerName && ownerName.toLowerCase() === (actitem.username || '').toLowerCase()) ||
session.admin || session.is_moderator
);
+19
View File
@@ -286,8 +286,27 @@ export default (router, tpl) => {
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
// Dashboard counters (cheap aggregate queries; failures just show 0)
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 };
try {
const [[r], [u], [t]] = await Promise.all([
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
FROM "user"`,
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`
]);
dash.trash = t?.n || 0;
dash.open_reports = r?.n || 0;
dash.users = u?.users || 0;
dash.anon_users = u?.anon || 0;
} catch (e) {
console.error('[ADMIN] dashboard counters failed:', e.message);
}
res.reply({
body: tpl.render("admin", {
dash,
totals: await lib.countf0cks(),
session: req.session,
manual_approval: getManualApproval(),
+13 -15
View File
@@ -3,7 +3,7 @@ import lib from "../lib.mjs";
import url from "url";
import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
export default (router, tpl) => {
// ── Merged random + item load: single request instead of two ────────────
@@ -177,6 +177,8 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
@@ -205,6 +207,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.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])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -229,13 +234,7 @@ export default (router, tpl) => {
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (query.onara === '1' || cookieOnara === true);
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
if (effectiveOnara) {
try {
itemPage = await f0cklib.getItemPage({
@@ -444,6 +443,8 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
@@ -483,6 +484,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.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])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -510,13 +514,7 @@ export default (router, tpl) => {
- Render: ${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (query.onara === '1' || cookieOnara === true);
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
if (effectiveOnara || query.get_page === '1') {
try {
itemPage = await f0cklib.getItemPage({
+133
View File
@@ -16,6 +16,14 @@ import {
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs';
// Maximum number of passkeys a single anonymous identity may hold
const MAX_ANON_PASSKEYS = 4;
const countPasskeys = async userId => {
const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`;
return rows[0]?.n || 0;
};
export default router => {
router.group(/^\/api\/v2\/anon/, group => {
@@ -426,6 +434,128 @@ export default router => {
}
});
// ─── Add Passkey to current anonymous identity ────────────────────────────
// Resolves the logged-in anonymous user, or null if the session isn't an anon identity
const getAnonSessionUserId = async req => {
if (!req.session?.id) return null;
const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`;
return rows.length > 0 ? req.session.id : null;
};
/**
* POST /api/v2/anon/passkey/add/begin
* Registration options for an additional passkey on the current anonymous identity.
*/
group.post(/\/passkey\/add\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`;
if (existing.length >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
const challenge = generateChallenge({ type: 'anon-add', userId });
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
// Stop the authenticator from registering a second copy of a passkey it already holds
options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id }));
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] add/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/add/finish
* Verify attestation and attach the new passkey to the current anonymous identity.
*/
group.post(/\/passkey\/add\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) {
return res.json({ success: false, msg: 'Invalid CSRF token' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Re-check here too: two add flows could have been started in parallel
if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Add verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`;
if (taken.length > 0) {
return res.json({ success: false, msg: 'This passkey is already registered.' }, 409);
}
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
const auditIp = resolveAuditIP(req);
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
`;
await db`
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null})
ON CONFLICT (credential_id) DO NOTHING
`;
const passkeyCount = await countPasskeys(userId);
return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS });
} catch (err) {
console.error('[ANON_PASSKEY] add/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ─────────────────────────────────────────────────────────────
/**
@@ -448,6 +578,7 @@ export default router => {
FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id}
ORDER BY ai.created_at ASC
LIMIT 1
`;
@@ -462,6 +593,8 @@ export default router => {
hw_fingerprint: rows[0].hw_fingerprint,
credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name,
passkey_count: await countPasskeys(req.session.id),
passkey_max: MAX_ANON_PASSKEYS,
csrf_token: req.session.csrf_token
});
}
+3 -2
View File
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from '../../settings.mjs';
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession, getSessionOwnerName } from '../../settings.mjs';
import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs';
import path from "path";
@@ -2315,7 +2315,8 @@ export default router => {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
const ownerName = getSessionOwnerName(req.session);
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) {
+5 -4
View File
@@ -6,7 +6,7 @@ import cfg from "../../config.mjs";
import fs from "fs";
import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs";
import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs";
export default router => {
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
@@ -156,7 +156,7 @@ export default router => {
WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)}
AND item_id IN (
SELECT id FROM items WHERE username = ${req.session.user}
SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)}
)
`;
}
@@ -206,7 +206,7 @@ export default router => {
const ownedItems = await db`
SELECT id FROM items
WHERE id IN ${db(itemIds)}
AND username = ${req.session.user}
AND username = ${getSessionOwnerName(req.session)}
AND active = true AND is_deleted = false
`;
eligibleIds = ownedItems.map(r => r.id);
@@ -440,7 +440,8 @@ export default router => {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
const ownerName = getSessionOwnerName(req.session);
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
+30 -25
View File
@@ -2,6 +2,11 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { getSessionOwnerName, getEnableItemSlugs, isOnaraEnabledFor } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = (id, slug) => (getEnableItemSlugs() && slug) ? slug : id;
/**
* chan.mjs — 4chan thread viewer & catalogue routes
@@ -28,17 +33,8 @@ export default (router, tpl) => {
* Helper to fetch data via curl respecting SOCKS5 proxy if configured.
*/
async function fetchWithProxy(url) {
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: 'utf8' });
const { bin, args } = chanCurl(url, ['--max-time', '30']);
const { stdout } = await queue.spawn(bin, args, { encoding: 'utf8' });
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) {
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
@@ -123,10 +119,12 @@ export default (router, tpl) => {
}
});
const rehosts = {};
const rehostPaths = {};
if (opUrls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`;
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`;
rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
@@ -135,6 +133,7 @@ export default (router, tpl) => {
}
threads.forEach(t => {
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
t.local_path = t.local_id ? rehostPaths[t.local_id] : null;
});
const data = {
@@ -219,6 +218,7 @@ export default (router, tpl) => {
is_video: isVideo,
is_image: isImage,
local_id: localId,
local_path: rehostInfo ? rehostInfo.path : null,
rehosted: !!localId,
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
@@ -255,11 +255,13 @@ export default (router, tpl) => {
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp };
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id;
rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
@@ -363,11 +365,13 @@ export default (router, tpl) => {
});
const rehosts = {};
const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp };
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id;
rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
@@ -412,7 +416,7 @@ export default (router, tpl) => {
localThumb = `/t/${li.id}.webp`;
}
if (req.session) {
canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) ||
canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) ||
req.session.admin || req.session.is_moderator);
}
}
@@ -456,9 +460,11 @@ export default (router, tpl) => {
external_board: board,
external_tid: tid,
external_id: targetPost.no,
external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`,
external_media_url: externalMediaUrl,
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
local_id: localId,
local_path: localId ? (rehostPaths[localId] || localId) : null,
rehosted: !!localId,
is_sfw: itemRating === 'sfw',
is_nsfw: itemRating === 'nsfw',
@@ -510,6 +516,7 @@ export default (router, tpl) => {
is_active: p.no === targetPost.no,
index: idx + 1,
local_id: pLocalId,
local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null,
rehosted: !!pLocalId,
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
width: p.w || null,
@@ -542,6 +549,7 @@ export default (router, tpl) => {
user_alternative_steuerung: req.session?.user_alternative_steuerung,
user_alternative_infobox: req.session?.user_alternative_infobox,
can_manage_item: canManage,
can_rate_item: canManage,
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
user_has_favorited: userHasFavorited,
isSubscribed: false,
@@ -583,13 +591,7 @@ export default (router, tpl) => {
}
// Full page render: if Onara is active, render thread page with onara modal open
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
const isOnara = isOnaraEnabledFor(req);
if (isOnara) {
const isModern = req.session?.use_new_layout;
@@ -677,10 +679,12 @@ export default (router, tpl) => {
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehosts = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
@@ -705,6 +709,7 @@ export default (router, tpl) => {
is_image: !isVideo,
index: idx + 1,
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null,
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
};
});
+33 -43
View File
@@ -2,9 +2,17 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { promises as fs } from "fs";
import path from "path";
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = async (id, slug) => {
if (!getEnableItemSlugs()) return id;
if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug;
return slug || id;
};
import { applyWordFilter } from "../wordfilter.mjs";
/**
@@ -45,18 +53,8 @@ export default (router) => {
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
*/
async function fetchWithProxy(url, asBuffer = false) {
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
const { bin, args } = chanCurl(url, ['--max-time', '30']);
const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' });
if (asBuffer) return stdout;
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) {
@@ -86,6 +84,7 @@ export default (router) => {
// Check which media URLs are already rehosted on this platform
const rehosts = {};
const rehostPaths = {};
const mediaPosts = posts.filter(p => p.tim && p.ext);
const cdn4Urls = [];
mediaPosts.forEach(p => {
@@ -98,7 +97,8 @@ export default (router) => {
});
if (cdn4Urls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`;
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`;
for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug);
rows.forEach(r => {
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
@@ -111,7 +111,7 @@ export default (router) => {
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths })
});
} catch (err) {
@@ -295,19 +295,10 @@ export default (router) => {
};
const contentType = mimes[ext] || 'application/octet-stream';
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '60',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']);
const { spawn } = await import('child_process');
const curl = spawn('curl', curlArgs);
const curl = spawn(curlBin, curlArgs);
res.writeHead(200, {
'Content-Type': contentType,
@@ -355,25 +346,22 @@ export default (router) => {
: null;
const session = req.session;
// Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account
const ownerName = getSessionOwnerName(session);
try {
const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
// Download via curl (lightweight)
const curlArgs = [
'-s', '-f', '-L', url, '-o', tmpPath,
const { bin: curlBin, args: curlArgs } = chanCurl(url, [
'-o', tmpPath,
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
'--connect-timeout', '30',
'--max-time', '300',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
'--max-time', '300'
]);
await queue.spawn('curl', curlArgs);
await queue.spawn(curlBin, curlArgs);
// Detect MIME
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
@@ -402,7 +390,7 @@ export default (router) => {
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' })
});
}
}
@@ -424,7 +412,7 @@ export default (router) => {
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' })
});
}
}
@@ -469,7 +457,7 @@ export default (router) => {
size: (await fs.stat(path.join(destDir, filename))).size,
checksum: insertChecksum,
phash: phash,
username: session.user,
username: ownerName,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
@@ -542,7 +530,7 @@ export default (router) => {
id: itemid,
dest: filename,
mime: mime,
username: session.user,
username: ownerName,
display_name: session.display_name || null,
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: false,
@@ -575,7 +563,7 @@ export default (router) => {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, item_id: itemid })
body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) })
});
} catch (err) {
@@ -630,9 +618,10 @@ export default (router) => {
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
}
const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase());
const ownerName = getSessionOwnerName(session);
const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(session.admin || session.is_moderator);
const isChanUser = !!(session.admin || (Array.isArray(session.groups) && session.groups.includes('4chan')));
const isChanUser = canUseChan(session);
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
(comment && typeof comment === 'string' && comment.trim().length > 0);
@@ -722,6 +711,7 @@ export default (router) => {
body: JSON.stringify({
success: true,
item_id: itemId,
item_path: await itemPath(itemId),
rating: ratingTag ? ratingTag.normalized : 'untagged',
tags: cleanTagObjects
})
+802 -802
View File
File diff suppressed because it is too large Load Diff
+210 -135
View File
@@ -16,12 +16,16 @@ export default (router, tpl) => {
// Moderator Dashboard
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c;
const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c;
res.reply({
body: tpl.render("mod", {
session: req.session,
pendingCount: parseInt(pendingCount),
trashCount: parseInt(trashCount),
reportsCount: parseInt(reportsCount) || 0,
manualApproval: getManualApproval(),
tmp: null
}, req)
@@ -39,11 +43,31 @@ export default (router, tpl) => {
});
// Approval Queue (View only — GET is safe, no state change)
// Tag badge classes for queue cards (shared by the approval queue and soft-deleted views)
const processQueueItems = (items) => items.map(p => ({
...p,
tags: (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
})
}));
const QUEUE_PAGE_SIZE = 20;
const queuePage = (req) => Math.max(1, +req.url.qs.page || 1);
// Approval queue: uploads waiting for approval (not deleted)
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
// View Queue
const page = +req.url.qs.page || 1;
const limit = 20;
// Fetch Pending (not deleted)
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
const pending = await db`
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i
@@ -52,12 +76,27 @@ export default (router, tpl) => {
where i.active = false and i.is_deleted = false
group by i.id
order by i.id desc
limit ${limit} offset ${(page - 1) * limit}
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`;
// Fetch Trash (deleted)
res.reply({
body: tpl.render('mod/approve', {
pending: processQueueItems(pending),
total,
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
session: req.session,
tmp: null
}, req)
});
});
// Soft deleted: removed items that still exist on disk and can be restored or purged
router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`;
const trash = await db`
select i.id, i.mime, i.username, i.dest,
select i.id, i.mime, i.username, i.dest,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
from "items" i
@@ -66,45 +105,121 @@ export default (router, tpl) => {
where i.active = false and i.is_deleted = true and i.is_purged = false
group by i.id
order by i.id desc
limit 20
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`;
const processItems = (items) => {
return items.map(p => {
const tags = (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
});
return {
...p,
tags
};
});
};
res.reply({
body: tpl.render('mod/approve', {
pending: processItems(pending),
trash: processItems(trash),
body: tpl.render('mod/trash', {
trash: processQueueItems(trash),
total,
page,
stats: { total: pending.length + trash.length },
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
session: req.session,
tmp: null
}, req)
});
});
const jsonReply = (res, code, obj) => {
const body = JSON.stringify(obj);
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
// Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders.
const moveItemFilesToPublic = async (item, id) => {
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = item.mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, item.dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (item.mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (item.is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
};
// Permanently remove an item's files and comments and flag it purged (admin action).
const purgeItem = async (item, id) => {
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${id}`;
await db`delete from comments where item_id = ${id}`;
};
const uploaderInfoFor = async (username) => {
try {
const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`;
return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {};
} catch { return {}; }
};
// F-005 Security: Approve action — POST with CSRF protection
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0);
@@ -120,12 +235,12 @@ export default (router, tpl) => {
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
limit 1) as tag_id
from "items" i
where i.id = ${id} and i.active = false
where i.id = ${id} and i.active = false and i.is_deleted = false
limit 1
`;
if (f0ck.length === 0) {
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` });
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -230,72 +345,7 @@ export default (router, tpl) => {
}
}
// Move files to public location
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = f0ck[0].mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (f0ck[0].mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (f0ck[0].is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
await moveItemFilesToPublic(f0ck[0], id);
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
@@ -320,26 +370,11 @@ export default (router, tpl) => {
if (item.is_deleted) {
// PURGE LOGIC (Strict Admin)
if (!req.session.admin) {
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." });
return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
// Delete files — respect symlink ownership
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
// DB Flag instead of delete
await db`update "items" set is_purged = true where id = ${id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${id}`;
await purgeItem(item, id);
// Fetch uploader details for audit log
let uploaderInfo = {};
@@ -701,26 +736,66 @@ export default (router, tpl) => {
}
});
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const rows = await db`
select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc,
(select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id
from "items" i
where i.id = ${id} and i.is_deleted = true and i.is_purged = false
limit 1
`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
await moveItemFilesToPublic(item, id);
await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) });
// Live grid update so the item reappears for open tabs
if ((item.visibility || 0) === 0) {
db`SELECT pg_notify('new_item', ${JSON.stringify({
id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id,
is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0
})})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err));
}
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' });
});
// Permanently purge one soft-deleted item (admins only)
router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const reason = (req.post?.reason || '').toString().trim();
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
await purgeItem(item, id);
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) });
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' });
});
// Purge Trash (POST) - Strict Admin
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => {
try {
// lib.auth already ensures session.admin
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
let count = 0;
for (const item of trash) {
try {
await safeDeleteMediaFile(item.dest, item.id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${item.id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${item.id}`;
await purgeItem(item, item.id);
count++;
} catch (e) { }
}
+4 -1
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
import lib from "../lib.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import { isAnonymizeSession, canAnonDo, getSessionOwnerName } from "../settings.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import fs from "fs/promises";
import path from "path";
@@ -174,6 +174,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.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])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+32
View File
@@ -47,6 +47,7 @@ export const DEFAULT_ANON_PERMISSIONS = Object.freeze({
rate_item: false,
filter: true,
exclude_tags: true,
chan: false, // 4chan viewer & rehost ("4chan mode")
anonymize_users: false,
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
@@ -124,6 +125,37 @@ export const isAnonSession = (session) => {
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
};
// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value).
// config `onara: false` disables it for everyone; `onara: true` is only the default for users
// without a stored preference. `forceOn` covers explicit requests like ?onara=1.
export const isOnaraEnabledFor = (req, forceOn = false) => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
if (c === false) return false;
if (forceOn) return true;
const ck = req?.cookies || {};
const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara;
if (raw !== undefined) return raw === '1' || raw === 'true';
if (req?.session && req.session.onara !== undefined && req.session.onara !== null) return !!req.session.onara;
return c === true;
};
// The name items.username holds for this session's uploads. Anonymous sessions have
// user = 'anonymous' (shared by all anon users); their real account is the shadow login (anon_xxxx).
export const getSessionOwnerName = (session) => {
if (!session || typeof session !== 'object') return null;
if (session.is_anon) return session.anon_login || session.login || null;
return session.user || null;
};
// 4chan viewer & rehost access: admins, members of the '4chan' group, and anonymous
// users when anonymous_permissions.chan is enabled
export const canUseChan = (session) => {
if (!session || typeof session !== 'object') return false;
if (session.admin) return true;
if (Array.isArray(session.groups) && session.groups.includes('4chan')) return true;
return !!(session.is_anon && canAnonDo('chan'));
};
export const checkAnonPermission = (session, action) => {
if (!isAnonSession(session)) return true;
return canAnonDo(action);