init f0ckm

This commit is contained in:
2026-04-25 19:51:52 +02:00
commit b646107eb7
241 changed files with 70364 additions and 0 deletions

1142
src/inc/routes/admin.mjs Normal file

File diff suppressed because it is too large Load Diff

272
src/inc/routes/ajax.mjs Normal file
View File

@@ -0,0 +1,272 @@
import f0cklib from "../routeinc/f0cklib.mjs";
import url from "url";
import cfg from "../config.mjs";
export default (router, tpl) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
// flummpress uses req.url.qs for query string parameters
query = req.url.qs || {};
}
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}`;
if (query.userHall && query.userHallOwner) {
contextUrl = `/user/${encodeURIComponent(query.userHallOwner)}/hall/${encodeURIComponent(query.userHall)}/${req.params.itemid}`;
} else if (query.user) {
contextUrl = query.fav === 'true'
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
}
if (query.mime) {
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
}
console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode,
session: !!req.session,
url: contextUrl,
user: query.user,
tag: query.tag,
hall: query.hall,
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id
});
const tAjaxFetch = Date.now();
if (!data.success) {
const errorHtml = tpl.render('error-partial', {
message: data.message || '404 - Not f0cked',
tmp: null
}, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: errorHtml, pagination: '', error: true })
});
}
// Load xD score server-side (needed for badge), but do NOT embed comments into the response.
// 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) {
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
}
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
data.isSubscribed = sub;
// xD Score for item view badge
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
const xdScore = f0cklib.computeXdScore(commentsForScore);
const xdMeta = f0cklib.xdScoreMeta(xdScore);
data.item.xd_score = xdScore;
data.item.xd_tier = xdMeta.tier;
data.item.xd_label = xdMeta.label;
// Do NOT set commentsJSON — client will fetch async
data.commentsJSON = null;
data.comments = [];
} else {
data.isSubscribed = false;
data.commentsJSON = null;
data.comments = [];
data.item.xd_score = 0;
data.item.xd_tier = 0;
data.item.xd_label = '';
}
const tAjaxAux = Date.now();
// Inject session into data for the template
if (req.session) {
data.session = { ...req.session };
} else {
data.session = false;
}
// Inject missing variables normally provided by req or middleware
data.url = { pathname: contextUrl }; // Template expects url.pathname
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
data.hidePagination = true;
// Precompute hall display data for the template
if (data.item && data.item.halls && data.item.halls.length) {
const currentHallSlug = data.tmp && data.tmp.hall
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
: null;
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
} else if (data.item) {
data.item.primaryHall = null;
data.item.otherHalls = [];
}
// Precompute boolean helpers for template @if() — flummpress uses non-greedy regex
// that stops at the first ')' inside @if(...), so any method call with parens
// (indexOf, .some, etc.) must be precomputed here as plain booleans.
if (data.item) {
const session = data.session;
const item = data.item;
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
}
// Render both the item content and the pagination
const itemHtml = tpl.render('ajax-item', data, req);
const paginationHtml = tpl.render('snippets/pagination', data, req);
const tAjaxRender = Date.now();
console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
- getf0ck: ${tAjaxFetch - tAjaxStart}ms
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
- Render: ${tAjaxRender - tAjaxAux}ms`);
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
html: itemHtml,
pagination: paginationHtml,
title: data.title,
id: itemid
})
});
});
router.get('/ajax/halls', async (req, res) => {
try {
const halls = await f0cklib.getHalls();
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(halls)
});
} catch (err) {
res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: true, msg: 'Failed to fetch halls' })
});
}
});
// Infinite scroll endpoint for index thumbnails
router.get(/\/ajax\/items/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const data = await f0cklib.getf0cks({
page: page,
tag: query.tag || null,
hall: query.hall || null,
user: query.user || null,
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
fav: query.fav === 'true',
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0
});
if (!data.success) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
html: '',
hasMore: false
})
});
}
// Render just the thumbnail items
const itemsHtml = tpl.render('snippets/items-grid', {
items: data.items,
link: data.link
}, req);
// Render pagination
const paginationHtml = tpl.render('snippets/pagination', {
pagination: data.pagination,
link: data.link
}, req);
// Render title/header (for tags/users)
const titleHtml = tpl.render('snippets/page-title', {
tmp: {
user: query.user ? String(query.user).toLowerCase() : null,
tag: query.tag ? String(query.tag).toLowerCase() : null,
hall: query.hall ? String(query.hall).toLowerCase() : null,
mime: query.mime ? String(query.mime).toLowerCase() : null,
mode: query.mode,
view_mode: query.fav === 'true' ? 'favs' : 'f0cks'
},
total: data.total || 0,
session: (req.session && req.session.user) ? { ...req.session } : false
}, req);
const hasMore = data.pagination.next !== null;
return res.reply({
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
body: JSON.stringify({
success: true,
html: itemsHtml,
titleHtml: titleHtml,
pagination: paginationHtml,
hasMore: hasMore,
nextPage: data.pagination.next,
currentPage: data.pagination.page
})
});
});
return router;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,626 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
export default router => {
router.group(/^\/api\/v2\/settings/, group => {
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
if (!req.post.avatar) {
return res.json({
msg: 'no avatar provided',
debug: req.post
}, 400); // bad request
}
const avatar = +req.post.avatar;
const itemid = (await db`
select id
from "items"
where id = ${+avatar} and active = true
`)?.[0]?.id;
if (!itemid) {
return res.json({
msg: 'itemid not found'
}, 404); // not found
}
const q = await db`
update "user_options" set ${db({
avatar
}, 'avatar')
}
where user_id = ${+req.session.id}
`;
return res.json({
msg: q
}, 200);
});
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
// Check if user has a custom avatar file
const userOpts = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
`)[0];
if (!userOpts?.avatar_file) {
return res.json({
success: false,
msg: 'No custom avatar uploaded'
}, 400);
}
// Set avatar to 0 so avatar_file takes priority
await db`
update user_options
set avatar = 0
where user_id = ${+req.session.id}
`;
return res.json({
success: true,
avatar_file: userOpts.avatar_file,
msg: 'Switched to custom avatar'
}, 200);
});
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
});
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
const tagname = req.post.tagname;
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_append(excluded_tags, ${tag.id})
where user_id = ${+req.session.id} and not (${tag.id} = any(excluded_tags))
`;
// Return updated list
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
});
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
const tagname = decodeURIComponent(req.params.tag);
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_remove(excluded_tags, ${tag.id})
where user_id = ${+req.session.id}
`;
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
// 6-char alphanumeric code
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
try {
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token, type }, 200);
} catch (e) {
console.error('Token gen error:', e);
// Fallback for schema if link_token doesn't have type yet (optional, but good for safety)
// If migration failed or not applied to link_token... wait, check schema for link_token first.
// Schema for link_token: user_id, token, created_at. NO TYPE.
// Ah, I need to add 'type' to link_token too OR just rely on the bot to know which type it is verifying?
// Actually, the bot trigger knows its type. When !link <token> is sent to Discord bot, it checks token.
// If I use same table for both, a token generated for Matrix could be used on Discord if not careful.
// It's safer to add type to link_token OR just rely on who claims it.
// If I don't add type to link_token, then a token is just "allow linking".
// If I send !link TOKEN to Matrix bot, it links Matrix account.
// If I send !link TOKEN to Discord bot, it links Discord account.
// This seems fine without adding type to link_token, because the USER triggers the action on the specific platform.
// So I will stick to the existing schema for link_token for now to avoid another migration if possible.
// BUT, I should check if I really need date restriction or type.
// Let's keep it simple: Token is just a key. Authenticated user generated it.
// Whoever consumes it (Discord bot or Matrix bot) links THEIR account to that user_id.
// So NO CHANGE needed for link_token table schema.
// Reverting to original simple insert (ignoring type in DB, just returning it for frontend convenience if needed)
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token, type }, 200);
}
});
// Get linked accounts (Discord & Matrix)
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
try {
const aliases = await db`
SELECT alias, type FROM user_alias
WHERE userid = ${req.session.id}
ORDER BY type DESC, alias ASC
`;
// Sanitize aliases
const sanitized = aliases.map(a => ({
alias: a.alias.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
type: a.type || 'discord' // Default to discord if null (though migration sets default)
}));
return res.json({ success: true, aliases: sanitized }, 200);
} catch (e) {
console.error('Get linked error:', e);
return res.json({ success: false, msg: 'Error fetching linked accounts' }, 500);
}
});
// Unlink account
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
try {
const alias = decodeURIComponent(req.params.alias);
const type = req.params.type;
const result = await db`
DELETE FROM user_alias
WHERE lower(alias) = lower(${alias})
AND userid = ${req.session.id}
AND type = ${type}
RETURNING alias
`;
if (result.length > 0) {
return res.json({ success: true, msg: 'Account unlinked' }, 200);
} else {
return res.json({ success: false, msg: 'Account not found' }, 404);
}
} catch (e) {
console.error('Unlink error:', e);
return res.json({ success: false, msg: 'Error unlinking account' }, 500);
}
});
// Backward compatibility routes for Discord (Deprecated)
// Discord Token Generation (Redirect to generic)
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
// Just call the logic inline
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
try {
await db`
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
`;
return res.json({ success: true, token }, 200);
} catch (e) { return res.json({ success: false }, 500); }
});
// Get linked Discord accounts (Legacy)
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
});
// Unlink Discord account (Legacy)
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
const alias = decodeURIComponent(req.params.alias);
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, msg: 'Account unlinked' }, 200);
});
// Update MOTD visibility preference
group.put(/\/motd/, lib.loggedin, async (req, res) => {
const show = req.post.show === true || req.post.show === 'true';
try {
await db`
update user_options
set show_motd = ${show}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.show_motd = show;
return res.json({ success: true, show }, 200);
} catch (e) {
console.error('Update MOTD pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Autoplay (on load) preference
group.put(/\/autoplay/, lib.loggedin, async (req, res) => {
const disable_autoplay = req.post.disable_autoplay === true || req.post.disable_autoplay === 'true';
try {
await db`
update user_options
set disable_autoplay = ${disable_autoplay}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.disable_autoplay = disable_autoplay;
return res.json({ success: true, disable_autoplay }, 200);
} catch (e) {
console.error('Update Autoplay pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Swiping preference
group.put(/\/swiping/, lib.loggedin, async (req, res) => {
const disable_swiping = req.post.disable_swiping === true || req.post.disable_swiping === 'true';
try {
await db`
update user_options
set disable_swiping = ${disable_swiping}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.disable_swiping = disable_swiping;
return res.json({ success: true, disable_swiping }, 200);
} catch (e) {
console.error('Update Swiping pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update New Layout visibility preference
group.put(/\/layout/, lib.loggedin, async (req, res) => {
const use_new_layout = req.post.use_new_layout === true || req.post.use_new_layout === 'true';
try {
await db`
update user_options
set use_new_layout = ${use_new_layout}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.use_new_layout = use_new_layout;
return res.json({ success: true, use_new_layout }, 200);
} catch (e) {
console.error('Update Layout pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post;
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
return res.json({ success: false, msg: 'Invalid color format' }, 400);
}
try {
await db`
update user_options
set username_color = ${color}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.username_color = color;
return res.json({ success: true, color }, 200);
} catch (e) {
console.error('Update Username Color error:', e);
return res.json({ success: false, msg: 'Error updating color' }, 500);
}
});
// Update password
group.put(/\/password/, lib.loggedin, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
if (!new_password || !new_password_confirm) {
return res.json({ success: false, msg: 'New password and confirmation are required' }, 400);
}
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (!user.force_password_change) {
if (!current_password) {
return res.json({ success: false, msg: 'Current password is required' }, 400);
}
const valid = await lib.verify(current_password, user.password);
if (!valid) {
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
}
}
if (new_password !== new_password_confirm) {
return res.json({ success: false, msg: 'New passwords do not match' }, 400);
}
if (new_password.length < 20) {
return res.json({ success: false, msg: 'New password must be at least 20 characters long' }, 400);
}
const hash = await lib.hash(new_password);
await db`update "user" set password = ${hash}, force_password_change = false where id = ${+req.session.id}`;
// Clear flag in session too
if (req.session) req.session.force_password_change = false;
// Invalidate all other sessions (Issue 21 fix)
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
return res.json({ success: true, msg: 'Password updated successfully' }, 200);
});
// Update email
group.put(/\/email/, lib.loggedin, async (req, res) => {
const { email } = req.post;
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
if (!email.includes('@')) return res.json({ success: false, msg: 'Invalid email address' }, 400);
await db`update "user" set email = ${email.trim()} where id = ${+req.session.id}`;
return res.json({ success: true, msg: 'Email updated successfully' }, 200);
});
// Update Display Name
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
const { display_name } = req.post;
if (display_name !== undefined && typeof display_name !== 'string') {
return res.json({ success: false, msg: 'Invalid display name format' }, 400);
}
const cleanDisplayName = display_name ? display_name.trim().substring(0, 32) : null;
try {
await db`
update user_options
set display_name = ${cleanDisplayName}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.display_name = cleanDisplayName;
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
} catch (e) {
console.error('Update Display Name error:', e);
return res.json({ success: false, msg: 'Error updating display name' }, 500);
}
});
// Update Description
group.put(/\/description/, lib.loggedin, async (req, res) => {
if (!cfg.websrv.enable_profile_description) {
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
}
const { description } = req.post;
if (description !== undefined && typeof description !== 'string') {
return res.json({ success: false, msg: 'Invalid description format' }, 400);
}
const cleanDescription = description ? description.trim().substring(0, 255) : null;
try {
await db`
update user_options
set description = ${cleanDescription}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.description = cleanDescription;
return res.json({ success: true, description: cleanDescription }, 200);
} catch (e) {
console.error('Update Description error:', e);
return res.json({ success: false, msg: 'Error updating description' }, 500);
}
});
// Update Font preference
group.put(/\/font/, lib.loggedin, async (req, res) => {
const { font } = req.post;
try {
await db`
update user_options
set font = ${font || null}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.font = font || null;
return res.json({ success: true, font: font || null }, 200);
} catch (e) {
console.error('Update Font error:', e);
return res.json({ success: false, msg: 'Error updating font' }, 500);
}
});
// Lightweight "who am I right now" endpoint — reads directly from DB (not session cache)
// Used by the frontend to sync display_name after it may have been changed by an admin
group.get(/\/me$/, lib.loggedin, async (req, res) => {
const row = (await db`
SELECT u.login, u.user, uo.display_name
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE u.id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) return res.json({ success: false }, 404);
return res.json({
success: true,
login: row.login,
user: row.user,
display_name: row.display_name || null
}, 200);
});
// Update min xD score filter preference
group.put(/\/min_xd_score/, lib.loggedin, async (req, res) => {
const raw = req.post.min_xd_score;
const min_xd_score = parseInt(raw, 10);
if (isNaN(min_xd_score) || min_xd_score < 0 || min_xd_score > 999) {
return res.json({ success: false, msg: 'Invalid value: must be 0999' }, 400);
}
try {
await db`
update user_options
set min_xd_score = ${min_xd_score}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.min_xd_score = min_xd_score;
return res.json({ success: true, min_xd_score }, 200);
} catch (e) {
console.error('Update min_xd_score error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update background blur preference
group.put(/\/background/, lib.loggedin, async (req, res) => {
const show_background = req.post.show_background === 'true' || req.post.show_background === true;
try {
await db`
update user_options
set show_background = ${show_background}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.show_background = show_background;
return res.json({ success: true, show_background }, 200);
} catch (e) {
console.error('Update background error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Ruffle (Flash) preferences
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
const ruffle_volume = parseFloat(req.post.ruffle_volume);
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
}
try {
await db`
update user_options
set ruffle_volume = ${ruffle_volume},
ruffle_background = ${ruffle_background}
where user_id = ${+req.session.id}
`;
if (req.session) {
req.session.ruffle_volume = ruffle_volume;
req.session.ruffle_background = ruffle_background;
}
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
} catch (e) {
console.error('Update Ruffle pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update quote_emojis preference (render :emoji: inside quote replies)
group.put(/\/quote_emojis/, lib.loggedin, async (req, res) => {
const quote_emojis = req.post.quote_emojis === true || req.post.quote_emojis === 'true';
try {
await db`
update user_options
set quote_emojis = ${quote_emojis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.quote_emojis = quote_emojis;
return res.json({ success: true, quote_emojis }, 200);
} catch (e) {
console.error('Update quote_emojis error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update embed_youtube_in_comments preference
group.put(/\/embed_youtube_in_comments/, lib.loggedin, async (req, res) => {
const embed_youtube_in_comments = req.post.embed_youtube_in_comments === true || req.post.embed_youtube_in_comments === 'true';
try {
await db`
update user_options
set embed_youtube_in_comments = ${embed_youtube_in_comments}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.embed_youtube_in_comments = embed_youtube_in_comments;
return res.json({ success: true, embed_youtube_in_comments }, 200);
} catch (e) {
console.error('Update embed_youtube_in_comments error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update hide_koepfe preference (hide the Köpfe background images if enabled in config)
group.put(/\/hide_koepfe/, lib.loggedin, async (req, res) => {
const hide_koepfe = req.post.hide_koepfe === true || req.post.hide_koepfe === 'true';
try {
await db`
update user_options
set hide_koepfe = ${hide_koepfe}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.hide_koepfe = hide_koepfe;
return res.json({ success: true, hide_koepfe }, 200);
} catch (e) {
console.error('Update hide_koepfe error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update per-user language preference
group.put(/\/language/, lib.loggedin, async (req, res) => {
if (cfg.websrv.allow_language_change === false) {
return res.json({ success: false, msg: 'Language change is disabled by the site administrator' }, 403);
}
const { language } = req.post;
// NULL means "use site default"; only allow known locale codes
const ALLOWED = ['en', 'de', 'nl', 'zange', null, ''];
const lang = (language === '' || language === null || language === undefined) ? null : language;
if (!ALLOWED.includes(lang)) {
return res.json({ success: false, msg: 'Unsupported language' }, 400);
}
try {
await db`
update user_options
set language = ${lang}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.language = lang;
return res.json({ success: true, language: lang }, 200);
} catch (e) {
console.error('Update language error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
return group;
});
return router;
};

View File

@@ -0,0 +1,245 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import audit from "../../audit.mjs";
import queue from "../../queue.mjs";
import cfg from "../../config.mjs";
import fs from "fs";
import path from "path";
export default router => {
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
group.get(/$/, lib.loggedin, async (req, res) => {
// get tags
if (!req.params.postid) {
return res.json({
success: false,
msg: 'missing postid'
});
}
return res.json({
success: true,
tags: await lib.getTags(+req.params.postid)
});
});
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (!req.params.postid || !req.post.tagname) {
return res.json({
success: false,
msg: 'missing postid or tag'
});
}
const postid = +req.params.postid;
const tagname = req.post.tagname?.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) {
return res.json({
success: false,
msg: `The tag "${tagname}" is reserved for system rating modes.`
});
}
if (tagname.length > 70) {
return res.json({
success: false,
msg: 'tag is too long!'
});
}
try {
let tagid = (await db`
select id
from "tags"
where normalized = slugify(${tagname})
`)?.[0]?.id;
if (!tagid) { // create new tag
tagid = (await db`
insert into "tags" ${db({
tag: tagname
})
}
returning id
`)[0].id;
}
await db`
insert into "tags_assign" ${db({
tag_id: +tagid,
item_id: +postid,
user_id: +req.session.id
})
}
`;
} catch (err) {
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
return res.json({
success: false,
msg: isDuplicate ? 'Tag already exists' : err.message,
tags: await lib.getTags(postid)
});
}
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({
success: true,
postid: postid,
tag: tagname,
tags: freshTags
});
});
group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => {
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
const postid = +req.params.postid;
const nsflId = cfg.nsfl_tag_id || 3;
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
const cycle = [1, 2, nsflId];
const currentTags = await lib.getTags(postid);
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (1+1)%3 = 0 → SFW
const nextTagId = cycle[(cycleIdx + 1) % cycle.length];
try {
// Remove any existing rating tag
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
if (nextTagId > 0) {
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
}
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
const { label, cls } = labels[nextTagId];
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId });
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
group.put(/\/toggle$/, lib.modAuth, async (req, res) => {
// xD
if (!req.params.postid) {
return res.json({
success: false,
msg: 'missing postid'
});
}
const postid = +req.params.postid;
const currentTags = await lib.getTags(postid);
const hasSFW = currentTags.some(tag => tag.id === 1);
const hasNSFW = currentTags.some(tag => tag.id === 2);
let auditDetails = { tag: 'sfw' };
if (!hasSFW && !hasNSFW) {
// insert
await db`
insert into "tags_assign" ${db({
item_id: +postid,
tag_id: 1,
user_id: +req.session.id
})
}
`;
auditDetails = { tag: 'sfw', action: 'added' };
}
else {
// update
await db`
update "tags_assign"
set tag_id = (array[2,1])[tag_id]
where tag_id = any(array[1,2])
and item_id = ${+postid}
`;
if (hasSFW) auditDetails = { tag: 'nsfw', from: 'sfw' };
if (hasNSFW) auditDetails = { tag: 'sfw', from: 'nsfw' };
}
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
// Generate blurred thumbnail if toggling TO NSFW
if (hasSFW && !hasNSFW) {
// Was SFW, now NSFW - check if blur exists and generate if not
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
// Doesn't exist - generate it
await queue.genBlurredThumbnail(postid, false);
}
}
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' (toggle) for item ${postid}`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({
success: true,
tags: freshTags
});
});
group.delete(/\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
// delete tag
if (!req.params.postid || !req.params.tagname) {
return res.json({
success: false,
msg: 'missing postid or tagname'
});
}
const postid = +req.params.postid;
const tagname = decodeURIComponent(req.params.tagname);
const tags = await lib.getTags(postid);
const tagid = tags.filter(t => t.tag === tagname)[0]?.id ?? null;
if (!tagid) {
return res.json({
success: false,
msg: 'tag is not assigned',
tags: await lib.getTags(postid)
});
}
let q = await db`
delete from "tags_assign"
where tag_id = ${+tagid}
and item_id = ${+postid}
`;
const reply = !!q;
if (reply) {
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
}
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' (delete) for item ${postid}`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({
success: reply,
tagid,
tags: freshTags
})
});
});
return router;
};

View File

@@ -0,0 +1,565 @@
import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import queue from '../../queue.mjs';
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
console.log('[UPLOAD DEBUG] collectBody started');
const chunks = [];
req.on('data', chunk => {
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
chunks.push(chunk);
});
req.on('end', () => {
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
resolve(Buffer.concat(chunks));
});
req.on('error', err => {
console.error('[UPLOAD DEBUG] Stream error:', err);
reject(err);
});
// Ensure stream is flowing
if (req.isPaused()) {
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
req.resume();
}
});
};
export default router => {
router.group(/^\/api\/v2/, group => {
const saveComment = async (itemid, userid, content) => {
if (!content || !content.trim()) return;
try {
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: userid,
parent_id: null,
content: content.trim()
}, 'item_id', 'user_id', 'parent_id', 'content')}
`;
} catch (err) {
console.error('[UPLOAD] Failed to save upload comment:', err);
}
};
// Find-or-create a tag and assign it to an item (no-op on duplicate)
const assignTag = async (itemid, tagName, userId) => {
try {
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')}`;
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
}
const tagId = tagRow[0].id;
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: userId })} on conflict do nothing`;
} catch (err) {
console.error(`[UPLOAD] Failed to assign tag "${tagName}":`, err);
}
};
// Derive automatic tags from a URL:
// - registered domain (e.g. "barkaka.net" from "www.foo.barkaka.net")
// - "youtube" for YouTube URLs
const autoTagsFromUrl = (urlString) => {
const tags = [];
try {
const { hostname } = new URL(urlString);
// Strip port if present
const host = hostname.replace(/:\d+$/, '').toLowerCase();
const parts = host.split('.');
// Known short second-level domains (add more as needed)
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
let domain;
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
// e.g. foo.co.uk → co.uk is the tld → registered = foo.co.uk
domain = parts.slice(-3).join('.');
} else {
// Normal: strip all subdomains, keep last two labels
domain = parts.slice(-2).join('.');
}
if (domain) tags.push(domain);
// YouTube-specific tag
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
tags.push('youtube');
}
} catch (e) {
// Malformed URL — skip auto-tags
}
return [...new Set(tags)];
};
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
}
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc } = req.post || {};
if (!inputUrl || !inputUrl.trim()) {
return res.json({ success: false, msg: 'URL is required' }, 400);
}
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
return res.json({ success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
}
if (rating === 'nsfl' && !cfg.enable_nsfl) {
return res.json({ success: false, msg: 'NSFL mode is currently disabled' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
const minTags = getMinTags();
if (tags.length < minTags) {
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
}
// Upload limit check
if (!req.session.admin && !req.session.is_moderator) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db`
SELECT count(*) as count FROM items
WHERE username = ${req.session.user} AND stamp > ${twelveHoursAgo} AND is_deleted = false
`;
if (parseInt(uploadCount[0].count) >= 69) {
return res.json({ success: false, msg: 'Upload limit reached (69 per 12 hours)' }, 429);
}
}
const url = inputUrl.trim();
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex);
// Check repost by source URL (skip for YouTube — reposts are allowed and harmless)
if (!ytMatch && !getBypassDuplicateCheck()) {
const repostLink = await queue.checkrepostlink(url);
if (repostLink) {
return res.json({ success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
}
}
const isApprovalRequired = getManualApproval();
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
// ===== YOUTUBE EMBED =====
const videoId = ytMatch[1];
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
// YouTube reposts are allowed — same video can be posted multiple times
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
const filename = `yt:${videoId}`;
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
dest: filename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${videoId}_${Date.now()}`,
phash: null,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
// Auto-subscribe uploader
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
// Download YouTube thumbnail as our thumbnail
try {
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
await fs.unlink(tmpThumb).catch(() => {});
} catch (err) {
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
// Assign rating tag
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: req.session.id })} on conflict do nothing`;
if (rating === 'nsfw' || rating === 'nsfl') {
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
}
// Assign user tags + auto-tags
const autoTags = autoTagsFromUrl(ytUrl); // always includes 'youtube' + 'youtube.com'
const allTags = [...new Set([...tags, ...autoTags])];
for (const tagName of allTags) {
await assignTag(itemid, tagName, req.session.id);
}
if (isApprovalRequired) await queue.notifyAdmins(itemid).catch(() => {});
// Save upload comment
await saveComment(itemid, req.session.id, comment);
// Assign OC tags if the uploader ticked the OC checkbox
if (is_oc) {
const ocTags = ['oc', 'original content'];
for (const tagname of ocTags) {
await assignTag(itemid, tagname, req.session.id);
}
}
return res.json({
success: true,
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
itemid: itemid,
manual_approval: isApprovalRequired
});
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const session = {
id: req.session.id,
user: req.session.user,
admin: req.session.admin,
is_moderator: req.session.is_moderator,
display_name: req.session.display_name
};
// Return immediately to avoid proxy timeouts
res.json({
success: true,
pending: true,
msg: 'URL processing started in background. You will receive a notification when it is finished.'
});
// Background processing block
(async () => {
try {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
let maxfilesize = cfg.main.maxfilesize;
if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
const uuid = await queue.genuuid();
const isInstagram = /instagram\.com/i.test(url);
const dlError = (err) => {
if (!err) return `Failed to download from ${url}`;
const errStr = String(err.stderr || err.message || '');
const httpCode = errStr.match(/HTTP Error (\d+)/i)?.[1]
|| errStr.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|| null;
if (httpCode) return `Failed to download from ${url} (HTTP ${httpCode})`;
if (err.code != null) return `Failed to download from ${url} (code ${err.code})`;
return `Failed to download from ${url}`;
};
let source;
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
try {
source = (await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
url,
'--max-filesize', `${maxfilesize / 1024}k`,
'--postprocessor-args', 'ffmpeg:-bitexact',
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath',
'--merge-output-format', 'mp4'
])).stdout.trim();
} catch (err) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
if (isInstagram) throw err;
try {
source = (await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
url,
'--max-filesize', `${maxfilesize / 1024}k`,
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath'
])).stdout.trim();
} catch (err2) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
let referer = url;
try {
const parsedUrl = new URL(url);
let host = parsedUrl.hostname;
if (host.includes('imgur.com')) host = 'imgur.com';
referer = `${parsedUrl.protocol}//${host}/`;
} catch (e) {}
const curlArgs = [
'-s', '-f', '-L', url, '-o', fallbackTmp,
'--max-filesize', `${maxfilesize}`,
'--connect-timeout', '30',
'--max-time', '300',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'--referer', referer,
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'-H', 'Accept-Language: en-US,en;q=0.9'
];
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);
}
await queue.spawn('curl', curlArgs);
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
const extension = cfg.mimes[fallbackMime];
if (extension) {
const finalPath = path.join(cfg.paths.tmp, `${uuid}.${extension}`);
await fs.rename(fallbackTmp, finalPath);
source = finalPath;
} else {
await fs.unlink(fallbackTmp).catch(() => {});
throw new Error(dlError(null));
}
}
}
if (!source || source.match(/larger than/)) throw new Error('File too large or download failed');
const { stat } = await import('fs/promises');
const size = (await stat(source)).size;
if (size > maxfilesize) {
await fs.unlink(source).catch(() => {});
throw new Error(`File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`);
}
let mime = (await queue.spawn('file', ['--mime-type', '-b', source])).stdout.trim();
const expectedExt = cfg.mimes[mime];
if (expectedExt) {
const currentExt = path.extname(source).slice(1).toLowerCase();
if (currentExt === 'unknown_video' || (currentExt !== expectedExt && !((currentExt === 'jpg' && expectedExt === 'jpeg') || (currentExt === 'jpeg' && expectedExt === 'jpg')))) {
const newSource = path.join(path.dirname(source), path.basename(source, path.extname(source)) + '.' + expectedExt);
await fs.rename(source, newSource);
source = newSource;
}
}
if (mime === 'video/x-matroska') {
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.mkv$/, '.mp4')]);
await fs.unlink(source).catch(() => {});
source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) {
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
await fs.unlink(source).catch(() => {});
source = source.replace(/\.opus$/, '.ogg');
mime = 'audio/ogg';
}
if (!Object.keys(cfg.mimes).includes(mime)) {
await fs.unlink(source).catch(() => {});
throw new Error(`Unsupported file type: ${mime}`);
}
const checksum = (await queue.spawn('sha256sum', [source])).stdout.trim().split(' ')[0];
if (!getBypassDuplicateCheck()) {
const repostSum = await queue.checkrepostsum(checksum);
if (repostSum) {
await fs.unlink(source).catch(() => {});
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ url, msg: 'Duplicate detected (Checksum)' })})`;
return;
}
}
let phash = null;
try {
phash = await queue.generatePHash(source);
if (phash && !getBypassDuplicateCheck()) {
const phashMatch = await queue.checkrepostphash(phash);
if (phashMatch) {
await fs.unlink(source).catch(() => {});
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ url, msg: 'Visual duplicate detected (PHash)' })})`;
return;
}
}
} catch (e) { console.error('[UPLOAD-URL-ASYNC] PHash error:', e); }
const filename = path.basename(source);
const isApprovalRequired = getManualApproval();
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
let linkedToExistingUrl = false;
if (getBypassDuplicateCheck()) {
const existing = await db`SELECT dest FROM items WHERE checksum = ${checksum} OR checksum LIKE ${checksum + '_bypass_%'} ORDER BY id DESC LIMIT 1`;
if (existing.length > 0) {
try {
const realTarget = await fs.realpath(path.join(cfg.paths.b, existing[0].dest));
await fs.symlink(realTarget, path.resolve(path.join(destDir, filename)));
linkedToExistingUrl = true;
} catch (e) { }
}
}
if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename));
await fs.unlink(source).catch(() => { });
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
const [{ id: itemid }] = await db`
insert into items ${db({
src: url,
dest: filename,
mime: mime,
size: size,
checksum: insertChecksum,
phash: phash,
username: session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
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 })}`;
const autoTags = autoTagsFromUrl(url);
const allTags = [...new Set([...tags, ...autoTags])];
for (const tagName of allTags) {
await assignTag(itemid, tagName, session.id);
}
if (isApprovalRequired) await queue.notifyAdmins(itemid).catch(() => {});
await saveComment(itemid, session.id, comment);
if (is_oc) {
for (const tagname of ['oc', 'original content']) {
await assignTag(itemid, tagname, session.id);
}
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: mime,
username: session.user,
display_name: session.display_name || null,
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
is_oc: !!is_oc
})})`;
} catch (err) {
console.error('[UPLOAD-URL] new_item notify failed:', err);
}
}
// Push to Matrix Channel (only if auto-approved)
if (!isApprovalRequired) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD-URL] Matrix notification sent for item ${itemid}`);
}
}
} catch (err) {
console.error('[UPLOAD-URL] Matrix notification error:', err);
}
}
// Completion notification
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
} catch (err) {
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
// Error notification
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: err.message })})`;
}
})();
}
} catch (err) {
console.error('[UPLOAD-URL ERROR]', err);
return res.json({ success: false, msg: 'Upload failed: ' + (err.message || 'Unknown error') }, 500);
}
});
});
return router;
};

23
src/inc/routes/banned.mjs Normal file
View File

@@ -0,0 +1,23 @@
import cfg from "../config.mjs";
export default (router, tpl) => {
router.get(/^\/banned\/?$/, async (req, res) => {
if (!req.session || !req.session.banned) {
return res.writeHead(302, {
"Location": "/"
}).end();
}
res.reply({
body: tpl.render("banned", {
session: req.session,
reason: req.session.ban_reason,
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent',
ban_video: cfg.websrv.ban_video,
hideNavbar: true
}, req)
});
});
return router;
};

251
src/inc/routes/chat.mjs Normal file
View File

@@ -0,0 +1,251 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
const MAX_MSG_LEN = 500;
const RATE_LIMIT_MS = 1000; // 1s between messages
const userLastMsg = new Map();
// Cached state — loaded from DB on first use, written through on every change
let chatBackground = null;
let chatTopic = null;
let _settingsLoaded = false;
async function loadSettings() {
if (_settingsLoaded) return;
_settingsLoaded = true;
try {
const rows = await db`SELECT key, value FROM global_chat_settings WHERE key IN ('background','topic')`;
for (const row of rows) {
if (row.key === 'background') chatBackground = row.value || null;
if (row.key === 'topic') chatTopic = row.value || null;
}
} catch (err) {
// Table may not exist yet (migration not applied) — degrade gracefully
console.warn('[Chat] Could not load settings (run global_chat_settings.sql migration):', err.message);
}
}
async function saveSetting(key, value) {
try {
await db`
INSERT INTO global_chat_settings (key, value) VALUES (${key}, ${value})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
} catch (err) {
console.warn('[Chat] Could not persist setting:', key, err.message);
}
}
// Build allowed-host regex from config
function buildBgHostRegex() {
const siteHost = new URL(cfg.main.url.base || `http://${cfg.main.url.domain}`).host;
const escaped = [siteHost, ...(cfg.websrv.allowed_comment_images || [])].map(h =>
h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(`^https?://(?:${escaped.join('|')})/`, 'i');
}
export default (router, tpl) => {
// GET /api/chat — fetch recent messages
router.get('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
}
try {
const messages = await db`
SELECT gc.id, gc.user_id, gc.message, gc.created_at,
u.user as username, uo.avatar, uo.avatar_file,
uo.username_color, uo.display_name
FROM global_chat gc
JOIN "user" u ON u.id = gc.user_id
LEFT JOIN user_options uo ON uo.user_id = gc.user_id
ORDER BY gc.created_at DESC
LIMIT 50
`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, messages: messages.reverse() })
});
} catch (err) {
console.error('[Chat] GET error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/chat — send a message
router.post('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
}
const message = (req.post?.message || '').trim();
if (!message || message.length > MAX_MSG_LEN) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Invalid message' }) });
}
// Rate limit
const now = Date.now();
const lastMs = userLastMsg.get(req.session.id) || 0;
if (now - lastMs < RATE_LIMIT_MS) {
return res.reply({ code: 429, body: JSON.stringify({ success: false, msg: 'Slow down!' }) });
}
userLastMsg.set(req.session.id, now);
try {
await db`
INSERT INTO global_chat (user_id, message)
VALUES (${req.session.id}, ${message})
`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] POST error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// GET /api/chat/background — return current background CSS
router.get('/api/chat/background', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
await loadSettings();
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, background: chatBackground })
});
});
// POST /api/chat/background — admin: set chat panel background
router.post('/api/chat/background', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const { url, opts } = req.post || {};
if (!url) {
// Clear background
chatBackground = null;
await saveSetting('background', null);
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: null })})`;
return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) });
}
// Validate URL against allowed hosts
const hostRegex = buildBgHostRegex();
if (!hostRegex.test(url)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL not from an allowed host' }) });
}
// Build the CSS background shorthand
const safeOpts = (opts || 'center / cover no-repeat')
.replace(/[;<>{}]/g, '');
const css = `url(${JSON.stringify(url)}) ${safeOpts}`;
chatBackground = css;
await saveSetting('background', css);
try {
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: css })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, background: css })
});
} catch (err) {
console.error('[Chat] BACKGROUND error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// GET /api/chat/topic — return current topic text
router.get('/api/chat/topic', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
await loadSettings();
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, topic: chatTopic })
});
});
// POST /api/chat/topic — admin: set or clear the pinned topic
router.post('/api/chat/topic', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const raw = (req.post?.topic || '').trim();
chatTopic = raw || null;
await saveSetting('topic', chatTopic);
try {
await db`SELECT pg_notify('global_chat_topic', ${JSON.stringify({ topic: chatTopic })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, topic: chatTopic })
});
} catch (err) {
console.error('[Chat] TOPIC error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/chat/:id — admin: delete a single message
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const id = parseInt(req.params.id, 10);
if (!id) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
try {
await db`DELETE FROM global_chat WHERE id = ${id}`;
await db`SELECT pg_notify('global_chat_delete', ${JSON.stringify({ id })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] DELETE message error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/chat — admin: clear all messages
router.delete('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
try {
await db`TRUNCATE global_chat RESTART IDENTITY`;
await db`SELECT pg_notify('global_chat_clear', '')`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] CLEAR error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

687
src/inc/routes/comments.mjs Normal file
View File

@@ -0,0 +1,687 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
export default (router, tpl) => {
// Get comments for an item
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
const itemId = req.params.itemid;
const sort = req.url.qs?.sort || 'new'; // 'new' or 'old'
// Require login unless comments are public
if (!req.session && cfg.main.hide_comments_from_public) {
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comments: [],
require_login: true,
user_id: null,
is_admin: false
})
});
}
try {
// Check locked status
const item = await db`SELECT is_comments_locked FROM items WHERE id = ${itemId}`;
const is_locked = item.length > 0 ? item[0].is_comments_locked : false;
const comments = await f0cklib.getComments(itemId, sort, false);
let is_subscribed = false;
if (req.session) {
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId} AND is_subscribed = true`;
if (sub.length > 0) is_subscribed = true;
}
// Transform for frontend if needed, or send as is
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comments,
is_subscribed,
is_locked,
user_id: req.session ? req.session.user : null,
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
})
})
} catch (err) {
console.error(err);
return res.reply({
code: 500,
body: JSON.stringify({ success: false, message: "Database error" })
});
}
});
// Browse User Comments
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
const user = decodeURIComponent(req.params.user);
try {
// Check if user exists and get ID + avatar
const u = await db`
SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color
FROM "user"
LEFT JOIN user_options ON "user".id = user_options.user_id
WHERE "user".user ILIKE ${user}
`;
if (!u.length) {
return res.reply({ code: 404, body: "User not found" });
}
const userId = u[0].id;
const sort = req.url.qs?.sort || 'new';
const page = +(req.url.qs?.page || 1);
const limit = 20;
const offset = (page - 1) * limit;
const isJson = req.url.qs?.json === 'true';
if (!req.session || !req.session.user) {
if (cfg.main.hide_comments_from_public) {
if (isJson) {
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: false, require_login: true })
});
} else {
return res.redirect('/login');
}
}
}
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
/* <mode-override> */
// prioritize query mode (from AJAX) over session default
let mode = req.mode;
if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) {
mode = parseInt(req.url.qs.mode);
}
/* </mode-override> */
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
const comments = await db`
SELECT c.*, i.mime, i.id as item_id
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
AND i.active = true AND i.is_deleted = false
AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
// Process mentions for user comments page too
// Note: Since we need to modify 'comments', we do it before any map/HTML escaping
// However, f0cklib.processMentions returns a new array with modified content.
// But we actually need to do this BEFORE the existing 'processedComments' map which does HTML escaping.
// Wait, f0cklib.processMentions adds Markdown links: [@user](/user/user).
// HTML escaping later will break this: [&quot;@user...
// So we need to ensure formatting happens appropriately.
// Actually, let's use processMentions here.
// But notice below 'processedComments' logic manually escapes HTML and handles emojis.
// If we add Markdown links now, 'escapeHtml' will destroy them.
// We should probably rely on marked.js on the client side?
// The client 'user_comments.js' uses marked.js!
// So if we inject Markdown links, they will be rendered as links by marked.js.
// BUT 'processedComments' escapes HTML.
// Ideally, we should let marked handle everything or be careful.
// Let's modify comments content in-place (or new array) before mapping
const mentionsProcessed = await f0cklib.processMentions(comments);
const processedComments = mentionsProcessed.map(c => {
return {
...c,
content: c.content
};
});
if (isJson) {
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, comments: processedComments, user: u[0] })
});
}
const data = {
user: u[0],
comments: processedComments,
hidePagination: true,
tmp: null // for header/footer
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({ body: tpl.render('comments_user-partial', data, req) });
}
return res.reply({ body: tpl.render('comments_user', data, req) });
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: "Error" });
}
});
// In-memory rate limiter for comment posting.
// Tracks timestamps of recent posts per user_id in a sliding window.
// Map: userId -> number[] (unix ms timestamps)
const commentRateLimiter = new Map();
const COMMENT_RATE_LIMIT = 25; // max comments
const COMMENT_RATE_WINDOW = 60_000; // per 60 seconds
const isCommentRateLimited = (userId) => {
const now = Date.now();
const windowStart = now - COMMENT_RATE_WINDOW;
const timestamps = (commentRateLimiter.get(userId) || []).filter(t => t > windowStart);
if (timestamps.length >= COMMENT_RATE_LIMIT) return true;
timestamps.push(now);
commentRateLimiter.set(userId, timestamps);
// Prune entries for users inactive for > 5 minutes to avoid unbounded growth
if (commentRateLimiter.size > 5000) {
const pruneWindow = now - 300_000;
for (const [uid, ts] of commentRateLimiter) {
if (!ts.some(t => t > pruneWindow)) commentRateLimiter.delete(uid);
}
}
return false;
};
// Post a comment
router.post('/api/comments', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
// Rate limit regular users (admins and mods are exempt)
if (!req.session.admin && !req.session.is_moderator) {
if (isCommentRateLimited(req.session.id)) {
return res.reply({ code: 429, body: JSON.stringify({ success: false, message: "You're posting too fast. Please slow down." }) });
}
}
console.log("DEBUG: POST /api/comments");
// Use standard framework parsing
const body = req.post || {};
const item_id = parseInt(body.item_id, 10);
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
const content = body.content;
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
? parseFloat(body.video_time)
: null;
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
}
try {
// Check if thread is locked (admins and mods can still post)
if (!req.session.admin && !req.session.is_moderator) {
const lockCheck = await db`SELECT COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${item_id}`;
if (lockCheck.length > 0 && lockCheck[0].is_locked) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "This thread is locked" }) });
}
}
const insertData = {
item_id,
user_id: req.session.id,
parent_id: parent_id || null,
content: content
};
if (video_time !== null) insertData.video_time = video_time;
const newComment = await db`
INSERT INTO comments ${db(insertData)}
RETURNING id, created_at, video_time
`;
const commentId = parseInt(newComment[0].id, 10);
// Notify Subscribers (excluding the author)
// 1. Get subscribers (active only)
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id} AND is_subscribed = true`;
// Mentions Logic: Parse content for @username and [@User Name] (space-containing names).
// Strip spoiler wrappers first so mentions inside [spoiler]...[/spoiler] are visible.
const strippedContent = content.replace(/\[spoiler\]/gi, '').replace(/\[\/spoiler\]/gi, '');
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)|\[@([^\]]+)\]/g;
const matches = [...strippedContent.matchAll(mentionRegex)];
const mentionedNames = [...new Set(matches.map(m => (m[1] || m[2]).trim()))];
const lowerNames = mentionedNames.map(n => n.toLowerCase());
let mentionedUsers = [];
if (lowerNames.length > 0) {
// Fetch IDs via login column (lowercase)
mentionedUsers = await db`SELECT id, user FROM "user" WHERE login IN ${db(lowerNames)}`;
}
// 2. Get parent author
let parentAuthor = [];
if (parent_id) {
parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`;
}
// 3. Prepare notifications with priority: Mention > Reply > Subscription
// Use a Map to ensure one notification per user
const notificationsMap = new Map(); // UserId -> { type, ... }
// A. Mentions
mentionedUsers.forEach(u => {
if (u.id !== req.session.id) {
notificationsMap.set(u.id, 'mention');
}
});
// B. Reply (Parent Author)
if (parentAuthor.length > 0) {
const pid = parentAuthor[0].user_id;
// Only if not already mentioned
if (pid !== req.session.id && !notificationsMap.has(pid)) {
notificationsMap.set(pid, 'comment_reply');
}
}
// C. Subscribers
const parentUserId = parentAuthor.length > 0 ? parentAuthor[0].user_id : -1;
// Get uploader ID to distinguish notification type
const itemInfo = await db`
SELECT u.id as uploader_id
FROM items i
JOIN "user" u ON (i.username ILIKE u.login OR i.username ILIKE u.user)
WHERE i.id = ${item_id}
LIMIT 1
`;
const uploaderId = itemInfo.length > 0 ? itemInfo[0].uploader_id : null;
subscribers.forEach(s => {
// If not self, and not already notified (as mention or reply)
if (s.user_id !== req.session.id && !notificationsMap.has(s.user_id)) {
// Use specialized type for uploader
const type = (uploaderId && s.user_id === uploaderId) ? 'upload_comment' : 'subscription';
notificationsMap.set(s.user_id, type);
}
});
// 4. Batch insert non-bundleable, handle bundleable separately
const bundleable = [];
const nonBundleable = [];
for (const [uid, type] of notificationsMap.entries()) {
const notif = {
user_id: uid,
type: type,
item_id: item_id,
reference_id: commentId
};
if (type === 'upload_comment') {
bundleable.push(notif);
} else {
nonBundleable.push(notif);
}
}
if (nonBundleable.length > 0) {
await db`INSERT INTO notifications ${db(nonBundleable)}`;
}
for (const n of bundleable) {
// Try to update existing unread notification for this item/user/type
const updated = await db`
UPDATE notifications
SET created_at = NOW(), reference_id = ${n.reference_id}
WHERE user_id = ${n.user_id}
AND item_id = ${n.item_id}
AND type = 'upload_comment'
AND is_read = false
RETURNING id
`;
if (updated.length === 0) {
await db`INSERT INTO notifications ${db(n)}`;
}
}
// Notify for live updates
// Fetch the trigger-updated xd_score from the DB (trigger runs synchronously before we get here)
const [xdRow] = await db`SELECT xd_score FROM items WHERE id = ${item_id}`;
const livePayload = {
type: 'comment',
id: commentId,
item_id: item_id,
parent_id: parent_id || null,
body: content,
username: req.session.user,
user_id: req.session.id,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
created_at: new Date().toISOString(),
username_color: req.session.username_color,
display_name: req.session.display_name || null,
xd_score: xdRow?.xd_score ?? null,
video_time: newComment[0]?.video_time ?? null
};
// 1. Thread live update
db.notify('comments', JSON.stringify(livePayload));
// 2. Sidebar activity update
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
type: 'comment',
body: content,
id: commentId
}));
// Automatically subscribe user to the thread
const subResult = await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${req.session.id}, ${item_id})
ON CONFLICT (user_id, item_id) DO NOTHING
RETURNING 1
`;
const is_new_subscription = subResult.length > 0;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comment: newComment[0],
is_new_subscription
})
});
} catch (err) {
console.error(err);
return res.reply({
code: 500,
body: JSON.stringify({ success: false, message: "Database error" })
});
}
});
// Subscribe toggle
router.post(/\/api\/subscribe\/(?<itemid>\d+)/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemid;
try {
const existing = await db`
SELECT is_subscribed FROM comment_subscriptions
WHERE user_id = ${req.session.id} AND item_id = ${itemId}
`;
let subscribed = false;
if (existing.length > 0) {
subscribed = !existing[0].is_subscribed;
await db`UPDATE comment_subscriptions SET is_subscribed = ${subscribed} WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
} else {
await db`INSERT INTO comment_subscriptions (user_id, item_id, is_subscribed) VALUES (${req.session.id}, ${itemId}, true)`;
subscribed = true;
}
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, subscribed })
});
} catch (e) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Delete comment
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const commentId = req.params.id;
console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
try {
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
if (!req.session.admin && !req.session.is_moderator && comment[0].user_id !== req.session.id) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
// Log all deletions in audit log
const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided');
await audit.log(req.session.id, 'delete_comment', 'comment', commentId, {
item_id: comment[0].item_id,
reason: reason,
old_content: comment[0].content
});
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
// Notify for live update
db.notify('comments', JSON.stringify({
type: 'delete',
item_id: comment[0].item_id,
comment_id: commentId
}));
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (e) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Edit comment (admin/mod only)
router.post(/\/api\/comments\/(?<id>\d+)\/edit/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
const commentId = req.params.id;
const body = req.post || {};
const content = body.content;
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
}
try {
const comment = await db`SELECT id, user_id, item_id, content FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const oldContent = comment[0].content;
await audit.log(req.session.id, 'edit_comment', 'comment', commentId, {
item_id: comment[0].item_id,
old_content: oldContent.substring(0, 2000),
new_content: content.substring(0, 2000)
});
await db`UPDATE comments SET content = ${content}, updated_at = NOW() WHERE id = ${commentId}`;
// Notify for live update
db.notify('comments', JSON.stringify({
type: 'edit',
item_id: comment[0].item_id,
comment_id: commentId,
content: content
}));
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Toggle pin comment (admin/mod only)
router.post(/\/api\/comments\/(?<id>\d+)\/pin/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
const commentId = req.params.id;
try {
const comment = await db`SELECT id, COALESCE(is_pinned, false) as is_pinned, item_id FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const newPinned = !comment[0].is_pinned;
await db`UPDATE comments SET is_pinned = ${newPinned} WHERE id = ${commentId}`;
await audit.log(req.session.id, 'pin_comment', 'comment', commentId, { item_id: comment[0].item_id, is_pinned: newPinned });
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, is_pinned: newPinned })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Toggle lock thread (admin/mod only)
router.post(/\/api\/comments\/(?<itemid>\d+)\/lock/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
const itemId = req.params.itemid;
try {
const item = await db`SELECT id, COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${itemId}`;
if (!item.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const newLocked = !item[0].is_locked;
await db`UPDATE items SET is_comments_locked = ${newLocked} WHERE id = ${itemId}`;
await audit.log(req.session.id, newLocked ? 'lock_thread' : 'unlock_thread', 'item', itemId);
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, is_locked: newLocked })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Recent Activity Page
router.get(/\/activity\/?/, async (req, res) => {
try {
const page = +(req.url.qs?.page || 1);
const limit = 50;
const offset = (page - 1) * limit;
/* <mode-override> */
// prioritize query mode (from AJAX) over session default
let mode = req.mode;
if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) {
mode = parseInt(req.url.qs.mode);
}
/* </mode-override> */
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
const comments = await db`
SELECT
c.*,
i.mime,
i.id as item_id,
i.dest as item_dest,
u.user as username,
uo.avatar,
uo.avatar_file,
uo.username_color,
uo.display_name
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
WHERE c.is_deleted = false
AND i.active = true
AND i.is_deleted = false
AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const processedComments = comments.map(c => {
return {
...c,
content: (c.content || '').trim(),
username_color: c.username_color
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
};
});
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
body: JSON.stringify({
success: true,
comments: processedComments,
page,
hasMore: processedComments.length === limit
})
});
}
// Standalone page no longer exists
return res.reply({ code: 404, body: "Page not found" });
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: "Error loading activity data" });
}
});
// Subscribe to all own uploads
router.post('/api/v2/user/subscribe-all-uploads', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
try {
const result = await db`
INSERT INTO comment_subscriptions (user_id, item_id)
SELECT ${req.session.id}, i.id
FROM items i
WHERE i.username ILIKE ${req.session.login} OR i.username ILIKE ${req.session.user}
ON CONFLICT DO NOTHING
`;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
message: `Successfully subscribed to your uploads`
})
});
} catch (err) {
console.error('[API] Failed to subscribe to all uploads:', err);
res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, message: "Internal server error" })
});
}
});
return router;
};

