diff --git a/.gitignore b/.gitignore index c6aa7dad..e958b9e6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ gulp/res/js/locals.js gulp/res/js/post.js gulp/res/js/modal.js tmp/ +.idea/ diff --git a/configs/main.js.example b/configs/main.js.example index f9dc3dd9..3cede01e 100644 --- a/configs/main.js.example +++ b/configs/main.js.example @@ -41,12 +41,16 @@ module.exports = { //settings for captchas captchaOptions: { - type: 'grid', //"text", "grid" or "google". If using google, make sure your CSP header in nginx config allows the google domain. + type: 'grid', //"text", "grid", "hcaptcha" or "google". If using google/hcaptcha, make sure your CSP header in nginx config allows the google/hcaptcha domain. generateLimit: 1000, //max number of captchas to have generated at any time, prevent mass unsolved captcha spam, especially on TOR. google: { //options for google captcha, when captcha type is google siteKey: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', secretKey: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' }, + hcaptcha: { + siteKey: "10000000-ffff-ffff-ffff-000000000001", + secretKey: "0x0000000000000000000000000000000000000000" + }, grid: { size: 4, imageSize: 120, @@ -97,8 +101,10 @@ module.exports = { editPost: 30, }, - //how many threads to show on overboard + //how many threads to show on overboard index view overboardLimit: 20, + //how many threads to show on overboard catalog view + overboardCatalogLimit: 50, //cache templates in memory. disable only if editing templates and doing dev work cacheTemplates: true, diff --git a/controllers/forms/actions.js b/controllers/forms/actions.js index e3b8e1ac..92785870 100644 --- a/controllers/forms/actions.js +++ b/controllers/forms/actions.js @@ -117,13 +117,13 @@ module.exports = async (req, res, next) => { }); } else if (req.body.move) { res.locals.posts = res.locals.posts.filter(p => { - //filter to remove any posts already in the thread (or the OP) of move destionation + //filter to remove any posts already in the thread (or the OP) of move destination return p.postId !== req.body.move_to_thread && p.thread !== req.body.move_to_thread; }); if (res.locals.posts.length === 0) { return dynamicResponse(req, res, 429, 'message', { 'title': 'Conflict', - 'error': 'Destionation thread cannot match source thread for move action', + 'error': 'Destination thread cannot match source thread for move action', 'redirect': `/${req.params.board}/` }); } diff --git a/controllers/pages.js b/controllers/pages.js index 3b73c6e7..b038bdb4 100644 --- a/controllers/pages.js +++ b/controllers/pages.js @@ -22,7 +22,7 @@ const express = require('express') , { globalManageSettings, globalManageReports, globalManageBans, globalManageBoards, globalManageRecent, globalManageAccounts, globalManageNews, globalManageLogs } = require(__dirname+'/../models/pages/globalmanage/') , { changePassword, blockBypass, home, register, login, create, - board, catalog, banners, randombanner, news, captchaPage, overboard, + board, catalog, banners, randombanner, news, captchaPage, overboard, overboardCatalog, captcha, thread, modlog, modloglist, account, boardlist } = require(__dirname+'/../models/pages/'); //homepage @@ -41,7 +41,8 @@ router.get('/:board/catalog.(html|json)', Boards.exists, catalog); //catalog router.get('/:board/logs.html', Boards.exists, modloglist);//modlog list router.get('/:board/logs/:date(\\d{2}-\\d{2}-\\d{4}).html', Boards.exists, paramConverter, modlog); //daily log router.get('/:board/banners.html', Boards.exists, banners); //banners -router.get('/all.html', overboard); //overboard +router.get('/overboard.html', overboard); //overboard +router.get('/catalog.html', overboardCatalog); //overboard catalog view router.get('/create.html', useSession, sessionRefresh, isLoggedIn, create); //create new board router.get('/randombanner', randombanner); //random banner @@ -68,7 +69,7 @@ router.get('/globalmanage/accounts.html', useSession, sessionRefresh, isLoggedIn router.get('/globalmanage/settings.html', useSession, sessionRefresh, isLoggedIn, calcPerms, hasPerms(0), csrf, globalManageSettings); //captcha -if (captchaOptions.type !== 'google') { +if (captchaOptions.type !== 'google' && captchaOptions.type !== 'hcaptcha') { router.get('/captcha', geoAndTor, processIp, captcha); //get captcha image and cookie } router.get('/captcha.html', captchaPage); //iframed for noscript users diff --git a/db/posts.js b/db/posts.js index 6232b45b..ec9321c2 100644 --- a/db/posts.js +++ b/db/posts.js @@ -250,13 +250,32 @@ module.exports = { }).toArray(); }, - getCatalog: (board) => { + getCatalog: (board, sortSticky=true, catalogLimit=0) => { + const threadsQuery = { + thread: null, + } + if (board) { + if (Array.isArray(board)) { + //array for overboard catalog + threadsQuery['board'] = { + '$in': board + } + } else { + threadsQuery['board'] = board; + } + } + let threadsSort = { + 'bumped': -1, + }; + if (sortSticky === true) { + threadsSort = { + 'sticky': -1, + 'bumped': -1 + } + } // get all threads for catalog - return db.find({ - 'thread': null, - 'board': board - }, { + return db.find(threadsQuery, { 'projection': { 'salt': 0, 'password': 0, @@ -264,10 +283,10 @@ module.exports = { 'reports': 0, 'globalreports': 0, } - }).sort({ - 'sticky': -1, - 'bumped': -1, - }).toArray(); + }) + .limit(catalogLimit) + .sort(threadsSort) + .toArray(); }, diff --git a/gulp/res/css/style.css b/gulp/res/css/style.css index e23ef465..9f351f8a 100644 --- a/gulp/res/css/style.css +++ b/gulp/res/css/style.css @@ -116,6 +116,14 @@ pre { white-space: pre; } +.aa { + font-family: Monapo, Mona, 'MS Pgothic', 'MS P繧エ繧キ繝�け', IPAMonaPGothic, 'IPA 繝「繝翫� P繧エ繧キ繝�け', submona !important; + font-size: 16px; + display: block; + overflow-x: auto; + white-space: pre; +} + .code:not(.hljs) { white-space: unset; } diff --git a/gulp/res/css/themes/win95.css b/gulp/res/css/themes/win95.css index 7b19502e..461e9251 100644 --- a/gulp/res/css/themes/win95.css +++ b/gulp/res/css/themes/win95.css @@ -65,7 +65,7 @@ font-weight: bold .navbar a { color:black; } -.anchor:target+.post-container .post-info, .post-container.highlighted .post-info, .anchor:target+table tbody tr th { +.anchor:target+.post-container .post-info, .post-container.highlighted .post-info, .post-container.hoverhighlighted .post-info, .anchor:target+table tbody tr th { background: darkblue!important; color: white!important; } diff --git a/gulp/res/js/captcha.js b/gulp/res/js/captcha.js index 3b563c85..5c6edfcb 100644 --- a/gulp/res/js/captcha.js +++ b/gulp/res/js/captcha.js @@ -80,6 +80,16 @@ class CaptchaController { xhr.send(null); } + removeCaptcha() { + const postForm = document.getElementById('postform'); + const captchaField = postForm.querySelector('.captcha'); + if (captchaField) { + //delete the whole row + const captchaRow = captchaField.closest('.row'); + captchaRow.remove(); + } + } + addMissingCaptcha() { const postSubmitButton = document.getElementById('submitpost'); const captchaFormSectionHtml = captchaformsection({ captchaGridSize }); diff --git a/gulp/res/js/captchaformsection.js b/gulp/res/js/captchaformsection.js index 0ad155ce..08d056dd 100644 --- a/gulp/res/js/captchaformsection.js +++ b/gulp/res/js/captchaformsection.js @@ -3,7 +3,7 @@ function pug_escape(e){var a=""+e,t=pug_match_html.exec(a);if(!t)return e;var r, var pug_match_html=/["&<>]/;function captchaformsection(locals) {var pug_html = "", pug_mixins = {}, pug_interp;; var locals_for_with = (locals || {}); - (function (captchaGridSize, captchaType, googleRecaptchaSiteKey, minimal) { + (function (captchaGridSize, captchaType, googleRecaptchaSiteKey, hcaptchaSiteKey, minimal) { pug_mixins["captchaexpand"] = pug_interp = function(){ var block = (this && this.block), attributes = (this && this.attributes) || {}; pug_html = pug_html + "\u003Cdetails class=\"row label mr-0\"\u003E\u003Csummary class=\"pv-5\"\u003ECaptcha\u003Cspan class=\"required\"\u003E*\u003C\u002Fspan\u003E\u003C\u002Fsummary\u003E"; @@ -11,6 +11,9 @@ switch (captchaType){ case 'google': pug_html = pug_html + "\u003Cdiv" + (" class=\"g-recaptcha\""+pug_attr("data-sitekey", `${googleRecaptchaSiteKey}`, true, false)+" data-theme=\"dark\" data-size=\"compact\" data-callback=\"recaptchaCallback\"") + "\u003E\u003C\u002Fdiv\u003E\u003Cnoscript\u003EPlease enable JavaScript to solve the captcha.\u003C\u002Fnoscript\u003E"; break; +case 'hcaptcha': +pug_html = pug_html + "\u003Cdiv" + (" class=\"h-captcha\""+pug_attr("data-sitekey", `${hcaptchaSiteKey}`, true, false)+" data-theme=\"dark\" data-size=\"compact\" data-callback=\"recaptchaCallback\"") + "\u003E\u003C\u002Fdiv\u003E\u003Cnoscript\u003EPlease enable JavaScript to solve the captcha.\u003C\u002Fnoscript\u003E"; + break; case 'text': pug_html = pug_html + "\u003Cnoscript class=\"no-m-p\"\u003E\u003Ciframe" + (" class=\"captcha\""+" src=\"\u002Fcaptcha.html\""+pug_attr("width=210", true, true, false)+" height=\"80\" scrolling=\"no\" loading=\"lazy\"") + "\u003E\u003C\u002Fiframe\u003E\u003C\u002Fnoscript\u003E\u003Cdiv class=\"jsonly captcha\" style=\"display:none;\"\u003E\u003C\u002Fdiv\u003E\u003Cinput" + (" class=\"captchafield\""+" type=\"text\" name=\"captcha\" autocomplete=\"off\" placeholder=\"Captcha text\" pattern=\".{6}\""+pug_attr("required", true, true, false)+" title=\"6 characters\"") + "\u002F\u003E"; break; @@ -34,7 +37,9 @@ pug_mixins["captchaexpand"](); locals_for_with.captchaType : typeof captchaType !== 'undefined' ? captchaType : undefined, "googleRecaptchaSiteKey" in locals_for_with ? locals_for_with.googleRecaptchaSiteKey : - typeof googleRecaptchaSiteKey !== 'undefined' ? googleRecaptchaSiteKey : undefined, "minimal" in locals_for_with ? + typeof googleRecaptchaSiteKey !== 'undefined' ? googleRecaptchaSiteKey : undefined, "hcaptchaSiteKey" in locals_for_with ? + locals_for_with.hcaptchaSiteKey : + typeof hcaptchaSiteKey !== 'undefined' ? hcaptchaSiteKey : undefined, "minimal" in locals_for_with ? locals_for_with.minimal : typeof minimal !== 'undefined' ? minimal : undefined)); ;;return pug_html;} \ No newline at end of file diff --git a/gulp/res/js/forms.js b/gulp/res/js/forms.js index dbc73f0f..fbfadbaa 100644 --- a/gulp/res/js/forms.js +++ b/gulp/res/js/forms.js @@ -67,15 +67,13 @@ let recaptchaResponse = null; function recaptchaCallback(response) { recaptchaResponse = response; } - class formHandler { constructor(form) { this.form = form; this.enctype = this.form.getAttribute('enctype'); this.messageBox = form.querySelector('#message'); - this.captchaField = form.querySelector('.captchafield') || form.querySelector('.g-recaptcha'); - + this.captchaField = form.querySelector('.captchafield') || form.querySelector('.g-recaptcha') || form.querySelector('.h-captcha'); this.submit = form.querySelector('input[type="submit"]'); if (this.submit) { this.originalSubmitText = this.submit.value; @@ -126,11 +124,12 @@ class formHandler { formSubmit(e) { const xhr = new XMLHttpRequest(); let postData; + const captchaResponse = recaptchaResponse; if (this.enctype === 'multipart/form-data') { this.fileInput && (this.fileInput.disabled = true); postData = new FormData(this.form); - if (recaptchaResponse) { - postData.append('captcha', recaptchaResponse); + if (captchaResponse) { + postData.append('captcha', captchaResponse); } this.fileInput && (this.fileInput.disabled = false); if (this.files && this.files.length > 0) { @@ -141,8 +140,8 @@ class formHandler { } } else { postData = new URLSearchParams([...(new FormData(this.form))]); - if (recaptchaResponse) { - postData.set('captcha', recaptchaResponse); + if (captchaResponse) { + postData.set('captcha', captchaResponse); } } if (this.banned @@ -169,8 +168,15 @@ class formHandler { } xhr.onreadystatechange = () => { if (xhr.readyState === 4) { - if (recaptchaResponse && grecaptcha) { + if (captchaResponse && grecaptcha) { grecaptcha.reset(); + } else if(captchaResponse && hcaptcha) { + hcaptcha.reset(); + } + if (xhr.getResponseHeader('x-captcha-enabled') === 'false') { + //remove captcha if it got disabled after you opened the page + captchaController.removeCaptcha(); + this.captchaField = null; } this.submit.disabled = false; this.submit.value = this.originalSubmitText; diff --git a/gulp/res/js/quote.js b/gulp/res/js/quote.js index a1ac940e..40d98b85 100644 --- a/gulp/res/js/quote.js +++ b/gulp/res/js/quote.js @@ -54,7 +54,7 @@ window.addEventListener('DOMContentLoaded', (event) => { } const quote = function(e) { - const quoteNum = this.textContent.replace('[Reply]', '').split(' ')[0].trim(); + const quoteNum = this.textContent.trim(); if (isThread && !e.ctrlKey) { addQuote(quoteNum); } else { diff --git a/gulpfile.js b/gulpfile.js index 034d2428..d22aa701 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -138,15 +138,17 @@ async function wipe() { async function css() { try { //a little more configurable - let bypassHeight = configs.captchaOptions.type === 'google' ? 500 - : configs.captchaOptions.type === 'grid' ? 330 - : 235; + let bypassHeight = (configs.captchaOptions.type === 'google' || configs.captchaOptions.type === 'hcaptcha') + ? 500 + : configs.captchaOptions.type === 'grid' + ? 330 + : 235; let captchaHeight = configs.captchaOptions.type === 'text' ? 80 : configs.captchaOptions.type === 'grid' ? configs.captchaOptions.grid.imageSize+30 - : 200; //'google' doesnt need this set + : 200; //google/hcaptcha doesnt need this set let captchaWidth = configs.captchaOptions.type === 'text' ? 210 : configs.captchaOptions.type === 'grid' ? configs.captchaOptions.grid.imageSize+30 - : 200; //'google' doesnt need this set + : 200; //google/hcaptcha doesnt need this set const cssLocals = `:root { --attachment-img: url('/file/attachment.png'); --spoiler-img: url('/file/spoiler.png'); @@ -216,6 +218,8 @@ async function cache() { Redis.deletePattern('banners:*'), Redis.deletePattern('users:*'), Redis.deletePattern('blacklisted:*'), + Redis.deletePattern('overboard'), + Redis.deletePattern('catalog'), ]); Redis.redisClient.quit(); } @@ -235,6 +239,8 @@ function custompages() { ]) .pipe(gulppug({ locals: { + early404Fraction: configs.early404Fraction, + early404Replies: configs.early404Replies, meta: configs.meta, enableWebring: configs.enableWebring, globalLimits: configs.globalLimits, @@ -244,6 +250,7 @@ function custompages() { postFilesSize: formatSize(configs.globalLimits.postFilesSize.max), captchaType: configs.captchaOptions.type, googleRecaptchaSiteKey: configs.captchaOptions.google.siteKey, + hcaptchaSitekey: configs.captchaOptions.hcaptcha.siteKey, captchaGridSize: configs.captchaOptions.grid.size, commit, } diff --git a/helpers/captcha/verify.js b/helpers/captcha/verify.js index a93f1d3c..54d9b982 100644 --- a/helpers/captcha/verify.js +++ b/helpers/captcha/verify.js @@ -25,7 +25,7 @@ module.exports = async (req, res, next) => { } } - const captchaInput = req.body.captcha || req.body['g-recaptcha-response']; + const captchaInput = req.body.captcha || req.body['g-recaptcha-response'] || req.body["h-captcha-response"]; const captchaId = req.cookies.captchaid; try { await checkCaptcha(captchaInput, captchaId); @@ -45,7 +45,7 @@ module.exports = async (req, res, next) => { //it was correct, so mark as solved for other middleware res.locals.solvedCaptcha = true; - if (captchaOptions.type !== 'google') { + if (captchaOptions.type !== 'google' && captchaOptions.type !== 'hcaptcha') { //for builtin captchas, clear captchaid cookie, delete file and reset quota res.clearCookie('captchaid'); await Promise.all([ diff --git a/helpers/checks/captcha.js b/helpers/checks/captcha.js index 970c5e1b..f9d8da53 100644 --- a/helpers/checks/captcha.js +++ b/helpers/checks/captcha.js @@ -16,7 +16,7 @@ module.exports = async (captchaInput, captchaId) => { } //make sure they have captcha cookie and its 24 chars - if (captchaOptions.type !== 'google' + if ((captchaOptions.type !== 'google' && captchaOptions.type !== 'hcaptcha') && (!captchaId || captchaId.length !== 24)) { throw 'Captcha expired'; } @@ -69,6 +69,24 @@ module.exports = async (captchaInput, captchaId) => { throw 'Incorrect captcha answer'; } break; + case 'hcaptcha': + let hcaptchaResponse; + try { + const form = new FormData(); + form.append('secret', captchaOptions.hcaptcha.secretKey); + form.append('sitekey', captchaOptions.hcaptcha.siteKey); + form.append('response', captchaInput[0]); + hcaptchaResponse = await fetch('https://hcaptcha.com/siteverify', { + method: 'POST', + body: form, + }).then(res => res.json()); + } catch (e) { + throw 'Captcha error occurred'; + } + if (!hcaptchaResponse || !hcaptchaResponse.success) { + throw 'Incorrect captcha answer'; + } + break; default: throw 'Captcha config error'; break; diff --git a/helpers/posting/markdown.js b/helpers/posting/markdown.js index 23c30602..744c4e63 100644 --- a/helpers/posting/markdown.js +++ b/helpers/posting/markdown.js @@ -12,8 +12,8 @@ const greentextRegex = /^>((?!>\d+|>>/\w+(/\d*)?).*)/gm , detectedRegex = /(\(\(\(.+?\)\)\))/gm , linkRegex = /https?\://[^\s<>\[\]{}|\\^]+/g , codeRegex = /(?:(?[a-z+]{1,10})\r?\n)?(?[\s\S]+)/i - , includeSplitRegex = /(```[\s\S]+?```)/gm - , splitRegex = /```([\s\S]+?)```/gm + , includeSplitRegex = /(\[code\][\s\S]+?\[\/code\])/gm + , splitRegex = /\[code\]([\s\S]+?)\[\/code\]/gm , trimNewlineRegex = /^\s*(\r?\n)*|(\r?\n)*$/g , getDomain = (string) => string.split(/\/\/|\//)[1] //unused atm , escape = require(__dirname+'/escape.js') @@ -82,6 +82,8 @@ module.exports = { } else if (lang !== 'plain' && highlightOptions.languageSubset.includes(lang)) { const { value } = highlight(lang, trimFix, true); return `language: ${lang}\n${value}`; + } else if (lang === 'aa') { + return `${escape(matches.groups.code)}`; } return `${escape(trimFix)}`; }, diff --git a/helpers/posting/name.js b/helpers/posting/name.js index ef0d5fee..3dcce993 100644 --- a/helpers/posting/name.js +++ b/helpers/posting/name.js @@ -1,7 +1,7 @@ 'use strict'; -const getTripCode = require(__dirname+'/tripcode.js') - , nameRegex = /^(?(?!##).*?)?(?:##(?[^ ]{1}.*?))?(?##(? .*?)?)?$/ +const { getInsecureTrip, getSecureTrip } = require(__dirname+'/tripcode.js') + , nameRegex = /^(?(?!##|#).*?)?(?:(?##|#)(?[^# ].+?))?(?##(? .*?)?)?$/ , staffLevels = ['Admin', 'Global Staff', 'Board Owner', 'Board Mod'] , staffLevelsRegex = new RegExp(`(${staffLevels.join('|')})+`, 'igm') @@ -25,7 +25,11 @@ module.exports = async (inputName, permLevel, boardSettings, boardOwner, usernam } //tripcode if (groups.tripcode) { - tripcode = `!!${(await getTripCode(groups.tripcode))}`; + if (groups.secure.length === 1) { + tripcode = `!${getInsecureTrip(groups.tripcode)}`; + } else { + tripcode = `!!${(await getSecureTrip(groups.tripcode))}`; + } } //capcode if (permLevel < 4 && groups.capcode) { diff --git a/helpers/posting/tripcode.js b/helpers/posting/tripcode.js index cf0147ab..4a01cba3 100644 --- a/helpers/posting/tripcode.js +++ b/helpers/posting/tripcode.js @@ -2,11 +2,42 @@ const { tripcodeSecret } = require(__dirname+'/../../configs/main.js') , { createHash } = require('crypto') + , { encode } = require('iconv-lite') + , crypt = require('unix-crypt-td-js') + , replace = { + ':': 'A', + ';': 'B', + '<': 'C', + '=': 'D', + '>': 'E', + '?': 'F', + '@': 'G', + '[': 'a', + '\\': 'b', + ']': 'c', + '^': 'd', + '_': 'e', + '`': 'f', + }; -module.exports = async (password) => { +module.exports = { - const tripcodeHash = createHash('sha256').update(password + tripcodeSecret).digest('base64'); - const tripcode = tripcodeHash.substring(tripcodeHash.length-10); - return tripcode; + getSecureTrip: async (password) => { + const tripcodeHash = createHash('sha256').update(password + tripcodeSecret).digest('base64'); + const tripcode = tripcodeHash.substring(tripcodeHash.length-10); + return tripcode; + }, + + getInsecureTrip: (password) => { + const encoded = encode(password, 'SHIFT_JIS'); + let salt = `${encoded}H..` + .substring(1, 3) + .replace(/[^.-z]/g, '.'); + for (let find in replace) { + salt = salt.split(find).join(replace[find]); + } + const hashed = crypt(encoded, salt); + return hashed.slice(-10); + }, } diff --git a/helpers/render.js b/helpers/render.js index 9ed250c9..cd0af511 100644 --- a/helpers/render.js +++ b/helpers/render.js @@ -29,6 +29,9 @@ switch (captchaOptions.type) { case 'google': renderLocals.googleRecaptchaSiteKey = captchaOptions.google.siteKey; break; + case 'hcaptcha': + renderLocals.hcaptchaSitekey = captchaOptions.hcaptcha.siteKey; + break; case 'grid': renderLocals.captchaGridSize = captchaOptions.grid.size; break; diff --git a/models/forms/editpost.js b/models/forms/editpost.js index 4dadff3b..a4e4a1a8 100644 --- a/models/forms/editpost.js +++ b/models/forms/editpost.js @@ -3,7 +3,6 @@ const { Posts, Bans, Modlogs } = require(__dirname+'/../../db/') , { createHash } = require('crypto') , Mongo = require(__dirname+'/../../db/db.js') - , getTripCode = require(__dirname+'/../../helpers/posting/tripcode.js') , { prepareMarkdown } = require(__dirname+'/../../helpers/posting/markdown.js') , messageHandler = require(__dirname+'/../../helpers/posting/message.js') , nameHandler = require(__dirname+'/../../helpers/posting/name.js') diff --git a/models/forms/makepost.js b/models/forms/makepost.js index b0672292..6b763bc6 100644 --- a/models/forms/makepost.js +++ b/models/forms/makepost.js @@ -553,6 +553,9 @@ module.exports = async (req, res, next) => { 'board': res.locals.board }; + //let frontend script know if captcha is still enabled + res.set('x-captcha-enabled', captchaMode > 0); + if (req.headers['x-using-live'] != null && data.thread) { //defer build and post will come live res.json({ diff --git a/models/pages/captcha.js b/models/pages/captcha.js index e8e8753f..8fc39863 100644 --- a/models/pages/captcha.js +++ b/models/pages/captcha.js @@ -2,7 +2,9 @@ const { Captchas, Ratelimits } = require(__dirname+'/../../db/') , { secureCookies, rateLimitCost, captchaOptions } = require(__dirname+'/../../configs/main.js') - , generateCaptcha = captchaOptions.type !== 'google' ? require(__dirname+`/../../helpers/captcha/generators/${captchaOptions.type}.js`) : null + , generateCaptcha = (captchaOptions.type !== 'google' && captchaOptions.type !== 'hcaptcha') + ? require(__dirname+`/../../helpers/captcha/generators/${captchaOptions.type}.js`) + : null , production = process.env.NODE_ENV === 'production'; module.exports = async (req, res, next) => { diff --git a/models/pages/index.js b/models/pages/index.js index db80f61f..d289ea25 100644 --- a/models/pages/index.js +++ b/models/pages/index.js @@ -20,4 +20,5 @@ module.exports = { modloglist: require(__dirname+'/modloglist.js'), boardlist: require(__dirname+'/boardlist.js'), overboard: require(__dirname+'/overboard.js'), + overboardCatalog: require(__dirname+'/overboardcatalog.js'), } diff --git a/models/pages/overboardcatalog.js b/models/pages/overboardcatalog.js new file mode 100644 index 00000000..c5511da6 --- /dev/null +++ b/models/pages/overboardcatalog.js @@ -0,0 +1,26 @@ +'use strict'; + +const { Posts, Boards } = require(__dirname+'/../../db/') + , cache = require(__dirname+'/../../redis.js') + , { overboardCatalogLimit } = require(__dirname+'/../../configs/main.js'); + +module.exports = async (req, res, next) => { + + let threads = (await cache.get('catalog')) || []; + if (!threads || threads.length === 0) { + try { + const listedBoards = await Boards.getLocalListed(); + threads = await Posts.getCatalog(listedBoards, false, overboardCatalogLimit); + cache.set('catalog', threads, 60); + } catch (err) { + return next(err); + } + } + + res + .set('Cache-Control', 'public, max-age=60') + .render('overboardcatalog', { + threads, + }); + +} diff --git a/package-lock.json b/package-lock.json index f8d0a1e9..550e6e4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1108,6 +1108,14 @@ "type-is": "~1.6.17" }, "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "qs": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", @@ -3834,11 +3842,11 @@ } }, "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "ieee754": { @@ -4777,6 +4785,14 @@ "ms": "^2.1.1" } }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4847,6 +4863,16 @@ "debug": "^3.2.6", "iconv-lite": "^0.4.4", "sax": "^1.2.4" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "semver": { @@ -5999,6 +6025,16 @@ "http-errors": "1.7.2", "iconv-lite": "0.4.24", "unpipe": "1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "rc": { @@ -7444,6 +7480,11 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==" }, + "unix-crypt-td-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 4ed1c71e..c485c487 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "gulp-uglify-es": "^2.0.0", "highlight.js": "^10.2.0", "i18n-iso-countries": "^6.0.0", + "iconv-lite": "^0.6.2", "ioredis": "^4.14.1", "ip6addr": "^0.2.3", "mongodb": "^3.6.2", @@ -43,7 +44,8 @@ "semver": "^7.3.2", "socket.io": "^2.3.0", "socket.io-redis": "^5.4.0", - "socks-proxy-agent": "^5.0.0" + "socks-proxy-agent": "^5.0.0", + "unix-crypt-td-js": "^1.1.4" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/schedules/webring.js b/schedules/webring.js index a79a0b30..f0c8c27b 100644 --- a/schedules/webring.js +++ b/schedules/webring.js @@ -28,7 +28,9 @@ module.exports = async () => { headers: { 'User-Agent':'' } - }).then(res => res.json()).catch(e => console.error); + }) + .then(res => res.json()) + .catch(e => {}); })); for (let i = 0; i < rings.length; i++) { const ring = rings[i]; diff --git a/server.js b/server.js index 0bcab133..f9e19dbc 100644 --- a/server.js +++ b/server.js @@ -81,6 +81,9 @@ const express = require('express') case 'google': app.locals.googleRecaptchaSiteKey = captchaOptions.google.siteKey; break; + case 'hcaptcha': + app.locals.hcaptchaSiteKey = captchaOptions.hcaptcha.siteKey; + break; case 'grid': app.locals.captchaGridSize = captchaOptions.grid.size; break; diff --git a/views/custompages/faq.pug.example b/views/custompages/faq.pug.example index 4e77c58c..b1c3c701 100644 --- a/views/custompages/faq.pug.example +++ b/views/custompages/faq.pug.example @@ -30,6 +30,7 @@ block content ul.mv-0 li: a(href='#make-a-board') How do I make my own board? li: a(href='#staff-and-permissions') How do staff, users and permissions work? + li: a(href='#antispam') What do the board settings for antispam do? .table-container.flex-center.mv-5 .anchor#whats-an-imageboard table @@ -63,22 +64,27 @@ block content p | Names should be input like: input(disabled='true' spellcheck='false' type='text' value='Name##Tripcode## Capcode') - | . Tripcode and capcode are optional components. Tripcodes and capcodes may not contain "##" and the whitespace before capcodes is significant. + | . Tripcode and capcode are optional components. Tripcodes and capcodes may not contain "#" and the whitespace before capcodes is significant. p Valid examples: ol.mv-0 - li Name - li Name##tripcode - li Name## capcode - li Name##tripcode## capcode - li ##tripcode## capcode + li name + li #tripcode li ##tripcode li ## capcode - p The capcode can also be left blank to display just your role. - p From the examples, you can see that all components can be used in combination or independently. In a post number 4 would look like: + li name#tripcode + li name##tripcode + li name## capcode + li name#tripcode## capcode + li name##tripcode## capcode + li #tripcode## capcode + li ##tripcode## capcode + li ## + p The last example is considered a blank capcode and can be used as a shortcut to display your role. + p Each component can be used in combination or independently. In a post number 9 would look like: - const examplePost = { "date" : new Date("2019-08-02T09:48:44.180Z"), - "name" : "Name", + "name" : "name", "board" : "example", "tripcode" : "!!X8NXmAS44=", "capcode" : "##Board Owner capcode", @@ -97,13 +103,13 @@ block content "postId" : 123 } +post(examplePost) - p The name appears bold in the top left, followed by the tripcode in regular weight with a !! prefix, then the capcode in a different color, bold and with a ## prefix. The colours may vary between themes but are generally distinct from eachother + p The name appears bold in the top left, followed by the tripcode in regular weight with a !! prefix, then the capcode in a different color, bold and with a ## prefix. The colours may vary between themes but are generally distinct from each other b Name p The name is simply what name you want to be shown alongside your post. Other users can post with the same name so there is nothing preventing impersonation. This is not related to your username (for registered users). b Tripcode - p A tripcode is a password of sorts, which users can provide in the tripcode component of their name. This tripcode is used in conjunction with a server-known secret to generate a unique* tripcode portion of the name. Long, unique tripcodes can be used as a form of identity. It is important that you keep tripcodes secret if you use them for some form of identity. A compromised tripcode can be used for impersonation and cannot be revoked in any way. + p A tripcode is a password of sorts, which users can provide in the tripcode component of their name. This tripcode is used in conjunction with a server-known secret to generate a unique* tripcode portion of the name. Long, unique tripcodes can be used as a form of identity. It is important that you keep tripcodes secret if you use them for some form of identity. A compromised tripcode can be used for impersonation and cannot be revoked in any way. Single # before tripcodes will use the traditional (what is now sometimes known as "insecure") tripcode algorithm shared by many imageboard softwares and websites. Double # before tripcodes will use a sha256 hash with server-side secret for a more secure, non-portable tripcode. b Capcode p A capcode is a component of the name field only available to authenticated users. This includes admins, global staff, board owners and board moderators. If there is no text after the ##, the role will be displayed alone. Leaving a space and putting custom text will be prefixed by the role name. This way, the role is always shown to prevent role impersonation. @@ -174,17 +180,29 @@ block content span.mono inline monospace tr td - | ```language + | [code]language br - | code block + | int main() {...} br - | ``` + | [/code] + td + span.code int main() {...} + tr + td + pre + | [code]aa + | ∧_∧ + | ( ・ω・) Let's try that again. + | [/code] td - span.code code block + pre.aa + | ∧_∧ + | ( ・ω・) Let's try that again. tr td(colspan=2) | The "language" of code blocks is optional. Without it, automatic language detection is used. - | If the language is "plain", highlighting is disabled for the code block. Not all languages are supported, a subset of popular languages is used. + | If the language is "plain", highlighting is disabled for the code block. If "aa" is used, the font will be adjusted for Japanese Shift JIS art. + | Not all programming languages are supported, a subset of popular languages is used. | If the language is not in the supported list, the code block will be rendered like "plain" with no highlighting. | Languages supported: #{codeLanguages.join(', ')} .table-container.flex-center.mv-5 @@ -262,9 +280,32 @@ block content li Board moderator: All below, move/merge, ban, delete-by-ip, sticky/sage/lock/cycle li Regular user: Reports, and post spoiler/delete/unlink if the board has them enabled | Administrators have the ability to assign a permission level directly to users through the global management page. Typically a user is level 4 (regular user), 1 (global staff) or 0 (administrator). - | Level 2 and 3 are usually only aplicable to users when on a board they are owner or moderator. However, level 2 or 3 can be assigned manually to create global board owners or global board moderators - | who have board owner or moderation permissions on all boards, but without access to the global moderation interfaces. If assigning global boad owners (level 2), they will have access to the board settings + | Level 2 and 3 are usually only applicable to users when on a board they are owner or moderator. However, level 2 or 3 can be assigned manually to create global board owners or global board moderators + | who have board owner or moderation permissions on all boards, but without access to the global moderation interfaces. If assigning global board owners (level 2), they will have access to the board settings | and the ability to reassign board owners. + .table-container.flex-center.mv-5 + .anchor#antispam + table + tr + th: a(href='#antispam') What do the board settings for antispam do? + tr + td + p Lock Mode: Choose to lock posting new threads or all posts. + p Captcha Mode: Choose to enforce captchas for posting threads or all posts. + p PPH Trigger Threshold: Trigger an action after a certain amount of PPH. + p PPH Trigger Action: The action to trigger. + p TPH Trigger Threshold: Trigger an action after a certain amount of TPH. + p TPH Trigger Action: The action to trigger. + p Trigger Reset Lock Mode: If a trigger threshold was reached, reset the lock mode to this at the end of the hour. + p Trigger Reset Captcha Mode: If a trigger threshold was reached, reset the captcha mode to this at the end of the hour. + p Early 404: When a new thread is posted, delete any existing threads with less than #{early404Replies} replies beyond the first 1/#{early404Fraction} of threads. + p Disable .onion file posting: Prevent users posting through a .onion hidden service posting images. + p Blocked Countries: Block country codes (based on geo Ip data) from posting. + p Filters: Newline separated list of words or phrases to match in posts. Checks name, message, filenames, subject, and filenames. + p Strict Filtering: More aggressively match filters, by normalising the input compared against the filters. + p Filter Mode: What to do when a post matches a filter. + p Filter Auto Ban Duration: How long to automatically ban for when filter mode is set to ban. Input the duration in time format described in the #[a(href='#moderation') moderation section]. + .table-container.flex-center.mv-5 .anchor#contact table diff --git a/views/includes/captcha.pug b/views/includes/captcha.pug index 02a731c8..c69ee627 100644 --- a/views/includes/captcha.pug +++ b/views/includes/captcha.pug @@ -1,12 +1,15 @@ case captchaType when 'google' - div(class="g-recaptcha" data-sitekey=`${googleRecaptchaSiteKey}` data-theme="dark" data-size="compact" data-callback="recaptchaCallback") + div(class='g-recaptcha' data-sitekey=`${googleRecaptchaSiteKey}` data-theme='dark' data-size='compact' data-callback='recaptchaCallback') + noscript Please enable JavaScript to solve the captcha. + when 'hcaptcha' + div(class='h-captcha' data-sitekey=`${hcaptchaSiteKey}` data-theme='dark' data-size='compact' data-callback='recaptchaCallback') noscript Please enable JavaScript to solve the captcha. when 'text' noscript.no-m-p iframe.captcha(src='/captcha.html' 'width=210' height='80' scrolling='no' loading='lazy') .jsonly.captcha(style='display:none;') - input.captchafield(type='text' name='captcha' autocomplete='off' placeholder='Captcha text' pattern=".{6}" required title='6 characters') + input.captchafield(type='text' name='captcha' autocomplete='off' placeholder='Captcha text' pattern='.{6}' required title='6 characters') when 'grid' unless minimal a.text-center(href='/faq.html#captcha') Instructions diff --git a/views/includes/head.pug b/views/includes/head.pug index b3a529f5..2396a537 100644 --- a/views/includes/head.pug +++ b/views/includes/head.pug @@ -19,4 +19,6 @@ include ./favicon.pug script(src=`/js/all.js?v=${commit}`) if captchaType === 'google' - script(src="https://www.google.com/recaptcha/api.js" async defer) + script(src='https://www.google.com/recaptcha/api.js' async defer) +if captchaType === 'hcaptcha' + script(src='https://hcaptcha.com/1/api.js' async defer) diff --git a/views/includes/navbar.pug b/views/includes/navbar.pug index 33dc5884..d44b1454 100644 --- a/views/includes/navbar.pug +++ b/views/includes/navbar.pug @@ -1,11 +1,11 @@ unless minimal nav.navbar a.nav-item(href='/index.html') Home - //a.nav-item(href='/all.html') Overboard a.nav-item(href='/boards.html' style=(enableWebring ? 'line-height: 1.5em' : null)) | Boards if enableWebring .rainbow +Webring + a.nav-item(href='/overboard.html') Overboard a.nav-item(href='/account.html') Account if board a.nav-item(href=`/${board._id}/manage/reports.html`) Manage diff --git a/views/mixins/catalogtile.pug b/views/mixins/catalogtile.pug index a7d6c299..93da734d 100644 --- a/views/mixins/catalogtile.pug +++ b/views/mixins/catalogtile.pug @@ -1,4 +1,4 @@ -mixin catalogtile(board, post, index) +mixin catalogtile(post, index) .catalog-tile(data-board=post.board data-post-id=post.postId data-user-id=post.userId @@ -9,9 +9,14 @@ mixin catalogtile(board, post, index) data-date=post.date data-replies=post.replyposts data-bump=post.bumped) - - const postURL = `/${board._id}/${modview ? 'manage/' : ''}thread/${post.postId}.html#${post.postId}` + - const postURL = `/${post.board}/${modview ? 'manage/' : ''}thread/${post.postId}.html#${post.postId}` .post-info - input.left.post-check(type='checkbox', name='checkedposts' value=post.postId) + if !index + div + | Thread from + a.no-decoration.post-subject(href=`/${post.board}/`) /#{post.board}/ + else + input.left.post-check(type='checkbox', name='checkedposts' value=post.postId) if modview a.left.ml-5.bold(href=`recent.html?postid=${post.postId}`) [+] include ../includes/posticons.pug @@ -20,8 +25,9 @@ mixin catalogtile(board, post, index) span(title='Replies') R: #{post.replyposts} | / span(title='Files') F: #{post.replyfiles} - | / - span(title='Page') P: #{Math.ceil(index/10)} + if index + | / + span(title='Page') P: #{Math.ceil(index/10)} if post.files.length > 0 .post-file-src a(href=postURL) @@ -31,7 +37,7 @@ mixin catalogtile(board, post, index) else if file.hasThumb img.catalog-thumb(src=`/file/thumb-${file.hash}${file.thumbextension}` width=file.geometry.thumbwidth height=file.geometry.thumbheight loading='lazy') else if file.attachment - div.attachmentimg.catalog-thumb + div.attachmentimg.catalog-thumb(data-mimetype=file.mimetype) else if file.mimetype.startsWith('audio') div.audioimg.catalog-thumb else diff --git a/views/mixins/post.pug b/views/mixins/post.pug index b148f21a..3903cc6d 100644 --- a/views/mixins/post.pug +++ b/views/mixins/post.pug @@ -1,16 +1,17 @@ include ./report.pug -mixin post(post, truncate, manage=false, globalmanage=false, ban=false) +mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboard=false) .anchor(id=post.postId) div(class=`post-container ${post.thread || ban === true ? '' : 'op'}` data-board=post.board data-post-id=post.postId data-user-id=post.userId data-name=post.name data-tripcode=post.tripcode data-subject=post.subject) - const postURL = `/${post.board}/${(modview || manage || globalmanage) ? 'manage/' : ''}thread/${post.thread || post.postId}.html`; .post-info span label - if globalmanage - input.post-check(type='checkbox', name='globalcheckedposts' value=post._id) - else if !ban - input.post-check(type='checkbox', name='checkedposts' value=post.postId) - | + if !overboard + if globalmanage + input.post-check(type='checkbox', name='globalcheckedposts' value=post._id) + else if !ban + input.post-check(type='checkbox', name='checkedposts' value=post.postId) + | if manage - const ip = permLevel > ipHashPermLevel ? post.ip.single.slice(-10) : post.ip.raw; a.bold(href=`${upLevel ? '../' : ''}recent.html?ip=${encodeURIComponent(ip)}`) [#{ip}] @@ -49,10 +50,10 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false) a.noselect.no-decoration(href=`${postURL}#${post.postId}`) No. span.post-quoters a.no-decoration(href=`${postURL}#postform`) #{post.postId} - if !post.thread - | - span.noselect: a(href=`${postURL}#postform`) [Reply] | + if !post.thread && (truncate || manage || globalmanage) + | + span.noselect: a(href=`${postURL}`) [Open] select.jsonly.postmenu option(value='single') Hide if post.userId @@ -86,7 +87,7 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false) else if file.hasThumb img.file-thumb(src=`/file/thumb-${file.hash}${file.thumbextension}` height=file.geometry.thumbheight width=file.geometry.thumbwidth loading='lazy') else if file.attachment - div.attachmentimg.file-thumb + div.attachmentimg.file-thumb(data-mimetype=file.mimetype) else if type === 'audio' div.audioimg.file-thumb else diff --git a/views/pages/boardlist.pug b/views/pages/boardlist.pug index a726a7be..a7e1d482 100644 --- a/views/pages/boardlist.pug +++ b/views/pages/boardlist.pug @@ -5,9 +5,6 @@ block head block content h1.board-title Board List - h4.board-description - | or try the - a(href='/all.html') overboard .flexcenter.mv-10 form.form-post(action='/boards.html' method='GET') input(type='hidden' value=page) diff --git a/views/pages/catalog.pug b/views/pages/catalog.pug index 6e257fbe..366028e2 100644 --- a/views/pages/catalog.pug +++ b/views/pages/catalog.pug @@ -36,7 +36,7 @@ block content else .catalog for thread, i in threads - +catalogtile(board, thread, i+1) + +catalogtile(thread, i+1) hr(size=1) if modview +managenav('catalog') diff --git a/views/pages/editpost.pug b/views/pages/editpost.pug index 42dd2e49..19519b21 100644 --- a/views/pages/editpost.pug +++ b/views/pages/editpost.pug @@ -41,9 +41,6 @@ block content a.noselect.no-decoration(href=`${postURL}#${post.postId}`) No. span.post-quoters a.no-decoration(href=`${postURL}#postform`) #{post.postId} - if !post.thread - | - span.noselect: a(href=`${postURL}#postform`) [Reply] .post-data pre.post-message textarea.edit.fw(name='message' rows='15' placeholder='Message') #{post.nomarkup} diff --git a/views/pages/managesettings.pug b/views/pages/managesettings.pug index 04a0bf8e..ebcfc5b7 100644 --- a/views/pages/managesettings.pug +++ b/views/pages/managesettings.pug @@ -202,7 +202,10 @@ block content option(value='2', selected=board.settings.messageR9KMode === 2) Board Wide .col.w900 .row - h4.mv-5 Antispam: + h4.mv-5 Antispam + | ( + a(href='/faq.html#antispam') more info + | ): .row .label Lock Mode select(name='lock_mode') diff --git a/views/pages/overboard.pug b/views/pages/overboard.pug index a2cefe07..14efdc76 100644 --- a/views/pages/overboard.pug +++ b/views/pages/overboard.pug @@ -6,8 +6,12 @@ block head block content .board-header - h1.board-title Overboard + h1.board-title Overboard Index h4.board-description Recently bumped threads from all listed boards + | + | ( + a(href='/catalog.html') Catalog View + | ) hr(size=1) if threads.length === 0 p No posts. @@ -15,7 +19,7 @@ block content for thread in threads h4.no-m-p Thread from #[a(href=`/${thread.board}/index.html`) /#{thread.board}/] .thread - +post(thread, true) + +post(thread, true, false, false, false, true) for post in thread.replies - +post(post, true) + +post(post, true, false, false, false, true) hr(size=1) diff --git a/views/pages/overboardcatalog.pug b/views/pages/overboardcatalog.pug new file mode 100644 index 00000000..b7e0f85d --- /dev/null +++ b/views/pages/overboardcatalog.pug @@ -0,0 +1,33 @@ +extends ../layout.pug +include ../mixins/catalogtile.pug + +block head + title Catalog + + +block content + .board-header.mb-5 + h1.board-title Overboard Catalog + h4.board-description Recently bumped threads from all listed boards + | + | ( + a(href='/overboard.html') Index View + | ) + br + include ../includes/stickynav.pug + .wrapbar + .pages.jsonly + input#catalogfilter(type='text' placeholder='Filter') + select.ml-5.right#catalogsort + option(value="" disabled selected hidden) Sort by + option(value="bump") Bump order + option(value="date") Creation date + option(value="replies") Reply count + hr(size=1) + if threads.length === 0 + p No posts. + else + .catalog + for thread, i in threads + +catalogtile(thread, false) + hr(size=1)