Merge branch 'develop' into jschan-replace-filter

merge-requests/346/merge
Thomas Lynch 2 weeks ago
commit da73073bcb
Signed by: fatchan
GPG Key ID: A7E5E8B7E11EE92D
  1. 14
      .eslintrc.json
  2. 19
      CHANGELOG.md
  3. 9
      INSTALLATION.md
  4. 2
      README.md
  5. 2
      configs/nginx/snippets/jschan_common_routes.conf
  6. 4
      configs/template.js.example
  7. 2
      controllers/forms/actions.js
  8. 6
      controllers/forms/deleteaccounts.js
  9. 2
      controllers/forms/editbans.js
  10. 2
      controllers/forms/makepost.js
  11. 2
      db/posts.js
  12. 18
      gulp/res/css/style.css
  13. 2
      gulp/res/js/expand.js
  14. 8
      gulp/res/js/forms.js
  15. 2
      gulp/res/js/hover.js
  16. 2
      gulp/res/js/live.js
  17. 4
      gulp/res/js/localstorage.js
  18. 2
      gulp/res/js/quote.js
  19. 2
      gulp/res/js/tegaki-replay.js
  20. 4
      gulp/res/js/theme.js
  21. 2
      gulp/res/js/viewfulltext.js
  22. 4
      lib/captcha/generators/grid.js
  23. 4
      lib/captcha/generators/grid.test.js
  24. 2
      lib/captcha/generators/text.test.js
  25. 2
      lib/converter/formatsize.test.js
  26. 6
      lib/converter/timeutils.test.js
  27. 4
      lib/file/mimetypes.js
  28. 43
      lib/file/video/videothumbnail.js
  29. 2
      lib/input/decodequeryip.test.js
  30. 2
      lib/input/escaperegexp.test.js
  31. 2
      lib/input/pagequeryconverter.test.js
  32. 14
      lib/input/schema.test.js
  33. 8
      lib/input/setting.test.js
  34. 6
      lib/input/settingsdiff.test.js
  35. 6
      lib/middleware/input/paramconverter.test.js
  36. 2
      lib/middleware/ip/processip.js
  37. 2
      lib/middleware/misc/referrercheck.js
  38. 2
      lib/permission/permission.js
  39. 8
      lib/post/checkfilters.test.js
  40. 2
      lib/post/getfilterstrings.js
  41. 2
      lib/post/markdown/escape.test.js
  42. 4
      lib/post/markdown/handler/diceroll.test.js
  43. 4
      lib/post/markdown/handler/linkmatch.test.js
  44. 2
      lib/post/markdown/markdown.js
  45. 2
      lib/post/name.test.js
  46. 4
      lib/post/tripcode.test.js
  47. 13
      lib/redis/redis.js
  48. 2
      models/forms/banposter.js
  49. 32
      models/forms/deleteaccounts.js
  50. 15
      models/forms/makepost.js
  51. 2
      models/forms/upgradebans.js
  52. 5
      models/pages/manage/logs.js
  53. 89
      package-lock.json
  54. 4
      package.json
  55. 2
      schedules/tasks/inactiveaccounts.js
  56. 3
      schedules/tasks/prune.js
  57. 3
      schedules/tasks/webring.js
  58. 20
      test/cleanup.js
  59. 58
      test/global.js
  60. 22
      test/setup.js
  61. 120
      tools/restore_posts.js
  62. 2
      views/includes/postform.pug
  63. 8
      views/mixins/boardtable.pug
  64. 10
      views/pages/globalmanageaccounts.pug
  65. 10
      views/pages/globalmanageboardlist.pug
  66. 8
      views/pages/home.pug

