434 lines
20 KiB
JavaScript
434 lines
20 KiB
JavaScript
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) => {
|
|
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
|
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) => {
|
|
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
|
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) => {
|
|
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
|
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 boolean helpers for template @if() — must match index.mjs pattern
|
|
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.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
|
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(',') : '';
|
|
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.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
|
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 : '';
|
|
data.item_has_dimensions = !!(item.width && item.height);
|
|
}
|
|
|
|
// 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(() => {});
|
|
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
|
|
|
// xD Score
|
|
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;
|
|
|
|
// Comments loaded async by client
|
|
data.commentsJSON = null;
|
|
data.comments = [];
|
|
} else {
|
|
data.comments = [];
|
|
data.isSubscribed = false;
|
|
data.commentsJSON = null;
|
|
data.item.xd_score = 0;
|
|
data.item.xd_tier = 0;
|
|
data.item.xd_label = '';
|
|
}
|
|
|
|
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);
|
|
// F-016 Security: Sanitize slug to prevent path traversal
|
|
const safeSlug = path.basename(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}_${safeSlug}.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}_${safeSlug}_${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 AND COALESCE(i.visibility, 0) = 0
|
|
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', '600x300^', '-gravity', 'center', '-extent', '600x300', 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 (cfg.websrv.userhalls_enabled === false) return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
|
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 (cfg.websrv.userhalls_enabled === false) return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
|
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 (cfg.websrv.userhalls_enabled === false) return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
|
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 (cfg.websrv.userhalls_enabled === false) return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
|
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
|
|
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
|
const safeSlug = path.basename(slug);
|
|
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
|
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${safeSlug}.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' }));
|
|
}
|
|
|
|
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
|
const safeSlug = path.basename(slug);
|
|
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}_${safeSlug}.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}_${safeSlug}_${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;
|
|
};
|