51
src/inc/routes/emojis.mjs Normal file
View File

@@ -0,0 +1,51 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
// Admin View
router.get(/^\/admin\/emojis\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/emojis", { session: req.session, tmp: null }, req)
});
});
// List all emojis (Public)
router.get('/api/v2/emojis', async (req, res) => {
try {
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojis })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Delete emoji (Admin only)
router.post(/\/api\/v2\/admin\/emojis\/(?<id>\d+)\/delete/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const id = req.params.id;
try {
await db`DELETE FROM custom_emojis WHERE id = ${id}`;
await db`NOTIFY emojis_updated, '{}'`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
return router;
};

112
src/inc/routes/halls.mjs Normal file
View File

@@ -0,0 +1,112 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import url from "url";
import path from "path";
import fs from "fs/promises";
export default (router, tpl) => {
// Main Halls Overview
router.get(/^\/halls$/, async (req, res) => {
const mode = req.mode ?? 0;
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
const hallsList = await f0cklib.getHallsOverview(mode, excludedTags);
const data = {
hallsList: hallsList,
tmp: null,
hidePagination: true,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Halls',
description: `Browse curated item collections`,
url: `https://${cfg.main.url.domain}/halls`
}
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
body: tpl.render('halls-partial', data, req)
});
}
res.reply({
body: tpl.render('halls', data, req)
});
});
// Hall Thumbnail Route
router.get(/^\/hall_image\/(?<hallSlug>.+)$/, async (req, res) => {
const hallSlug = decodeURIComponent(req.params.hallSlug);
const mode = +(req.url.qs?.m ?? 0);
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
try {
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const customPath = path.join(CUSTOM_DIR, `${hallSlug}.webp`);
// Serve custom image if it exists (skip mosaic entirely)
try {
const stat = await fs.stat(customPath);
const etag = '"' + stat.mtimeMs.toString(16) + '-' + stat.size.toString(16) + '"';
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
return res.end();
}
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'no-cache', 'ETag': etag });
return res.end(await fs.readFile(customPath));
} catch (e) { /* no custom image, fall through to mosaic */ }
const hash = (await import('crypto')).createHash('md5').update(`${hallSlug}_${mode}`).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
// Try cache first
try {
await fs.access(cachePath);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
} catch (e) {}
// Generate Mosaic
let modeFilter = db``;
if (mode === 0) modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
else if (mode === 1) modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
const items = await db`
SELECT i.id
FROM items i
JOIN halls_assign ha ON ha.item_id = i.id
JOIN halls h ON h.id = ha.hall_id
${modeFilter}
WHERE h.slug = ${hallSlug} AND i.active = true
ORDER BY RANDOM()
LIMIT 3
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(CACHE_DIR, { recursive: true });
const { execFile } = await import('child_process');
const util = await import('util');
const execFilePromise = util.promisify(execFile);
// If only 1 or 2 items, just use what we have
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
} catch (e) {
console.error('[HALL_IMAGE] Error:', e);
}
// Default placeholder
res.writeHead(302, { 'Location': '/s/img/favicon.gif' });
res.end();
});
return router;
};

489
src/inc/routes/index.mjs Normal file
View File

@@ -0,0 +1,489 @@
import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
const auth = async (req, res, next) => {
if (!req.session)
return res.redirect("/login");
return next();
};
export default (router, tpl) => {
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
const user = decodeURIComponent(req.params.user);
const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null);
const query = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires, "user".created_at, user_options.*, user_options.display_name
from "user"
left join user_options on "user".id = user_options.user_id
where "user".user ilike ${user} OR "user".login ilike ${user}
limit 1
`;
let userData = query[0];
if (!userData) {
// Fallback: Check if user exists as a "Ghost" (uploading in items table but no account)
const ghostQuery = await db`
SELECT username, MIN(stamp) as first_upload
FROM items
WHERE username ILIKE ${user} AND active = true
GROUP BY username
LIMIT 1
`;
if (ghostQuery.length) {
userData = {
id: null,
user: user,
created_at: new Date(ghostQuery[0].first_upload * 1000),
activated: true,
banned: false,
is_ghost: true
};
} else {
return res.reply({
code: 404,
body: tpl.render('error', {
message: 'this user does not exists',
tmp: null
}, req)
});
}
}
let f0cks, favs;
const count = {
f0cks: 0,
favs: 0,
tags: 0
};
try {
const isRandom = req.cookies.random_mode === '1';
f0cks = await f0cklib.getf0cks({
user: user,
mode: req.mode,
mime: mime,
fav: false,
session: !!req.session,
user_id: req.session?.id,
random: isRandom
});
if ('items' in f0cks) {
count.f0cks = f0cks.total ?? f0cks.items.length;
f0cks.items = f0cks.items.slice(0, 12);
}
} catch (err) {
console.error('[PROFILE] getf0cks failed for user:', user, err);
f0cks = false;
count.f0cks = 0;
}
if (!userData.is_ghost) {
try {
const isRandom = req.cookies.random_mode === '1';
favs = await f0cklib.getf0cks({
user: user,
mode: req.mode,
mime: mime,
fav: true,
session: !!req.session,
user_id: req.session?.id,
random: isRandom
});
if (favs && 'items' in favs) {
count.favs = favs.total ?? favs.items.length;
favs.items = favs.items.slice(0, 12);
}
} catch (err) {
favs = false;
count.favs = 0;
}
}
if (!userData.is_ghost) {
try {
const [comms, tags, halls] = await Promise.all([
db`
select count(*)
from comments c
join items i on c.item_id = i.id
where c.user_id = ${userData.id}
and c.is_deleted = false
and i.active = true
and i.is_deleted = false
`,
db`
select count(*)
from tags_assign
where user_id = ${userData.id}
and tag_id > 2
`,
db`
select count(*)
from user_halls
where user_id = ${userData.id}
`
]);
count.comments = +comms[0].count;
count.tags = +tags[0].count;
count.halls = +halls[0].count;
} catch (e) {
count.comments = count.comments || 0;
count.tags = 0;
}
}
userData.timestamp = {
timeago: lib.timeAgo(userData.created_at),
timefull: userData.created_at
};
if (userData.banned) {
if (!userData.ban_expires) {
userData.ban_duration = "Permanent";
} else {
const diff = ~~((new Date(userData.ban_expires) - new Date()) / 1e3);
if (diff <= 0) {
userData.ban_duration = "Expiration pending refresh";
} else {
const epochs = [
["year", 31536000],
["month", 2592000],
["week", 604800],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1]
];
let durationStr = "Expires in ";
for (let [name, seconds] of epochs) {
const interval = ~~(diff / seconds);
if (interval >= 1) {
durationStr += `${interval} ${name}${interval === 1 ? "" : "s"}`;
break;
}
}
userData.ban_duration = durationStr;
}
}
}
const data = {
user: userData,
f0cks,
count,
favs,
tmp: null,
session: req.session ? { ...req.session } : false,
page_meta: {
title: userData.user,
description: userData.is_ghost ? `${count.f0cks} legacy uploads` : `${count.f0cks} uploads, ${count.favs} favorites`,
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userData.user)}`,
image: userData.avatar_file
? `https://${cfg.main.url.domain}/a/${userData.avatar_file}`
: userData.avatar
? `https://${cfg.main.url.domain}/t/${userData.avatar}.webp`
: `https://${cfg.main.url.domain}/a/default.png`
}
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({ body: tpl.render('user-partial', data, req) });
}
return res.reply({ body: tpl.render('user', data, req) });
});
/* <routing-refactor> */
const handleGenericRoute = async (req, res) => {
const tRouteStart = Date.now();
const mode = req.params.itemid ? 'item' : 'index';
// Auto-persist strict mode from URL to session if it's there
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
}
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
user: req.params.user,
tag: req.params.tag,
mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null),
page: req.params.page,
itemid: req.params.itemid,
hall: req.params.hall,
fav: req.params.mode == 'favs',
mode: req.mode,
session: !!req.session,
user_id: req.session?.id,
exclude: req.session ? (req.session.excluded_tags || []) : [],
url: decodeURIComponent(req.url.pathname || req.url),
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0))
});
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
// For index/grid views with zero items (empty DB), render an empty grid instead of error
if (mode !== 'item') {
data.items = [];
data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null };
data.total = 0;
data.success = true;
if (!data.link) {
if (req.params.hall) data.link = { main: '/h/' + req.params.hall + '/', path: 'p/', suffix: '' };
else if (req.params.tag) data.link = { main: '/tag/' + req.params.tag + '/', path: 'p/', suffix: '' };
else data.link = { main: '/', path: 'p/', suffix: '' };
}
data.tmp = data.tmp || {};
if (req.params.hall && !data.tmp.hall) {
const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`;
data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall;
}
if (req.params.tag && !data.tmp.tag) data.tmp.tag = req.params.tag;
} else {
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
// Return 404 only for truly missing items
const statusCode = data.item ? 200 : 404;
return res.reply({
code: statusCode,
body: tpl.render('error', {
message: data.message,
item: data.item, // For OG meta tags on filtered NSFW items
domain: cfg.main.url.domain,
tmp: null
}, req)
});
}
}
if (mode === 'item') {
data.hidePagination = true;
// Precompute hall display data for the template
if (data.item && data.item.halls && data.item.halls.length) {
const currentHallSlug = data.tmp && data.tmp.hall
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
: null;
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
} else if (data.item) {
data.item.primaryHall = null;
data.item.otherHalls = [];
}
if (req.session || !cfg.main.hide_comments_from_public) {
// Mark notifications as read
if (req.session?.id) {
f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
}
// Subscription status — just a boolean, cheap to embed
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
data.isSubscribed = sub;
// xD Score — fetch comments only for the score; do NOT embed into the page
// Comments are always loaded async by the client via /api/comments/:id
// This avoids blocking the browser's main thread when a comment has a huge xD payload.
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
const xdScore = f0cklib.computeXdScore(commentsForScore);
const xdMeta = f0cklib.xdScoreMeta(xdScore);
data.item.xd_score = xdScore;
data.item.xd_tier = xdMeta.tier;
data.item.xd_label = xdMeta.label;
// Do NOT set commentsJSON — client will fetch async
data.commentsJSON = null;
data.comments = [];
} else {
data.isSubscribed = false;
data.commentsJSON = null;
data.comments = [];
data.item.xd_score = 0;
data.item.xd_tier = 0;
data.item.xd_label = '';
}
} else {
// Ensure total is defined for list views (to prevent template error)
if (data.total === undefined) data.total = 0;
}
// Explicitly inject session for template logic (Navbar)
// Only inject session for authenticated users to avoid showing member UI to guests
data.session = (req.session && req.session.user) ? { ...req.session } : false;
// Precompute boolean helpers for template @if() — the flummpress template engine uses a
// non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters.
// This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if()
// will produce broken JS and a "Unexpected token '{'" parse error.
// Solution: precompute all such conditions as plain booleans here.
if (mode === 'item' && data.item) {
const session = data.session;
const item = data.item;
// Is the current user a moderator/admin?
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
// Can the current user manage this item (owner, admin, or mod)?
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
// Is the item's MIME type suitable for metadata extraction?
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
// Has the current user favorited this item?
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
// Hall columns for display
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
const tRenderStart = Date.now();
// Check if AJAX request for standard grid view
if (req.headers['x-requested-with'] === 'XMLHttpRequest' && mode === 'index') {
const body = tpl.render('index-partial', data, req);
console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (partial) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`);
return res.reply({ body });
}
const body = tpl.render(mode, data, req);
console.log(`[${new Date().toISOString()}] [ROUTE] Render complete in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`);
return res.reply({ body });
};
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
/* </routing-refactor> */
router.get(/^\/(about)$/, (req, res) => {
res.reply({
body: tpl.render('about', {
tmp: null,
mail: cfg.main.mail,
discord: cfg.main.discord,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'about',
description: 'About w0bm.com',
url: `https://${cfg.main.url.domain}/about`
}
}, req)
});
});
router.get(/^\/(terms)$/, (req, res) => {
res.reply({
body: tpl.render('terms', {
tmp: null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'terms',
description: 'Terms of service',
url: `https://${cfg.main.url.domain}/terms`
}
}, req)
});
});
router.get(/^\/(rules)$/, (req, res) => {
res.reply({
body: tpl.render('rules', {
tmp: null,
domain: cfg.main.url.domain,
session: req.session ? { ...req.session } : false,
page_meta: {
title: 'rules',
description: 'Rules and guidelines',
url: `https://${cfg.main.url.domain}/rules`
}
}, req)
});
});
router.get(/^\/mode\/(\d)/, async (req, res) => {
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
const mode = modeMatch ? +modeMatch[1] : 0;
if (cfg.allowedModes[mode]) {
if (req.session) {
req.session.mode = mode;
const blah = {
user_id: req.session.id,
mode: mode,
theme: req.theme ?? (cfg.websrv.theme || "f0ck")
};
await db`
insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme')
}
on conflict ("user_id") do update set
mode = excluded.mode,
theme = excluded.theme,
user_id = excluded.user_id
`;
}
}
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`
},
body: JSON.stringify({ success: true, mode: mode })
});
}
return res.writeHead(302, {
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
"Set-Cookie": `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`,
"Location": "/"
}).end();
});
router.get(/^\/strict\/(0|1)$/, async (req, res) => {
const urlStr = req.url.pathname || req.url;
const strict = +urlStr.split("/")[2] === 1;
if (req.session) {
console.log(`[DEBUG] Setting strict mode to ${strict} for user ${req.session.id}`);
req.session.strict_mode = strict;
await db`
insert into "user_options" (user_id, strict_mode, mode, theme, fullscreen)
values (${req.session.id}, ${strict}, ${req.session.mode ?? 0}, ${req.session.theme ?? (cfg.websrv.theme || 'f0ck')}, ${req.session.fullscreen ?? 0})
on conflict ("user_id") do update set
strict_mode = excluded.strict_mode
`;
} else {
console.log(`[DEBUG] No session found for strict toggle!`);
}
return res.reply({
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ success: true, strict: strict })
});
});
return router;
};

View File

@@ -0,0 +1,11 @@
import db from '../sql.mjs';
import lib from '../lib.mjs';
import cfg from '../config.mjs';
import queue from '../queue.mjs';
import path from 'path';
export default (router, tpl) => {
return router;
};

12
src/inc/routes/matrix.mjs Normal file
View File

@@ -0,0 +1,12 @@
import cfg from "../config.mjs";
export default (router, tpl) => {
router.get(/^\/matrix(\/)?$/, async (req, res) => {
res.reply({
body: tpl.render("matrix", {
session: req.session,
tmp: null
}, req)
});
});
};

View File

@@ -0,0 +1,53 @@
import db from "../sql.mjs";
import path from "path";
import fs from "fs/promises";
import { parseMultipart, collectBody } from "../multipart.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
export default (router, tpl) => {
// Admin View
router.get(/^\/admin\/memes\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/memes", { session: req.session, tmp: null }, req)
});
});
// List all memes (Public API)
router.get('/api/v2/memes', async (req, res) => {
try {
const memes = await db`SELECT id, template_id, name, url, category FROM meme_templates ORDER BY name ASC`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, memes })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Delete meme template (Admin only)
router.post(/\/api\/v2\/admin\/memes\/(?<id>\d+)\/delete/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const id = req.params.id;
try {
await db`DELETE FROM meme_templates WHERE id = ${id}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
return router;
};

60
src/inc/routes/meme.mjs Normal file
View File

@@ -0,0 +1,60 @@
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import db from "../sql.mjs";
// templates are now fetched from the database
export default (router, tpl) => {
// Template selection page
router.get(/^\/meme$/, lib.userauth, async (req, res) => {
if (!cfg.websrv.meme_creator) {
res.writeHead(404).end('Not Found');
return;
}
const templates = await db`SELECT template_id as id, name, url, category, sub_category FROM meme_templates ORDER BY created_at DESC`;
// Extract unique categories for filtering
const categories = ['All', ...new Set(templates.map(t => t.category || 'General'))].sort();
res.reply({
body: tpl.render('meme-select', {
templates: templates,
categories: categories,
page_meta: {
title: 'Meme Creator - Select Template',
description: 'Select a template to create your meme',
url: `https://${cfg.main.url.domain}/meme`
}
}, req)
});
});
// Meme creator page
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
if (!cfg.websrv.meme_creator) {
res.writeHead(404).end('Not Found');
return;
}
const templateId = req.params?.id || req.url.pathname.match(/\/meme\/([a-z0-9-]+)/)?.[1];
const templateSearch = await db`SELECT template_id as id, name, url, category, sub_category FROM meme_templates WHERE template_id = ${templateId} LIMIT 1`;
const template = templateSearch[0];
if (!template) {
res.writeHead(404).end('Template not found');
return;
}
res.reply({
body: tpl.render('meme-creator', {
template: template,
page_meta: {
title: `Create Meme - ${template.name}`,
description: `Create a meme using the ${template.name} template`,
url: `https://${cfg.main.url.domain}/meme/${templateId}`
}
}, req)
});
});
return router;
};

