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: '502 Bad Gateway' }); } 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; };