@ -1,6 +1,6 @@
{
"ignorePatterns": [
"docker/", "tmp/", "static/", "gulp/res/css/", "gulp/res/icons/", "gulp/res/img/", "tools/", "views/", "node_modules/",
"docker/", "tmp/", "static/", "gulp/res/css/", "gulp/res/icons/", "gulp/res/img/", "views/", "node_modules/",
"gulp/res/js/pugfilters.js",
"gulp/res/js/locals.js",
"gulp/res/js/post.js",
@ -30,6 +30,18 @@
"ecmaVersion": "latest"
},
"rules": {
"block-spacing": [
"error",
"always"
],
"keyword-spacing": [
"error",
{ "before": true, "after": true }
],
"space-before-blocks": [
"error",
"always"
],
"brace-style": [
"error",
"1tbs",

@ -1,3 +1,22 @@
### 1.6.0
- Filters now have a "replace" mode, by @disco.
- Global account management now has an option delete all boards owned by an account when deleting it.
- Bugfix moving posts to non existing board not correctly returning an error sometimes.
### 1.5.0
- Thanks very much to @disco and @some_random_guy for valuable contributions for this release while I have been busy and on vacation.
- Remove spamhaus ZEN from the default included DNSBLs.
- Improved handling of video thumbnailing with transparent vp8 and vp9 codecs.
- Video codec now included in processedFile data.
- Fix frontend bug of hovering quotes on some global/manage pages.
- Added a tool to restore deleted posts from a backup.
- Fix issue with scheduled file pruning.
- Fix minor issue of text wrapping on board tables.
- Update vendorised dependencies (gm, express-fileupload, etc).
- Add a few more eslint rules to more strictly enforce code style.
- No longer exlude the tools folder from linting.
- Npm audit fix.
### 1.4.1
- Update the INSTALLATION.md (install instructions).
- Add a "Bypass DNSBL" account/role permission.

@ -46,6 +46,13 @@ hsudo apt install -y mongodb-org
sudo systemctl enable --now mongod
```
NOTE: If at this point, mongod doesn't start or has an error, fix the permissions ([stackoverflow](https://stackoverflow.com/questions/64608581/mongodb-code-exited-status-14-failed-but-not-any-clear-errors/66107451#66107451)):
```bash
sudo chown -R mongodb:mongodb /var/lib/mongodb
sudo chown mongodb:mongodb /tmp/mongodb-27017.sock
sudo service mongod restart
```
[Enable authentication](https://www.mongodb.com/features/mongodb-authentication):
```bash
#NOTE: change "CHANGE-ME-YOUR-SECURE-MONGODB-PASSWORD" to something secure.
@ -53,8 +60,6 @@ mongosh admin --eval "db.getSiblingDB('jschan').createUser({user: 'jschan', pwd:
sudo sh -c "cat > /etc/mongod.conf" <<'EOF'
storage:
dbPath: /var/lib/mongodb
journal:
enabled: true
systemLog:
destination: file
logAppend: true

@ -8,7 +8,7 @@ Mirror(s):
Live instances (Unofficial):
- 🇺🇸 https://94chan.org
- 🇵🇹 https://ptchan.org
- 🇵🇹/🇧🇷 https://ptchan.org
- 🇮🇳 https://indiachan.net
Contact via:

@ -65,7 +65,7 @@ location ~* \.js$ {
}
# Files (image, video, audio, other)
location ~* \.(png|jpg|jpeg|webmanifest|xml|ico|apng|bmp|webp|pjpeg|jfif|gif|mp4|webm|mov|mkv|svg|flac|mp3|m4a|ogg|wav|opus|ttf)$ {
location ~* \.(png|jpg|jpeg|webmanifest|xml|ico|apng|bmp|webp|pjpeg|jfif|gif|mp4|webm|mov|mkv|svg|flac|mp3|m4a|ogg|wav|opus|ttf|woff2)$ {
access_log off;
expires max;
root /path/to/jschan/static;

@ -58,7 +58,7 @@ module.exports = {
kind of dns cache e.g. unbound to improve performance. DNSBL only checked for posting */
dnsbl: {
enabled: false,
blacklists: ['tor.dan.me.uk', 'zen.spamhaus.org'],
blacklists: ['tor.dan.me.uk'],
cacheTime: 3600 //in seconds, idk whats a good value
},
@ -383,7 +383,7 @@ module.exports = {
captchaMode: 0, //0=disabled, 1=for threads, 2=for all posts
tphTrigger: 10, //numebr of threads in an hour before trigger action is activated
pphTrigger: 50, //number of posts in an hour before ^
//0=none, 1=captcha enable for threads, 2=captcha enable for all posts, 3=lock board
//0=none, 1=captcha enable for threads, 2=captcha enable for all posts, 3=lock thread creation, 4=lock board
tphTriggerAction: 1,
pphTriggerAction: 2,
//0=dont change, 1=unlock board/disable captcha, 2=lock thread creation/enable captcha for thread creation

@ -65,7 +65,7 @@ module.exports = {
const destinationBoard = await Boards.findOne(req.body.move_to_board);
if (res.locals.permissions.get(Permissions.MANAGE_GLOBAL_GENERAL)
|| (res.locals.permissions.get(Permissions.MANAGE_BOARD_GENERAL)
&& destinationBoard.staff[res.locals.user.username] != null)) {
&& destinationBoard && destinationBoard.staff[res.locals.user.username] != null)) {
res.locals.destinationBoard = destinationBoard;
}
return res.locals.destinationBoard != null;

@ -3,12 +3,13 @@
const deleteAccounts = require(__dirname+'/../../models/forms/deleteaccounts.js')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody } = require(__dirname+'/../../lib/input/schema.js');
, { Permissions } = require(__dirname+'/../../lib/permission/permissions.js')
, { existsBody, checkSchema, lengthBody } = require(__dirname+'/../../lib/input/schema.js');
module.exports = {
paramConverter: paramConverter({
allowedArrays: ['checkedaccounts'],
allowedArrays: ['checkedaccounts', 'delete_owned_boards'],
}),
controller: async (req, res, next) => {
@ -17,6 +18,7 @@ module.exports = {
const errors = await checkSchema([
{ result: lengthBody(req.body.checkedaccounts, 1), expected: false, error: __('Must select at least one account') },
{ result: !existsBody(req.body.delete_owned_boards) || res.locals.permissions.get(Permissions.GLOBAL_MANAGE_BOARDS), expected: true, error: __('No permission') },
]);
if (errors.length > 0) {

@ -47,7 +47,7 @@ module.exports = {
let amount = 0;
let message;
try {
switch(req.body.option) {
switch (req.body.option) {
case 'unban':
amount = await removeBans(req, res, next);
message = __('Removed %s bans', amount);

@ -58,7 +58,7 @@ module.exports = {
const fixedMessage = req.body.rawMessage.replace(/\r\n/igm, '\n');
res.locals.recoveredAddress = await web3EthAccountsRecover(fixedMessage, req.body.signature);
return true;
} catch(e) {
} catch (e) {
console.warn(e);
return false;
}

@ -713,7 +713,7 @@ module.exports = {
hotThreads: async () => {
const { hotThreadsLimit, hotThreadsThreshold, hotThreadsMaxAge } = config.get;
if (hotThreadsLimit === 0){ //0 limit = no limit in mongodb
if (hotThreadsLimit === 0) { //0 limit = no limit in mongodb
return [];
}
const listedBoards = await Boards.getLocalListed();

@ -1193,6 +1193,14 @@ a.button {
color: initial!important;
}
.prev-show {
display: none
}
input[type="checkbox"]:checked + .prev-show {
display: inline-block!important;
}
input[type="button"][disabled] {
opacity: 0.5;
}
@ -1402,6 +1410,11 @@ table.boardtable.w900 td:last-child {
color: white;
}
table.boardtable .nobreak {
word-break: keep-all;
overflow-wrap: break-word;
}
/* ^="f" means only where attachment is false, to prevent showing on attachment files with audio or video mime type */
.post-file-src[data-type="audio"][data-attachment^="f"]::after,
.post-file-src[data-type="video"][data-attachment^="f"]::after {
@ -1453,6 +1466,10 @@ table.boardtable.w900 td:last-child {
color: white;
}
table.accounttable td:nth-child(1) {
text-align: left;
}
.post-file-src * {
max-width: 100%;
visibility: visible;
@ -1474,6 +1491,7 @@ table, .boardtable {
white-space: nowrap;
}
#settingsmodal{
min-width: 400px;
}

@ -99,7 +99,7 @@ window.addEventListener('DOMContentLoaded', () => {
toggle(thumbElement, expandedElement, fileName, pfs);
} else if (thumbElement.style.opacity !== '0.5') {
let source;
switch(type) {
switch (type) {
case 'image':
e.preventDefault();
if (!isSpoilered) {

@ -74,7 +74,7 @@ function doModal(data, postcallback, loadcallback) {
}, 100);
}
}
} catch(e) {
} catch (e) {
console.error(e);
}
}
@ -230,7 +230,7 @@ class postFormHandler {
this.form.elements.address.value = accounts[0];
this.form.elements.nonce.value = nonce;
this.form.requestSubmit();
} catch(e) {
} catch (e) {
console.warn(e);
} finally {
e.target.style.pointerEvents = 'auto';
@ -333,9 +333,9 @@ class postFormHandler {
//if the google/hcaptcha/yandex was filled, reset it now
if (captchaResponse && grecaptcha) {
grecaptcha.reset();
} else if(captchaResponse && hcaptcha) {
} else if (captchaResponse && hcaptcha) {
hcaptcha.reset();
} else if(captchaResponse && window.smartCaptcha) {
} else if (captchaResponse && window.smartCaptcha) {
window.smartCaptcha.reset();
}

@ -101,7 +101,7 @@ window.addEventListener('DOMContentLoaded', () => {
postJson = hovercache.replies.find(r => r.postId == hash);
}
}
if (!postJson) {//wasnt cached or cache outdates
if (!postJson) { //wasnt cached or cache outdates
this.style.cursor = 'wait';
let json;
try {

@ -111,7 +111,7 @@ window.addEventListener('settingsReady', function() { //after domcontentloaded
//insert at end of thread, but insert at top for globalmanage
//console.log('got new post', data);
const postData = data;
lastPostIds[postData.board] = Math.max(lastPostIds[postData.board], postData.postId);
lastPostIds[postData.board] = Math.max(lastPostIds[postData.board] || 0, postData.postId);
//create a new post
const postHtml = post({
viewRawIp,

@ -3,7 +3,7 @@
const isCatalog = /^\/(\w+\/(manage\/)?)?catalog.html/.test(window.location.pathname);
const isThread = /\/\w+\/thread\/\d+.html/.test(window.location.pathname);
const isModView = /\/\w+\/manage\/(thread\/)?(index|\d+).html/.test(window.location.pathname);
const isManage = /\/(\w+\/manage|globalmanage)\/(recent|reports|bans|boards|logs|settings|banners|accounts|news|custompages).html/.test(window.location.pathname);
const isManage = /\/(\w+\/manage|globalmanage)\/(recent|reports|bans|boards|(global)?logs|settings|banners|accounts|roles|news|filters|custompages).html/.test(window.location.pathname);
const isGlobalRecent = window.location.pathname === '/globalmanage/recent.html';
const isRecent = isGlobalRecent || window.location.pathname.endsWith('/manage/recent.html');
@ -26,7 +26,7 @@ function appendLocalStorageArray(key, value) {
function deleteStartsWith(startString='hovercache') {
//clears cache when localstorage gets full
const hoverCaches = Object.keys(localStorage).filter(k => k.startsWith(startString));
for(let i = 0; i < hoverCaches.length; i++) {
for (let i = 0; i < hoverCaches.length; i++) {
localStorage.removeItem(hoverCaches[i]);
}
}

@ -49,7 +49,6 @@ window.addEventListener('DOMContentLoaded', () => {
};
const addQuote = function(number) {
openPostForm();
let quoteText = `>>${number}\n`;
let selection;
if (window.getSelection) {
@ -65,6 +64,7 @@ window.addEventListener('DOMContentLoaded', () => {
.join('\n'); //join it back together and newline
quoteText += `${quotedSelection}\n`;
}
openPostForm();
addToMessageBox(quoteText);
messageBox.focus();
messageBox.dispatchEvent(new Event('input'));

@ -1,5 +1,5 @@
/* globals Tegaki */
function showTegakiReplay(e){
function showTegakiReplay(e) {
e.preventDefault(); //prevent nojs download fallback
Tegaki.open({
replayMode: true,

@ -62,7 +62,7 @@ function toggleBoardCss() {
}
function changeTheme(type) {
switch(type) {
switch (type) {
case 'theme':
case 'codetheme': {
const theme = localStorage.getItem(type);
@ -91,7 +91,7 @@ function changeTheme(type) {
themeLink.onload = function() {
css = '';
const rulesKey = themeLink.sheet.rules != null ? 'rules' : 'cssRules';
for(let i = 0; i < themeLink.sheet[rulesKey].length; i++) {
for (let i = 0; i < themeLink.sheet[rulesKey].length; i++) {
css += themeLink.sheet[rulesKey][i].cssText; //add all the rules to the css
}
//update localstorage with latest version

@ -26,7 +26,7 @@ window.addEventListener('DOMContentLoaded', () => {
postJson = hovercache.replies.find(r => r.postId == postId);
}
}
if (!postJson) {//wasnt cached or cache outdates
if (!postJson) { //wasnt cached or cache outdates
this.style.cursor = 'wait';
let json;
try {

@ -38,12 +38,12 @@ module.exports = async (captchaOptions) => {
const spaceSize = (width-padding)/size;
const fontMinSize = Math.floor(width*0.16);
const fontMaxSize = Math.floor(width*0.25);
for(let j = 0; j < size; j++) { //for each row
for (let j = 0; j < size; j++) { //for each row
//x offset for whole row (not per character or it gets way too difficult to solve)
let cxOffset = await randomRange(0, Math.floor(spaceSize * 1.5));
for(let i = 0; i < size; i++) { //for character in row
for (let i = 0; i < size; i++) { //for character in row
const index = (j*size)+i;
const cyOffset = iconYOffset > 0 ? (await randomRange(0, iconYOffset)) : 0;
let character;

@ -20,7 +20,7 @@ const cases = [
];
describe('generate gridv1 captcha', () => {
for(let captchaOptions of cases) {
for (let captchaOptions of cases) {
test(captchaOptions.name, async () => {
const { captcha } = await gridv1(captchaOptions);
expect(await new Promise((res, rej) => {
@ -36,7 +36,7 @@ describe('generate gridv1 captcha', () => {
});
describe('generate gridv2 captcha', () => {
for(let captchaOptions of cases) {
for (let captchaOptions of cases) {
test(captchaOptions.name, async () => {
const { captcha } = await gridv2(captchaOptions);
expect(await new Promise((res, rej) => {

@ -12,7 +12,7 @@ describe('generate text captcha', () => {
{ name: 'text captcha with non-default font', font: '/usr/share/fonts/type1/gsfonts/p052003l.pfb', text: { wave: 0, line: false, paint: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: 'text captcha with all the above', font: '/usr/share/fonts/type1/gsfonts/p052003l.pfb', text: { wave: 5, line: true, paint: 5, noise: 5 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
];
for(let captchaOptions of cases) {
for (let captchaOptions of cases) {
test(captchaOptions.name, async () => {
const { captcha } = await generateCaptcha(captchaOptions);
expect(await new Promise((res, rej) => {

@ -11,7 +11,7 @@ describe('formatSize() - convert bytes to human readable file size', () => {
{in: 100, out: '100B'},
{in: 0, out: '0B'},
];
for(let i in cases) {
for (let i in cases) {
test(`should output ${cases[i].out} for an input of ${cases[i].in} bytes`, () => {
expect(formatSize(cases[i].in)).toBe(cases[i].out);
});

@ -22,7 +22,7 @@ describe('timeutils relativeString, relativeColor, durationString', () => {
{ in: { start: new Date('2132-04-07T08:00:00.000Z'), end: new Date('2022-04-07T08:00:00.000Z') }, out: '110 years ago'},
{ in: { end: new Date('2132-04-07T08:00:00.000Z'), start: new Date('2022-04-07T08:00:00.000Z') }, out: '110 years from now'},
];
for(let i in relativeStringCases) {
for (let i in relativeStringCases) {
test(`relativeString should output ${relativeStringCases[i].out} for an input of ${relativeStringCases[i].in}`, () => {
expect(relativeString(relativeStringCases[i].in.start, relativeStringCases[i].in.end, i18n)).toStrictEqual(relativeStringCases[i].out);
});
@ -36,7 +36,7 @@ describe('timeutils relativeString, relativeColor, durationString', () => {
{ in: { start: new Date('2132-04-07T08:00:00.000Z'), end: new Date('2022-04-07T08:00:00.000Z') }, out: '#000000'},
{ in: { end: new Date('2132-04-07T08:00:00.000Z'), start: new Date('2022-04-07T08:00:00.000Z') }, out: '#0098ff'},
];
for(let i in relativeColorCases) {
for (let i in relativeColorCases) {
test(`relativeColor should output ${relativeColorCases[i].out} for an input of ${relativeColorCases[i].in}`, () => {
expect(relativeColor(relativeColorCases[i].in.start, relativeColorCases[i].in.end)).toStrictEqual(relativeColorCases[i].out);
});
@ -50,7 +50,7 @@ describe('timeutils relativeString, relativeColor, durationString', () => {
{ in: 2*60*60*1000, out: '02:00:00' },
{ in: 999*60*60*1000, out: '999:00:00' },
];
for(let i in durationStringCases) {
for (let i in durationStringCases) {
test(`durationString should output ${durationStringCases[i].out} for an input of ${durationStringCases[i].in}`, () => {
expect(durationString(durationStringCases[i].in)).toStrictEqual(durationStringCases[i].out);
});

@ -62,6 +62,8 @@ module.exports = {
return config.get.allowMimeNoMatch;
},
image, animatedImage, video, audio, other
getOther: () => other,
image, animatedImage, video, audio
};

@ -5,7 +5,22 @@ const ffmpeg = require('fluent-ffmpeg')
, uploadDirectory = require(__dirname+'/../uploaddirectory.js');
module.exports = (file, geometry, timestamp) => {
const { thumbSize } = config.get;
let inputArgs = [
`-ss ${timestamp}`,
'-t 0',
];
let outputArgs = [
`-vf scale=${geometry.width > geometry.height ? thumbSize + ':-2' : '-2:' + thumbSize}`,
'-frames:v 1'
];
// workaround: FFmpeg native WebM decoder doesn't handle alpha.
if (file.codec === 'vp8') { inputArgs.push('-c:v libvpx'); }
if (file.codec === 'vp9') { inputArgs.push('-c:v libvpx-vp9'); }
return new Promise((resolve, reject) => {
const command = ffmpeg(`${uploadDirectory}/file/${file.filename}`)
.on('end', () => {
@ -14,28 +29,10 @@ module.exports = (file, geometry, timestamp) => {
.on('error', function(err) {
return reject(err);
});
if (timestamp === 0) {
//bypass issue with some dumb files like audio album art covert not working with .screenshots
command
.inputOptions([
'-t',
0
])
.outputOptions([
`-vf scale=${geometry.width > geometry.height ? thumbSize + ':-2' : '-2:' + thumbSize}`,
'-frames:v 1'
])
.output(`${uploadDirectory}/file/thumb/${file.hash}${file.thumbextension}`)
.run();
} else {
command.screenshots({
timestamps: [timestamp],
count: 1,
filename: `${file.hash}${file.thumbextension}`,
folder: `${uploadDirectory}/file/thumb/`,
size: geometry.width > geometry.height ? `${thumbSize}x?` : `?x${thumbSize}`
//keep aspect ratio, but also making sure taller/wider thumbs dont exceed thumbSize in either dimension
});
}
command
.inputOptions(inputArgs)
.outputOptions(outputArgs)
.output(`${uploadDirectory}/file/thumb/${file.hash}${file.thumbextension}`)
.run();
});
};

@ -15,7 +15,7 @@ describe('decode query ip', () => {
{ in: { query: { ip: '8s7AGX4n.qHsw9mp.uw54Nfl.IP' }, permission: NO_PERMISSION }, out: '8s7AGX4n.qHsw9mp.uw54Nfl.IP' },
];
for(let i in cases) {
for (let i in cases) {
test(`should output ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(decodeQueryIp(cases[i].in.query, cases[i].in.permission)).toStrictEqual(cases[i].out);
});

@ -8,7 +8,7 @@ describe('escape regular expression', () => {
{ in: '.*+?^${}()|[]\\', out: '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\' },
];
for(let i in cases) {
for (let i in cases) {
test(`should output ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(escapeRegExp(cases[i].in)).toStrictEqual(cases[i].out);
});

@ -15,7 +15,7 @@ describe('page query converter', () => {
{ in: { page: 10, other: 'test' }, out: { offset: limit*9, 'queryString': 'other=test', page: 10 } },
{ in: { other: 'test' }, out: { offset: 0, 'queryString': 'other=test', page: 1 } },
];
for(let i in cases) {
for (let i in cases) {
test(`should contain ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(pageQueryConverter(cases[i].in, limit)).toStrictEqual(cases[i].out);
});

@ -10,7 +10,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { body: { test: 1 }, expected: false }, out: 1 },
{ in: { body: { test: '' }, expected: true }, out: 0 },
];
for(let i in existsBodyCases) {
for (let i in existsBodyCases) {
test(`existsBody should output ${existsBodyCases[i].out} for an input of ${existsBodyCases[i].in.body.test}`, async () => {
const result = await checkSchema([
{ result: existsBody(existsBodyCases[i].in.body.test), expected: existsBodyCases[i].in.expected, error: 'error' },
@ -31,7 +31,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { body: { test: 'hellohellohello' }, min: 0, max: 10, expected: false }, out: 1 },
{ in: { body: { test: 'hellohellohello' }, max: 10, expected: false }, out: 1 },
];
for(let i in lengthBodyCases) {
for (let i in lengthBodyCases) {
test(`lengthBody should output ${lengthBodyCases[i].out} for an input of ${lengthBodyCases[i].in.body.test}`, async () => {
const result = await checkSchema([
{ result: lengthBody(lengthBodyCases[i].in.body.test, lengthBodyCases[i].in.min, lengthBodyCases[i].in.max), expected: lengthBodyCases[i].in.expected, error: 'error' },
@ -48,7 +48,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { body: { test: 9 }, min: 10, expected: true }, out: 1 },
{ in: { body: { test: 10 }, min: 10, expected: true }, out: 0 },
];
for(let i in numberBodyCases) {
for (let i in numberBodyCases) {
test(`numberBody should output ${numberBodyCases[i].out} for an input of ${numberBodyCases[i].in.body.test}`, async () => {
const result = await checkSchema([
{ result: numberBody(numberBodyCases[i].in.body.test, numberBodyCases[i].in.min, numberBodyCases[i].in.max), expected: numberBodyCases[i].in.expected, error: 'error' },
@ -61,7 +61,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { body: { test: 5 }, minOld: 0, minNew: 0, maxOld: 10, maxNew: 5, expected: true }, out: 0 },
{ in: { body: { test: 5 }, minOld: 0, minNew: 6, maxOld: 10, maxNew: 10, expected: true }, out: 1 },
];
for(let i in numberBodyVariableCases) {
for (let i in numberBodyVariableCases) {
test(`numberBodyVariable should output ${numberBodyVariableCases[i].out} for an input of ${numberBodyVariableCases[i].in.body.test}`, async () => {
const { body, minOld, minNew, maxOld, maxNew } = numberBodyVariableCases[i].in;
const result = await checkSchema([
@ -75,7 +75,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { a: 0, b: 100, expected: true }, out: 0 },
{ in: { a: 101, b: 100, expected: true }, out: 1 },
];
for(let i in minmaxBodyCases) {
for (let i in minmaxBodyCases) {
test(`minmaxBody should output ${minmaxBodyCases[i].out} for an input of ${minmaxBodyCases[i].in}`, async () => {
const { a, b, expected } = minmaxBodyCases[i].in;
const result = await checkSchema([
@ -95,7 +95,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { a: 'x', b: new Set(['a', 'b', 'c']), expected: false }, out: 0 },
{ in: { a: 'a', b: new Set(['a', 'b', 'c']), expected: true }, out: 0 },
];
for(let i in inArrayBodyCases) {
for (let i in inArrayBodyCases) {
test(`inArrayBody should output ${inArrayBodyCases[i].out} for an input of ${inArrayBodyCases[i].in}`, async () => {
const { a, b, expected } = inArrayBodyCases[i].in;
const result = await checkSchema([
@ -111,7 +111,7 @@ describe('schema checking (input handling after paramconverter)', () => {
{ in: { a: 'x', b: ['a', 'b', 'c'], expected: false }, out: 0 },
{ in: { a: 'a', b: ['a', 'b', 'c'], expected: true }, out: 0 },
];
for(let i in arrayInBodyCases) {
for (let i in arrayInBodyCases) {
test(`arrayInBody should output ${arrayInBodyCases[i].out} for an input of ${arrayInBodyCases[i].in}`, async () => {
const { a, b, expected } = arrayInBodyCases[i].in;
const result = await checkSchema([

@ -8,7 +8,7 @@ describe('trimSetting, numberSetting, booleanSetting, arraySetting', () => {
{ in: 1, out: 'OLDSETTING' },
{ in: null, out: 'OLDSETTING' },
];
for(let i in trimCases) {
for (let i in trimCases) {
test(`trimSetting should output ${trimCases[i].out} for an input of ${trimCases[i].in}`, () => {
expect(trimSetting(trimCases[i].in, 'OLDSETTING')).toStrictEqual(trimCases[i].out);
});
@ -22,7 +22,7 @@ describe('trimSetting, numberSetting, booleanSetting, arraySetting', () => {
{ in: '', out: 'OLDSETTING' },
{ in: 'string', out: 'OLDSETTING' },
];
for(let i in numberCases) {
for (let i in numberCases) {
test(`numberSetting should output ${numberCases[i].out} for an input of ${numberCases[i].in}`, () => {
expect(numberSetting(numberCases[i].in, 'OLDSETTING')).toStrictEqual(numberCases[i].out);
});
@ -37,7 +37,7 @@ describe('trimSetting, numberSetting, booleanSetting, arraySetting', () => {
{ in: [], out: true },
{ in: [1], out: true },
];
for(let i in booleanCases) {
for (let i in booleanCases) {
test(`booleanSetting should output ${booleanCases[i].out} for an input of ${booleanCases[i].in}`, () => {
expect(booleanSetting(booleanCases[i].in)).toStrictEqual(booleanCases[i].out);
});
@ -60,7 +60,7 @@ describe('trimSetting, numberSetting, booleanSetting, arraySetting', () => {
xxx`, out: [' hello ', '123', 'xxx'] },
];
for(let i in arrayCases) {
for (let i in arrayCases) {
test(`arraySetting should output ${arrayCases[i].out} for an input of ${arrayCases[i].in}`, () => {
expect(arraySetting(arrayCases[i].in, 'OLDSETTING', 10)).toStrictEqual(arrayCases[i].out);
});

@ -7,7 +7,7 @@ describe('getDotProp, includeChildren, compareSettings in settingsdiff', () => {
{ in: { object: {a:null}, prop: 'a.b.c' }, out: null },
{ in: { object: {}, prop: 'a.b.c' }, out: null },
];
for(let i in getDotPropCases) {
for (let i in getDotPropCases) {
test(`getDotProp should output ${getDotPropCases[i].out} for an input of ${getDotPropCases[i].in}`, () => {
expect(getDotProp(getDotPropCases[i].in.object, getDotPropCases[i].in.prop)).toStrictEqual(getDotPropCases[i].out);
});
@ -18,7 +18,7 @@ describe('getDotProp, includeChildren, compareSettings in settingsdiff', () => {
{ in: { object: null, prop: 'a' }, out: {} },
{ in: { object: {a:null}, prop: 'a' }, out: {} },
];
for(let i in includeChildrenCases) {
for (let i in includeChildrenCases) {
test(`includeChildren should output ${includeChildrenCases[i].out} for an input of ${includeChildrenCases[i].in}`, () => {
expect(includeChildren(includeChildrenCases[i].in.object, includeChildrenCases[i].in.prop, ['example'])).toStrictEqual(includeChildrenCases[i].out);
});
@ -50,7 +50,7 @@ describe('getDotProp, includeChildren, compareSettings in settingsdiff', () => {
out: new Set(['1']),
},
];
for(let i in compareSettingsCases) {
for (let i in compareSettingsCases) {
test(`compareSettings should output ${compareSettingsCases[i].out} for an input of ${compareSettingsCases[i].in}`, () => {
expect(compareSettings(compareSettingsCases[i].in.entries, compareSettingsCases[i].in.old, compareSettingsCases[i].in.new, 4)).toStrictEqual(compareSettingsCases[i].out);
});

@ -129,7 +129,7 @@ describe('paramconverter', () => {
},
];
for(let i in bodyCases) {
for (let i in bodyCases) {
test(`${i} should output ${bodyCases[i].out} for an input of ${bodyCases[i].in}`, () => {
const converter = paramConverter(bodyCases[i].in.options);
if (bodyCases[i].out === 'error') {
@ -175,7 +175,7 @@ describe('paramconverter', () => {
},
];
for(let i in paramCases) {
for (let i in paramCases) {
test(`${i} should output ${paramCases[i].out} for an input of ${paramCases[i].in}`, () => {
const converter = paramConverter(paramCases[i].in.options);
if (paramCases[i].out === 'error') {
@ -208,7 +208,7 @@ describe('paramconverter', () => {
},
];
for(let i in dateCases) {
for (let i in dateCases) {
test(`${i} should output ${dateCases[i].out} for an input of ${dateCases[i].in}`, () => {
const converter = paramConverter(dateCases[i].in.options);
const locals = {locals:{}};

@ -50,7 +50,7 @@ module.exports = (req, res, next) => {
type,
};
next();
} catch(e) {
} catch (e) {
//should never get here
console.error('Ip parse failed', e);
const { __ } = res.locals;

@ -20,7 +20,7 @@ module.exports = (req, res, next) => {
try {
const url = new URL(req.headers.referer);
validReferer = allowedHostSet.has(url.hostname);
} catch(e) {
} catch (e) {
//referrer is invalid url
}
if (refererCheck === true && (!req.headers.referer || !validReferer)) {

@ -39,7 +39,7 @@ class Permission extends BigBitfield {
}
applyInheritance() {
if (this.get(Permissions.ROOT)){ //root gets all perms
if (this.get(Permissions.ROOT)) { //root gets all perms
this.setAll(Permission.allPermissions);
} else if (this.get(Permissions.MANAGE_BOARD_OWNER)) { //BOs and "global staff"
this.setAll(Permissions._MANAGE_BOARD_BITS);

@ -54,7 +54,7 @@ describe('checkFilters() - basic filter matching', () => {
{ in: 'ace', out: true },
{ in: 'áçé', out: false },
];
for(let s of strings) {
for (let s of strings) {
const { combinedString, strictCombinedString } = getFilterStrings(getDummyReq(s.in), getDummyRes());
test(`should not error and match ${s.out} for filter ${normalFilter._id}`, async () => {
const res = await checkFilters([normalFilter], combinedString, strictCombinedString);
@ -72,7 +72,7 @@ describe('checkFilters() - basic filter not matching', () => {
{ in: 'zzz', out: false },
{ in: '123', out: false },
];
for(let s of strings) {
for (let s of strings) {
const { combinedString, strictCombinedString } = getFilterStrings(getDummyReq(s.in), getDummyRes());
test(`should not error and match ${s.out} for filter ${normalFilter._id}`, async () => {
const res = await checkFilters([normalFilter], combinedString, strictCombinedString);
@ -90,7 +90,7 @@ describe('checkFilters() - strict filter matching', () => {
{ in: 'ace', out: true },
{ in: 'áçé', out: true },
];
for(let s of strings) {
for (let s of strings) {
const { combinedString, strictCombinedString } = getFilterStrings(getDummyReq(s.in), getDummyRes());
test(`should not error and match ${s.out} for filter ${normalFilter._id}`, async () => {
const res = await checkFilters([strictFilter], combinedString, strictCombinedString);
@ -108,7 +108,7 @@ describe('checkFilters() - strict filter not matching', () => {
{ in: 'zzz', out: false },
{ in: '123', out: false },
];
for(let s of strings) {
for (let s of strings) {
const { combinedString, strictCombinedString } = getFilterStrings(getDummyReq(s.in), getDummyRes());
test(`should not error and match ${s.out} for filter ${normalFilter._id}`, async () => {
const res = await checkFilters([strictFilter], combinedString, strictCombinedString);

@ -22,7 +22,7 @@ module.exports = (req, res) => {
strictCombinedString += combinedString.split(/(%[^%]+)/).map(part => {
try {
return decodeURIComponent(part);
} catch(e) {
} catch (e) {
return '';
}
}).join('');

@ -11,7 +11,7 @@ describe('simpleEscape() - convert some characters to html entities', () => {
{ in: '>', out: '&gt;' },
{ in: '"', out: '&quot;' },
];
for(let i in cases) {
for (let i in cases) {
test(`should output ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(simpleEscape(cases[i].in)).toBe(cases[i].out);
});

@ -10,7 +10,7 @@ describe('diceroll markdown', () => {
{ in: '##3%8-5', out: '##3%8-5=' },
{ in: '##0%0', out: '##0%0' },
];
for(let i in prepareCases) {
for (let i in prepareCases) {
test(`should contain ${prepareCases[i].out} for an input of ${prepareCases[i].in}`, () => {
const output = prepareCases[i].in.replace(diceroll.regexPrepare, diceroll.prepare.bind(null, false));
expect(output).toContain(prepareCases[i].out);
@ -24,7 +24,7 @@ describe('diceroll markdown', () => {
{ in: '##0%0&#x3D;10', out: '##0%0&#x3D;' },
{ in: '##0%0', out: '##0%0' },
];
for(let i in markdownCases) {
for (let i in markdownCases) {
test(`should contain ${markdownCases[i].out} for an input of ${markdownCases[i].in}`, () => {
const output = markdownCases[i].in.replace(diceroll.regexMarkdown, diceroll.markdown.bind(null, false));
expect(output).toContain(markdownCases[i].out);

@ -17,7 +17,7 @@ describe('link markdown', () => {
{ in: 'https:&#x2F;&#x2F;', out: 'https:&#x2F;&#x2F;' },
];
for(let i in rootCases) {
for (let i in rootCases) {
test(`should contain ${rootCases[i].out} for an input of ${rootCases[i].in}`, () => {
expect(rootCases[i].in.replace(linkRegex, linkmatch.bind(null, ROOT))).toContain(rootCases[i].out);
});
@ -32,7 +32,7 @@ describe('link markdown', () => {
{ in: 'https:&#x2F;&#x2F;', out: 'https:&#x2F;&#x2F;' },
];
for(let i in cases) {
for (let i in cases) {
test(`should contain ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(cases[i].in.replace(linkRegex, linkmatch.bind(null, NO_PERMISSION))).toContain(cases[i].out);
});

@ -75,7 +75,7 @@ module.exports = {
const escaped = simpleEscape(chunks[i]);
const newlineFix = escaped.replace(/^\r?\n/,''); //fix ending newline because of codeblock
chunks[i] = module.exports.processRegularChunk(newlineFix, permissions);
} else if (permissions.get(Permissions.USE_MARKDOWN_CODE)){
} else if (permissions.get(Permissions.USE_MARKDOWN_CODE)) {
chunks[i] = module.exports.processCodeChunk(chunks[i], highlightOptions);
}
}

@ -23,7 +23,7 @@ describe('name/trip/capcode handler', () => {
{ in: 'test#12345## Admin', out: { name: 'test', tripcode: '!CSZ6G0yP9Q', capcode: '## Admin' } },
];
for(let i in cases) {
for (let i in cases) {
test(`should contain ${cases[i].out.capcode} for an input of ${cases[i].in}`, async () => {
const output = await name(cases[i].in, (cases[i].perm || ROOT), {forceAnon: false, defaultName: 'Anon'}, 'a', {a:ROOT}, 'b', i18n.__);
expect(output).toStrictEqual(cases[i].out);

@ -7,7 +7,7 @@ describe('getSecureTrip() - "secure" tripcodes', () => {
{ in: '13245' },
{ in: '1324512345123451234512345123451234512345' },
];
for(let i in cases) {
for (let i in cases) {
test(`should not error for an input of ${cases[i].in}`, async () => {
expect((await getSecureTrip(cases[i].in)));
});
@ -21,7 +21,7 @@ describe('getInsecureTrip() - "insecure" tripcodes', () => {
{ in: '13245', out: 'VPkdFNhOGY' },
{ in: '1324512345123451234512345123451234512345', out: '9ovLU2O1wk' },
];
for(let i in cases) {
for (let i in cases) {
test(`should contain ${cases[i].out} for an input of ${cases[i].in}`, () => {
expect(getInsecureTrip(cases[i].in)).toBe(cases[i].out);
});

@ -1,10 +1,10 @@
'use strict';
const Redis = require('ioredis')
, secrets = require(__dirname+'/../../configs/secrets.js')
, sharedClient = new Redis(secrets.redis)
, subscriber = new Redis(secrets.redis)
, publisher = new Redis(secrets.redis)
, { redis, debugLogs } = require(__dirname+'/../../configs/secrets.js')
, sharedClient = new Redis(redis)
, subscriber = new Redis(redis)
, publisher = new Redis(redis)
, messageCallbacks = {
'config': [],
'roles': [],
@ -35,7 +35,7 @@ module.exports = {
}
});
subscriber.on('message', (channel, message) => {
secrets.debugLogs && console.log(`Subscriber message from channel ${channel}`);
debugLogs && console.log(`Subscriber message from channel ${channel}`);
let data;
if (message) {
data = JSON.parse(message);
@ -97,6 +97,7 @@ module.exports = {
//delete value with key
del: (keyOrKeys) => {
debugLogs && console.log('redis del():', keyOrKeys);
if (Array.isArray(keyOrKeys)) {
return sharedClient.del(...keyOrKeys);
} else {
@ -121,7 +122,7 @@ module.exports = {
let results;
try {
results = await pipeline.exec();
} catch(e) {
} catch (e) {
return reject(e);
}
const data = {};

@ -72,7 +72,7 @@ module.exports = async (req, res) => {
}
}
if (req.body.report_ban || req.body.global_report_ban){
if (req.body.report_ban || req.body.global_report_ban) {
const banBoard = req.body.global_report_ban ? null : req.params.board;
res.locals.posts.forEach(post => {
let ips = [];

@ -2,12 +2,17 @@
const { Accounts, Boards } = require(__dirname+'/../../db/')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, deleteBoard = require(__dirname+'/../../models/forms/deleteboard.js')
, cache = require(__dirname+'/../../lib/redis/redis.js');
module.exports = async (req, res) => {
const { __, __n } = res.locals;
const accountsWithBoards = await Accounts.getOwnedOrStaffBoards(req.body.checkedaccounts);
const checkedDeleteOwnedBoards = new Set(req.body.delete_owned_boards||[]);
// console.log('checkedDeleteOwnedBoards', checkedDeleteOwnedBoards);
const deleteCacheBoards = new Set();
const deleteBoards = new Set();
if (accountsWithBoards.length > 0) {
const bulkWrites = [];
for (let i = 0; i < accountsWithBoards.length; i++) {
@ -23,12 +28,12 @@ module.exports = async (req, res) => {
},
'update': {
'$unset': {
[`staff.${acc.username}`]: '',
[`staff.${acc._id}`]: '',
}
}
}
});
cache.del(acc.staffBoards.map(b => `board:${b}`));
acc.staffBoards.forEach(sb => deleteCacheBoards.add(`board:${sb}`));
}
if (acc.ownedBoards.length > 0) {
//remove as owner of any boards they own
@ -44,23 +49,34 @@ module.exports = async (req, res) => {
'owner': null,
},
'$unset': {
[`staff.${acc.username}`]: '',
[`staff.${acc._id}`]: '',
},
}
}
});
cache.del(acc.ownedBoards.map(b => `board:${b}`));
acc.ownedBoards.forEach(ob => {
deleteCacheBoards.add(`board:${ob}`);
if (checkedDeleteOwnedBoards.has(acc._id)) {
deleteBoards.add(ob);
}
});
}
}
await Boards.db.bulkWrite(bulkWrites);
//invalidate caches for any board they were owner/staff of
cache.del([...deleteCacheBoards]);
}
const amount = await Accounts.deleteMany(req.body.checkedaccounts).then(res => res.deletedCount);
if (deleteBoards.size > 0) {
await Promise.all([...deleteBoards].map(async uri => {
const _board = await Boards.findOne(uri);
return deleteBoard(uri, _board);
}));
}
//and delete any of their active sessions
await Promise.all(req.body.checkedaccounts.map((username) => {
return cache.deletePattern(`sess:*:${username}`);
}));
//invalidate any of their active sessions
await cache.del(req.body.checkedaccounts.map(username => `sess:*:${username}`));
return dynamicResponse(req, res, 200, 'message', {
'title': __('Success'),

@ -266,7 +266,7 @@ module.exports = async (req, res) => {
await moveUpload(file, processedFile.filename, 'file');
}
};
if (mimeTypes.other.has(processedFile.mimetype)) {
if (mimeTypes.getOther().has(processedFile.mimetype)) {
//"other" mimes from config, overrides main type to avoid codec issues in browser or ffmpeg for unsupported filetypes
processedFile.hasThumb = false;
processedFile.attachment = true;
@ -313,6 +313,7 @@ module.exports = async (req, res) => {
const videoStreams = audioVideoData.streams.filter(stream => stream.width != null); //filter to only video streams or something with a resolution
if (videoStreams.length > 0) {
processedFile.thumbextension = thumbExtension;
processedFile.codec = videoStreams[0].codec_name;
processedFile.geometry = {width: videoStreams[0].coded_width, height: videoStreams[0].coded_height};
if (Math.floor(processedFile.geometry.width*processedFile.geometry.height) > globalLimits.postFilesSize.videoResolution) {
await deleteTempFiles(req).catch(console.error);
@ -327,16 +328,16 @@ module.exports = async (req, res) => {
await saveFull();
if (!existsThumb) {
const numFrames = videoStreams[0].nb_frames;
if (numFrames === 'N/A' && subtype === 'webm') {
await videoThumbnail(processedFile, processedFile.geometry, videoThumbPercentage+'%');
} else {
await videoThumbnail(processedFile, processedFile.geometry, ((numFrames === 'N/A' || numFrames <= 1) ? 0 : videoThumbPercentage+'%'));
}
const timestamp = ((numFrames === 'N/A' && subtype !== 'webm') || numFrames <= 1) ? 0 : processedFile.duration * videoThumbPercentage / 100;
try {
await videoThumbnail(processedFile, processedFile.geometry, timestamp);
} catch (err) { /*No keyframe after timestamp probably. ignore, we'll retry*/ }
//check and fix bad thumbnails in all cases, helps prevent complaints from child molesters who want improper encoding handled better
//for example, can fail on videos without keyframes after the seek timestamp e.g. music with only an album cover frame
let videoThumbStat = null;
try {
videoThumbStat = await fsStat(`${uploadDirectory}/file/thumb/${processedFile.hash}${processedFile.thumbextension}`);
} catch (err) { /*ENOENT probably, ignore*/}
} catch (err) { /*ENOENT probably, ignore*/ }
if (!videoThumbStat || videoThumbStat.code === 'ENOENT' || videoThumbStat.size === 0) {
//create thumb again at 0 timestamp and lets hope it exists this time
await videoThumbnail(processedFile, processedFile.geometry, 0);

@ -6,7 +6,7 @@ module.exports = async (req) => {
const nReturned = await Bans.upgrade(req.params.board, req.body.checkedbans, req.body.upgrade)
.then(explain => {
if (explain && explain.stages){
if (explain && explain.stages) {
return explain.stages[0].nReturned;
}
return 0;

@ -17,10 +17,6 @@ module.exports = async (req, res, next) => {
if (username) {
filter.user = username;
}
const uri = typeof req.query.uri === 'string' ? req.query.uri : null;
if (uri) {
filter.board = uri;
}
let logs, maxPage;
try {
@ -39,7 +35,6 @@ module.exports = async (req, res, next) => {
csrf: req.csrfToken(),
queryString,
username,
uri,
permissions: res.locals.permissions,
viewRawIp: res.locals.permissions.get(Permissions.VIEW_RAW_IP),
logs,

89
package-lock.json generated

@ -1,15 +1,15 @@
{
"name": "jschan",
"version": "1.4.1",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jschan",
"version": "1.4.1",
"version": "1.5.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fatchan/express-fileupload": "^1.4.3",
"@fatchan/express-fileupload": "^1.4.5",
"@fatchan/gm": "^1.3.2",
"@socket.io/redis-adapter": "^7.2.0",
"bcrypt": "^5.1.1",
@ -1538,9 +1538,9 @@
}
},
"node_modules/@fatchan/express-fileupload": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@fatchan/express-fileupload/-/express-fileupload-1.4.3.tgz",
"integrity": "sha512-DW8x4RepeeVReqPze6McCEhQ+Wwh9wcqy0JcL7lNnXKN80fKlWjefHPTmGd63QUDnwb5Pr34nwzqXTRxCYOAMg==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@fatchan/express-fileupload/-/express-fileupload-1.4.5.tgz",
"integrity": "sha512-DBYyicLeokZv5nUqEx2IiRA20ybg8ZiUUj4xDLpip40IcHZaCR/jzr0oi/qEhCfwt9pbKO/Y1mmzSjxRE7yccA==",
"dependencies": {
"busboy": "^1.6.0"
},
@ -2295,14 +2295,14 @@
"integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ=="
},
"node_modules/@pm2/js-api": {
"version": "0.6.7",
"resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.6.7.tgz",
"integrity": "sha512-jiJUhbdsK+5C4zhPZNnyA3wRI01dEc6a2GhcQ9qI38DyIk+S+C8iC3fGjcjUbt/viLYKPjlAaE+hcT2/JMQPXw==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz",
"integrity": "sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==",
"dependencies": {
"async": "^2.6.3",
"axios": "^0.21.0",
"debug": "~4.3.1",
"eventemitter2": "^6.3.1",
"extrareqp2": "^1.0.0",
"ws": "^7.0.0"
},
"engines": {
@ -6007,13 +6007,14 @@
}
},
"node_modules/es5-ext": {
"version": "0.10.62",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz",
"integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==",
"version": "0.10.63",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz",
"integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==",
"hasInstallScript": true,
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
@ -6202,6 +6203,25 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esniff/node_modules/type": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
"integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@ -6290,6 +6310,15 @@
"@scure/bip39": "1.2.1"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/eventemitter2": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz",
@ -6577,6 +6606,14 @@
"node": ">= 0.4"
}
},
"node_modules/extrareqp2": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz",
"integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
@ -7002,9 +7039,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
"integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
@ -8315,9 +8352,9 @@
}
},
"node_modules/ip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz",
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg=="
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ=="
},
"node_modules/ip-ptr": {
"version": "3.0.0",
@ -11925,13 +11962,13 @@
}
},
"node_modules/pm2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pm2/-/pm2-5.3.0.tgz",
"integrity": "sha512-xscmQiAAf6ArVmKhjKTeeN8+Td7ZKnuZFFPw1DGkdFPR/0Iyx+m+1+OpCdf9+HQopX3VPc9/wqPQHqVOfHum9w==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/pm2/-/pm2-5.3.1.tgz",
"integrity": "sha512-DLVQHpSR1EegaTaRH3KbRXxpPVaqYwAp3uHSCtCsS++LSErvk07WSxuUnntFblBRqNU/w2KQyqs12mSq5wurkg==",
"dependencies": {
"@pm2/agent": "~2.0.0",
"@pm2/io": "~5.0.0",
"@pm2/js-api": "~0.6.7",
"@pm2/js-api": "~0.8.0",
"@pm2/pm2-version-check": "latest",
"async": "~3.2.0",
"blessed": "0.1.81",
@ -14132,9 +14169,9 @@
}
},
"node_modules/socks/node_modules/ip": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="
},
"node_modules/source-map": {
"version": "0.6.1",

@ -1,11 +1,11 @@
{
"name": "jschan",
"version": "1.4.1",
"version": "1.5.0",
"migrateVersion": "1.4.1",
"description": "",
"main": "server.js",
"dependencies": {
"@fatchan/express-fileupload": "^1.4.3",
"@fatchan/express-fileupload": "^1.4.5",
"@fatchan/gm": "^1.3.2",
"@socket.io/redis-adapter": "^7.2.0",
"bcrypt": "^5.1.1",

@ -75,7 +75,7 @@ module.exports = {
if (inactiveUsernames.length > 0) {
accountsPromise = Accounts.deleteMany(inactiveUsernames);
}
} else{
} else {
debugLogs && console.log(`Removing staff positions from ${inactiveWithBoards.length} inactive accounts`);
const inactiveUsernames = inactiveWithBoards.map(acc => acc._id);
if (inactiveUsernames.length > 0) {

@ -38,7 +38,6 @@ module.exports = {
},
interval: timeUtils.DAY,
immediate: true,
condition: 'pruneImmediately'
condition: null,
};

@ -8,6 +8,7 @@ const fetch = require('node-fetch')
, { outputFile } = require('fs-extra')
, { SocksProxyAgent } = require('socks-proxy-agent')
, uploadDirectory = require(__dirname+'/../../lib/file/uploaddirectory.js')
, { version } = require(__dirname+'/../../package.json')
, timeUtils = require(__dirname+'/../../lib/converter/timeutils.js');
module.exports = {
@ -32,7 +33,7 @@ module.exports = {
timeout: 20000,
agent,
headers: {
'User-Agent':''
'User-Agent': `jschan/${version} (https://gitgud.io/fatchan/jschan/)`,
}
})
.then(res => res.json())

@ -103,6 +103,26 @@ module.exports = () => describe('delete tests and cleanup', () => {
body: params,
redirect: 'manual',
});
// console.log((await response.text()))
expect(response.ok).toBe(true);
});
test('delete test account (with delete_owned_boards option)', async () => {
const params = new URLSearchParams({
_csrf: csrfToken,
checkedaccounts: 'test2',
delete_owned_boards: 'test2',
});
const response = await fetch('http://localhost/forms/global/deleteaccounts', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
// console.log((await response.text()))
expect(response.ok).toBe(true);
});

@ -225,11 +225,49 @@ bad words`,
expect(response.status).toBe(302);
});
test('edit account permission', async () => {
test('register test2 account', async () => {
const params = new URLSearchParams({
_csrf: csrfToken,
username: 'test2',
password: 'test2',
passwordconfirm: 'test2',
captcha: '000000',
});
const response = await fetch('http://localhost/forms/register', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(302);
});
test('edit test account permission', async () => {
const params = new URLSearchParams({
_csrf: csrfToken,
username: 'test',
template: 'fz/P4B//gAA=',
template: 'fx/v4B//gAA=',
});
const response = await fetch('http://localhost/forms/global/editaccount', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.ok).toBe(true);
});
test('edit test2 account permission', async () => {
const params = new URLSearchParams({
_csrf: csrfToken,
username: 'test2',
template: 'fx/v4B//gAA=',
});
const response = await fetch('http://localhost/forms/global/editaccount', {
headers: {
@ -243,4 +281,20 @@ bad words`,
expect(response.ok).toBe(true);
});
test('transfer /deleteownertest/ to test2 account', async () => {
const params = new URLSearchParams();
params.append('username', 'test2');
params.append('_csrf', csrfToken);
const response = await fetch('http://localhost/forms/board/deleteownertest/transfer', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect(response.status).toBe(200);
});
});

@ -44,7 +44,7 @@ module.exports = () => describe('login and create test board', () => {
expect([200, 404]).toContain(response.status);
});
test('create test boards', async () => {
test('create /test/ board', async () => {
const params = new URLSearchParams();
params.set('uri', 'test');
params.set('name', 'test');
@ -65,7 +65,7 @@ module.exports = () => describe('login and create test board', () => {
expect([302, 409]).toContain(response2.status);
});
test('create another test board', async () => {
test('create /test2/ board', async () => {
const params = new URLSearchParams();
params.append('uri', 'test2');
params.append('name', 'test2');
@ -81,6 +81,22 @@ module.exports = () => describe('login and create test board', () => {
expect([302, 409]).toContain(response.status);
});
test('create /deleteownertest/ board', async () => {
const params = new URLSearchParams();
params.append('uri', 'deleteownertest');
params.append('name', 'deleteownertest');
const response = await fetch('http://localhost/forms/create', {
headers: {
'x-using-xhr': 'true',
'cookie': sessionCookie,
},
method: 'POST',
body: params,
redirect: 'manual',
});
expect([302, 409]).toContain(response.status);
});
test('change global settings, disable antispam', async () => {
const params = new URLSearchParams({
_csrf: csrfToken,
@ -141,7 +157,7 @@ module.exports = () => describe('login and create test board', () => {
flood_timers_same_content_same_ip: '0',
flood_timers_same_content_any_ip: '0',
flood_timers_any_content_same_ip: '0',
dnsbl_blacklists: 'tor.dan.me.uk\nzen.spamhaus.org',
dnsbl_blacklists: 'tor.dan.me.uk',
dnsbl_cache_time: '3600',
rate_limit_cost_captcha: '10',
rate_limit_cost_board_settings: '30',

@ -0,0 +1,120 @@
'use strict';
/*
* Restore a post or a full thread from a mongodump backup.
*/
(async () => {
const Mongo = require(__dirname+'/../db/db.js')
, { execSync } = require('child_process')
, { EJSON } = require('bson')
, config = require(__dirname+'/../lib/misc/config.js');
console.log('CONNECTING TO MONGODB');
await Mongo.connect();
await Mongo.checkVersion();
await config.load();
const db = Mongo.db.collection('posts');
if (process.argv.length !== 4) {
console.error('Usage: node restore_posts.js PATH_TO_posts.bson OBJECT_ID_OF_POST');
process.exit(2);
}
const path = process.argv[2];
const post_object_id = process.argv[3];
// for some reason, the node BSON package can't handle some mongodump BSON files like posts.bson
// so we'll run bsondump to convert to JSON, then use EJSON from the BSON package to parse the special type information
const postsdata = execSync(`bsondump --quiet --type json ${path}`, (error, stdout, stderr) => {
if (error) {
console.error(`error: ${error.message}`);
process.exit(1);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
process.exit(1);
}
return stdout;
});
// and, because it's a debug tool, bsondump outputs each document as a different json on a new line, instead of printing a valid json file
const posts_json = EJSON.parse('['+postsdata.toString().replace(/}(\r\n|\n|\r){/gm, '},{')+']');
// iterate over all the posts and find the one with the correct ID
let i = 0;
while (i < posts_json.length && posts_json[i]['_id'] != post_object_id) { i += 1; }
if (i == posts_json.length) { console.log('Object ID not found in posts'); process.exit(2); }
const data = posts_json[i];
let res = await insertPost(db, data);
console.log(res);
// if the post doesn't have a parent thread, it is a thread. we need to insert the child posts too
if (data.thread == null) {
for (const e of posts_json) {
if (e['thread'] == data['postId']) {
// since posts are already indexed by postID, which contains a timestamp to the nearest second, there's no risk for a post trying to add a backlink before the target exists
res = await insertPost(db, e);
console.log(res);
}
}
}
console.log('Make sure to run `gulp html` and restore the attached files');
process.exit();
})();
async function insertPost(db, data) {
const postMongoId = await db.insertOne(data).then(result => result.insertedId); //_id of post
const postId = data['postId'];
const board = data['board'];
//add backlinks to the posts this post quotes
if (data.thread && data.quotes.length > 0) {
await db.updateMany({
'_id': {
'$in': data.quotes.map(q => q._id)
},
}, {
'$addToSet': {
'backlinks': { _id: postMongoId, postId: postId }
}
});
}
//restore invalidated quotes now this post exists again
if (data.thread && data.backlinks.length > 0) {
const threadId = data['thread'];
await db.updateMany({
'_id': {
'$in': data.backlinks.map(q => q._id)
}
},
{
'$addToSet': {
'quotes': { _id: postMongoId, thread: threadId, postId: postId }
}
});
await db.updateMany({
'_id': {
'$in': data.backlinks.map(q => q._id)
}
},
[{
'$set': {
message: {
$replaceOne: { input: '$message', find: `<span class="invalid-quote">&gt;&gt;${postId}</span>`, replacement: `<a class="quote" href="/${board}/thread/${threadId}.html#${postId}">&gt;&gt;${postId}</a>` }
}
}
}]
);
}
return ({ postId, postMongoId });
}

@ -39,7 +39,7 @@ section.form-wrapper.flex-center
span.required *
- const minLength = (isThread ? board.settings.minReplyMessageLength : board.settings.minThreadMessageLength) || 0;
- const maxLength = Math.min((isThread ? board.settings.maxReplyMessageLength : board.settings.maxThreadMessageLength), globalLimits.fieldLength.message) || globalLimits.fieldLength.message;
textarea#message(name='message', rows='5', autocomplete='off' minlength=minLength maxlength=maxLength required=messageRequired)
textarea#message(name='message', rows='5', minlength=minLength maxlength=maxLength required=messageRequired)
if board.settings.maxFiles > 0 && Object.values(board.settings.allowedFileTypes).some(x => x === true)
- const maxFiles = board.settings.maxFiles;
section.row

@ -8,9 +8,9 @@ mixin boardtable(ppd=false, activity=false, owner=false)
th #{__('Description')}
th #{__('PPH')}
if ppd
th #{__('PPD')}
th #{__('Users')}
th #{__('Posts')}
th.nobreak #{__('PPD')}
th.nobreak #{__('Users')}
th.nobreak #{__('Posts')}
if activity
th #{__('Latest Activity')}
th.nobreak #{__('Latest Activity')}
block

@ -25,7 +25,7 @@ block content
form.form-post.nogrow(action=`/forms/global/deleteaccounts` method='POST' enctype='application/x-www-form-urlencoded')
input(type='hidden' name='_csrf' value=csrf)
.table-container.flex-left.text-center
table
table.accounttable
tr
th
th #{__('Username')}
@ -36,7 +36,13 @@ block content
th #{__('Permissions')}
for account in accounts
tr
td: input(type='checkbox', name='checkedaccounts' value=account._id)
td
input.postform-style(type='checkbox', name='checkedaccounts' value=account._id)
|
if permissions.get(Permissions.MANAGE_GLOBAL_BOARDS)
.prev-show
input(type='checkbox', name='delete_owned_boards' value=account._id)
| Delete all owned boards
td #{account._id}
td
if account.ownedBoards.length > 0

@ -67,12 +67,12 @@ block content
else
td -
td #{board.settings.description}
td #{board.pph}
td #{board.ppd}
td #{board.ips}
td #{board.sequence_value-1}
td.nobreak #{board.pph}
td.nobreak #{board.ppd}
td.nobreak #{board.ips}
td.nobreak #{board.sequence_value-1}
if board.lastPostTimestamp
td(style=`background-color: ${board.lastPostTimestamp.color}`) #{board.lastPostTimestamp.text}
td.nobreak(style=`background-color: ${board.lastPostTimestamp.color}`) #{board.lastPostTimestamp.text}
else
td -
.pages.text-center.mt-5.mv-0

@ -62,10 +62,10 @@ block content
|
a(href=`/${board._id}/index.html`) /#{board._id}/ - #{board.settings.name}
td #{board.settings.description}
td #{board.pph}
td #{board.ppd}
td #{board.ips}
td #{board.sequence_value-1}
td.nobreak #{board.pph}
td.nobreak #{board.ppd}
td.nobreak #{board.ips}
td.nobreak #{board.sequence_value-1}
if localStats.total-localStats.unlisted > boards.length
tr
td(colspan=6)

Loading…
Cancel
Save