399
src/inc/routes/messages.mjs Normal file
View File

@@ -0,0 +1,399 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { getPrivateMessages } from "../settings.mjs";
export default (router, tpl) => {
const json = (res, body, code = 200) =>
res.reply({ code, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body) });
// Block all DM routes when private messaging is disabled in config
const dmEnabled = (req, res, next) => {
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
if (next) next();
};
// ─── Public Key Management ───────────────────────────────────────────────
// Upload / refresh current user's public key
router.post('/api/dm/pubkey', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const body = req.post || {};
const pubkey = body.pubkey;
const fingerprint = body.fingerprint || null;
if (!pubkey || typeof pubkey !== 'string' || pubkey.length > 4096) {
return json(res, { success: false, msg: 'Invalid pubkey' }, 400);
}
// Basic JWK validation — must look like JSON with kty field
try {
const parsed = JSON.parse(pubkey);
if (!parsed.kty || parsed.kty !== 'EC' || parsed.crv !== 'P-256') {
return json(res, { success: false, msg: 'Invalid key type — expected EC P-256' }, 400);
}
} catch {
return json(res, { success: false, msg: 'Invalid JSON' }, 400);
}
try {
await db`
INSERT INTO user_pubkeys ${db({ user_id: req.session.id, pubkey, fingerprint, updated_at: db`NOW()` })}
ON CONFLICT (user_id) DO UPDATE
SET pubkey = EXCLUDED.pubkey,
fingerprint = EXCLUDED.fingerprint,
updated_at = NOW()
`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] pubkey upsert failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Fetch a user's public key by user ID
router.get(/\/api\/dm\/pubkey\/(?<userId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const userId = parseInt(req.params.userId, 10);
try {
const rows = await db`SELECT pubkey, fingerprint FROM user_pubkeys WHERE user_id = ${userId}`;
if (!rows.length) return json(res, { success: false, msg: 'No key registered for this user' }, 404);
return json(res, { success: true, pubkey: rows[0].pubkey, fingerprint: rows[0].fingerprint });
} catch (err) {
console.error('[DM] pubkey fetch failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// ─── Key Vault (seed-phrase encrypted private key backup) ────────────────
// Upload or replace the encrypted private key blob
router.post('/api/dm/keyvault', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const body = req.post || {};
const { salt, iv, ciphertext } = body;
if (!salt || !iv || !ciphertext ||
typeof salt !== 'string' || typeof iv !== 'string' || typeof ciphertext !== 'string') {
return json(res, { success: false, msg: 'Missing salt, iv or ciphertext' }, 400);
}
// Length guards — salt: 24 chars (16 bytes b64), iv: 16 chars (12 bytes b64), ciphertext: privkey JWK is ~200 bytes + AES overhead → max 512
if (salt.length > 32 || iv.length > 24 || ciphertext.length > 1024) {
return json(res, { success: false, msg: 'Payload out of bounds' }, 400);
}
try {
await db`
INSERT INTO user_dm_keyvault ${db({ user_id: req.session.id, salt, iv, ciphertext, updated_at: db`NOW()` })}
ON CONFLICT (user_id) DO UPDATE
SET salt = EXCLUDED.salt,
iv = EXCLUDED.iv,
ciphertext = EXCLUDED.ciphertext,
updated_at = NOW()
`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] keyvault upsert failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Fetch the encrypted blob for the current user
router.get('/api/dm/keyvault', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
try {
const rows = await db`SELECT salt, iv, ciphertext, version FROM user_dm_keyvault WHERE user_id = ${req.session.id}`;
if (!rows.length) return json(res, { success: false, msg: 'No vault found' }, 404);
return json(res, { success: true, vault: rows[0] });
} catch (err) {
console.error('[DM] keyvault fetch failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Delete the vault entry (user wants to reset)
router.delete('/api/dm/keyvault', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
try {
await db`DELETE FROM user_dm_keyvault WHERE user_id = ${req.session.id}`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] keyvault delete failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// ─── Conversations List ──────────────────────────────────────────────────
// Returns the list of unique users the current user has exchanged messages with,
// plus last message metadata (no ciphertext exposed unnecessarily here — only metadata)
router.get('/api/dm/conversations', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
try {
const rows = await db`
WITH convos AS (
SELECT
CASE WHEN sender_id = ${req.session.id} THEN recipient_id ELSE sender_id END AS other_id,
MAX(id) AS last_msg_id
FROM private_messages
WHERE sender_id = ${req.session.id} OR recipient_id = ${req.session.id}
GROUP BY other_id
),
unread_counts AS (
SELECT sender_id AS other_id, COUNT(*) AS unread
FROM private_messages
WHERE recipient_id = ${req.session.id} AND is_read = false
GROUP BY sender_id
)
SELECT
u.id AS user_id,
u.user AS username,
uo.avatar,
uo.avatar_file,
uo.username_color,
uo.display_name,
pm.created_at AS last_message_at,
COALESCE(uc.unread, 0) AS unread_count
FROM convos c
JOIN "user" u ON u.id = c.other_id
LEFT JOIN user_options uo ON uo.user_id = u.id
JOIN private_messages pm ON pm.id = c.last_msg_id
LEFT JOIN unread_counts uc ON uc.other_id = c.other_id
LEFT JOIN user_conversation_states ucs ON ucs.user_id = ${req.session.id} AND ucs.other_id = c.other_id
WHERE (ucs.is_hidden IS NULL OR ucs.is_hidden = false)
ORDER BY pm.created_at DESC
`;
return json(res, { success: true, conversations: rows });
} catch (err) {
console.error('[DM] conversations failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// ─── Message Thread ──────────────────────────────────────────────────────
// Fetch paginated messages between current user and another user
// Supports ?before=id (older pages) and ?after=id (live updates since last known msg)
router.get(/\/api\/dm\/thread\/(?<userId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const otherId = parseInt(req.params.userId, 10);
const limit = 50;
const before = req.url.qs?.before ? parseInt(req.url.qs.before, 10) : null;
const after = req.url.qs?.after ? parseInt(req.url.qs.after, 10) : null;
// Validate other user exists
const other = await db`SELECT id, user FROM "user" WHERE id = ${otherId} LIMIT 1`;
if (!other.length) return json(res, { success: false, msg: 'User not found' }, 404);
try {
// Unhide for the viewing user
await db`
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
VALUES (${req.session.id}, ${otherId}, false)
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false
`;
const messages = await db`
SELECT
pm.id,
pm.sender_id,
pm.recipient_id,
pm.ciphertext,
pm.iv,
pm.is_read,
pm.created_at
FROM private_messages pm
WHERE (
(pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId})
OR
(pm.sender_id = ${otherId} AND pm.recipient_id = ${req.session.id})
)
${after ? db`AND pm.id > ${after}` : db``}
${before ? db`AND pm.id < ${before}` : db``}
ORDER BY pm.id ${after ? db`ASC` : db`DESC`}
LIMIT ${after ? 200 : limit + 1}
`;
// Only applies to paginated (before) fetches
const hasMore = !after && messages.length > limit;
if (hasMore) messages.pop();
return json(res, {
success: true,
messages: after ? messages : messages.reverse(),
hasMore,
other: other[0]
});
} catch (err) {
console.error('[DM] thread fetch failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Send a message (store ciphertext)
router.post(/\/api\/dm\/send\/(?<userId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const recipientId = parseInt(req.params.userId, 10);
if (recipientId === req.session.id) return json(res, { success: false, msg: "Can't DM yourself" }, 400);
const body = req.post || {};
const { ciphertext, iv } = body;
if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') {
return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400);
}
// Sanity length bounds: ciphertext max ~64 KB encoded, iv is 16 chars (12 bytes base64url)
if (ciphertext.length > 65536 || iv.length > 32) {
return json(res, { success: false, msg: 'Payload too large' }, 413);
}
// Verify recipient exists
const recip = await db`SELECT id FROM "user" WHERE id = ${recipientId} LIMIT 1`;
if (!recip.length) return json(res, { success: false, msg: 'Recipient not found' }, 404);
try {
const msg = await db.begin(async sql => {
const [m] = await sql`
INSERT INTO private_messages ${db({
sender_id: req.session.id,
recipient_id: recipientId,
ciphertext,
iv
})}
RETURNING id, created_at
`;
// Unhide for both parties
await sql`
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
VALUES (${req.session.id}, ${recipientId}, false), (${recipientId}, ${req.session.id}, false)
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false
`;
return m;
});
// Notify recipient via SSE (pg_notify wakes the db.listen in notifications.mjs)
await db`SELECT pg_notify('private_message', ${JSON.stringify({
id: msg.id,
sender_id: req.session.id,
recipient_id: recipientId,
created_at: msg.created_at
})})`;
return json(res, { success: true, id: msg.id, created_at: msg.created_at });
} catch (err) {
console.error('[DM] send failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Mark a conversation as read
router.post(/\/api\/dm\/read\/(?<userId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const otherId = parseInt(req.params.userId, 10);
try {
await db`UPDATE private_messages
SET is_read = true
WHERE recipient_id = ${req.session.id} AND sender_id = ${otherId} AND is_read = false`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] read mark failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Hide a whole conversation (Close DM)
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const otherId = parseInt(req.params.userId, 10);
try {
await db`
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
VALUES (${req.session.id}, ${otherId}, true)
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = true
`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] hide conversation failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Total unread DM count (for navbar badge polling)
router.get('/api/dm/unread', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });
if (!req.session) return json(res, { success: false, count: 0 }, 401);
try {
const [row] = await db`
SELECT COUNT(*) AS count FROM private_messages
WHERE recipient_id = ${req.session.id} AND is_read = false
`;
return json(res, { success: true, count: parseInt(row.count, 10) });
} catch (err) {
return json(res, { success: false, count: 0 }, 500);
}
});
// Resolve username → user_id (needed for compose from user profile)
router.get(/\/api\/dm\/resolve\/(?<username>[^/]+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const uname = decodeURIComponent(req.params.username);
try {
const rows = await db`SELECT id, user FROM "user" WHERE login = ${uname.toLowerCase()} LIMIT 1`;
if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404);
return json(res, { success: true, user_id: rows[0].id, username: rows[0].user });
} catch (err) {
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// ─── Pages ───────────────────────────────────────────────────────────────
// Inbox page
router.get('/messages', async (req, res) => {
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
if (!req.session) return res.redirect('/login');
return res.html(tpl.render('messages', {
session: req.session,
hidePagination: true,
link: { main: '/messages', path: '/' },
domain: cfg.main.url.domain
}, req));
});
// Conversation page — /messages/:username
router.get(/\/messages\/(?<username>[^/]+)/, async (req, res) => {
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
if (!req.session) return res.redirect('/login');
const uname = decodeURIComponent(req.params.username);
const rows = await db`
SELECT u.id, u.user, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
FROM "user" u LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE u.login = ${uname.toLowerCase()} LIMIT 1
`;
if (!rows.length) return res.reply({ code: 404, body: 'User not found' });
const other = rows[0];
return res.html(tpl.render('messages-conversation', {
session: req.session,
other,
hidePagination: true,
link: { main: '/messages', path: '/' },
domain: cfg.main.url.domain
}, req));
});
return router;
};

805
src/inc/routes/mod.mjs Normal file
View File

@@ -0,0 +1,805 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { promises as fs } from "fs";
import cfg from "../config.mjs";
import fetch from "flumm-fetch";
import https from "https";
import path from "path";
import { getManualApproval } from "../settings.mjs";
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
import { setMotd } from "../motd.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
export default (router, tpl) => {
// Moderator Dashboard
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
res.reply({
body: tpl.render("mod", {
session: req.session,
pendingCount: parseInt(pendingCount),
manualApproval: getManualApproval(),
tmp: null
}, req)
});
});
// Moderator Reports View
router.get(/^\/mod\/reports\/?$/, lib.modAuth, async (req, res) => {
res.reply({
body: tpl.render("mod_reports", {
session: req.session,
tmp: null
}, req)
});
});
// Approval Queue (Ported/Shared from Admin)
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
// Quick Approve Action
if (req.url.qs?.id) {
const id = +req.url.qs.id;
const f0ck = await db`
select i.dest, i.mime, i.username, i.id, ta.tag_id
from "items" i
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
where i.id = ${id} and i.active = false
limit 1
`;
if (f0ck.length === 0) {
return res.reply({ body: `f0ck ${id}: f0ck not found` });
}
// Fetch uploader details for audit log
let uploaderInfo = {};
try {
const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
if (uploader.length > 0) {
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
}
} catch (err) { }
// ACTION: Approve
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
// This prevents duplicate webhooks from double-clicks or race conditions.
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
if (result.count === 1) {
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
// Notify User (WebSocket/Internal)
try {
const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
if (uploader.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, item_id)
VALUES (${uploader[0].id}, 'approve', 0, ${id})
`;
}
} catch (err) {
console.error('[MOD APPROVE] Failed to notify user:', err);
}
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
}
}
// Move files to public location
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
for (const p of movePaths) {
try {
await fs.access(p.b);
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
};
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
await moveSafe(p.b, bDst);
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (f0ck[0].mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
break;
} catch (e) { }
}
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
return res.writeHead(302, { "Location": `/${id}` }).end();
}
// View Queue
const page = +req.url.qs.page || 1;
const limit = 20;
// Fetch Pending (not deleted)
const pending = await db`
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
where i.active = false and i.is_deleted = false
group by i.id
order by i.id desc
limit ${limit} offset ${(page - 1) * limit}
`;
// Fetch Trash (deleted)
const trash = await db`
select i.id, i.mime, i.username, i.dest,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
where i.active = false and i.is_deleted = true and i.is_purged = false
group by i.id
order by i.id desc
limit 20
`;
const processItems = (items) => {
return items.map(p => {
const tags = (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
});
return {
...p,
tags
};
});
};
res.reply({
body: tpl.render('mod/approve', {
pending: processItems(pending),
trash: processItems(trash),
page,
stats: { total: pending.length + trash.length },
session: req.session,
tmp: null
}, req)
});
});
// Deny / Delete Item
router.get(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
if (!req.url.qs?.id) return res.reply({ success: false, msg: "No ID provided" });
const id = +req.url.qs.id;
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`;
if (f0ck.length > 0) {
const item = f0ck[0];
if (item.is_deleted) {
// PURGE LOGIC (Strict Admin)
if (!req.session.admin) {
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
}
// Delete files — respect symlink ownership
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
// DB Flag instead of delete
await db`update "items" set is_purged = true where id = ${id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${id}`;
// Fetch uploader details for audit log
let uploaderInfo = {};
try {
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
if (uploader.length > 0) {
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
}
} catch (err) { }
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, ...uploaderInfo });
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, action: "purge", msg: "Item purged" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
return res.reply({ success: true, action: "purge" });
} else {
// DENY LOGIC (Move to trash)
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.b, item.dest), t: path.join(cfg.paths.t, `${id}.webp`), ca: path.join(cfg.paths.ca, `${id}.webp`) }
];
for (const p of movePaths) {
try {
await fs.access(p.b);
// Use safe move that handles symlink ownership
await moveToDeleted(item.dest, id);
await fs.copyFile(p.t, path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(p.t).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.copyFile(p.ca, path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(p.ca).catch(() => { });
}
break;
} catch (e) { }
}
const reason = req.url.qs?.reason || "Denied by moderator";
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
// Fetch uploader details for audit log and notification
let uploaderId = null;
let uploaderInfo = {};
try {
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
if (uploader.length > 0) {
uploaderId = uploader[0].id;
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
// Send Notification to User
await db`
INSERT INTO notifications (user_id, type, reference_id, item_id, data)
VALUES (${uploaderId}, 'deny', 0, ${id}, ${db.json({ reason })})
`;
}
} catch (err) {
console.error('[MOD DENY] Failed to notify uploader:', err);
}
await audit.log(req.session.id, 'deny_item', 'item', id, { filename: item.dest, reason, ...uploaderInfo });
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, action: "deny", msg: "Item denied" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
return res.reply({ success: true, action: "deny" });
}
}
return res.reply({ success: false, msg: "Item not found" });
});
// Audit Log View
router.get(/^\/mod\/audit\/?/, lib.modAuth, async (req, res) => {
const page = +(req.url.qs?.page || 1);
const limit = 50;
const offset = (page - 1) * limit;
const logs = await db`
SELECT al.*, u.user as username
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
const total = totalResult[0].c;
const pages = Math.ceil(total / limit);
const processLogEntry = (l) => {
const entry = { ...l };
let details = l.details;
if (typeof details === 'string') {
try {
details = JSON.parse(details);
} catch (e) {
details = {};
}
}
entry.created_at_fmt = new Date(l.created_at).toLocaleString();
// Also keep standard created_at for client side
entry.created_at = entry.created_at_fmt;
entry.reason = details ? details.reason : null;
entry.old_content = details ? details.old_content : null;
entry.new_content = details ? details.new_content : null;
entry.item_id = details ? details.item_id : null;
if (entry.action === 'toggle_tag' && details) {
if (details.action === 'added') {
entry.new_content = details.tag;
} else if (details.from) {
entry.old_content = details.from;
entry.new_content = details.tag;
}
}
if (details && details.uploader_name) {
entry.uploader_info = `Uploader: ${details.uploader_name} (ID: ${details.uploader_id || '?'})`;
}
let otherDetails = {};
if (details && typeof details === 'object') {
for (const [key, val] of Object.entries(details)) {
const isStandard = ['reason', 'old_content', 'new_content', 'item_id', 'uploader_name', 'uploader_id'].includes(key);
const isToggle = entry.action === 'toggle_tag' && ['tag', 'from', 'action'].includes(key);
if (!isStandard && !isToggle) {
otherDetails[key] = val;
}
}
}
entry.details_json = Object.keys(otherDetails).length > 0 ? JSON.stringify(otherDetails) : null;
delete entry.details;
return entry;
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const processed = logs.map(processLogEntry);
const body = JSON.stringify({
success: true,
logs: processed,
page,
pages,
hasMore: page < pages
});
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
const processedLogs = logs.map(processLogEntry);
res.reply({
body: tpl.render("mod/audit", {
session: req.session,
logs: processedLogs,
page,
pages,
tmp: null
}, req)
});
});
// Bulk Deny (POST)
router.post(/^\/mod\/deny-multi\/?/, lib.modAuth, async (req, res) => {
try {
const ids = req.post.ids;
if (!Array.isArray(ids)) throw new Error('ids must be an array');
let count = 0;
for (const id of ids) {
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${+id} limit 1`;
if (f0ck.length > 0) {
const item = f0ck[0];
if (item.is_deleted) {
// Purge (Strict Admin)
if (!req.session.admin) continue;
// Purge — respect symlink ownership
await safeDeleteMediaFile(item.dest, +id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${+id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${+id}`;
// Fetch uploader details for audit log
let uploaderInfo = {};
try {
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
if (uploader.length > 0) {
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
}
} catch (err) { }
await audit.log(req.session.id, 'purge_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo });
} else {
// Deny (Move to trash)
// Deny — safe move respecting symlink ownership
await moveToDeleted(item.dest, +id);
await fs.copyFile(path.join(cfg.paths.t, `${item.id}.webp`), path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.copyFile(path.join(cfg.paths.ca, `${item.id}.webp`), path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_deleted = true, active = false where id = ${+id}`;
// Fetch uploader details for audit log
let uploaderInfo = {};
try {
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
if (uploader.length > 0) {
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
}
} catch (err) { }
await audit.log(req.session.id, 'deny_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo });
}
count++;
}
}
return res.reply({ success: true, count });
} catch (err) {
return res.reply({ success: false, msg: lib.logError(err) }, 500);
}
});
// Serve pending files (Stream with Range support)
// Supports /mod/pending/b/filename.ext (Binaries)
// Supports /mod/pending/t/id.webp (Thumbnails)
router.get(/^\/mod\/pending\/(?<type>[btca])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
const { type, file } = req.params;
const filePath = path.join(cfg.paths.pending, type, file);
try {
const stats = await fs.stat(filePath);
const fileSize = stats.size;
const range = req.headers.range;
const ext = file.split('.').pop();
const mimeType = {
'mp4': 'video/mp4', 'webm': 'video/webm',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
}[ext] || 'application/octet-stream';
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const fileStream = (await import('fs')).createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': mimeType,
});
fileStream.pipe(res);
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': mimeType,
});
(await import('fs')).createReadStream(filePath).pipe(res);
}
} catch (err) {
console.error(err);
res.writeHead(404).end('File not found');
}
});
// Serve deleted files (Stream with Range support)
// Supports /mod/deleted/b/filename.ext (Binaries)
// Supports /mod/deleted/t/id.webp (Thumbnails)
router.get(/^\/mod\/deleted\/(?<type>[bt])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
const file = decodeURIComponent(req.params.file);
const type = req.params.type; // 'b' or 't'
console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`);
const filePath = path.join(cfg.paths.deleted, type, file);
try {
const stat = await fs.stat(filePath);
const fileSize = stat.size;
const range = req.headers.range;
const ext = file.split('.').pop();
const mimeType = {
'mp4': 'video/mp4', 'webm': 'video/webm',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
}[ext] || 'application/octet-stream';
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const fileStream = (await import('fs')).createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': mimeType,
});
fileStream.pipe(res);
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': mimeType,
});
(await import('fs')).createReadStream(filePath).pipe(res);
}
} catch (err) {
console.error(err);
res.writeHead(404).end('File not found');
}
});
// Purge Trash (POST) - Strict Admin
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
try {
// lib.auth already ensures session.admin
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
let count = 0;
for (const item of trash) {
try {
await safeDeleteMediaFile(item.dest, item.id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${item.id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${item.id}`;
count++;
} catch (e) { }
}
await audit.log(req.session.id, 'purge_trash', 'system', 0, { count });
const body = JSON.stringify({ success: true, count });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
} catch (err) {
const body = JSON.stringify({ success: false, msg: err.message });
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
});
// MOTD Routes
router.get(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => {
const settings = await db`SELECT value FROM site_settings WHERE key = 'motd' LIMIT 1`;
const motd = settings.length > 0 ? settings[0].value : '';
res.reply({
body: tpl.render("mod/motd", {
session: req.session,
motd: motd,
tmp: null
}, req)
});
});
router.post(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => {
try {
let motd = (req.post?.motd ?? '');
if (motd.trim().length > 0) {
// Append .t name suffix (display_name if set, otherwise username)
const name = req.session.display_name || req.session.user;
const suffix = ` t. ${name}`;
if (!motd.endsWith(suffix)) {
motd = motd.trim() + suffix;
}
}
await db`INSERT INTO site_settings (key, value) VALUES ('motd', ${motd}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setMotd(motd);
// Log it in audit
await audit.log(req.session.id, 'update_motd', 'system', 0, { motd });
try {
await db`SELECT pg_notify('motd', ${motd ? motd.substring(0, 7000) : ''})`;
} catch (e) {
console.error('[MOD MOTD] Notify failed:', e.message);
}
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const body = JSON.stringify({ success: true, motd });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
return res.writeHead(302, { "Location": "/mod/motd" }).end();
} catch (err) {
console.error('[MOD MOTD] Save failed:', err);
const msg = 'Failed to save MOTD: ' + err.message;
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const body = JSON.stringify({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
return res.reply({ code: 500, body: msg });
}
});
// Pin / Unpin Item
router.post(/^\/mod\/pin\/?/, lib.modAuth, async (req, res) => {
if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" }));
const id = +req.url.qs.id;
const result = await db`update "items" set is_pinned = true where id = ${id} and active = true`;
if (result.count === 1) {
await audit.log(req.session.id, 'pin_item', 'item', id);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: true }));
}
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found or not active" }));
});
router.post(/^\/mod\/unpin\/?/, lib.modAuth, async (req, res) => {
if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" }));
const id = +req.url.qs.id;
const result = await db`update "items" set is_pinned = false where id = ${id}`;
if (result.count === 1) {
await audit.log(req.session.id, 'unpin_item', 'item', id);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: false }));
}
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found" }));
});
// Hall Management
router.post(/^\/mod\/halls\/add\/?$/, lib.modAuth, async (req, res) => {
const { id, hall, description } = req.post;
if (!id || !hall) {
const body = JSON.stringify({ success: false, msg: "Missing id or hall" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
const result = await f0cklib.addItemToHall(id, hall, req.session.id, description);
if (result.success) {
await audit.log(req.session.id, 'add_to_hall', 'item', +id, { hall, description });
}
const body = JSON.stringify({ success: result.success, msg: result.message });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
});
router.post(/^\/mod\/halls\/remove\/?$/, lib.modAuth, async (req, res) => {
const { id, hall } = req.post;
if (!id || !hall) {
const body = JSON.stringify({ success: false, msg: "Missing id or hall" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
const result = await f0cklib.removeItemFromHall(id, hall);
if (result.success) {
await audit.log(req.session.id, 'remove_from_hall', 'item', +id, { hall });
}
const body = JSON.stringify({ success: result.success, msg: result.message });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
});
router.post(/^\/mod\/halls\/update\/?$/, lib.modAuth, async (req, res) => {
const { hall, description } = req.post;
if (!hall) {
const body = JSON.stringify({ success: false, msg: "Missing hall slug" });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
const result = await f0cklib.updateHallMetadata(hall, description);
if (result.success) {
await audit.log(req.session.id, 'update_hall_metadata', 'hall', 0, { hall, description });
}
const body = JSON.stringify({ success: result.success, msg: result.message });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
});
// Hall Manager page (also accessible by mods)
router.get(/^\/mod\/halls\/?$/, lib.modAuth, async (req, res) => {
const hallsList = await f0cklib.getHalls();
res.reply({
body: tpl.render('admin/halls', {
session: req.session,
hallsList,
tmp: null
}, req)
});
});
return router;
};

View File

@@ -0,0 +1,581 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { setMotd } from "../motd.mjs";
export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId
function pruneInactiveClients(sessionId, currentTabId) {
for (const client of clients) {
if (client.sessionId === sessionId && client.tabId !== currentTabId) {
console.log(`[SSE] Pruning inactive client ${client.tabId} for session ${sessionId}`);
client.close();
}
}
}
// Global listener for notifications
db.listen('notifications', (payload) => {
try {
const data = JSON.parse(payload);
const userId = data.user_id;
for (const client of clients) {
if (client.userId === userId) {
client.send({ type: 'notify', data });
}
}
} catch (e) {
console.error('Notification broadcast error:', e);
}
}).catch(err => console.error('DB Listen error:', err));
// Global listener for warnings
db.listen('warnings', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting warning to user ${data.user_id}`);
for (const client of clients) {
if (client.userId === data.user_id) {
client.send({ type: 'warning', data });
}
}
} catch (e) {
console.error('Warning broadcast error:', e);
}
}).catch(err => console.error('DB Listen Warning error:', err));
// Global listener for profile updates (display name changes etc.)
db.listen('profile_update', (payload) => {
try {
const data = JSON.parse(payload);
for (const client of clients) {
if (client.userId === data.user_id) {
client.send({ type: 'profile_update', data });
}
}
} catch (e) {
console.error('Profile update broadcast error:', e);
}
}).catch(err => console.error('DB Listen Profile Update error:', err));
// Global listener for activity
db.listen('activity', async (payload) => {
try {
const data = JSON.parse(payload);
// We need the username, avatar, and item mime for the preview
// trigger only gave us user_id and item_id
const [details] = await db`
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON i.id = ${data.item_id}
WHERE u.id = ${data.user_id}
`;
if (details) {
data.username = details.username;
data.avatar = details.avatar;
data.avatar_file = details.avatar_file;
data.mime = details.mime;
data.username_color = details.username_color;
data.display_name = details.display_name || null;
data.tag_id = details.tag_id;
} else {
data.username = 'System';
}
// Broadcast to ALL connected clients
for (const client of clients) {
client.send({ type: 'activity', data });
}
} catch (e) {
console.error('Activity broadcast error:', e);
}
}).catch(err => console.error('DB Listen Activity error:', err));
// Global listener for tags
db.listen('tags', async (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting tag update for item ${data.item_id} to ${clients.size} clients`);
// Broadcast to ALL connected clients
for (const client of clients) {
client.send({ type: 'tags', data });
}
} catch (e) {
console.error('Tag broadcast error:', e);
}
}).catch(err => console.error('DB Listen Tag error:', err));
// Global listener for comments
db.listen('comments', async (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting comment update (${data.type}) for item ${data.item_id} to ${clients.size} clients`);
// Broadcast to ALL connected clients
for (const client of clients) {
client.send({ type: 'comments', data });
}
} catch (e) {
console.error('Comment broadcast error:', e);
}
}).catch(err => console.error('DB Listen Comment error:', err));
// Global listener for favorites
db.listen('favorites', async (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting favorite update for item ${data.item_id} to ${clients.size} clients`);
// Broadcast to ALL connected clients
for (const client of clients) {
client.send({ type: 'favorites', data });
}
} catch (e) {
console.error('Favorite broadcast error:', e);
}
}).catch(err => console.error('DB Listen Favorite error:', err));
// Global listener for MOTD
db.listen('motd', (payload) => {
try {
console.log(`[SSE] Broadcasting MOTD update to ${clients.size} clients`);
setMotd(payload);
for (const client of clients) {
client.send({ type: 'motd', data: { motd: payload } });
}
} catch (e) {
console.error('MOTD broadcast error:', e);
}
}).catch(err => console.error('DB Listen MOTD error:', err));
// Global listener for new items (live grid updates)
db.listen('new_item', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'new_item', data });
}
} catch (e) {
console.error('New item broadcast error:', e);
}
}).catch(err => console.error('DB Listen new_item error:', err));
// Global listener for item deletions — broadcasts to all clients so they can remove the item live
db.listen('delete_item', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting delete_item (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'delete_item', data });
}
} catch (e) {
console.error('Delete item broadcast error:', e);
}
}).catch(err => console.error('DB Listen delete_item error:', err));
// Global listener for emoji updates
db.listen('emojis_updated', () => {
try {
console.log(`[SSE] Broadcasting emojis_updated to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'emojis_updated' });
}
} catch (e) {
console.error('Emoji update broadcast error:', e);
}
}).catch(err => console.error('DB Listen emojis_updated error:', err));
// Global listener for private messages — deliver only to the recipient
db.listen('private_message', (payload) => {
try {
const data = JSON.parse(payload);
// Only send to the recipient — sender already knows they sent it
for (const client of clients) {
if (client.userId === data.recipient_id) {
client.send({ type: 'private_message', data: {
id: data.id,
sender_id: data.sender_id,
created_at: data.created_at
}});
}
}
} catch (e) {
console.error('Private message broadcast error:', e);
}
}).catch(err => console.error('DB Listen private_message error:', err));
// Global listener for global chat messages
db.listen('global_chat', async (payload) => {
try {
const data = JSON.parse(payload);
// Enrich with user info
const [user] = await db`
SELECT u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id
WHERE u.id = ${data.user_id}
`;
if (user) {
data.username = user.username;
data.avatar = user.avatar;
data.avatar_file = user.avatar_file;
data.username_color = user.username_color;
data.display_name = user.display_name || null;
}
for (const client of clients) {
client.send({ type: 'global_chat', data });
}
} catch (e) {
console.error('Global chat broadcast error:', e);
}
}).catch(err => console.error('DB Listen global_chat error:', err));
// Global listener for chat clear — broadcast to all clients immediately
db.listen('global_chat_clear', () => {
try {
console.log(`[SSE] Broadcasting global_chat_clear to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'global_chat_clear' });
}
} catch (e) {
console.error('Global chat clear broadcast error:', e);
}
}).catch(err => console.error('DB Listen global_chat_clear error:', err));
// Global listener for single chat message deletion
db.listen('global_chat_delete', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting global_chat_delete (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'global_chat_delete', data });
}
} catch (e) {
console.error('Global chat delete broadcast error:', e);
}
}).catch(err => console.error('DB Listen global_chat_delete error:', err));
// Global listener for chat panel background changes
db.listen('global_chat_background', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting global_chat_background to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'global_chat_background', data });
}
} catch (e) {
console.error('Global chat background broadcast error:', e);
}
}).catch(err => console.error('DB Listen global_chat_background error:', err));
// Global listener for chat topic changes
db.listen('global_chat_topic', (payload) => {
try {
const data = JSON.parse(payload);
for (const client of clients) {
client.send({ type: 'global_chat_topic', data });
}
} catch (e) {
console.error('Global chat topic broadcast error:', e);
}
}).catch(err => console.error('DB Listen global_chat_topic error:', err));
export default (router, tpl) => {
async function getNotificationHistory(userId, page = 1, limit = 50) {
const offset = (page - 1) * limit;
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${userId}
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
`;
const hasMore = notifications.length > limit;
if (hasMore) notifications.pop();
// Pre-process for template
const processed = notifications.map(n => {
let reason = 'No reason provided';
if (n.data) {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
return { ...n, reason };
});
return {
notifications: processed,
hasMore,
page
};
}
// Get unread notifications
router.get('/api/notifications', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT 20
`;
const processed = notifications.map(n => {
let reason = 'No reason provided';
if (n.data) {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
return { ...n, reason };
});
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, notifications: processed })
});
} catch (err) {
console.error(err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Mark all as read
router.post('/api/notifications/read', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Mark single as read (optional, for clicking)
router.post(/\/api\/notifications\/(?<id>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const id = req.params.id;
console.log(`[NotificationRoute] Marking notification ${id} as read for user ${req.session.id}`);
try {
await db`UPDATE notifications SET is_read = true WHERE id = ${id} AND user_id = ${req.session.id}`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Mark all notifications for a specific item as read
// 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;
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report'];
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} 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 NOT (type = ANY(${SYSTEM_TYPES}))
`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// SSE Stream
router.get('/api/notifications/stream', (req, res) => {
const tabId = req.url.qs?.tabId || 'unknown';
const sessionCookie = req.cookies?.session;
const isGuest = !sessionCookie;
// Use session cookie as the primary identifier.
// For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
const sessionId = sessionCookie || `guest-${tabId}`;
// Pruning/Active logic only for logged-in users
if (!isGuest) {
const currentActive = activeTabs.get(sessionId);
if (currentActive && currentActive !== tabId) {
// Check if the current active tab is actually still connected
const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive);
if (activeClient) {
// console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`);
res.writeHead(204); // No Content
return res.end();
}
}
}
const headers = {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no' // Prevent Nginx from buffering
};
res.writeHead(200, headers);
res.write(': ok\n\n'); // Warmup
const client = {
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
sessionId,
tabId,
send: (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch (err) {
// console.error('[SSE] Failed to send to client:', err.message);
}
},
close: () => {
try {
res.end();
} catch (err) {}
}
};
// Send any unacknowledged warnings on connection
if (!isGuest && req.session?.id) {
db`
SELECT id, reason
FROM user_warnings
WHERE user_id = ${req.session.id} AND acknowledged = FALSE
ORDER BY created_at ASC
`.then(warnings => {
warnings.forEach(warning => {
client.send({
type: 'warning',
data: {
warning_id: warning.id,
reason: warning.reason
}
});
});
}).catch(e => console.error('[SSE] Failed to fetch initial warnings:', e));
}
// Set as active tab and prune others (only for logged-in users)
if (!isGuest) {
activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
}
clients.add(client);
// Keep-alive ping
const pingInterval = setInterval(() => {
try {
res.write(': ping\n\n');
} catch (e) {
// Connection likely closed
}
}, 30000);
res.on('close', () => {
clearInterval(pingInterval);
clients.delete(client);
if (activeTabs.get(sessionId) === tabId) {
// activeTabs.delete(sessionId); // Keep it set so we know who was last active
}
});
});
// Active Signal
router.get('/api/notifications/active', (req, res) => {
const tabId = req.url.qs?.tabId;
const sessionId = req.cookies?.session;
// Only track active tabs for logged-in users
if (tabId && sessionId) {
console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`);
activeTabs.set(sessionId, tabId);
pruneInactiveClients(sessionId, tabId);
return res.reply({ body: JSON.stringify({ success: true }) });
}
// For guests, this is a no-op
return res.reply({ body: JSON.stringify({ success: true }) });
});
// Notification History Page
router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login');
const data = await getNotificationHistory(req.session.id, 1);
data.session = req.session;
data.hidePagination = true;
data.pagination = {
page: 1,
next: data.hasMore ? 2 : null
};
data.link = { main: '/notifications', path: '/' };
data.domain = cfg.main.url.domain; // For header
return res.html(tpl.render('notifications', data, req));
});
// AJAX Notification History
router.get('/ajax/notifications', async (req, res) => {
if (!req.session) return res.json({
success: false
}, 401);
const page = parseInt(req.url.qs.page) || 1;
const data = await getNotificationHistory(req.session.id, page);
const html = tpl.render('snippets/notifications-list', data, req);
return res.json({
success: true,
html,
hasMore: data.hasMore,
currentPage: page,
nextPage: data.hasMore ? page + 1 : null
});
});
return router;
};

View File

@@ -0,0 +1,26 @@
import db from "../../inc/sql.mjs";
import cfg from "../../inc/config.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
export default (router, tpl) => {
router.get(/^\/picdump$/, async (req, res) => {
const dump = await db`
SELECT *
FROM items
WHERE (
to_timestamp(stamp) >= date_trunc('week', CURRENT_TIMESTAMP - interval '1 week') AND
to_timestamp(stamp) < date_trunc('week', CURRENT_TIMESTAMP)
) AND
mime LIKE 'image/%'
ORDER BY stamp DESC
`;
res.reply({
body: tpl.render('picdump', {
dump,
tmp: null
}, req)
});
});
return router;
};

70
src/inc/routes/random.mjs Normal file
View File

@@ -0,0 +1,70 @@
import cfg from "../../inc/config.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
export default (router, tpl) => {
router.get(/^\/random$/, async (req, res) => {
let referer = req.headers.referer ?? '';
let opts = {};
if (referer) {
try {
const refUrl = new URL(referer);
const path = refUrl.pathname;
const query = refUrl.search;
console.log("[RANDOM] Parsing referer path:", path);
// Regex that is less strict about start/end but captures groups
// Captures: /h/slug, /tag/name, /user/name/(f0cks|favs), /(video|audio|image), /p/N, /ID
const hallMatch = path.match(/\/h\/(?<hall>[^/]+)/);
const tagMatch = path.match(/\/tag\/(?<tag>[^/]+)/);
const userMatch = path.match(/\/user\/(?<user>[^/]+)\/(?<mode>f0cks|favs)/);
const mimeMatch = path.match(/\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*)/);
const pageMatch = path.match(/\/p\/(?<page>\d+)/);
const itemMatch = path.match(/\/(?<itemid>\d+)$/);
if (hallMatch) opts.hall = hallMatch.groups.hall;
if (tagMatch) opts.tag = tagMatch.groups.tag;
if (userMatch) {
opts.user = userMatch.groups.user;
opts.mode = userMatch.groups.mode;
}
if (mimeMatch) opts.mime = mimeMatch.groups.mime;
if (pageMatch) opts.page = pageMatch.groups.page;
if (query.includes('strict=1')) opts.strict = true;
console.log("[RANDOM] Detected opts:", opts);
} catch (e) {
console.error("[RANDOM] Failed to parse referer URL:", referer, e);
}
}
const data = await f0cklib.getRandom({
user: opts.user,
tag: opts.tag,
hall: opts.hall,
mime: opts.mime || (req.cookies.mime || null),
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
strict: opts.strict,
session: !!req.session
});
console.log("data", data);
if (!data.success) {
return res.reply({
code: 404,
body: tpl.render('error', {
message: data.message,
tmp: null
}, req)
});
}
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`));
});
return router;
};

View File

@@ -0,0 +1,95 @@
import db from "../../inc/sql.mjs";
import lib from "../lib.mjs";
import config from "../config.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import fetch from "flumm-fetch";
export default (router, tpl) => {
router.get(/^\/ranking$/, lib.loggedin, async (req, res) => {
try {
const list = await db`
select
"user".user, "user".admin,
coalesce("user_options".avatar, ${await lib.getDefaultAvatar()}) as avatar,
"user_options".avatar_file,
"user_options".display_name,
count(distinct(tag_id, item_id)) as count
from "tags_assign"
left join "user" on "user".id = "tags_assign".user_id
left join "user_options" on "user_options".user_id = "user".id
group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
order by count desc
`;
const stats = await lib.countf0cks();
const hoster = await db`
with t as (
select
split_part(substring(src, position('//' in src)+2), '/', 1) part
from items
)
select t.part, count(t.part) as c
from t
group by t.part
order by c desc
limit 20
`;
const favotop = await db`
select favorites.item_id, count(*) favs
from favorites
join items on items.id = favorites.item_id
where items.active = true
group by favorites.item_id
having count(*) > 1
order by favs desc
limit 10
`;
let xdtop = [];
if (config.websrv.enable_xd_score) {
const xdRows = await db`
select id, xd_score
from items
where active = true and is_deleted = false and xd_score > 0
order by xd_score desc
limit 10
`;
xdtop = xdRows.map(item => {
const meta = f0cklib.xdScoreMeta(item.xd_score);
return {
...item,
xd_tier: meta.tier,
xd_label: meta.label
};
});
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
res.reply({
body: tpl.render('ranking', {
list,
stats,
hoster,
favotop,
xdtop,
tmp: null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'ranking',
description: 'User ranking and site statistics',
url: `https://${config.main.url.domain}/ranking`
}
}, req)
});
} catch (err) {
res.end(JSON.stringify(err.message));
}
});
return router;
};

193
src/inc/routes/register.mjs Normal file
View File

@@ -0,0 +1,193 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import security from "../security.mjs";
import { getRegistrationOpen, getDefaultLayout } from "../settings.mjs";
import { sendMail } from "../../lib/smtp.mjs";
import cfg from "../config.mjs";
import crypto from "crypto";
export default (router, tpl) => {
router.get(/^\/register(\/)?$/, async (req, res) => {
if (req.session) {
return res.writeHead(302, { "Location": "/?already_logged_in=1" }).end();
}
let url = "/#register";
if (req.url.qs?.token) url += `:${req.url.qs.token}`;
return res.writeHead(302, { "Location": url }).end();
});
router.get(/^\/activate\/(?<token>.+)$/, async (req, res) => {
const token = req.params.token;
const user = await db`SELECT id FROM "user" WHERE activation_token = ${token}`;
if (user.length === 0) {
const errorMsg = encodeURIComponent("Invalid or expired activation token.");
return res.writeHead(302, { "Location": `/#register:error:${errorMsg}` }).end();
}
await db`UPDATE "user" SET activated = TRUE, activation_token = NULL WHERE id = ${user[0].id}`;
const successMsg = encodeURIComponent("Account activated! You can now login.");
return res.writeHead(302, { "Location": `/#register:success:${successMsg}` }).end();
});
router.post(/^\/register(\/)?$/, async (req, res) => {
let { username, email, password, password_confirm, token, email_confirm_field } = req.post;
if (username) username = username.trim();
const ip = security.getRealIP(req);
// Honeypot check
if (email_confirm_field) {
console.log(`[SPAM] Honeypot triggered by IP: ${ip}, User: ${username}`);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Spam detected." }));
}
if (await security.isRateLimited(ip, null, 'register')) {
const errorMsg = "Too many attempts.";
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
});
}
const renderError = async (msg) => {
await security.recordAttempt(ip, username, 'register', false);
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
});
};
const renderSuccess = async (msg) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
});
};
// Input Validation
if (!username || username.trim().length === 0) {
return renderError("Username is required.");
}
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
}
if (!password || password.length < 20) {
return renderError("Password must be at least 20 characters long.");
}
if (password !== password_confirm) {
return renderError("Passwords do not match.");
}
// Registration Logic
let activated = true;
let activationToken = null;
if (!token && !getRegistrationOpen()) {
return renderError("Invite token is required for registration.");
}
if (token) {
const tokenRow = await db`
select * from invite_tokens where token = ${token} and is_used = false
`;
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
// Token used, so it will be activated by default
} else {
// No token, Open Registration
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
activated = false;
activationToken = crypto.randomBytes(32).toString('hex');
}
// Check user existence
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()} or "user" = ${username}`;
if (existing.length > 0) return renderError("Username taken");
// Create User
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
let userId;
try {
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
returning id
`;
userId = newUser[0].id;
// Assign default avatar file
const avatarId = null;
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);
if (err.code === '23505') { // Unique constraint violation
return renderError("Username taken");
}
return renderError("An unexpected error occurred. Please try again later.");
}
// If not activated, send email and return success message
if (!activated) {
const activationLink = `${cfg.main.url.full}/activate/${activationToken}`;
const mailBody = `Hello ${username},\n\nThank you for registering. Please activate your account by clicking the link below:\n\n${activationLink}\n\nIf you did not request this, please ignore this email.`;
try {
if (cfg.smtp && cfg.smtp.host) {
await sendMail(cfg.smtp, {
to: email,
subject: "Activate your account",
body: mailBody
});
} else {
console.log(`[SMTP] No configuration found. Activation link: ${activationLink}`);
}
} catch (e) {
console.error(`[SMTP] Send failed:`, e.message);
// We'll still proceed since the user is created, but they'll be stuck.
// In production they should see an error, but let's keep it simple for now.
}
await renderSuccess("Registration successful! Please check your email to activate your account.");
return;
}
if (token) {
const tokenRow = await db`select id from invite_tokens where token = ${token} and is_used = false`;
if (tokenRow.length > 0) {
await db`
update invite_tokens
set is_used = true, used_by = ${userId}
where id = ${tokenRow[0].id}
`;
}
}
await security.recordAttempt(ip, username, 'register', true);
const successMsg = "Registration successful! You can now login.";
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
}
// Redirect to home with login success message
return res.writeHead(302, { "Location": "/?login=success" }).end();
});
return router;
};

