bvcx
This commit is contained in:
@@ -544,6 +544,26 @@ export default new class {
|
||||
return next();
|
||||
};
|
||||
|
||||
async chanAuth(req, res, next) {
|
||||
if (!req.session || !req.session.user) {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: "Unauthorized" }), type: 'application/json' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'));
|
||||
if (!hasGroup) {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
|
||||
}
|
||||
return res.reply({
|
||||
code: 403,
|
||||
body: `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access Denied</title><link rel="stylesheet" href="/s/css/f0ck.css"></head><body style="background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif;"><div style="text-align:center;padding:30px;background:#1a1a1a;border-radius:12px;border:1px solid #333;max-width:460px;"><div style="font-size:3rem;color:#4ade80;margin-bottom:15px;">🍀</div><h2 style="margin:0 0 10px;">Access Restricted</h2><p style="color:#aaa;line-height:1.5;">This 4chan viewer is only accessible to users with the <strong>4chan</strong> group.</p><a href="/" style="display:inline-block;margin-top:15px;color:#4ade80;text-decoration:none;">← Return to Home</a></div></body></html>`
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
// Middleware: authenticate via X-Api-Key header (upload-only)
|
||||
async apiKeyAuth(req, res, next) {
|
||||
const key = req.headers['x-api-key'];
|
||||
|
||||
@@ -1165,7 +1165,7 @@ export default (router, tpl) => {
|
||||
WITH ghost_users AS (
|
||||
SELECT
|
||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, ARRAY[]::text[] as groups, true as activated,
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||
FROM items i
|
||||
WHERE i.username IS NOT NULL AND i.username != ''
|
||||
@@ -1240,7 +1240,7 @@ export default (router, tpl) => {
|
||||
users = await db`
|
||||
WITH filtered_users AS (
|
||||
SELECT
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.groups, u.activated,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||
FROM "user" u
|
||||
@@ -1446,6 +1446,45 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/set-groups\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, groups } = req.post;
|
||||
if (!user_id) throw new Error('Missing user_id');
|
||||
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||
if (!target.length) throw new Error('User not found.');
|
||||
|
||||
let groupsArr = [];
|
||||
if (Array.isArray(groups)) {
|
||||
groupsArr = groups.map(s => String(s).trim().toLowerCase()).filter(Boolean);
|
||||
} else if (typeof groups === 'string') {
|
||||
groupsArr = groups.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
await db`
|
||||
UPDATE "user"
|
||||
SET groups = ${groupsArr}
|
||||
WHERE id = ${+user_id}
|
||||
`;
|
||||
|
||||
// Invalidate target user's session cache and session table so new groups load immediately
|
||||
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
|
||||
|
||||
await audit.log(req.session.id, 'admin_set_groups', 'user', +user_id, {
|
||||
target_login: target[0].login,
|
||||
groups: groupsArr
|
||||
});
|
||||
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: true,
|
||||
groups: groupsArr,
|
||||
msg: `Groups for "${target[0].login}" updated to: ${groupsArr.join(', ') || 'none'}`
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Set groups failed:', err);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
|
||||
+19
-3
@@ -17,6 +17,14 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
@@ -122,7 +130,7 @@ export default (router, tpl) => {
|
||||
|
||||
// xD Score + comments — parallelize subscription + comments fetch
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) {
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const [sub, commentsForScore] = await Promise.all([
|
||||
@@ -296,6 +304,14 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
let contextUrl = `/${req.params.itemid}`;
|
||||
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
|
||||
if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`;
|
||||
@@ -372,8 +388,8 @@ export default (router, tpl) => {
|
||||
// Comments are always loaded async by the client via /api/comments/:id to avoid
|
||||
// blocking the browser's main thread on posts with huge comment payloads.
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
// Mark notifications as read (only when actually viewed, not on prefetch)
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
|
||||
@@ -1866,12 +1866,30 @@ export default router => {
|
||||
|
||||
// Support both numeric item ID and string slug
|
||||
const isNumeric = /^\d+$/.test(String(rawPostid));
|
||||
const itemRow = await db`
|
||||
let itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!itemRow.length) {
|
||||
const chanUrl = req.post?.chan_url ?? req.body?.chan_url ?? req.post?.url ?? req.body?.url;
|
||||
if (chanUrl) {
|
||||
itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE src = ${chanUrl} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
if (!itemRow.length && isNumeric) {
|
||||
itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE (src LIKE ${'%/' + rawPostid + '.%'} OR src LIKE ${'%/' + rawPostid + 's.%'}) AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!itemRow.length) {
|
||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
|
||||
/**
|
||||
* chan.mjs — 4chan thread viewer & catalogue routes
|
||||
* Restricted to users with the '4chan' group (and admins).
|
||||
*/
|
||||
export default (router, tpl) => {
|
||||
|
||||
const COMMON_BOARDS = [
|
||||
{ id: 'wsg', name: 'Worksafe GIF', icon: 'fa-film' },
|
||||
{ id: 'gif', name: 'Adult GIF', icon: 'fa-video' },
|
||||
{ id: 'b', name: 'Random', icon: 'fa-dice' },
|
||||
{ id: 'v', name: 'Video Games', icon: 'fa-gamepad' },
|
||||
{ id: 'vg', name: 'Video Game Generals', icon: 'fa-gamepad' },
|
||||
{ id: 'a', name: 'Anime & Manga', icon: 'fa-tv' },
|
||||
{ id: 'g', name: 'Technology', icon: 'fa-microchip' },
|
||||
{ id: 'pol', name: 'Politically Incorrect', icon: 'fa-globe' },
|
||||
{ id: 'tv', name: 'Television & Film', icon: 'fa-tv' },
|
||||
{ id: 'mu', name: 'Music', icon: 'fa-music' },
|
||||
{ id: 'fit', name: 'Fitness', icon: 'fa-dumbbell' },
|
||||
{ id: 'ck', name: 'Food & Cooking', icon: 'fa-utensils' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 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)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function formatComment(com) {
|
||||
if (!com) return '';
|
||||
// Format quote lines
|
||||
let formatted = com.replace(/(?:^|<br\s*\/?>)((?:>|>)((?!>|>)[^\n<]+))/g, '$1<span class="chan-quote">>$2</span>');
|
||||
// Format post cross-references >>123456
|
||||
formatted = formatted.replace(/(?:>|>)>(\d+)/g, '<a href="#p$1" class="chan-ref" data-post="$1">>>$1</a>');
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 0. GET /4 -> redirect to default board catalogue
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/?$/, lib.chanAuth, (req, res) => res.redirect('/4/wsg/catalogue'));
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. GET /4/:board/catalogue (and alias /4/:board/catalog)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?:catalogue|catalog)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
|
||||
try {
|
||||
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
|
||||
const threads = [];
|
||||
for (const page of pages) {
|
||||
for (const t of (page.threads || [])) {
|
||||
threads.push({
|
||||
no: t.no,
|
||||
sub: t.sub || '',
|
||||
com: t.com ? t.com.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '').slice(0, 180) : '',
|
||||
replies: t.replies || 0,
|
||||
images: t.images || 0,
|
||||
tim: t.tim,
|
||||
ext: t.ext,
|
||||
sticky: !!t.sticky,
|
||||
closed: !!t.closed,
|
||||
time: t.time,
|
||||
thumb: t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const opUrls = [];
|
||||
threads.forEach(t => {
|
||||
if (t.tim && t.ext) {
|
||||
const ext = (t.ext || '').toLowerCase();
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${t.tim}${ext}`);
|
||||
}
|
||||
});
|
||||
const rehosts = {};
|
||||
if (opUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
const data = {
|
||||
board,
|
||||
threads,
|
||||
common_boards: COMMON_BOARDS,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/ - Catalogue`,
|
||||
description: `4chan /${board}/ thread catalogue`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/catalogue`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/catalogue', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to fetch catalogue for /${board}/:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load catalogue for /${board}/. The board may not exist or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. GET /4/:board/:thread — Overview of all posts in that thread
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const data = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = data.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Format posts for template
|
||||
const formattedPosts = posts.map(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null;
|
||||
const localId = (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
|
||||
|
||||
return {
|
||||
no: p.no,
|
||||
sub: p.sub || '',
|
||||
com_raw: p.com || '',
|
||||
com_formatted: formatComment(p.com || ''),
|
||||
name: p.name || 'Anonymous',
|
||||
trip: p.trip || '',
|
||||
time: p.time,
|
||||
timeago: lib.timeAgo(p.time * 1000, req.lang || 'en'),
|
||||
date_str: p.now || new Date(p.time * 1000).toLocaleString(),
|
||||
has_media: !!(p.tim && p.ext),
|
||||
tim: p.tim,
|
||||
ext: ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
fsize: p.fsize ? lib.formatSize(p.fsize) : null,
|
||||
dest: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}` : null,
|
||||
thumb: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg` : null,
|
||||
external_media_url: externalMediaUrl,
|
||||
is_video: isVideo,
|
||||
is_image: isImage,
|
||||
local_id: localId,
|
||||
rehosted: !!localId,
|
||||
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false
|
||||
};
|
||||
});
|
||||
|
||||
const firstMediaPost = formattedPosts.find(p => p.has_media);
|
||||
|
||||
const viewData = {
|
||||
board,
|
||||
tid,
|
||||
op,
|
||||
posts: formattedPosts,
|
||||
media_count: mediaPosts.length,
|
||||
first_media_no: firstMediaPost ? firstMediaPost.no : null,
|
||||
rehosts_count: Object.keys(rehosts).length,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/${tid} - ${op.sub || 'Thread Overview'}`,
|
||||
description: op.sub || (op.com ? op.com.replace(/<[^>]+>/g, '').slice(0, 160) : `4chan /${board}/ thread ${tid}`),
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/thread', viewData, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load thread /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load thread /${board}/${tid}. It may be archived or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. GET /4/:board/:thread/:post — Show media item in normal item view
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/(?<post>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
const postNo = Number(req.params.post);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
if (!mediaPosts.length) {
|
||||
// No media at all in thread, redirect to thread overview
|
||||
return res.redirect(`/4/${board}/${tid}`);
|
||||
}
|
||||
|
||||
// Find target post
|
||||
let targetPost = mediaPosts.find(p => p.no === postNo);
|
||||
if (!targetPost) {
|
||||
// Post has no media or doesn't exist; pick closest or first media post
|
||||
targetPost = mediaPosts[0];
|
||||
}
|
||||
|
||||
// Check rehost status in DB
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaIndex = mediaPosts.findIndex(p => p.no === targetPost.no);
|
||||
const prevMedia = mediaIndex > 0 ? mediaPosts[mediaIndex - 1] : null;
|
||||
const nextMedia = mediaIndex < mediaPosts.length - 1 ? mediaPosts[mediaIndex + 1] : null;
|
||||
|
||||
const ext = (targetPost.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = targetPost.tim ? `https://i.4cdn.org/${board}/${targetPost.tim}${ext}` : null;
|
||||
const localId = (targetPost.tim && rehosts[targetPost.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
|
||||
|
||||
let itemRating = null;
|
||||
let itemTags = [];
|
||||
let canManage = false;
|
||||
let localDest = null;
|
||||
let localThumb = null;
|
||||
let localTitle = null;
|
||||
let itemFavorites = [];
|
||||
let userHasFavorited = false;
|
||||
if (localId) {
|
||||
try {
|
||||
const localItem = await db`SELECT id, username, dest, title, mime, width, height FROM items WHERE id = ${localId} LIMIT 1`;
|
||||
if (localItem.length > 0) {
|
||||
const li = localItem[0];
|
||||
if (li.title) localTitle = li.title;
|
||||
if (li.dest) {
|
||||
localDest = `/b/${li.dest}`;
|
||||
localThumb = `/t/${li.id}.webp`;
|
||||
}
|
||||
if (req.session) {
|
||||
canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) ||
|
||||
req.session.admin || req.session.is_moderator);
|
||||
}
|
||||
}
|
||||
itemTags = await lib.getTags(localId, req.session);
|
||||
const ratingTag = itemTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
if (ratingTag) itemRating = ratingTag.normalized;
|
||||
itemFavorites = await db`
|
||||
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
|
||||
where "favorites".item_id = ${localId}
|
||||
`;
|
||||
userHasFavorited = lib.userHasFavorited(req.session, itemFavorites);
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Failed to fetch tags for rehosted item:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct item matching f0ckm's standard item view expectations
|
||||
const item = {
|
||||
id: targetPost.no,
|
||||
slug: null,
|
||||
title: localTitle || targetPost.sub || op.sub || `/${board}/${tid} #${targetPost.no}`,
|
||||
dest: localDest || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}${ext}`,
|
||||
thumbnail: localThumb || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}s.jpg`,
|
||||
mime: isVideo ? (ext === '.mp4' ? 'video/mp4' : 'video/webm') : (ext === '.gif' ? 'image/gif' : (ext === '.png' ? 'image/png' : 'image/jpeg')),
|
||||
width: targetPost.w || null,
|
||||
height: targetPost.h || null,
|
||||
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
|
||||
username: targetPost.name || 'Anonymous',
|
||||
stamp: targetPost.time,
|
||||
timestamp: {
|
||||
timeago: lib.timeAgo(targetPost.time * 1000, req.lang || 'en'),
|
||||
timefull: new Date(targetPost.time * 1000).toISOString()
|
||||
},
|
||||
comment: targetPost.com ? targetPost.com.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '') : '',
|
||||
is_chan: true,
|
||||
external_board: board,
|
||||
external_tid: tid,
|
||||
external_id: targetPost.no,
|
||||
external_media_url: externalMediaUrl,
|
||||
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
|
||||
local_id: localId,
|
||||
rehosted: !!localId,
|
||||
is_sfw: itemRating === 'sfw',
|
||||
is_nsfw: itemRating === 'nsfw',
|
||||
is_nsfl: itemRating === 'nsfl',
|
||||
is_untagged: !itemRating,
|
||||
favorites: itemFavorites,
|
||||
user_has_favorited: userHasFavorited,
|
||||
halls: [],
|
||||
user_halls: [],
|
||||
tags: itemTags
|
||||
};
|
||||
|
||||
// F0ckm convention: Next post (right arrow / D) corresponds to pagination.prev,
|
||||
// Previous post (left arrow / A) corresponds to pagination.next.
|
||||
const pagination = {
|
||||
prev: nextMedia ? nextMedia.no : null,
|
||||
next: prevMedia ? prevMedia.no : null
|
||||
};
|
||||
|
||||
const link = {
|
||||
main: `/4/${board}/${tid}/`,
|
||||
mainDisplay: `/4/${board}/${tid}/`,
|
||||
suffix: ''
|
||||
};
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const chanMediaList = mediaPosts.map((p, idx) => {
|
||||
const pExt = (p.ext || '').toLowerCase();
|
||||
const pVid = ['.webm', '.mp4'].includes(pExt);
|
||||
const pUrl = `https://i.4cdn.org/${board}/${p.tim}${pExt}`;
|
||||
const pLocalId = (p.tim && rehosts[p.tim]) || (pUrl && rehosts[pUrl]) || null;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext: pExt,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${pExt}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: pVid,
|
||||
is_image: !pVid,
|
||||
is_active: p.no === targetPost.no,
|
||||
index: idx + 1,
|
||||
local_id: pLocalId,
|
||||
rehosted: !!pLocalId,
|
||||
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
|
||||
width: p.w || null,
|
||||
height: p.h || null
|
||||
};
|
||||
});
|
||||
|
||||
const chanThreadMeta = {
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
active_post: targetPost.no,
|
||||
active_index: mediaIndex + 1,
|
||||
media_count: mediaPosts.length,
|
||||
media_posts: chanMediaList
|
||||
};
|
||||
|
||||
const data = {
|
||||
item,
|
||||
pagination,
|
||||
link,
|
||||
chan_thread: chanThreadMeta,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
enable_item_title: true,
|
||||
enable_item_slugs: false,
|
||||
user_alternative_steuerung: req.session?.user_alternative_steuerung,
|
||||
user_alternative_infobox: req.session?.user_alternative_infobox,
|
||||
can_manage_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,
|
||||
item_username_lower: (item.username || '').toLowerCase(),
|
||||
item_rating_class: item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')),
|
||||
item_rating_label: item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')),
|
||||
is_flash_item: false,
|
||||
is_archive_item: false,
|
||||
item_has_dimensions: !!(item.width && item.height),
|
||||
is_mod_or_admin: !!(req.session && (req.session.admin || req.session.is_moderator)),
|
||||
halls_enabled: false,
|
||||
default_layout: cfg.websrv?.default_layout || 'legacy',
|
||||
page_meta: {
|
||||
title: `/${board}/${tid}/${targetPost.no} - ${item.title}`,
|
||||
description: item.comment || `4chan media post #${targetPost.no} in /${board}/${tid}`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}/${targetPost.no}`
|
||||
}
|
||||
};
|
||||
|
||||
// Handle AJAX requests from loadItemAjax
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
|
||||
if (isAjax) {
|
||||
const isModern = req.session?.use_new_layout;
|
||||
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
|
||||
const html = tpl.render(partialTpl, data, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
html,
|
||||
item,
|
||||
pagination,
|
||||
chan_thread: chanThreadMeta
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Standard full page render
|
||||
return res.reply({
|
||||
body: tpl.render('item', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load item /4/${board}/${tid}/${postNo}:`, err.message);
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
if (isAjax) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}: ${err.message}`
|
||||
})
|
||||
});
|
||||
}
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4. GET /api/v2/chan/:board/:thread/media — Media list for thread (sidebar)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/chan\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/media\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
const op = posts[0] || {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const media = mediaPosts.map((p, idx) => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const externalMediaUrl = `https://i.4cdn.org/${board}/${p.tim}${ext}`;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: isVideo,
|
||||
is_image: !isVideo,
|
||||
index: idx + 1,
|
||||
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
|
||||
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
|
||||
};
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
total: media.length,
|
||||
items: media,
|
||||
media
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Media API error for /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to fetch thread media' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
+198
-30
@@ -5,6 +5,7 @@ import queue from "../queue.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||
import { applyWordFilter } from "../wordfilter.mjs";
|
||||
|
||||
/**
|
||||
* external.mjs — External source handlers (4chan threads, etc.)
|
||||
@@ -67,7 +68,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/:tid
|
||||
// Proxies 4chan thread JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, tid } = req.params || {};
|
||||
|
||||
@@ -86,11 +87,23 @@ export default (router) => {
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
const cdn4Urls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
if (cdn4Urls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
|
||||
rows.forEach(r => { rehosts[r.src] = r.id; });
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[EXTERNAL] DB src check error:', e.message);
|
||||
}
|
||||
@@ -159,7 +172,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/catalog
|
||||
// Proxies 4chan board catalog JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board } = req.params || {};
|
||||
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
@@ -197,7 +210,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/find/:postno
|
||||
// Resolves a post number to its parent thread ID
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, postno } = req.params || {};
|
||||
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
@@ -259,7 +272,7 @@ export default (router) => {
|
||||
// F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
|
||||
const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
|
||||
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, file } = req.params || {};
|
||||
|
||||
@@ -318,8 +331,8 @@ export default (router) => {
|
||||
|
||||
// POST /api/v2/scroller/rehost
|
||||
// Downloads an external item and adds it to the platform
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename, width: postWidth, height: postHeight } = req.post || {};
|
||||
|
||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||
|
||||
@@ -335,13 +348,11 @@ export default (router) => {
|
||||
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|
||||
|| null;
|
||||
|
||||
let rating = initialRating;
|
||||
if (board === 'gif') rating = 'nsfw';
|
||||
else if (board === 'wsg') rating = 'sfw';
|
||||
|
||||
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
|
||||
}
|
||||
// Rating is optional: do not force sfw/nsfw based on board
|
||||
const validRatings = ['sfw', 'nsfw', 'nsfl'];
|
||||
const rating = (initialRating && validRatings.includes(String(initialRating).toLowerCase()))
|
||||
? String(initialRating).toLowerCase()
|
||||
: null;
|
||||
|
||||
const session = req.session;
|
||||
|
||||
@@ -418,6 +429,29 @@ export default (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
let itemWidth = Number(postWidth) || null;
|
||||
let itemHeight = Number(postHeight) || null;
|
||||
if (!itemWidth || !itemHeight) {
|
||||
try {
|
||||
if (mime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', [
|
||||
'identify', '-format', '%wx%h\n', finalTmp + '[0]'
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const m = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (m) { itemWidth = parseInt(m[1], 10); itemHeight = parseInt(m[2], 10); }
|
||||
} else if (mime.startsWith('video/')) {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', [
|
||||
'-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', finalTmp
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const parts = probeOut.trim().split(',');
|
||||
if (parts.length >= 2) {
|
||||
const w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
||||
if (w > 0 && h > 0) { itemWidth = w; itemHeight = h; }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const isApprovalRequired = getManualApproval();
|
||||
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
@@ -442,8 +476,10 @@ export default (router) => {
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'width', 'height', 'slug')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -462,16 +498,14 @@ export default (router) => {
|
||||
console.error('[REHOST] Thumbnail error:', err);
|
||||
}
|
||||
|
||||
// Tags
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||
// Tags: Only assign rating tag if explicitly specified; do NOT auto-tag board or sfw/nsfw
|
||||
if (rating) {
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||
}
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
// Board tag in chan-style format e.g. /gif/, /wsg/
|
||||
if (board) tags.push(`/${board}/`);
|
||||
// Auto-tag rating based on board
|
||||
if (board === 'wsg') tags.push('sfw');
|
||||
else if (board === 'gif') tags.push('nsfw');
|
||||
const rawList = Array.isArray(tagsRaw) ? tagsRaw : (typeof tagsRaw === 'string' ? tagsRaw.split(',') : []);
|
||||
const tags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
for (const tagName of tags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
@@ -483,9 +517,21 @@ export default (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Insert optional first comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
try {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[REHOST] Comment insert error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
||||
|
||||
@@ -498,7 +544,7 @@ export default (router) => {
|
||||
mime: mime,
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: false,
|
||||
is_album: false,
|
||||
album_count: 0
|
||||
@@ -542,5 +588,127 @@ export default (router) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v2/scroller/rehost/details/:id
|
||||
// Retrieve current rating and tags for a rehosted item
|
||||
router.get(/^\/api\/v2\/scroller\/rehost\/details\/(?<id>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const itemId = Number(req.params.id);
|
||||
try {
|
||||
const tags = await lib.getTags(itemId, req.session);
|
||||
const ratingTag = tags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const rating = ratingTag ? ratingTag.normalized : 'untagged';
|
||||
const userTags = tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, item_id: itemId, rating, tags: userTags })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: err.message })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/scroller/rehost/details
|
||||
// Set rating, add tags, and or post a comment directly on a rehosted item
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/details\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const session = req.session;
|
||||
if (!session || !session.user) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
const { item_id, rating, tags, comment } = req.post || {};
|
||||
const itemId = parseInt(item_id, 10);
|
||||
if (!itemId || isNaN(itemId)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db`SELECT id, username FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
|
||||
if (!rows.length) {
|
||||
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 isAdmin = !!(session.admin || session.is_moderator);
|
||||
if (!isOwner && !isAdmin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
// 1. Update Rating if provided
|
||||
if (rating !== undefined && rating !== null && rating !== '') {
|
||||
const r = String(rating).toLowerCase().trim();
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
|
||||
await db`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${itemId}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
`;
|
||||
|
||||
if (r === 'sfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 1, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 2, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfl') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: nsflId, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
// If r === 'untagged', no rating tag is inserted
|
||||
}
|
||||
|
||||
// 2. Add Tags if provided
|
||||
const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
|
||||
const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
for (const tagName of cleanTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
if (tagRow.length) {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
}
|
||||
if (cleanTags.length > 0) {
|
||||
const freshTags = await lib.getTags(itemId, session);
|
||||
await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags }));
|
||||
}
|
||||
|
||||
// 3. Add Comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemId,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(itemId, session);
|
||||
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
item_id: itemId,
|
||||
rating: ratingTag ? ratingTag.normalized : 'untagged',
|
||||
tags: freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag)
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[REHOST-DETAILS] Error:', err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to save details' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -371,8 +371,13 @@ export default (router, tpl) => {
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
const isPrefetch = !!(
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
// Mark notifications as read (only when actually viewed, not on prefetch)
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
}
|
||||
// Subscription status — just a boolean, cheap to embed
|
||||
@@ -652,7 +657,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
router.get(/^(?!\/(s|b|t|ca|a|4|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
/* </routing-refactor> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
|
||||
@@ -235,7 +235,12 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
const isPrefetch = !!(
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
if (req.session?.id && !isPrefetch) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
|
||||
// xD Score
|
||||
|
||||
+16
-2
@@ -604,6 +604,7 @@ process.on('uncaughtException', err => {
|
||||
const runMigration = async (query) => {
|
||||
try { await query; } catch (e) { /* ignore if table doesn't exist yet */ }
|
||||
};
|
||||
await runMigration(db`ALTER TABLE "user" ADD COLUMN IF NOT EXISTS groups text[] DEFAULT '{}'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`);
|
||||
@@ -989,7 +990,7 @@ process.on('uncaughtException', err => {
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
@@ -1024,6 +1025,9 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
}
|
||||
// csrf_token is loaded from user_sessions table via the session query above
|
||||
if (req.session) {
|
||||
req.session.can_chan = !!(req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')));
|
||||
}
|
||||
|
||||
// Ban check (Session)
|
||||
if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
|
||||
@@ -1114,7 +1118,7 @@ process.on('uncaughtException', err => {
|
||||
// Validate that the original session matches the current admin cookie
|
||||
if (impData.orig && impData.orig === lib.sha256(req.cookies.session)) {
|
||||
const targetRow = await db`
|
||||
SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change,
|
||||
SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change,
|
||||
"user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file,
|
||||
"user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color,
|
||||
"user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge,
|
||||
@@ -1956,6 +1960,7 @@ process.on('uncaughtException', err => {
|
||||
return (c !== undefined && c !== null) ? !!c : null;
|
||||
},
|
||||
is_onara_item: false,
|
||||
can_extract_meta: false,
|
||||
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||
get enable_item_slugs() { return getEnableItemSlugs(); },
|
||||
@@ -2115,6 +2120,13 @@ process.on('uncaughtException', err => {
|
||||
const activeSession = activeReq?.session || data?.session || null;
|
||||
const isAnonymized = isAnonymizeSession(activeSession);
|
||||
const anonAnonymize = getAnonAnonymize();
|
||||
const canChan = !!(activeSession && (activeSession.admin || (Array.isArray(activeSession.groups) && activeSession.groups.includes('4chan'))));
|
||||
if (activeSession && typeof activeSession === 'object') {
|
||||
activeSession.can_chan = canChan;
|
||||
}
|
||||
if (data && data.item && typeof data.item.size === 'number') {
|
||||
data.item.size = lib.formatSize(data.item.size);
|
||||
}
|
||||
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
@@ -2122,6 +2134,8 @@ process.on('uncaughtException', err => {
|
||||
recaptcha_enabled: perRequestRecaptcha,
|
||||
is_anonymized: isAnonymized,
|
||||
anon_anonymize: anonAnonymize,
|
||||
can_chan: canChan,
|
||||
item_has_dimensions: !!(data && data.item && data.item.width && data.item.height),
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
user_alternative_steuerung: useAltSteuerung,
|
||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||
|
||||
Reference in New Issue
Block a user