init f0ckm
This commit is contained in:
1142
src/inc/routes/admin.mjs
Normal file
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
272
src/inc/routes/ajax.mjs
Normal 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;
|
||||
};
|
||||
1058
src/inc/routes/apiv2/index.mjs
Normal file
1058
src/inc/routes/apiv2/index.mjs
Normal file
File diff suppressed because it is too large
Load Diff
626
src/inc/routes/apiv2/settings.mjs
Normal file
626
src/inc/routes/apiv2/settings.mjs
Normal 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, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
||||
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 0–999' }, 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;
|
||||
};
|
||||
245
src/inc/routes/apiv2/tags.mjs
Normal file
245
src/inc/routes/apiv2/tags.mjs
Normal 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;
|
||||
};
|
||||
565
src/inc/routes/apiv2/upload.mjs
Normal file
565
src/inc/routes/apiv2/upload.mjs
Normal 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
23
src/inc/routes/banned.mjs
Normal 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
251
src/inc/routes/chat.mjs
Normal 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
687
src/inc/routes/comments.mjs
Normal 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: ["@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
51
src/inc/routes/emojis.mjs
Normal 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
112
src/inc/routes/halls.mjs
Normal 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
489
src/inc/routes/index.mjs
Normal 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;
|
||||
};
|
||||
11
src/inc/routes/maintenance.mjs
Normal file
11
src/inc/routes/maintenance.mjs
Normal 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
12
src/inc/routes/matrix.mjs
Normal 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)
|
||||
});
|
||||
});
|
||||
};
|
||||
53
src/inc/routes/meme-manager.mjs
Normal file
53
src/inc/routes/meme-manager.mjs
Normal 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
60
src/inc/routes/meme.mjs
Normal 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
399
src/inc/routes/messages.mjs
Normal 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
805
src/inc/routes/mod.mjs
Normal 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;
|
||||
};
|
||||
581
src/inc/routes/notifications.mjs
Normal file
581
src/inc/routes/notifications.mjs
Normal 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;
|
||||
};
|
||||
26
src/inc/routes/picdump.mjs
Normal file
26
src/inc/routes/picdump.mjs
Normal 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
70
src/inc/routes/random.mjs
Normal 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;
|
||||
};
|
||||
95
src/inc/routes/ranking.mjs
Normal file
95
src/inc/routes/ranking.mjs
Normal 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
193
src/inc/routes/register.mjs
Normal 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
167
src/inc/routes/reports.mjs
Normal 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
316
src/inc/routes/scroller.mjs
Normal 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
185
src/inc/routes/search.mjs
Normal 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)
|
||||
});
|
||||
});
|
||||
};
|
||||
64
src/inc/routes/settings.mjs
Normal file
64
src/inc/routes/settings.mjs
Normal 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
71
src/inc/routes/static.mjs
Normal 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")
|
||||
});
|
||||
});
|
||||
};
|
||||
174
src/inc/routes/subscriptions.mjs
Normal file
174
src/inc/routes/subscriptions.mjs
Normal 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;
|
||||
};
|
||||
243
src/inc/routes/tag_image.mjs
Normal file
243
src/inc/routes/tag_image.mjs
Normal 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 '<';
|
||||
case '>': return '>';
|
||||
case '&': return '&';
|
||||
case '\'': return ''';
|
||||
case '"': return '"';
|
||||
}
|
||||
});
|
||||
};
|
||||
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
31
src/inc/routes/theme.mjs
Normal 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
151
src/inc/routes/toptags.mjs
Normal 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
52
src/inc/routes/upload.mjs
Normal 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;
|
||||
};
|
||||
390
src/inc/routes/user_halls.mjs
Normal file
390
src/inc/routes/user_halls.mjs
Normal 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;
|
||||
};
|
||||
81
src/inc/routes/warnings.mjs
Normal file
81
src/inc/routes/warnings.mjs
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user