167
src/inc/routes/reports.mjs Normal file
View File

@@ -0,0 +1,167 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
export default (router, tpl) => {
// User: Submit a new report
router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
if (!reason || reason.trim().length === 0) {
return res.json({ success: false, msg: "Reason is required." }, 400);
}
// At least one target must be specified
if (!item_id && !comment_id && !reported_user_id) {
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
}
const reportRes = await db`
INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
VALUES (
${req.session.id},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
${reason.trim()}
)
RETURNING id
`;
const report_id = reportRes[0].id;
// Notify all mods and admins
try {
const mods = await db`SELECT id FROM "user" WHERE admin = true OR is_moderator = true`;
if (mods.length > 0) {
let resolved_item_id = item_id ? +item_id : null;
// If reporting a comment, resolve the item_id it belongs to
if (!resolved_item_id && comment_id) {
const comm = await db`SELECT item_id FROM comments WHERE id = ${comment_id}`;
if (comm.length > 0) resolved_item_id = comm[0].item_id;
}
const notificationsToAdd = mods.map(m => ({
user_id: m.id,
type: 'report',
reference_id: report_id,
item_id: resolved_item_id // Can be null now after migration
}));
await db`INSERT INTO notifications ${db(notificationsToAdd)}`;
}
} catch (notifyErr) {
console.error('[REPORT] Failed to send notifications:', notifyErr);
}
return res.json({ success: true, msg: "Report submitted successfully." });
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
// Mod/Admin: Get pending/all reports
router.get(/^\/api\/v2\/mod\/reports\/?$/, lib.modAuth, async (req, res) => {
try {
const status = req.url.qs?.status || 'pending';
const page = +(req.url.qs?.page || 1);
const limit = 20;
const offset = (page - 1) * limit;
const reports = await db`
SELECT r.*,
rep.user AS reporter_name,
COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name,
COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id,
COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin,
i.dest AS item_dest,
tgt_auth.id AS item_user_id,
tgt_auth.user AS item_user_name,
(SELECT coalesce(json_agg(json_build_object('id', t.id, 'tag', t.tag, 'normalized', t.normalized)), '[]')
FROM tags_assign ta
JOIN tags t ON ta.tag_id = t.id
WHERE ta.item_id = COALESCE(r.item_id, c.item_id)) as item_tags,
c.content AS comment_body,
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
COALESCE(i.mime, ci.mime) AS resolved_item_mime
FROM reports r
LEFT JOIN "user" rep ON r.reporter_id = rep.id
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
LEFT JOIN items i ON r.item_id = i.id
LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user
LEFT JOIN comments c ON r.comment_id = c.id
LEFT JOIN items ci ON c.item_id = ci.id
LEFT JOIN "user" comm_auth ON c.user_id = comm_auth.id
WHERE r.status = ${status}
ORDER BY r.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
// Compute badges for tags (sync with lib.getTags logic)
for (const r of reports) {
if (r.item_tags) {
for (const t of r.item_tags) {
if (t.tag.startsWith(">")) t.badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") t.badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") t.badge = "badge-russia badge-light";
else if (t.normalized === "german") t.badge = "badge-german badge-light";
else if (t.normalized === "dutch") t.badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") t.badge = "badge-success";
else if (t.normalized === "nsfw") t.badge = "badge-danger";
else t.badge = "badge-light";
}
}
}
const totalRes = await db`SELECT count(*) as c FROM reports WHERE status = ${status}`;
const total = totalRes[0].c;
const emojis = await db`SELECT name, url FROM custom_emojis`;
return res.json({
success: true,
reports,
emojis,
page,
pages: Math.ceil(total / limit),
total
});
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
// Mod/Admin: Resolve a report
router.post(/^\/api\/v2\/mod\/reports\/(?<id>\d+)\/resolve\/?$/, lib.modAuth, async (req, res) => {
try {
const id = +req.params.id;
const { action } = req.post; // 'resolved' or 'rejected'
if (!['resolved', 'rejected'].includes(action)) {
return res.json({ success: false, msg: "Invalid action. Must be 'resolved' or 'rejected'." }, 400);
}
const result = await db`
UPDATE reports
SET status = ${action}, resolved_by = ${req.session.id}
WHERE id = ${id}
RETURNING id
`;
if (result.length === 0) {
return res.json({ success: false, msg: "Report not found." }, 404);
}
await audit.log(req.session.id, 'resolve_report', 'report', id, { status: action });
return res.json({ success: true, msg: `Report marked as ${action}.` });
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
return router;
};

316
src/inc/routes/scroller.mjs Normal file
View File

@@ -0,0 +1,316 @@
import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
// Serve the scroller page
router.get(/^\/abyss\/?$/, async (req, res) => {
if (cfg.websrv.private_society && !req.session) {
return res.reply({ code: 502, body: '<html><body>502 Bad Gateway</body></html>' });
}
return res.reply({
body: tpl.render('scroller', {
tmp: null,
session: req.session ? { ...req.session } : false,
enable_nsfl: !!cfg.enable_nsfl,
enable_swf: !!cfg.websrv.enable_swf,
page_meta: {
title: 'doomscroll',
description: 'Scroll through content endlessly',
url: `https://${cfg.main.url.domain}/abyss`
}
}, req)
});
});
// Lightweight meta refresh — returns live counts + tags for a batch of item IDs
// GET /api/v2/scroller/meta?ids=1,2,3
router.get(/^\/api\/v2\/scroller\/meta\/?$/, async (req, res) => {
if (cfg.websrv.private_society && !req.session) {
return res.reply({ code: 502, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
}
const qs = req.url.qs || {};
const ids = (qs.ids || '').split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 50);
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const sid = req.session ? +req.session.id : null;
try {
const rows = await db`
SELECT
items.id,
(SELECT string_agg(t.tag, ', ' ORDER BY ta2.tag_id)
FROM tags_assign ta2 JOIN tags t ON t.id = ta2.tag_id
WHERE ta2.item_id = items.id AND ta2.tag_id > 2 LIMIT 5) AS tag_list,
(SELECT COUNT(*) FROM favorites WHERE favorites.item_id = items.id) AS fav_count,
(SELECT COUNT(*) FROM comments WHERE comments.item_id = items.id AND comments.is_deleted = false) AS comment_count,
${sid ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sid})` : db`false`} AS is_faved
FROM items
WHERE items.id = ANY(${ids}::int[])
`;
const result = {};
for (const row of rows) {
result[row.id] = {
tags: row.tag_list || '',
fav_count: +row.fav_count || 0,
comment_count: +row.comment_count || 0,
is_faved: row.is_faved || false
};
}
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
body: JSON.stringify(result)
});
} catch (e) {
return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
}
});
// Tag autocomplete endpoint
router.get(/^\/api\/v2\/scroller\/tags\/?$/, async (req, res) => {
if (cfg.websrv.private_society && !req.session) {
return res.reply({ code: 502, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
}
const qs = req.url.qs || {};
const q = (qs.q || '').trim();
if (q.length < 1) {
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
}
try {
const slug = '%' + lib.slugify(q) + '%';
const rows = await db`
SELECT t.tag, t.normalized, COUNT(ta.item_id) as uses
FROM tags t
JOIN tags_assign ta ON ta.tag_id = t.id
WHERE t.id > 2
AND lower(t.normalized) ILIKE ${slug}
GROUP BY t.tag, t.normalized
ORDER BY uses DESC
LIMIT 10
`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rows.map(r => ({ tag: r.tag, normalized: r.normalized, uses: +r.uses })))
});
} catch (e) {
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
}
});
// JSON API: returns a batch of items for the scroller
router.get(/^\/api\/v2\/scroller\/feed\/?$/, async (req, res) => {
if (cfg.websrv.private_society && !req.session) {
return res.reply({
code: 502,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, items: [] })
});
}
const qs = req.url.qs || {};
const mode = qs.mode !== undefined ? +qs.mode : req.mode;
const limit = Math.min(+qs.limit || 12, 30);
const after = qs.after ? +qs.after : null;
const mime = qs.mime || null;
const tagFilter = qs.tag ? qs.tag.trim() : null;
const orderby = qs.orderby === 'newest' ? 'newest' : (qs.orderby === 'oldest' ? 'oldest' : 'random');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
// exclude= is a comma-separated list of item IDs already seen by the client (random mode dedup)
const excludeIds = qs.exclude
? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500)
: [];
const modeQuery = lib.getMode(mode ?? 0);
const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
// anchor= is a specific item ID to include first in the batch (used for hash-based deep links)
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
// MIME filter — SWF excluded unless the server has enable_swf turned on
const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie'];
const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``;
const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})`
: db``;
// Tag filter — support comma-separated list; items matching ANY tag are included (OR)
let tagSQL = db``;
if (tagFilter) {
const tagTerms = tagFilter.split(',').map(t => t.trim()).filter(Boolean);
if (tagTerms.length === 1) {
// Single tag: partial ILIKE match
const slug = '%' + lib.slugify(tagTerms[0]) + '%';
tagSQL = db`AND items.id IN (
SELECT ta.item_id FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE lower(t.normalized) ILIKE ${slug}
)`;
} else {
// Multiple tags: item must match at least ONE tag (OR / union)
const slugs = tagTerms.map(term => '%' + lib.slugify(term) + '%');
tagSQL = db`AND items.id IN (
SELECT ta.item_id FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ${slugs.map(s => db`lower(t.normalized) ILIKE ${s}`).reduce((a, b) => db`${a} OR ${b}`)}
)`;
}
}
// Cursor (pagination) — direction depends on order
let cursorSQL = db``;
if (after) {
if (orderby === 'newest') cursorSQL = db`AND items.id < ${after}`;
else if (orderby === 'oldest') cursorSQL = db`AND items.id > ${after}`;
// random: after-param not used; use exclude list instead
}
// Random mode: exclude all IDs the client has already seen
const excludeSQL = (orderby === 'random' && excludeIds.length > 0)
? db`AND items.id != ALL(${excludeIds}::int[])`
: db``;
// Order
const orderSQL = orderby === 'newest'
? db`ORDER BY items.id DESC`
: orderby === 'oldest'
? db`ORDER BY items.id ASC`
: db`ORDER BY random()`;
// Reusable SELECT columns fragment helper
const selectCols = (sessionId) => db`
items.id, items.mime, items.dest, items.username, items.stamp, items.src, items.is_oc,
uo.display_name, uo.avatar, uo.avatar_file, uo.username_color,
(
SELECT string_agg(t.tag, ', ' ORDER BY ta2.tag_id)
FROM tags_assign ta2 JOIN tags t ON t.id = ta2.tag_id
WHERE ta2.item_id = items.id AND ta2.tag_id > 2 LIMIT 5
) AS tag_list,
(SELECT ta3.tag_id FROM tags_assign ta3
WHERE ta3.item_id = items.id AND ta3.tag_id IN (1,2,${cfg.nsfl_tag_id || 3})
ORDER BY ta3.tag_id LIMIT 1) AS rating_tag_id,
(SELECT COUNT(*) FROM favorites WHERE favorites.item_id = items.id) AS fav_count,
(SELECT COUNT(*) FROM comments WHERE comments.item_id = items.id AND comments.is_deleted = false) AS comment_count,
${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved
`;
const sid = req.session ? +req.session.id : null;
try {
let rows;
if (anchorId) {
// Fetch the anchor item guaranteed first, then fill the rest randomly
const anchorRows = await db`
SELECT ${selectCols(sid)}
FROM items
LEFT JOIN "user" author_u ON author_u.user = items.username
LEFT JOIN user_options uo ON uo.user_id = author_u.id
WHERE items.id = ${anchorId}
AND items.active = true
AND ${db.unsafe(modeQuery)}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
`;
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
// Return empty so the frontend shows "This post is currently unavailable".
if (anchorRows.length === 0) {
rows = [];
} else {
const restRows = await db`
SELECT ${selectCols(sid)}
FROM items
LEFT JOIN "user" author_u ON author_u.user = items.username
LEFT JOIN user_options uo ON uo.user_id = author_u.id
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
${excludeSwfSQL}
AND items.id != ${anchorId}
${excludeSQL}
${mimeSQL}
${tagSQL}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${limit - 1}
`;
rows = [...anchorRows, ...restRows];
}
} else {
rows = await db`
SELECT ${selectCols(sid)}
FROM items
LEFT JOIN "user" author_u ON author_u.user = items.username
LEFT JOIN user_options uo ON uo.user_id = author_u.id
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
${excludeSwfSQL}
${cursorSQL}
${excludeSQL}
${mimeSQL}
${tagSQL}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
${orderSQL}
LIMIT ${limit}
`;
}
const items = rows.map(row => {
const isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
const isYouTube = row.mime === 'video/youtube';
const isAudio = row.mime && row.mime.startsWith('audio');
const isImage = row.mime && row.mime.startsWith('image');
let dest = row.dest;
if (!isYouTube && dest) dest = `${cfg.websrv.paths.images}/${row.dest}`;
const thumbnail = `${cfg.websrv.paths.thumbnails}/${row.id}.webp`;
let ratingLabel = '?'; let ratingClass = 'untagged';
if (row.rating_tag_id == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
else if (row.rating_tag_id == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
else if (row.rating_tag_id == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
return {
id: row.id,
mime: row.mime,
dest,
thumbnail,
username: row.username,
display_name: row.display_name || row.username,
avatar: row.avatar_file ? `/a/${row.avatar_file}` : (row.avatar ? `/t/${row.avatar}.webp` : '/a/default.png'),
username_color: row.username_color || null,
stamp: row.stamp,
timeago: lib.timeAgo(new Date(row.stamp * 1e3).toISOString()),
tags: row.tag_list || '',
is_oc: row.is_oc || false,
is_faved: row.is_faved || false,
fav_count: +row.fav_count || 0,
comment_count: +row.comment_count || 0,
is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'),
is_video: isVideo,
is_youtube: isYouTube,
is_audio: isAudio,
is_image: isImage,
rating_label: ratingLabel,
rating_class: ratingClass,
src_host: row.src ? (() => { try { return new URL(row.src).hostname; } catch { return ''; } })() : ''
};
});
// For ordered feeds, track last id for cursor
const lastItem = items[items.length - 1];
const nextCursor = lastItem ? lastItem.id : null;
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
body: JSON.stringify({ success: true, items, nextCursor })
});
} catch (e) {
console.error('[SCROLLER] Feed error:', e);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, items: [], error: e.message })
});
}
});
return router;
};

