esifawafwu
This commit is contained in:
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+210
-135
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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(',') : '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user