This commit is contained in:
2026-09-26 15:23:36 +02:00
parent 990f463534
commit 27f5e3563f
18 changed files with 1226 additions and 559 deletions
+4 -4
View File
@@ -89,7 +89,7 @@ export const handleBrandImageUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400);
}
const body = await collectBody(req, 5 * 1024 * 1024); // 5 MB hard cap
const body = await collectBody(req, 10 * 1024 * 1024); // 10 MB request body cap
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
@@ -97,12 +97,12 @@ export const handleBrandImageUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// 2 MB soft cap
const maxSize = 2 * 1024 * 1024;
// 5 MB cap
const maxSize = 5 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum is 2 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
msg: `File too large. Maximum is 5 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
}, 400);
}
+2
View File
@@ -3745,6 +3745,7 @@ const f0cklib = {
},
resolveNumericItemId,
computeBaseMode,
getGlobalfilter,
processMentions,
@@ -3756,6 +3757,7 @@ const f0cklib = {
clearCountCache: () => countCache.clear()
};
export { resolveNumericItemId };
export default f0cklib;
+40 -25
View File
@@ -1904,32 +1904,47 @@ export default router => {
`;
let favorited = false;
if (existing.length > 0) {
// del fav
await db`
delete from "favorites"
where user_id = ${+req.session.id}
and item_id = ${+postid}
`;
favorited = false;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, { action: 'unfavorite', targetId: postid });
}
const reqAction = req.post?.action ?? req.body?.action;
const reqFavorited = req.post?.favorited ?? req.body?.favorited;
let shouldFav;
if (reqAction === 'add' || reqAction === 'favorite' || reqFavorited === true || reqFavorited === 'true') {
shouldFav = true;
} else if (reqAction === 'delete' || reqAction === 'remove' || reqAction === 'unfavorite' || reqFavorited === false || reqFavorited === 'false') {
shouldFav = false;
} else {
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
await db`
insert into "favorites" ${db({
item_id: +postid,
user_id: +req.session.id
}, 'item_id', 'user_id')}
on conflict do nothing
`;
favorited = true;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, { action: 'favorite', targetId: postid });
shouldFav = existing.length === 0;
}
if (!shouldFav) {
if (existing.length > 0) {
// del fav
await db`
delete from "favorites"
where user_id = ${+req.session.id}
and item_id = ${+postid}
`;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, { action: 'unfavorite', targetId: postid });
}
}
favorited = false;
} else {
if (existing.length === 0) {
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
await db`
insert into "favorites" ${db({
item_id: +postid,
user_id: +req.session.id
}, 'item_id', 'user_id')}
on conflict do nothing
`;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, { action: 'favorite', targetId: postid });
}
}
favorited = true;
}
const favs = await db`
@@ -1970,7 +1985,7 @@ export default router => {
// If the caller is in anonymous mode, strip other users' identities
const isCallerAnonymized = isAnonymizeSession(req.session);
const clientFavs = isCallerAnonymized
? sanitizedFavs.map(f => {
? favs.map(f => {
const isSelf = req.session && req.session.id && f.user_id && Number(f.user_id) === Number(req.session.id);
if (isSelf) return f;
return {
+92 -40
View File
@@ -144,6 +144,48 @@ export default (router, tpl) => {
}
});
function formatPosts(posts, board, rehostMap, userFavSet, targetPostNo = null, lang = 'en') {
return 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 rehostInfo = (p.tim && rehostMap[p.tim]) || (externalMediaUrl && rehostMap[externalMediaUrl]) || null;
const localId = rehostInfo ? rehostInfo.id : null;
const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
const isoStr = new Date(effectiveTime * 1000).toISOString();
return {
no: p.no,
sub: p.sub || '',
com_raw: p.com || '',
com_formatted: formatComment(p.com || ''),
name: p.name || 'Anonymous',
trip: p.trip || '',
time: effectiveTime,
timeago: lib.timeAgo(effectiveTime * 1000, lang),
date_str: (localId && rehostInfo?.stamp) ? new Date(effectiveTime * 1000).toLocaleString() : (p.now || new Date(effectiveTime * 1000).toLocaleString()),
iso_str: isoStr,
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,
is_onara_active: targetPostNo ? p.no === targetPostNo : false
};
});
}
// ───────────────────────────────────────────────────────────────────────────
// 2. GET /4/:board/:thread — Overview of all posts in that thread
// ───────────────────────────────────────────────────────────────────────────
@@ -200,44 +242,7 @@ export default (router, tpl) => {
}
// 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 rehostInfo = (p.tim && rehostMap[p.tim]) || (externalMediaUrl && rehostMap[externalMediaUrl]) || null;
const localId = rehostInfo ? rehostInfo.id : null;
const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
const isoStr = new Date(effectiveTime * 1000).toISOString();
return {
no: p.no,
sub: p.sub || '',
com_raw: p.com || '',
com_formatted: formatComment(p.com || ''),
name: p.name || 'Anonymous',
trip: p.trip || '',
time: effectiveTime,
timeago: lib.timeAgo(effectiveTime * 1000, req.lang || 'en'),
date_str: (localId && rehostInfo?.stamp) ? new Date(effectiveTime * 1000).toLocaleString() : (p.now || new Date(effectiveTime * 1000).toLocaleString()),
iso_str: isoStr,
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 formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, null, req.lang || 'en');
const firstMediaPost = formattedPosts.find(p => p.has_media);
@@ -315,13 +320,19 @@ export default (router, tpl) => {
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehosts = {};
const rehostMap = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp };
rehosts[r.src] = r.id;
rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
if (m) {
rehosts[m[1]] = r.id;
rehostMap[m[1]] = info;
}
});
} catch (e) {
console.error('[CHAN] Rehost check error:', e.message);
@@ -529,7 +540,48 @@ export default (router, tpl) => {
});
}
// Standard full page render
// Full page render: if Onara is active, render thread page with onara modal open
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
if (isOnara) {
const isModern = req.session?.use_new_layout;
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
const itemHtml = tpl.render(partialTpl, data, req);
const formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, targetPost.no, req.lang || 'en');
const firstMediaPost = formattedPosts.find(p => p.has_media);
const threadViewData = {
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,
csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
page_meta: data.page_meta,
is_onara_item: true,
onara: true,
onara_item_html: itemHtml,
item: data.item
};
return res.reply({
body: tpl.render('chan/thread', threadViewData, req)
});
}
// Standard full page render (when Onara is disabled)
return res.reply({
body: tpl.render('item', data, req)
});
+10 -1
View File
@@ -632,7 +632,16 @@ export default (router) => {
const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase());
const isAdmin = !!(session.admin || session.is_moderator);
if (!isOwner && !isAdmin) {
const isChanUser = !!(session.admin || (Array.isArray(session.groups) && session.groups.includes('4chan')));
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
(comment && typeof comment === 'string' && comment.trim().length > 0);
if (hasTagsOrComment && !isOwner && !isAdmin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Only owner can edit tags and comment' }) });
}
if (!isOwner && !isAdmin && !isChanUser) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}