185
src/inc/routes/search.mjs Normal file
View File

@@ -0,0 +1,185 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import search from "../routeinc/search.mjs";
const _eps = 20;
export default (router, tpl) => {
router.get(/^\/search(\/)?$/, lib.loggedin, async (req, res) => {
let ret;
let mode = req.url.qs.mode;
let tag = req.url.qs.tag ?? [];
let page = req.url.qs.page ?? 1;
let total = 0;
let pagination, link;
if (tag.length > 0) {
if (tag.startsWith('src:')) {
total = (await db`
select count(*) as total
from "items"
where src ilike ${'%' + tag.substring(4) + '%'} and active = true
group by "items".id
`).length;
const pages = +Math.ceil(total / _eps);
const act_page = Math.min(pages, page || 1);
const offset = Math.max(0, (act_page - 1) * _eps);
ret = await db`
select *
from "items"
where
src ilike ${'%' + tag.substring(4) + '%'} and
active = true
group by "items".id
order by "items".id desc
offset ${offset}
limit ${_eps}
`;
const cheat = [];
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i);
pagination = {
start: 1,
end: pages,
prev: (act_page > 1) ? act_page - 1 : null,
next: (act_page < pages) ? act_page + 1 : null,
page: act_page,
cheat: cheat,
uff: false
};
link = {
main: `/search/?tag=${tag}`,
path: '&page='
};
}
else if (mode === 'strict') {
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);
if (tags.length > 0) {
const lowerTags = tags.map(t => t.toLowerCase());
const countResult = await db`
select count(sub.id) as total from (
select "items".id
from "items"
join "tags_assign" on "tags_assign".item_id = "items".id
join "tags" on "tags".id = "tags_assign".tag_id
where lower("tags".tag) in (${db(lowerTags)})
and "items".active = true
group by "items".id
having count(distinct lower("tags".tag)) = ${lowerTags.length}
) sub
`;
total = countResult.length > 0 ? countResult[0].total : 0;
const pages = +Math.ceil(total / _eps);
const act_page = Math.min(pages, page || 1);
const offset = Math.max(0, (act_page - 1) * _eps);
const rows = await db`
select "items".id, "items".username, "items".mime, min("tags".tag) as tag
from "items"
join "tags_assign" on "tags_assign".item_id = "items".id
join "tags" on "tags".id = "tags_assign".tag_id
where lower("tags".tag) in (${db(lowerTags)})
and "items".active = true
group by "items".id
having count(distinct lower("tags".tag)) = ${lowerTags.length}
order by "items".id desc
offset ${offset}
limit ${_eps}
`;
ret = rows.map(r => ({ ...r, score: 1.0 }));
const cheat = [];
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i);
pagination = {
start: 1,
end: pages,
prev: (act_page > 1) ? act_page - 1 : null,
next: (act_page < pages) ? act_page + 1 : null,
page: act_page,
cheat: cheat,
uff: false
};
link = {
main: `/search/?tag=${encodeURIComponent(tag)}&mode=strict`,
path: '&page='
};
} else {
total = 0;
ret = [];
}
}
else {
total = (await db`
select count(*) as total
from "tags"
left join "tags_assign" on "tags_assign".tag_id = "tags".id
left join "items" on "items".id = "tags_assign".item_id
where "tags".tag ilike ${'%' + tag + '%'}
group by "items".id, "tags".tag
`).length;
const pages = +Math.ceil(total / _eps);
const act_page = Math.min(pages, page || 1);
const offset = Math.max(0, (act_page - 1) * _eps);
const rows = await db`
select "items".id, "items".username, "items".mime, "tags".tag
from "tags"
left join "tags_assign" on "tags_assign".tag_id = "tags".id
left join "items" on "items".id = "tags_assign".item_id
where "tags".tag ilike ${'%' + tag + '%'} and "items".active = true
group by "items".id, "tags".tag
offset ${offset}
limit ${_eps}
`;
ret = search(rows, tag);
const cheat = [];
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i);
pagination = {
start: 1,
end: pages,
prev: (act_page > 1) ? act_page - 1 : null,
next: (act_page < pages) ? act_page + 1 : null,
page: act_page,
cheat: cheat,
uff: false
};
link = {
main: `/search/?tag=${encodeURIComponent(tag)}`,
path: '&page='
};
}
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
res.reply({
body: tpl.render("search", {
result: ret,
totals: await lib.countf0cks(),
searchstring: tag,
count: total,
pagination,
link,
session: (req.session && req.session.user) ? { ...req.session } : false,
mode: req.url.qs.mode
}, req)
});
});
};

