This commit is contained in:
2026-09-21 00:02:48 +02:00
parent 4e857fd5ef
commit 990f463534
17 changed files with 690 additions and 168 deletions
+1 -1
View File
@@ -1476,7 +1476,7 @@ const f0cklib = {
]);
console.log(`[GETF0CK_OPT] All queries finished in ${Date.now() - runTimings}ms`);
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: strict });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
+26 -11
View File
@@ -170,13 +170,19 @@ export default (router, tpl) => {
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehostMap = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
const rows = await db`SELECT id, src, 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);
@@ -199,7 +205,10 @@ export default (router, tpl) => {
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;
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,
@@ -208,9 +217,10 @@ export default (router, tpl) => {
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(),
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,
@@ -240,6 +250,7 @@ export default (router, tpl) => {
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: {
@@ -306,7 +317,7 @@ export default (router, tpl) => {
const rehosts = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
@@ -333,13 +344,15 @@ export default (router, tpl) => {
let localDest = null;
let localThumb = null;
let localTitle = null;
let localStamp = 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`;
const localItem = await db`SELECT id, username, dest, title, mime, width, height, stamp FROM items WHERE id = ${localId} LIMIT 1`;
if (localItem.length > 0) {
const li = localItem[0];
localStamp = li.stamp;
if (li.title) localTitle = li.title;
if (li.dest) {
localDest = `/b/${li.dest}`;
@@ -368,6 +381,7 @@ export default (router, tpl) => {
}
// Construct item matching f0ckm's standard item view expectations
const effectiveStamp = (localId && localStamp) ? Number(localStamp) : targetPost.time;
const item = {
id: targetPost.no,
slug: null,
@@ -379,10 +393,10 @@ export default (router, tpl) => {
height: targetPost.h || null,
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
username: targetPost.name || 'Anonymous',
stamp: targetPost.time,
stamp: effectiveStamp,
timestamp: {
timeago: lib.timeAgo(targetPost.time * 1000, req.lang || 'en'),
timefull: new Date(targetPost.time * 1000).toISOString()
timeago: lib.timeAgo(effectiveStamp * 1000, req.lang || 'en'),
timefull: new Date(effectiveStamp * 1000).toISOString()
},
comment: targetPost.com ? targetPost.com.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '') : '',
is_chan: true,
@@ -466,6 +480,7 @@ export default (router, tpl) => {
link,
chan_thread: chanThreadMeta,
session: req.session ? { ...req.session } : false,
csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
hidePagination: true,
+36 -19
View File
@@ -618,7 +618,7 @@ export default (router) => {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
const { item_id, rating, tags, comment } = req.post || {};
const { item_id, rating, tags, comment } = req.post || req.body || {};
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' }) });
@@ -636,11 +636,12 @@ export default (router) => {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
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);
// 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
@@ -658,22 +659,30 @@ export default (router) => {
// 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`;
// 2. Sync Tags if provided
if (tags !== undefined && tags !== null) {
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()));
// Delete existing non-rating tags for this item by this user (or all non-rating tags if owner)
await db`
DELETE FROM tags_assign
WHERE item_id = ${itemId}
AND tag_id NOT IN (1, 2, ${nsflId})
AND tag_id NOT IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))
${isOwner ? db`` : db`AND user_id = ${session.id}`}
`;
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 (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
@@ -689,7 +698,15 @@ export default (router) => {
}
const freshTags = await lib.getTags(itemId, session);
await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags })).catch(() => {});
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
const cleanTagObjects = freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => ({
id: t.id,
tag: t.tag,
normalized: t.normalized,
badge: t.badge
}));
return res.reply({
headers: { 'Content-Type': 'application/json' },
@@ -697,7 +714,7 @@ export default (router) => {
success: true,
item_id: itemId,
rating: ratingTag ? ratingTag.normalized : 'untagged',
tags: freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag)
tags: cleanTagObjects
})
});
} catch (err) {
+7 -5
View File
@@ -665,17 +665,19 @@ export default (router, tpl) => {
// Used when the user receives a live notification while already viewing that item.
// System-type notifications (item_deleted, deny, report, admin_pending) are excluded —
// they require explicit user acknowledgment.
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemId;
router.post(/\/api\/notifications\/item\/(?<itemId>[a-zA-Z0-9_-]{11}|\d+)\/read/, async (req, res) => {
if (!req.session?.id) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const rawItemId = req.params.itemId;
const numericId = await f0cklib.resolveNumericItemId(rawItemId);
if (!numericId) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
console.log(`[NotificationRoute] Marking notifications for item ${numericId} (${rawItemId}) as read for user ${req.session.id}`);
try {
await db`
UPDATE notifications
SET is_read = true
WHERE user_id = ${req.session.id}
AND item_id = ${+itemId}
AND item_id = ${numericId}
AND NOT (type = ANY(${SYSTEM_TYPES}))
`;
return res.reply({
+1 -1
View File
@@ -144,7 +144,7 @@ export default (router, tpl) => {
});
// Main tags page
router.get(/^\/tags$/, async (req, res) => {
router.get(/^\/tags?\/?$/, async (req, res) => {
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
const mode = req.mode ?? 0;
const query = req.url.qs || {};