This commit is contained in:
2026-09-20 21:43:02 +02:00
parent 5601c282b7
commit 4e857fd5ef
26 changed files with 3567 additions and 142 deletions
+20
View File
@@ -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'];
+41 -2
View File
@@ -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
View File
@@ -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;
+19 -1
View File
@@ -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);
}
+626
View File
@@ -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*\/?>)((?:&gt;|>)((?!&gt;|>)[^\n<]+))/g, '$1<span class="chan-quote">&gt;$2</span>');
// Format post cross-references >>123456
formatted = formatted.replace(/(?:&gt;|>)&gt;(\d+)/g, '<a href="#p$1" class="chan-ref" data-post="$1">&gt;&gt;$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
View File
@@ -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;
};
+8 -3
View File
@@ -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) => {
+6 -1
View File
@@ -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
View File
@@ -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,