View File

@@ -0,0 +1,64 @@
import db from "../../inc/sql.mjs";
import cfg from "../config.mjs";
const auth = async (req, res, next) => {
if (!req.session)
return res.redirect("/login");
return next();
};
export default (router, tpl) => {
router.group(/^\/settings/, group => {
group.get(/$/, auth, async (req, res) => {
const sessions = await db`
select *
from user_sessions
where user_id = ${+req.session.id}
order by last_used desc
`;
const excluded_tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
// Get custom avatar file if exists
const userOptions = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
`)[0];
// Get full user info
const user = (await db`
select email, created_at from "user" where id = ${+req.session.id}
`)[0];
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
console.log('Rendering settings. Excluded tags:', excluded_tags);
res.reply({
body: tpl.render('settings', {
tmp: null,
sessions,
excluded_tags: excluded_tags || [],
avatar_file: userOptions?.avatar_file || null,
email: user?.email || '',
joined: user?.created_at || null,
enable_swf: cfg.enable_swf,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'settings',
description: 'User settings',
url: `https://${cfg.main.url.domain}/settings`
}
}, req)
});
});
});
return router;
};

71
src/inc/routes/static.mjs Normal file
View File

@@ -0,0 +1,71 @@
import cfg from "../config.mjs";
import fs from "fs/promises";
import path from "path";
export default (router, tpl) => {
router.static({
dir: cfg.paths.b,
route: /^\/b\//
});
router.static({
dir: cfg.paths.emojis,
route: /^\/s\/emojis\//
});
router.static({
dir: cfg.paths.koepfe,
route: /^\/s\/koepfe\//
});
router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\//
});
router.static({
dir: cfg.paths.s,
route: /^\/s\//
});
router.static({
dir: cfg.paths.t,
route: /^\/t\//
});
router.static({
dir: cfg.paths.ca,
route: /^\/ca\//
});
router.static({
dir: cfg.paths.memes,
route: /^\/memes\//
});
router.static({
dir: cfg.paths.a,
route: /^\/a\//
});
router.get(/^\/robots\.txt$/, async (req, res) => {
res.reply({
type: "text/plain",
body: await fs.readFile(path.join(cfg.paths.s, "../robots.txt"), "utf-8")
});
});
router.get(/^\/manifest\.json$/, async (req, res) => {
res.reply({
type: "application/json",
body: await fs.readFile(path.join(cfg.paths.s, "../manifest.json"), "utf-8")
});
});
router.get(/^\/sw\.js$/, async (req, res) => {
res.reply({
type: "application/javascript",
body: await fs.readFile(path.join(cfg.paths.s, "../sw.js"), "utf-8")
});
});
};

View File

@@ -0,0 +1,174 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
import url from "url";
export default (router, tpl) => {
// Subscriptions Overview
router.get('/subscriptions', async (req, res) => {
if (!req.session) return res.redirect('/login');
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = parseInt(query.page) || 1;
const eps = cfg.websrv.eps || 300;
const offset = (page - 1) * eps;
try {
console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
const countRes = await db`
SELECT count(*) as total
FROM comment_subscriptions
WHERE user_id = ${req.session.id} AND is_subscribed = true
`;
const total = parseInt(countRes[0].total);
const pages = Math.ceil(total / eps);
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
ORDER BY s.created_at DESC
LIMIT ${eps} OFFSET ${offset}
`;
console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
const items = subs.map(i => ({
id: i.id,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`
}));
const pagination = {
start: 1,
end: pages,
prev: (page > 1) ? page - 1 : null,
next: (page < pages) ? page + 1 : null,
page: page
};
const link = { main: '/subscriptions', path: '?page=' };
return res.reply({
body: tpl.render('subscriptions', {
items,
pagination,
link,
totalCount: total,
hidePagination: true,
page_meta: {
title: 'subscriptions',
description: 'Your comment subscriptions',
url: `https://${cfg.main.url.domain}/subscriptions`
}
}, req)
});
} catch (e) {
console.error('[DEBUG SUB ERROR]', e);
return res.reply({ code: 500, body: 'Database Error' });
}
});
// AJAX Subscriptions for infinite scroll
router.get('/ajax/subscriptions', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = parseInt(query.page) || 1;
const eps = cfg.websrv.eps || 300;
const offset = (page - 1) * eps;
try {
const countRes = await db`
SELECT count(*) as total
FROM comment_subscriptions
WHERE user_id = ${req.session.id} AND is_subscribed = true
`;
const total = parseInt(countRes[0].total);
const pages = Math.ceil(total / eps);
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
ORDER BY s.created_at DESC
LIMIT ${eps} OFFSET ${offset}
`;
const items = subs.map(i => ({
id: i.id,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`
}));
const pagination = {
start: 1,
end: pages,
prev: (page > 1) ? page - 1 : null,
next: (page < pages) ? page + 1 : null,
page: page
};
const link = { main: '/subscriptions', path: '?page=' };
const html = tpl.render('snippets/subscriptions-grid', { items }, req);
const pagHtml = tpl.render('snippets/pagination', { pagination, link }, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
html: html,
pagination: pagHtml,
hasMore: page < pages,
currentPage: page
})
});
} catch (e) {
console.error('[DEBUG AJAX SUB ERROR]', e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Unsubscribe
router.post(/\/api\/subscriptions\/(?<itemid>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemid;
try {
await db`UPDATE comment_subscriptions SET is_subscribed = false WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

View File

@@ -0,0 +1,243 @@
import crypto from 'crypto';
import db from '../sql.mjs';
import lib from '../lib.mjs';
import cfg from '../config.mjs';
import path from 'path';
import fs from 'fs/promises';
import { execFile } from 'child_process';
import util from 'util';
import url from 'url';
const execFilePromise = util.promisify(execFile);
const CACHE_DIR = path.join(cfg.paths.s, '../tag_cache');
const CACHE_MAX_AGE = 3600; // 1 hour
// --- Reusable generation function ---
export async function regenerateTagImage(tag, mode) {
const hash = crypto.createHash('md5').update(tag).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
try {
let modeFilter = db``;
if (mode === 0) {
modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
} else if (mode === 1) {
modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
}
const items = await db`
SELECT i.id
FROM items i
JOIN tags_assign ta ON ta.item_id = i.id
JOIN tags t ON t.id = ta.tag_id
${modeFilter}
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
ORDER BY RANDOM()
LIMIT 3
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
return cachePath;
}
} catch (err) {
console.error(`[TAG_IMAGE] Failed to generate image for tag "${tag}" (mode ${mode}):`, err);
}
return null;
}
// --- Background listener: regenerate cache when tags change ---
db.listen('tags', async (payload) => {
try {
const data = JSON.parse(payload);
const itemId = data.item_id;
if (!itemId) return;
// Find all tags currently assigned to this item
const tags = await db`
SELECT DISTINCT t.tag
FROM tags t
JOIN tags_assign ta ON t.id = ta.tag_id
WHERE ta.item_id = ${itemId}
`;
// Also include any tags from the notification payload (covers deletions
// where the tag may no longer be assigned to the item)
const payloadTags = (data.tags || []).map(t => t.tag).filter(Boolean);
const allTagNames = new Set([
...tags.map(t => t.tag),
...payloadTags
]);
if (allTagNames.size === 0) return;
console.log(`[TAG_IMAGE] Tag change on item ${itemId}, regenerating cache for ${allTagNames.size} tag(s)`);
for (const tag of allTagNames) {
const hash = crypto.createHash('md5').update(tag).digest('hex');
// Delete existing cache files for this tag (all modes)
for (const mode of [0, 1, 3]) {
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
try {
await fs.unlink(cachePath);
} catch {
// File didn't exist, that's fine
}
}
// Regenerate for all modes (fire-and-forget per tag)
for (const mode of [0, 1, 3]) {
regenerateTagImage(tag, mode).catch(err =>
console.error(`[TAG_IMAGE] Background regen failed for "${tag}" mode ${mode}:`, err)
);
}
}
} catch (e) {
console.error('[TAG_IMAGE] Background listener error:', e);
}
}).catch(err => console.error('[TAG_IMAGE] DB Listen error:', err));
// --- SVG fallback helper ---
function generateFallbackSvg(tag) {
const hash = crypto.createHash('md5').update(tag).digest('hex');
const escapeXml = (unsafe) => {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
}
});
};
const displayTag = escapeXml(tag);
const c1 = '#' + hash.substring(0, 6);
const c2 = '#' + hash.substring(6, 12);
const c3 = '#' + hash.substring(12, 18);
const n1 = parseInt(hash.substring(18, 20), 16);
const n2 = parseInt(hash.substring(20, 22), 16);
return `
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="300" height="150" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
</svg>
`.trim();
}
export default (router, tpl) => {
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
const tag = decodeURIComponent(req.params.tag);
// Parse query parameters
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const mode = query.m ? parseInt(query.m) : (req.mode ?? 0);
const hash = crypto.createHash('md5').update(tag).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
// Try to serve cached image first
try {
const stats = await fs.stat(cachePath);
if (stats.size > 0) {
res.writeHead(200, {
'Content-Type': 'image/webp',
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
});
return res.end(await fs.readFile(cachePath));
}
} catch (e) {
// Cache miss, proceed to generation
}
// Generate on-demand
const generated = await regenerateTagImage(tag, mode);
if (generated) {
res.writeHead(200, {
'Content-Type': 'image/webp',
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
});
return res.end(await fs.readFile(generated));
}
// Fallback to deterministic SVG
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
});
res.end(generateFallbackSvg(tag));
});
// --- Admin: Force regeneration routes ---
router.get('/admin/tag_image/regenerate_all', lib.auth, async (req, res) => {
try {
const tags = await db`SELECT DISTINCT tag FROM tags JOIN tags_assign ON tags.id = tags_assign.tag_id`;
console.log(`[ADMIN] Triggering full tag image regeneration for ${tags.length} tags`);
// Fire-and-forget regeneration in batches to avoid overwhelming the system
const batchSize = 10;
(async () => {
for (let i = 0; i < tags.length; i += batchSize) {
const batch = tags.slice(i, i + batchSize);
await Promise.all(batch.map(async (t) => {
const hash = crypto.createHash('md5').update(t.tag).digest('hex');
// Delete existing cache files first
for (const m of [0, 1, 3]) {
try { await fs.unlink(path.join(CACHE_DIR, `${hash}_${m}.webp`)); } catch {}
await regenerateTagImage(t.tag, m).catch(e => console.error(`[ADMIN] Failed regen for ${t.tag} (mode ${m}):`, e));
}
}));
}
console.log(`[ADMIN] Full tag image regeneration completed`);
})().catch(err => console.error('[ADMIN] Full regen background task failed:', err));
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, message: `Started regeneration for ${tags.length} tags in background.` })
});
} catch (err) {
console.error('[ADMIN] Failed to trigger full regeneration:', err);
return res.reply({ code: 500, body: 'Internal Server Error' });
}
});
router.get(/^\/admin\/tag_image\/regenerate\/(?<tag>.+)$/, lib.auth, async (req, res) => {
const tag = decodeURIComponent(req.params.tag);
const hash = crypto.createHash('md5').update(tag).digest('hex');
try {
console.log(`[ADMIN] Regenerating images for tag: ${tag}`);
for (const m of [0, 1, 3]) {
try { await fs.unlink(path.join(CACHE_DIR, `${hash}_${m}.webp`)); } catch {}
await regenerateTagImage(tag, m);
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, message: `Regenerated images for tag "${tag}".` })
});
} catch (err) {
console.error(`[ADMIN] Failed to regenerate tag "${tag}":`, err);
return res.reply({ code: 500, body: 'Internal Server Error' });
}
});
return router;
};

31
src/inc/routes/theme.mjs Normal file
View File

@@ -0,0 +1,31 @@
import cfg from "../config.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
router.get(/^\/theme\//, async (req, res) => {
let theme = req.url.pathname.split('/')[2] ?? cfg.websrv.themes[0];
if(!cfg.websrv.themes.includes(theme))
theme = cfg.websrv.themes[0];
return res.writeHead(301, {
"Cache-Control": "no-cache, public",
"Set-Cookie": `theme=${theme}; ${lib.getCookieOptions(null, false)}`,
"Location": req.headers.referer ?? "/"
}).end();
});
router.get(/^\/tfull\//, async (req, res) => {
let full = req.session.fullscreen;
if(full == 1)
full = 0;
else
full = 1;
return res.writeHead(301, {
"Cache-Control": "no-cache, public",
"Set-Cookie": `fullscreen=${full}; ${lib.getCookieOptions(null, false)}`,
"Location": req.headers.referer ?? "/"
}).end();
});
return router;
};

151
src/inc/routes/toptags.mjs Normal file
View File

@@ -0,0 +1,151 @@
import db from "../../inc/sql.mjs";
import lib from "../../inc/lib.mjs";
import cfg from "../../inc/config.mjs";
import url from "url";
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
export default (router, tpl) => {
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj;
const modequery = lib.getMode(mode);
let restrictedFilter = db``;
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
restrictedFilter = db`
AND t.id NOT IN ${db(cfg.nsfp)}
AND NOT EXISTS (
SELECT 1 FROM tags_assign ta_res
WHERE ta_res.item_id = items.id
AND ta_res.tag_id IN ${db(cfg.nsfp)}
)
`;
}
const userExcludeFilter = excludedTags.length > 0
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = items.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
: db``;
// Step 1: Get tags sorted by exact count (fast, indexed)
// Group by normalized to merge duplicates (e.g. "Music" and "music")
const baseTags = await db`
SELECT MIN(t.id) as id, MIN(t.tag) as tag, t.normalized, COUNT(DISTINCT items.id) AS total_items
FROM tags t
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE items.active = true
AND t.id NOT IN (1, 2)
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}
GROUP BY t.normalized
HAVING COUNT(DISTINCT items.id) >= 1
ORDER BY total_items DESC, MIN(t.id) DESC
OFFSET ${offset}
LIMIT ${limit}
`;
// Step 2: In normal (non-strict) mode, replace counts with fuzzy counts
// Only runs for the ~50 tags being displayed, not all tags
if (!strict && baseTags.length > 0) {
await Promise.all(baseTags.map(async (tag) => {
if (!tag.normalized) return;
const [row] = await db`
SELECT COUNT(DISTINCT items.id) as total
FROM tags t
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}
`;
tag.total_items = +row.total;
}));
}
return baseTags;
};
const processTags = (tags) => tags.map(t => ({
...t,
safe_tag: t.normalized || encodeURIComponent(t.tag),
encoded_tag: encodeURIComponent(t.tag)
}));
// API endpoint for lazy loading tags
router.get(/^\/api\/tags$/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = Math.max(1, +(query.page ?? 1));
const offset = (page - 1) * TAGS_PER_PAGE;
const mode = req.mode ?? 0;
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
const tags = processTags(await getTagsQuery(mode, offset, TAGS_PER_PAGE, req.session, isStrict));
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
}
res.json({
success: true,
tags,
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
});
// Main tags page
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 || {};
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
const toptags = processTags(await getTagsQuery(mode, 0, TAGS_PER_PAGE, req.session, isStrict));
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
const data = {
toptags: toptags,
phrase,
tmp: null,
hidePagination: true,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Tags',
description: `Browse ${toptags.length}+ tags`,
url: `https://${cfg.main.url.domain}/tags`
}
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
body: tpl.render('tags-partial', data, req)
});
}
res.reply({
body: tpl.render('tags', data, req)
});
});
return router;
};

52
src/inc/routes/upload.mjs Normal file
View File

@@ -0,0 +1,52 @@
import lib from "../lib.mjs";
import db from "../sql.mjs";
import cfg from "../config.mjs";
import { getMinTags } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/upload$/, lib.userauth, async (req, res) => {
let maxfilesize = cfg.main.maxfilesize;
if (req.session.admin || req.session.is_moderator) {
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
}
const max_file_size = lib.formatSize(maxfilesize);
// Calculate uploads remaining (admins/mods are exempt)
let uploads_remaining = null;
if (!req.session.admin && !req.session.is_moderator) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db`
SELECT count(*) as count
FROM items
WHERE username = ${req.session.user}
AND stamp > ${twelveHoursAgo}
AND is_deleted = false
`;
uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count));
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
res.reply({
body: tpl.render('upload', {
tmp: null,
session: (req.session && req.session.user) ? { ...req.session } : false,
max_file_size: max_file_size,
min_tags: getMinTags(),
uploads_remaining: uploads_remaining,
allowed_mimes: Object.keys(cfg.mimes).join(','),
mimes_json: JSON.stringify(cfg.mimes),
web_url_upload: !!cfg.websrv.web_url_upload,
page_meta: {
title: 'upload',
description: 'Upload content to w0bm',
url: `https://${cfg.main.url.domain}/upload`
}
}, req)
});
});
return router;
};

View File

@@ -0,0 +1,390 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import fs from "fs/promises";
import path from "path";
import { createHash } from "crypto";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
const execFile = promisify(_execFile);
const slugify = (s) =>
s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
// Simple auth guard — redirects to login page for browser, 401 for API calls
const requireLogin = (req, res) => {
if (req.session) return true;
const isApi = req.url.pathname.startsWith('/api/');
if (isApi) {
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Login required' }));
} else {
res.writeHead(302, { Location: '/login' }).end();
}
return false;
};
// ── Helper: resolve user hall, checking privacy ──────────────────────────────
const resolveHall = async (ownerName, slug, viewerSession) => {
const hall = await f0cklib.getUserHallByOwnerName(ownerName, slug);
if (!hall) return null;
// Private check: only owner or admin can see private halls
if (hall.is_private) {
const isOwner = viewerSession && viewerSession.user?.toLowerCase() === ownerName.toLowerCase();
const isAdmin = viewerSession && viewerSession.admin;
if (!isOwner && !isAdmin) return null;
}
return hall;
};
export default (router, tpl) => {
// ── Public browse routes ────────────────────────────────────────────────────
// List halls for a user
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
const ownerName = decodeURIComponent(req.params.owner);
const mode = req.mode ?? 0;
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
// Resolve owner user record
const ownerRow = (await db`SELECT id, "user", admin FROM "user" WHERE "user" ILIKE ${ownerName} LIMIT 1`)[0];
if (!ownerRow) {
return res.reply({ code: 404, body: tpl.render('error', { message: 'User not found', tmp: null }, req) });
}
const viewerUserId = req.session?.id ?? null;
const hallsList = await f0cklib.getUserHalls(ownerRow.id, mode, excludedTags, viewerUserId);
const isOwner = viewerUserId === ownerRow.id;
const data = {
hallsList,
ownerUser: ownerRow,
isOwner,
tmp: null,
hidePagination: true,
session: req.session ? { ...req.session } : false,
page_meta: {
title: `${ownerRow.user}'s Halls`,
description: `Browse ${ownerRow.user}'s personal collections`,
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(ownerRow.user)}/halls`
}
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({ body: tpl.render('user-halls-partial', data, req) });
}
return res.reply({ body: tpl.render('user-halls', data, req) });
});
// Item grid for a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
const ownerName = decodeURIComponent(req.params.owner);
const slug = decodeURIComponent(req.params.slug);
const hall = await resolveHall(ownerName, slug, req.session);
if (!hall) {
return res.reply({ code: 404, body: tpl.render('error', { message: 'Hall not found', tmp: null }, req) });
}
const data = await f0cklib.getf0cks({
page: req.params.page,
mode: req.mode,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
userHallOwner: ownerName,
mime: req.cookies.mime || null,
random: req.cookies.random_mode === '1'
});
if (!data.success) {
data.items = [];
data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null };
data.total = 0;
data.success = true;
data.link = { main: `/user/${encodeURIComponent(hall.owner_name)}/hall/${encodeURIComponent(hall.slug)}/`, path: 'p/', suffix: '' };
data.tmp = { userHall: hall, userHallOwner: hall.owner_name };
}
data.session = req.session ? { ...req.session } : false;
data.isOwner = !!(req.session && req.session.id === hall.user_id);
data.page_meta = {
title: `${hall.name}${hall.owner_name}'s Hall`,
description: hall.description || `${hall.owner_name}'s collection`,
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(hall.owner_name)}/hall/${encodeURIComponent(hall.slug)}`
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({ body: tpl.render('index-partial', data, req) });
}
return res.reply({ body: tpl.render('index', data, req) });
});
// Single item within a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
const ownerName = decodeURIComponent(req.params.owner);
const slug = decodeURIComponent(req.params.slug);
const hall = await resolveHall(ownerName, slug, req.session);
if (!hall) {
return res.reply({ code: 404, body: tpl.render('error', { message: 'Hall not found', tmp: null }, req) });
}
const data = await f0cklib.getf0ck({
itemid: req.params.itemid,
mode: req.mode,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
userHallOwner: ownerName,
mime: req.cookies.mime || null,
random: req.cookies.random_mode === '1'
});
if (!data.success) {
return res.reply({
code: data.item ? 200 : 404,
body: tpl.render('error', { message: data.message, item: data.item, tmp: null }, req)
});
}
data.hidePagination = true;
data.session = req.session ? { ...req.session } : false;
// Precompute hall display
if (data.item?.halls?.length) {
data.item.primaryHall = data.item.halls[0];
data.item.otherHalls = data.item.halls.slice(1);
} else if (data.item) {
data.item.primaryHall = null;
data.item.otherHalls = [];
}
if (req.session || !cfg.main.hide_comments_from_public) {
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
const useLegacy = req.session
? (req.session.use_new_layout === false)
: (cfg.websrv.default_layout === 'legacy');
const sort = useLegacy ? 'old' : 'new';
data.comments = await f0cklib.getComments(req.params.itemid, sort, false);
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
data.commentsJSON = Buffer.from(JSON.stringify(data.comments || [])).toString('base64');
} else {
data.comments = [];
data.isSubscribed = false;
data.commentsJSON = Buffer.from('[]').toString('base64');
}
return res.reply({ body: tpl.render('item', data, req) });
});
// ── Thumbnail route ─────────────────────────────────────────────────────────
router.get(/^\/user_hall_image\/(?<userId>\d+)\/(?<slug>.+)$/, async (req, res) => {
const userId = +req.params.userId;
const slug = decodeURIComponent(req.params.slug);
const mode = +(req.url.qs?.m ?? 0);
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${slug}.webp`);
try {
// 1. Serve custom image if present
try {
const stat = await fs.stat(customPath);
const etag = '"' + stat.mtimeMs.toString(16) + '-' + stat.size.toString(16) + '"';
if (req.headers['if-none-match'] === etag) {
res.writeHead(304); return res.end();
}
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'no-cache', 'ETag': etag });
return res.end(await fs.readFile(customPath));
} catch (_) { /* no custom image */ }
// 2. Check mosaic cache
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
try {
await fs.access(cachePath);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
} catch (_) {}
// 3. Generate mosaic
const hall = await f0cklib.getUserHall(userId, slug);
if (!hall) { res.writeHead(302, { Location: '/s/img/favicon.gif' }); return res.end(); }
let modeFilter = db``;
if (mode === 0) modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
else if (mode === 1) modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
const items = await db`
SELECT i.id
FROM items i
JOIN user_halls_assign uha ON uha.item_id = i.id
${modeFilter}
WHERE uha.hall_id = ${hall.id} AND i.active = true
ORDER BY RANDOM()
LIMIT 3
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(CACHE_DIR, { recursive: true });
await execFile('magick', [
...inputs, '+append', '-background', 'none',
'-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath
]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
} catch (e) {
console.error('[USER_HALL_IMAGE]', e);
}
res.writeHead(302, { Location: '/s/img/favicon.gif' });
res.end();
});
// ── API: list own halls (for modal) ────────────────────────────────────────
router.get(/^\/api\/v2\/me\/halls\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
try {
const halls = await f0cklib.getUserHalls(req.session.id, 3, [], req.session.id);
const body = JSON.stringify({ success: true, halls });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(body);
} catch (e) {
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
}
});
// ── API: create hall ────────────────────────────────────────────────────────
router.post(/^\/api\/v2\/me\/halls\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const name = (req.post.name || '').trim();
const slug = slugify(req.post.slug || name);
const description = (req.post.description || '').trim() || null;
if (!name || !slug) {
return res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Name is required' }));
}
const result = await f0cklib.createUserHall(req.session.id, name, slug, description);
const status = result.success ? 200 : 409;
return res.writeHead(status, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
});
// ── API: update hall ────────────────────────────────────────────────────────
router.patch(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const slug = decodeURIComponent(req.params.slug);
const { name, slug: newSlugRaw, description, is_private } = req.post;
const newSlug = newSlugRaw ? slugify(newSlugRaw) : undefined;
const result = await f0cklib.updateUserHall(req.session.id, slug, {
name,
newSlug,
description,
is_private: is_private !== undefined ? (is_private === true || is_private === 'true' || is_private === 1) : undefined
});
const status = result.success ? 200 : 400;
return res.writeHead(status, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
});
// ── API: delete hall ────────────────────────────────────────────────────────
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const slug = decodeURIComponent(req.params.slug);
// Admins can also delete on behalf of any user if they pass ?user_id=
let targetUserId = req.session.id;
if (req.session.admin && req.url.qs?.user_id) {
targetUserId = +req.url.qs.user_id;
}
const result = await f0cklib.deleteUserHall(targetUserId, slug);
// Clean up custom image if it exists
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${slug}.webp`)).catch(() => {});
return res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
});
// ── API: add item to hall ────────────────────────────────────────────────────
router.post(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/items\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const slug = decodeURIComponent(req.params.slug);
const itemId = +req.post.item_id;
if (!itemId) {
return res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Missing item_id' }));
}
const hall = await f0cklib.getUserHall(req.session.id, slug);
if (!hall) {
return res.writeHead(404, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
}
const result = await f0cklib.addItemToUserHall(hall.id, itemId, req.session.id);
return res.writeHead(result.success ? 200 : 409, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
});
// ── API: remove item from hall ──────────────────────────────────────────────
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/items\/(?<itemid>\d+)\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const slug = decodeURIComponent(req.params.slug);
const itemId = +req.params.itemid;
const hall = await f0cklib.getUserHall(req.session.id, slug);
if (!hall) {
return res.writeHead(404, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
}
const result = await f0cklib.removeItemFromUserHall(hall.id, itemId);
return res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' })
.end(JSON.stringify(result));
});
// ── API: upload custom hall image (handled via bypass middleware in index.mjs) ─
// This stub is never reached for multipart uploads — the bypass intercepts first.
router.post(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/image\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
return res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Send as multipart/form-data' }));
});
// ── API: delete custom hall image (can go through normal router) ─────────
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/image\/?$/, async (req, res) => {
if (!requireLogin(req, res)) return;
const slug = decodeURIComponent(req.params.slug);
const hall = await f0cklib.getUserHall(req.session.id, slug);
if (!hall) {
return res.writeHead(404, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
}
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${slug}.webp`)).catch(() => {});
// Clear mosaic cache entries for all modes
for (const m of [0, 1, 2]) {
const h = createHash('md5').update(`uh_${req.session.id}_${slug}_${m}`).digest('hex');
await fs.unlink(path.join(CACHE_DIR, `${h}.webp`)).catch(() => {});
}
await db`UPDATE user_halls SET custom_image = false WHERE id = ${hall.id}`;
return res.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: true }));
});
return router;
};

View File

@@ -0,0 +1,81 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
export default (router, tpl) => {
// Mod/Admin: Issue a warning to a user
router.post(/^\/api\/v2\/mod\/warnings\/issue\/?$/, lib.modAuth, async (req, res) => {
try {
const { user_id, reason } = req.post;
if (!user_id || !reason || reason.trim().length === 0) {
return res.json({ success: false, msg: "User ID and reason are required." }, 400);
}
const result = await db`
INSERT INTO user_warnings (user_id, admin_id, reason)
VALUES (${+user_id}, ${req.session.id}, ${reason.trim()})
RETURNING id
`;
// Broadcast to SSE clients instantly
if (result.length > 0) {
await db`SELECT pg_notify('warnings', ${JSON.stringify({
user_id: +user_id,
warning_id: result[0].id,
reason: reason.trim()
})})`;
}
// Log it in audit
const targetUser = await db`SELECT login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`;
const username = targetUser.length > 0 ? targetUser[0].user : String(user_id);
await audit.log(req.session.id, 'issue_warning', 'user', +user_id, { reason: reason.trim(), target_user: username });
return res.json({ success: true, msg: "Warning issued successfully." });
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
// User: Fetch active (unacknowledged) warnings
router.get(/^\/api\/v2\/user\/warnings\/?$/, lib.loggedin, async (req, res) => {
try {
const warnings = await db`
SELECT id, reason, created_at
FROM user_warnings
WHERE user_id = ${req.session.id} AND acknowledged = FALSE
ORDER BY created_at ASC
`;
return res.json({ success: true, warnings });
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
// User: Acknowledge a warning
router.post(/^\/api\/v2\/user\/warnings\/(?<id>\d+)\/acknowledge\/?$/, lib.loggedin, async (req, res) => {
try {
const id = +req.params.id;
const result = await db`
UPDATE user_warnings
SET acknowledged = TRUE
WHERE id = ${id} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) {
return res.json({ success: false, msg: "Warning not found or already acknowledged." }, 404);
}
return res.json({ success: true, msg: "Warning acknowledged." });
} catch (err) {
return res.json({ success: false, msg: lib.logError(err) }, 500);
}
});
return router;
};