Merge branch 'develop' into 'master'

v1.3.0

Closes #538, #537, #531, and #535

See merge request fatchan/jschan!315
merge-requests/341/head v1.3.0
Thomas Lynch 9 months ago
commit 994c298f27
  1. 1
      .eslintrc.json
  2. 1
      .gitignore
  3. 24
      CHANGELOG.md
  4. 1
      README.md
  5. 2
      configs/nginx/snippets/jschan_common_routes.conf
  6. 2
      configs/nginx/snippets/security_headers_nocache.conf
  7. 10
      configs/template.js.example
  8. 8
      controllers/forms/globalsettings.js
  9. 57
      controllers/forms/login.js
  10. 22
      controllers/forms/makepost.js
  11. 67
      controllers/forms/register.js
  12. 3
      controllers/pages.js
  13. 8
      db/accounts.js
  14. 20
      gulp/res/css/style.css
  15. 4
      gulp/res/css/themes/yotsuba.css
  16. 61
      gulp/res/img/metamask-fox.svg
  17. 2
      gulp/res/js/counter.js
  18. 31
      gulp/res/js/embed.js
  19. 3
      gulp/res/js/expand.js
  20. 49
      gulp/res/js/filters.js
  21. 123
      gulp/res/js/forms.js
  22. 3
      gulp/res/js/quote.js
  23. 8
      gulp/res/js/renderweb3.js
  24. 24
      gulpfile.js
  25. 10
      lib/build/render.js
  26. 3
      lib/middleware/file/filemiddlewares.js
  27. 4
      lib/middleware/input/paramconverter.js
  28. 12
      lib/middleware/input/paramconverter.test.js
  29. 15
      lib/web3/web3.js
  30. 8
      locales/en-GB.json
  31. 8
      locales/it-IT.json
  32. 10
      locales/pt-BR.json
  33. 10
      locales/pt-PT.json
  34. 8
      locales/ru-RU.json
  35. 15
      migrations/1.2.2.js
  36. 25
      migrations/1.3.0.js
  37. 2
      models/forms/changeboardsettings.js
  38. 10
      models/forms/changeglobalsettings.js
  39. 4
      models/forms/changepassword.js
  40. 41
      models/forms/login.js
  41. 12
      models/forms/makepost.js
  42. 26
      models/forms/register.js
  43. 1
      models/pages/index.js
  44. 16
      models/pages/nonce.js
  45. 2533
      package-lock.json
  46. 18
      package.json
  47. 4
      server.js
  48. 3
      test/setup.js
  49. 2
      views/includes/captchasidelabel.pug
  50. 4
      views/includes/head.pug
  51. 5
      views/includes/postform.pug
  52. 16
      views/includes/registration.pug
  53. 2
      views/includes/web3signature.pug
  54. 3
      views/mixins/catalogtile.pug
  55. 2
      views/mixins/filters.pug
  56. 1
      views/mixins/modal.pug
  57. 12
      views/mixins/post.pug
  58. 5
      views/mixins/web3signature.pug
  59. 2
      views/pages/account.pug
  60. 59
      views/pages/globalmanagesettings.pug
  61. 12
      views/pages/login.pug
  62. 4
      views/pages/managesettings.pug
  63. 48
      views/pages/register.pug

@ -12,6 +12,7 @@
"gulp/res/js/watchedthread.js",
"gulp/res/js/threadwatcher.js",
"gulp/res/js/socket.io.js",
"gulp/res/js/web3.js",
"gulp/res/js/tegaki.js"
],
"env": {

1
.gitignore vendored

@ -27,6 +27,7 @@ gulp/res/js/watchedthread.js
gulp/res/js/pugruntime.js
gulp/res/js/uploaditem.js
gulp/res/js/socket.io.js
gulp/res/js/web3.js
# some ide and testing files, artefacts, etc
.idea/

@ -1,3 +1,27 @@
### 1.3.0
- Web3 integration with MetaMask, development generously funded by @0xBekket
- Users with the MetaMask browser extension can now register/login to accounts, and sign their messages with MetaMask.
- Signed post messages will display a signature and link to their wallet at the bottom of the post.
- The link uses etherscan by default but can be changed to another url with %s substituting the address, like other URL settings.
- A board-level and global option is available to disable web3 features.
- `signature` and `address` are available as new properties on signed posts through the API.
- No third party server communication or details are required for signing messages or authing.
- This is a very exciting feature with a lot of possibilities for expanding in future. Stay tuned.
- Client side filters can now be applied against flags or custom flags.
- Fix order of Tegaki upload so files come before replays so catalog shows the thumbnail.
- Video embeds now support youtube shorts and odysee.com.
- New global option for URI decoding filenames, to display some characters such as quotation mark (") better in filenames.
- Minor pt-PT language fixes.
- Small color adjustments to Yotsuba theme to match 4chan colors more closely.
- Update nginx config for new embeds in CSP, and some more file type support.
- Npm audit.
### 1.2.2
- Add a global setting to try and URI decode filenames
- Minor pt-PT and pt-BR language fixes
- Remove some unused dependencies
- Npm audit
### 1.2.1
- Bugfix ban upgrades not applying correctly due to bans format change.

@ -21,6 +21,7 @@ Contact via:
- [x] Manage everything from the web panel
- [x] Granular account permissions
- [x] Works properly with anonymizer networks (Tor, Lokinet, etc)
- [x] Web3 integration - register, login, and sign posts with [MetaMask](https://metamask.io)
- [x] [Tegaki](https://github.com/desuwa/tegaki) applet with drawing and replays
- [x] [API documentation](https://fatchan.gitgud.site/jschan-docs/)
- [x] Built-in webring (compatible w/ [lynxchan](https://gitlab.com/alogware/LynxChanAddon-Webring) & [infinity](https://gitlab.com/Tenicu/infinityaddon-webring))

@ -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|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)$ {
access_log off;
expires max;
root /path/to/jschan/static;

@ -1,4 +1,4 @@
add_header Content-Security-Policy "default-src 'self'; media-src 'self' blob:; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://www.youtube-nocookie.com/embed/ https://www.bitchute.com/embed/; connect-src 'self' wss://example.com/ wss://www.example.com/ wss://www.example.onion/ wss://example.onion/ wss://www.example.loki/ wss://example.loki/; font-src 'self'" always;
add_header Content-Security-Policy "default-src 'self'; media-src 'self' blob:; img-src 'self' blob:; object-src 'self' blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://www.youtube-nocookie.com/embed/ https://www.bitchute.com/embed/ https://odysee.com/%24/embed/; connect-src 'self' wss://example.com/ wss://www.example.com/ wss://www.example.onion/ wss://example.onion/ wss://www.example.loki/ wss://example.loki/; font-src 'self'" always;
add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin" always;
add_header X-Frame-Options "sameorigin" always;
add_header X-Content-Type-Options "nosniff" always;

@ -112,9 +112,10 @@ module.exports = {
//max age of a hot thread, with a reducing score multiplier from 0-1 bias towards this date
hotThreadsMaxAge: 2629800000,
//default url format/links for archive and reverse image search links, %s will be replaced by the url
//default url format/links for archive, reverse image search, and ethereum links, %s will be replaced by the url
archiveLinksURL: 'https://archive.today/?run=1&url=%s',
reverseImageLinksURL: 'https://tineye.com/search?url=%s',
ethereumLinksURL: 'https://etherscan.io/address/%s',
//cache templates in memory. disable only if editing templates and doing dev work
cacheTemplates: true,
@ -134,6 +135,10 @@ module.exports = {
//enable the webring (also copy configs/webring.json.example -> configs/webring.json and edit)
enableWebring: false,
//web3
enableWeb3: false,
ethereumNode: '',
//extension for thumbnails
thumbExtension: '.webp',
//whether to animate gif thumbnails
@ -202,6 +207,8 @@ module.exports = {
(spaces dont belong in filenames) */
spaceFileNameReplacement: '_',
uriDecodeFileNames: false,
//options for code block highlighting in posts
highlightOptions: {
@ -389,6 +396,7 @@ module.exports = {
geoFlags: false, //show geo flags, requires nginx setup
customFlags: false, //show custom flags
enableTegaki: true,
enableWeb3: false,
userPostDelete: true, //allow users to delete their posts
userPostSpoiler: true, //allow user to spoiler their post files
userPostUnlink: true, //alow user to unlink files fomr their post

@ -15,7 +15,7 @@ module.exports = {
paramConverter: paramConverter({
timeFields: ['hot_threads_max_age', 'inactive_account_time', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time', 'board_defaults_delete_protection_age'],
trimFields: ['captcha_options_grid_question', 'captcha_options_grid_trues', 'captcha_options_grid_falses', 'captcha_options_font', 'allowed_hosts', 'dnsbl_blacklists', 'other_mime_types',
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links', 'language', 'board_defaults_language'],
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'ethereum_links', 'reverse_links', 'language', 'board_defaults_language'],
numberFields: ['inactive_account_action', 'abandoned_board_action', 'auth_level', 'captcha_options_text_wave', 'captcha_options_text_paint', 'captcha_options_text_noise',
'captcha_options_grid_noise', 'captcha_options_grid_edge', 'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_grid_image_size',
'captcha_options_num_distorts_min', 'captcha_options_num_distorts_max', 'captcha_options_distortion', 'captcha_options_grid_icon_y_offset', 'flood_timers_same_content_same_ip', 'flood_timers_same_content_any_ip',
@ -73,6 +73,12 @@ module.exports = {
}
return false;
}, expected: true, error: __('Invalid reverse image search links URL format, must be a link containing %s where the url param belongs.') },
{ result: () => {
if (req.body.ethereum_links) {
return /https?\:\/\/[^\s<>\[\]{}|\\^]+%s[^\s<>\[\]{}|\\^]*/i.test(req.body.ethereum_links);
}
return false;
}, expected: true, error: __('Invalid ethereum links URL format, must be a link containing %s where the url param belongs.') },
{ result: existsBody(req.body.referrer_check) ? lengthBody(req.body.allowed_hosts, 1) : false, expected: false, error: __('Please enter at least one allowed host in the "Allowed Hosts" field when the "Referer Check" option is selected.') },
{ result: numberBody(req.body.inactive_account_time), expected: true, error: __('Invalid inactive account time') },
{ result: numberBody(req.body.inactive_account_action, 0, 2), expected: true, error: __('Inactive account action must be a number from 0-2') },

@ -3,25 +3,44 @@
const loginAccount = require(__dirname+'/../../models/forms/login.js')
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js')
, config = require(__dirname+'/../../lib/misc/config.js')
, { timingSafeEqual } = require('crypto')
, cache = require(__dirname+'/../../lib/redis/redis.js')
, { recover: web3EthAccountsRecover } = require('web3-eth-accounts')
, { isAddress: web3UtilsIsAddress } = require('web3-utils');
module.exports = {
paramConverter: paramConverter({
trimFields: ['username', 'password', 'twofactor'],
trimFields: ['username', 'password', 'twofactor', 'nonce', 'signature', 'address'],
}),
controller: async (req, res, next) => {
const { __ } = res.locals;
const { enableWeb3 } = config.get;
res.locals.isWeb3 = req.body.address && req.body.address.length > 0;
const errors = await checkSchema([
{ result: existsBody(req.body.username), expected: true, error: __('Missing username') },
{ result: existsBody(req.body.password), expected: true, error: __('Missing password') },
{ result: lengthBody(req.body.username, 0, 50), expected: false, error: __('Username must be 1-50 characters') },
{ result: lengthBody(req.body.password, 0, 100), expected: false, error: __('Password must be 1-100 characters') },
{ result: lengthBody(req.body.twofactor, 0, 6), expected: false, error: __('Invalid 2FA code') },
]);
let errors = [];
if (res.locals.isWeb3) {
errors = await checkSchema([
{ result: enableWeb3 === true, expected: true, error: __('Web3 logins disabled') },
{ result: web3UtilsIsAddress(req.body.address), expected: true, error: __('Invalid address') },
{ result: existsBody(req.body.nonce), expected: true, error: __('Missing nonce') },
{ result: existsBody(req.body.signature), expected: true, error: __('Missing signature') },
]);
} else {
errors = await checkSchema([
{ result: existsBody(req.body.username), expected: true, error: __('Missing username') },
{ result: existsBody(req.body.password), expected: true, error: __('Missing password') },
{ result: web3UtilsIsAddress(req.body.username), expected: false, error: __('Username must not be an ethereum address') },
{ result: lengthBody(req.body.username, 0, 50), expected: false, error: __('Username must be 1-50 characters') },
{ result: lengthBody(req.body.password, 0, 100), expected: false, error: __('Password must be 1-100 characters') },
{ result: lengthBody(req.body.twofactor, 0, 6), expected: false, error: __('Invalid 2FA code') },
]);
}
if (errors.length > 0) {
return dynamicResponse(req, res, 400, 'message', {
@ -32,6 +51,26 @@ module.exports = {
}
try {
if (res.locals.isWeb3) {
const { address, nonce, signature } = req.body;
const nonceRequest = await cache.del(`nonce:${address}:${nonce}`);
if (!nonceRequest || nonceRequest !== 1) {
return dynamicResponse(req, res, 400, 'message', {
'title': __('Bad Request'),
'error': __('Login timed out'),
'redirect': '/login.html'
});
}
let recoveredAddress = (await web3EthAccountsRecover(`Nonce: ${nonce}`, signature)).toLowerCase();
const match = await timingSafeEqual(Buffer.from(recoveredAddress), Buffer.from(address));
if (match !== true) {
return dynamicResponse(req, res, 400, 'message', {
'title': __('Bad Request'),
'error': __('Invalid login signature'),
'redirect': '/login.html'
});
}
}
await loginAccount(req, res, next);
} catch (err) {
return next(err);

@ -8,12 +8,13 @@ const makePost = require(__dirname+'/../../models/forms/makepost.js')
, config = require(__dirname+'/../../lib/misc/config.js')
, { Files } = require(__dirname+'/../../db/')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js')
, { recover: web3EthAccountsRecover } = require('web3-eth-accounts');
module.exports = {
paramConverter: paramConverter({
trimFields: ['message', 'name', 'subject', 'email', 'postpassword', 'password'],
trimFields: ['message', 'name', 'subject', 'email', 'postpassword', 'password', 'signature'],
allowedArrays: ['spoiler', 'strip_filename'],
processMessageLength: true,
numberFields: ['thread'],
@ -23,7 +24,7 @@ module.exports = {
const { __ } = res.locals;
const { globalLimits, disableAnonymizerFilePosting } = config.get;
const { globalLimits, disableAnonymizerFilePosting, enableWeb3 } = config.get;
const hasNoMandatoryFile = globalLimits.postFiles.max !== 0 && res.locals.board.settings.maxFiles !== 0 && res.locals.numFiles === 0;
const disableBoardAnonymizerFilePosting = res.locals.board.settings.disableAnonymizerFilePosting && !res.locals.permissions.get(Permissions.MANAGE_BOARD_GENERAL);
@ -50,6 +51,21 @@ module.exports = {
{ result: lengthBody(req.body.name, 0, globalLimits.fieldLength.name), expected: false, error: __('Name must be %s characters or less', globalLimits.fieldLength.name) },
{ result: lengthBody(req.body.subject, 0, globalLimits.fieldLength.subject), expected: false, error: __('Subject must be %s characters or less', globalLimits.fieldLength.subject) },
{ result: lengthBody(req.body.email, 0, globalLimits.fieldLength.email), expected: false, error: __('Email must be %s characters or less', globalLimits.fieldLength.email) },
{ result: async () => {
if (enableWeb3 === true && res.locals.board.settings.enableWeb3 === true
&& req.body.message && req.body.signature && req.body.signature.length < 200) {
try {
const fixedMessage = req.body.rawMessage.replace(/\r\n/igm, '\n');
res.locals.recoveredAddress = await web3EthAccountsRecover(fixedMessage, req.body.signature);
return true;
} catch(e) {
console.warn(e);
return false;
}
} else {
return true;
}
}, expected: true, error: __('Failed to verify message signature') },
]);
if (errors.length > 0) {

@ -4,29 +4,50 @@ const { Permissions } = require(__dirname+'/../../lib/permission/permissions.js'
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, registerAccount = require(__dirname+'/../../models/forms/register.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { alphaNumericRegex, checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
, config = require(__dirname+'/../../lib/misc/config.js')
, { timingSafeEqual } = require('crypto')
, { alphaNumericRegex, checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js')
, cache = require(__dirname+'/../../lib/redis/redis.js')
, { recover: web3EthAccountsRecover } = require('web3-eth-accounts')
, { isAddress: web3UtilsIsAddress } = require('web3-utils');
module.exports = {
paramConverter: paramConverter({
trimFields: ['username', 'password', 'passwordconfirm'],
trimFields: ['username', 'password', 'passwordconfirm', 'nonce', 'signature', 'address'],
}),
controller: async (req, res, next) => {
const { __ } = res.locals;
const { enableWeb3 } = config.get;
res.locals.isWeb3 = req.body.address && req.body.address.length > 0;
const errors = await checkSchema([
{ result: res.locals.permissions.get(Permissions.CREATE_ACCOUNT), blocking: true, expected: true, error: __('No permission') },
{ result: existsBody(req.body.username), expected: true, error: __('Missing username') },
{ result: lengthBody(req.body.username, 0, 50), expected: false, error: __('Username must be 50 characters or less') },
{ result: alphaNumericRegex.test(req.body.username), expected: true, error: __('Username must contain a-z 0-9 only') },
{ result: existsBody(req.body.password), expected: true, error: __('Missing password') },
{ result: lengthBody(req.body.password, 0, 50), expected: false, error: __('Password must be 50 characters or less') },
{ result: existsBody(req.body.passwordconfirm), expected: true, error: __('Missing password confirmation') },
{ result: lengthBody(req.body.passwordconfirm, 0, 100), expected: false, error: __('Password confirmation must be 100 characters or less') },
{ result: (req.body.password === req.body.passwordconfirm), expected: true, error: __('Password and password confirmation must match') },
], res.locals.permissions);
let errors = [];
if (res.locals.isWeb3) {
errors = await checkSchema([
{ result: res.locals.permissions.get(Permissions.CREATE_ACCOUNT), blocking: true, expected: true, error: __('No permission') },
{ result: enableWeb3 === true, expected: true, error: __('Web3 logins disabled') },
{ result: web3UtilsIsAddress(req.body.address), expected: true, error: __('Invalid address') },
{ result: existsBody(req.body.nonce), expected: true, error: __('Missing nonce') },
{ result: existsBody(req.body.signature), expected: true, error: __('Missing signature') },
]);
} else {
errors = await checkSchema([
{ result: res.locals.permissions.get(Permissions.CREATE_ACCOUNT), blocking: true, expected: true, error: __('No permission') },
{ result: existsBody(req.body.username), expected: true, error: __('Missing username') },
{ result: lengthBody(req.body.username, 0, 50), expected: false, error: __('Username must be 50 characters or less') },
{ result: lengthBody(req.body.username), expected: false, error: __('Username must be 50 characters or less') },
{ result: alphaNumericRegex.test(req.body.username), expected: true, error: __('Username must contain a-z 0-9 only') },
{ result: web3UtilsIsAddress(req.body.username), expected: false, error: __('Username must not be an ethereum address') },
{ result: existsBody(req.body.password), expected: true, error: __('Missing password') },
{ result: lengthBody(req.body.password, 0, 50), expected: false, error: __('Password must be 50 characters or less') },
{ result: existsBody(req.body.passwordconfirm), expected: true, error: __('Missing password confirmation') },
{ result: lengthBody(req.body.passwordconfirm, 0, 100), expected: false, error: __('Password confirmation must be 100 characters or less') },
{ result: (req.body.password === req.body.passwordconfirm), expected: true, error: __('Password and password confirmation must match') },
], res.locals.permissions);
}
if (errors.length > 0) {
return dynamicResponse(req, res, 400, 'message', {
@ -37,6 +58,26 @@ module.exports = {
}
try {
if (res.locals.isWeb3) {
const { address, nonce, signature } = req.body;
const nonceRequest = await cache.del(`nonce:${address}:${nonce}`);
if (!nonceRequest || nonceRequest !== 1) {
return dynamicResponse(req, res, 400, 'message', {
'title': __('Bad Request'),
'error': __('Login timed out'),
'redirect': '/login.html'
});
}
let recoveredAddress = (await web3EthAccountsRecover(`Nonce: ${nonce}`, signature)).toLowerCase();
const match = await timingSafeEqual(Buffer.from(recoveredAddress), Buffer.from(address));
if (match !== true) {
return dynamicResponse(req, res, 400, 'message', {
'title': __('Bad Request'),
'error': __('Invalid login signature'),
'redirect': '/login.html'
});
}
}
await registerAccount(req, res, next);
} catch (err) {
return next(err);

@ -24,7 +24,7 @@ const express = require('express')
globalManageRecent, globalManageAccounts, globalManageNews, globalManageLogs, globalManageRoles } = require(__dirname+'/../models/pages/globalmanage/')
, { changePassword, blockBypass, home, register, login, create, myPermissions, sessions, setupTwoFactor,
board, catalog, banners, boardSettings, globalSettings, randombanner, news, captchaPage, overboard, overboardCatalog,
captcha, thread, modlog, modloglist, account, boardlist, customPage, csrfPage } = require(__dirname+'/../models/pages/')
captcha, thread, modlog, modloglist, account, boardlist, customPage, csrfPage, noncePage } = require(__dirname+'/../models/pages/')
, threadParamConverter = paramConverter({ processThreadIdParam: true })
, logParamConverter = paramConverter({ processDateParam: true })
, filterParamConverter = paramConverter({ objectIdParams: ['filterid'] })
@ -134,6 +134,7 @@ router.get('/account.html', useSession, sessionRefresh, isLoggedIn, calcPerms, c
router.get('/mypermissions.html', useSession, sessionRefresh, isLoggedIn, calcPerms, myPermissions);
router.get('/twofactor.html', useSession, sessionRefresh, isLoggedIn, calcPerms, csrf, setupTwoFactor);
router.get('/sessions.html', useSession, sessionRefresh, isLoggedIn, calcPerms, csrf, sessions);
router.get('/nonce/:address([a-zA-Z0-9]{42}).json', noncePage); //nonce for web3 logins
router.get('/login.html', login);
router.get('/register.html', register);
router.get('/changepassword.html', changePassword);

@ -36,9 +36,12 @@ module.exports = {
return account;
},
insertOne: async (original, username, password, permissions) => {
insertOne: async (original, username, password, permissions, web3=false) => {
// hash the password
const passwordHash = await bcrypt.hash(password, 12);
let passwordHash;
if (password) {
passwordHash = await bcrypt.hash(password, 12);
}
//add to db
const res = await db.insertOne({
'_id': username,
@ -48,6 +51,7 @@ module.exports = {
'ownedBoards': [],
'staffBoards': [],
'twofactor': null,
web3,
});
cache.del(`users:${username}`);
return res;

@ -202,9 +202,15 @@ pre {
.pv-5 {
padding: 5px 0;
}
.p-0 {
padding: 0!important;
}
.vh {
visibility: hidden!important;
}
.wm {
width: max-content;
}
#settings, .dummy-link {
cursor: pointer;
}
@ -635,6 +641,11 @@ td {
word-break: break-all;
}
.web3-signature, .web3-address {
word-break: break-all;
cursor: text;
}
.modal-bg {
position: fixed;
top: 0;
@ -1223,7 +1234,7 @@ input[type=number] {
appearance:textfield;
}
input[type="submit"] {
input[type="submit"], .postform-style.dummy-link {
min-height: 2.5em;
cursor: pointer;
}
@ -1303,6 +1314,7 @@ iframe.embed-video {
width: 500px;
height: 280px;
margin-top: 3px;
background-color: var(--darken);
}
iframe.bypass {
@ -1540,7 +1552,8 @@ row.wrap.sb .col {
#tab-7:target .tab-7,
#tab-8:target .tab-8,
#tab-9:target .tab-9,
#tab-10:target .tab-10 {
#tab-10:target .tab-10,
#tab-11:target .tab-11 {
display: flex;
}
@ -1553,7 +1566,8 @@ row.wrap.sb .col {
#tab-7:target a[href="#tab-7"],
#tab-8:target a[href="#tab-8"],
#tab-9:target a[href="#tab-9"],
#tab-10:target a[href="#tab-10"] {
#tab-10:target a[href="#tab-10"],
#tab-11:target a[href="#tab-11"] {
border-bottom: 1px solid var(--background-rest);
background: var(--background-rest);
}

@ -10,13 +10,13 @@
--post-outline-color: #d9bfb7;
--label-color: #fca;
--box-border-color: #000;
--darken: #00000010;
--darken: #ead6ca;
--highlighted-post-color: #f0c0b0;
--highlighted-post-outline-color: #d99f91;
--board-title: #af0a0f;
--hr: #D9BFB7;
--font-color: #800000;
--name-color: #800000;
--name-color: #117743;
--capcode-color: #f00;
--subject-color: #f00;
--link-color: #800;

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns:ev="http://www.w3.org/2001/xml-events"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 318.6 318.6"
style="enable-background:new 0 0 318.6 318.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E2761B;stroke:#E2761B;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:#E4761B;stroke:#E4761B;stroke-linecap:round;stroke-linejoin:round;}
.st2{fill:#D7C1B3;stroke:#D7C1B3;stroke-linecap:round;stroke-linejoin:round;}
.st3{fill:#233447;stroke:#233447;stroke-linecap:round;stroke-linejoin:round;}
.st4{fill:#CD6116;stroke:#CD6116;stroke-linecap:round;stroke-linejoin:round;}
.st5{fill:#E4751F;stroke:#E4751F;stroke-linecap:round;stroke-linejoin:round;}
.st6{fill:#F6851B;stroke:#F6851B;stroke-linecap:round;stroke-linejoin:round;}
.st7{fill:#C0AD9E;stroke:#C0AD9E;stroke-linecap:round;stroke-linejoin:round;}
.st8{fill:#161616;stroke:#161616;stroke-linecap:round;stroke-linejoin:round;}
.st9{fill:#763D16;stroke:#763D16;stroke-linecap:round;stroke-linejoin:round;}
</style>
<polygon class="st0" points="274.1,35.5 174.6,109.4 193,65.8 "/>
<g>
<polygon class="st1" points="44.4,35.5 143.1,110.1 125.6,65.8 "/>
<polygon class="st1" points="238.3,206.8 211.8,247.4 268.5,263 284.8,207.7 "/>
<polygon class="st1" points="33.9,207.7 50.1,263 106.8,247.4 80.3,206.8 "/>
<polygon class="st1" points="103.6,138.2 87.8,162.1 144.1,164.6 142.1,104.1 "/>
<polygon class="st1" points="214.9,138.2 175.9,103.4 174.6,164.6 230.8,162.1 "/>
<polygon class="st1" points="106.8,247.4 140.6,230.9 111.4,208.1 "/>
<polygon class="st1" points="177.9,230.9 211.8,247.4 207.1,208.1 "/>
</g>
<g>
<polygon class="st2" points="211.8,247.4 177.9,230.9 180.6,253 180.3,262.3 "/>
<polygon class="st2" points="106.8,247.4 138.3,262.3 138.1,253 140.6,230.9 "/>
</g>
<polygon class="st3" points="138.8,193.5 110.6,185.2 130.5,176.1 "/>
<polygon class="st3" points="179.7,193.5 188,176.1 208,185.2 "/>
<g>
<polygon class="st4" points="106.8,247.4 111.6,206.8 80.3,207.7 "/>
<polygon class="st4" points="207,206.8 211.8,247.4 238.3,207.7 "/>
<polygon class="st4" points="230.8,162.1 174.6,164.6 179.8,193.5 188.1,176.1 208.1,185.2 "/>
<polygon class="st4" points="110.6,185.2 130.6,176.1 138.8,193.5 144.1,164.6 87.8,162.1 "/>
</g>
<g>
<polygon class="st5" points="87.8,162.1 111.4,208.1 110.6,185.2 "/>
<polygon class="st5" points="208.1,185.2 207.1,208.1 230.8,162.1 "/>
<polygon class="st5" points="144.1,164.6 138.8,193.5 145.4,227.6 146.9,182.7 "/>
<polygon class="st5" points="174.6,164.6 171.9,182.6 173.1,227.6 179.8,193.5 "/>
</g>
<polygon class="st6" points="179.8,193.5 173.1,227.6 177.9,230.9 207.1,208.1 208.1,185.2 "/>
<polygon class="st6" points="110.6,185.2 111.4,208.1 140.6,230.9 145.4,227.6 138.8,193.5 "/>
<polygon class="st7" points="180.3,262.3 180.6,253 178.1,250.8 140.4,250.8 138.1,253 138.3,262.3 106.8,247.4 117.8,256.4
140.1,271.9 178.4,271.9 200.8,256.4 211.8,247.4 "/>
<polygon class="st8" points="177.9,230.9 173.1,227.6 145.4,227.6 140.6,230.9 138.1,253 140.4,250.8 178.1,250.8 180.6,253 "/>
<g>
<polygon class="st9" points="278.3,114.2 286.8,73.4 274.1,35.5 177.9,106.9 214.9,138.2 267.2,153.5 278.8,140 273.8,136.4
281.8,129.1 275.6,124.3 283.6,118.2 "/>
<polygon class="st9" points="31.8,73.4 40.3,114.2 34.9,118.2 42.9,124.3 36.8,129.1 44.8,136.4 39.8,140 51.3,153.5 103.6,138.2
140.6,106.9 44.4,35.5 "/>
</g>
<polygon class="st6" points="267.2,153.5 214.9,138.2 230.8,162.1 207.1,208.1 238.3,207.7 284.8,207.7 "/>
<polygon class="st6" points="103.6,138.2 51.3,153.5 33.9,207.7 80.3,207.7 111.4,208.1 87.8,162.1 "/>
<polygon class="st6" points="174.6,164.6 177.9,106.9 193.1,65.8 125.6,65.8 140.6,106.9 144.1,164.6 145.3,182.8 145.4,227.6
173.1,227.6 173.3,182.8 "/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@ -1,6 +1,7 @@
window.addEventListener('DOMContentLoaded', () => {
const messageBox = document.getElementById('message');
const parentForm = messageBox && messageBox.form;
if (messageBox) {
const messageBoxLabel = messageBox.previousSibling;
@ -25,6 +26,7 @@ window.addEventListener('DOMContentLoaded', () => {
}
currentLength = messageBox.value.length;
updateCounter();
parentForm && parentForm.dispatchEvent(new CustomEvent('messageBoxChange'));
};
updateCounter();

@ -11,9 +11,12 @@ if (!isCatalog) { //dont show embed buttons in catalog
try {
const urlObject = new URL(url);
const searchParams = urlObject.searchParams;
const videoId = searchParams.get('v') || (urlObject.hostname === 'youtu.be' ? urlObject.pathname.substring(1) : null);
const videoId = searchParams.get('v') // /watch?v=
|| (urlObject.hostname === 'youtu.be' && urlObject.pathname.substring(1)) // /videoid
|| (urlObject.pathname.startsWith('/shorts/') && urlObject.pathname.substring(8)); // /shorts/videoi
if (videoId && videoId.length === 11) {
return `<iframe class="embed-video" src="https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" style="display:block;" allowfullscreen></iframe>`;
return ['<iframe class="embed-video" src="" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" style="display:block;" allowfullscreen></iframe>',
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`];
}
} catch (e) { /*invalid url*/ }
return null;
@ -26,7 +29,22 @@ if (!isCatalog) { //dont show embed buttons in catalog
const urlObject = new URL(url);
const videoId = urlObject.pathname.split('/')[2];
if (videoId) {
return `<iframe class="embed-video" src="https://www.bitchute.com/embed/${encodeURIComponent(videoId)}/" frameborder="0" scrolling="no" style="display:block;" allowfullscreen></iframe>`;
return ['<iframe class="embed-video" src="" frameborder="0" scrolling="no" style="display:block;" allowfullscreen></iframe>',
`https://www.bitchute.com/embed/${encodeURIComponent(videoId)}/`];
}
} catch (e) { /*invalid url*/ }
return null;
}
},
{
linkRegex: /^https?:\/\/(?:www\.)?odysee\.com\/.+\/.+/i,
toHtml: (url) => {
try {
const urlObject = new URL(url);
const videoId = urlObject.pathname;
if (videoId && videoId.startsWith('/@')) {
return ['<iframe class="embed-video" src="" frameborder="0" scrolling="no" style="display:block;" allowfullscreen></iframe>',
`https://odysee.com/$/embed${videoId}`];
}
} catch (e) { /*invalid url*/ }
return null;
@ -35,12 +53,13 @@ if (!isCatalog) { //dont show embed buttons in catalog
//TODO: add more of these
];
const toggleEmbed = (embedSpan, embedHtml) => {
const toggleEmbed = (embedSpan, embedHtml, embedSrc) => {
if (embedSpan.dataset.open === 'true') {
embedSpan.nextSibling.remove();
embedSpan.firstElementChild.textContent = 'Embed';
} else {
embedSpan.insertAdjacentHTML('afterend', embedHtml);
embedSpan.nextSibling.src = embedSrc;
embedSpan.firstElementChild.textContent = 'Close';
}
embedSpan.dataset.open = embedSpan.dataset.open === 'true' ? 'false' : 'true';
@ -50,7 +69,7 @@ if (!isCatalog) { //dont show embed buttons in catalog
for (let i = 0; i < l.length; i++) {
const embedHandler = supportedEmbeds.find(handler => handler.linkRegex.test(l[i].href));
if (!embedHandler) { continue; }
const embedHtml = embedHandler.toHtml(l[i].href);
const [embedHtml, embedSrc] = embedHandler.toHtml(l[i].href);
if (embedHtml) {
const embedSpan = document.createElement('span');
const openBracket = document.createTextNode('[');
@ -63,7 +82,7 @@ if (!isCatalog) { //dont show embed buttons in catalog
embedSpan.appendChild(embedLink);
embedSpan.appendChild(closeBracket);
l[i].parentNode.insertBefore(embedSpan, l[i].nextSibling);
embedLink.addEventListener('click', () => toggleEmbed(embedSpan, embedHtml), false);
embedLink.addEventListener('click', () => toggleEmbed(embedSpan, embedHtml, embedSrc, false));
}
}
};

@ -108,7 +108,8 @@ window.addEventListener('DOMContentLoaded', () => {
}
thumbElement.style.opacity = '0.5';
thumbElement.style.cursor = 'wait';
if (localStorage.getItem('imageloadingbars') == 'true') {
if (localStorage.getItem('imageloadingbars') == 'true'
&& window.URL.createObjectURL) {
const request = new XMLHttpRequest();
request.onprogress = (e) => {
const progress = Math.floor((e.loaded/e.total)*100);

@ -13,17 +13,19 @@ const getFiltersFromLocalStorage = () => {
single: new Set(),
fid: new Set(),
fname: new Set(),
fsub: new Set(),
ftrip: new Set(),
fsub: new Set(),
fmsg: new Set(),
fflag: new Set(),
fnamer: [],
ftripr: [],
fsubr: [],
fmsgr: [],
fflagr: [],
});
};
let { single, fid, fname, ftrip, fsub, fmsg, fnamer, ftripr, fsubr, fmsgr } = getFiltersFromLocalStorage();
let { single, fid, fname, ftrip, fsub, fmsg, fflag, fnamer, ftripr, fsubr, fmsgr, fflagr } = getFiltersFromLocalStorage();
let filtersTable;
const updateFiltersTable = () => {
@ -47,28 +49,33 @@ const updateSavedFilters = () => {
...([...fname].map(x => ({type:'fname', val:x}))),
...([...fsub].map(x => ({type:'fsub', val:x}))),
...([...fmsg].map(x => ({type:'fmsg', val:x}))),
...([...fflag].map(x => ({type:'fflag', val:x}))),
...fnamer.map(x => ({type:'fnamer', val:x.source.toString()})),
...ftripr.map(x => ({type:'ftripr', val:x.source.toString()})),
...fsubr.map(x => ({type:'fsubr', val:x.source.toString()})),
...fmsgr.map(x => ({type:'fmsgr', val:x.source.toString()})),
...fflagr.map(x => ({type:'fflagr', val:x.source.toString()})),
]));
updateFiltersTable();
};
const anyFilterMatches = (filteringPost) => {
const { board, postId, userId, name, subject, tripcode } = filteringPost.dataset;
const { board, postId, userId, name, subject, tripcode, country } = filteringPost.dataset;
const postMessage = filteringPost.querySelector('.post-message');
const message = postMessage ? postMessage.textContent : null;
const flag = country ? country.code : null;
return single.has(`${board}-${postId}`)
|| fid.has(userId)
|| fname.has(name)
|| ftrip.has(tripcode)
|| fsub.has(tripcode)
|| fsub.has(subject)
|| fmsg.has(message)
|| fflag.has(flag)
|| fnamer.some(r => r.test(name))
|| ftripr.some(r => r.test(tripcode))
|| fsubr.some(r => r.test(subject))
|| fmsgr.some(r => r.test(message));
|| fmsgr.some(r => r.test(message))
|| fflagr.some(r => r.test(flag));
};
const togglePostsHidden = (posts, state, single) => {
@ -138,6 +145,12 @@ const getPostsByFilter = (type, data) => {
case 'fmsgr':
posts = getPostsByMessage(data, true);
break;
case 'fflag':
posts = document.querySelectorAll(`[data-flag="${CSS.escape(data)}"]`);
break;
case 'fflagr':
posts = getPostsByRegex('data-flag', data);
break;
default:
break;
}
@ -189,6 +202,15 @@ const setFilterState = (type, data, state) => {
fmsgr.push(data);
}
break;
case 'fflag':
fflag[addOrDelete](data);
break;
case 'fflagr':
fflagr = fflagr.filter(r => r.source != data.source);
if (state) {
fflagr.push(data);
}
break;
default:
break;
}
@ -263,6 +285,9 @@ const postMenuChange = function() {
case 'fsub':
filterData = postDataset.subject;
break;
case 'fflag':
filterData = postDataset.flag;
break;
case 'moderate':
return moderatePost(postContainer);
case 'edit':
@ -303,14 +328,17 @@ const getHiddenElems = () => {
for (let name of fname) {
posts = posts.concat(getPostsByFilter('fname', name));
}
for (let tripcode of ftrip) {
posts = posts.concat(getPostsByFilter('ftrip', tripcode));
}
for (let subject of fsub) {
posts = posts.concat(getPostsByFilter('fsub', subject));
}
for (let message of fmsg) {
posts = posts.concat(getPostsByFilter('fmsg', message));
}
for (let tripcode of ftrip) {
posts = posts.concat(getPostsByFilter('ftrip', tripcode));
for (let flag of fflag) {
posts = posts.concat(getPostsByFilter('fflag', flag));
}
for (let namer of fnamer) {
posts = posts.concat(getPostsByFilter('fnamer', namer));
@ -324,6 +352,9 @@ const getHiddenElems = () => {
for (let messager of fmsgr) {
posts = posts.concat(getPostsByFilter('fmsgr', messager));
}
for (let flagr of fflagr) {
posts = posts.concat(getPostsByFilter('fflagr', flagr));
}
return posts;
};
@ -374,13 +405,15 @@ window.addEventListener('settingsReady', function() {
single = new Set(),
fid = new Set(),
fname = new Set(),
ftrip = new Set(),
fsub = new Set(),
fmsg = new Set(),
ftrip = new Set(),
fflag = new Set(),
fnamer = [],
ftripr = [],
fsubr = [],
fmsgr = [],
fflagr = [],
updateFiltersTable();
togglePostsHidden(document.querySelectorAll(`.${isCatalog ? 'catalog-tile': 'post-container' }`), false);
updateSavedFilters();

@ -118,6 +118,18 @@ class postFormHandler {
this.tegakiButton.addEventListener('click', () => this.doTegaki());
}
//if web3 signature button, attach the listeners for message signing
this.web3SignButton = form.querySelector('.web3-sign');
if (this.web3SignButton) {
this.web3SignButton.addEventListener('click', () => this.doWeb3Sign());
}
//if web3 login button, do login procedure on click
this.web3LoginButton = form.querySelector('.web3-login');
if (this.web3LoginButton) {
this.web3LoginButton.addEventListener('click', (e) => this.doWeb3Login(e));
}
//if file input, attach listeners for adding files, drag+drop, etc
this.fileInput = form.querySelector('input[type="file"]');
if (this.fileInput) {
@ -147,6 +159,7 @@ class postFormHandler {
}
form.addEventListener('submit', e => this.formSubmit(e));
form.addEventListener('messageBoxChange', () => this.handleMessageChange());
}
reset() {
@ -168,14 +181,15 @@ class postFormHandler {
onCancel: () => {},
onDone: () => {
const now = Date.now();
//add replay file if box was checked
let replayBlob;
if (saveReplay) {
const blob = Tegaki.replayRecorder.toBlob();
this.addFile(new File([blob], `${now}-tegaki.tgkr`, { type: 'tegaki/replay' }), { stripFilenames: false });
replayBlob = Tegaki.replayRecorder.toBlob();
}
//add tegaki image
Tegaki.flatten().toBlob(b => {
this.addFile(new File([b], `${now}-tegaki.png`, { type: 'image/png' }), { stripFilenames: false });
Tegaki.flatten().toBlob(imageBlob => {
this.addFile(new File([imageBlob], `${now}-tegaki.png`, { type: 'image/png' }), { stripFilenames: false });
//add replay file
replayBlob && this.addFile(new File([replayBlob], `${now}-tegaki.tgkr`, { type: 'tegaki/replay' }), { stripFilenames: false });
}, 'image/png');
//update file list
this.updateFilesText();
@ -190,6 +204,56 @@ class postFormHandler {
Tegaki.setColorPalette(2); //picks a better default color palette
}
handleMessageChange() {
if (!this.messageBox) { return; }
const emptyMessage = this.messageBox.value.length === 0;
if (this.web3SignButton) {
this.form.elements.signature.value = '';
this.web3SignButton.disabled = emptyMessage;
}
}
async doWeb3Login(e) {
e.target.style.pointerEvents = 'none'; //way of disabling dummy button to prevent double click
try {
const accounts = await window.jschanweb3.eth.requestAccounts();
const nonceResponse = await fetch(`/nonce/${encodeURIComponent(accounts[0])}.json`)
.then(res => res.json());
const nonce = nonceResponse && nonceResponse.nonce;
if (!nonce) { throw Error('Nonce request failed'); }
const signingMesssage = `Nonce: ${nonce}`;
const signature = await window.jschanweb3.currentProvider.request({
method: 'personal_sign',
params: [signingMesssage, accounts[0]],
});
this.form.elements.signature.value = signature;
this.form.elements.address.value = accounts[0];
this.form.elements.nonce.value = nonce;
this.form.requestSubmit();
} catch(e) {
console.warn(e);
} finally {
e.target.style.pointerEvents = 'auto';
}
}
async doWeb3Sign() {
if (!this.messageBox.value || this.messageBox.value.length === 0) {
return;
}
const messageContent = this.messageBox.value;
try {
const accounts = await window.jschanweb3.eth.requestAccounts();
const signature = await window.jschanweb3.currentProvider.request({
method: 'personal_sign',
params: [messageContent, accounts[0]],
});
this.form.elements.signature.value = signature;
} catch (e) {
console.warn(e);
}
}
updateFlagField() {
if (this.customFlagInput && this.customFlagInput.options.selectedIndex !== -1) {
this.selectedFlagImage.src = this.customFlagInput.options[this.customFlagInput.options.selectedIndex].dataset.src || '';
@ -242,22 +306,23 @@ class postFormHandler {
//prepare new request
const xhr = new XMLHttpRequest();
//disable submit button to prevent submitting while one in progress
this.submit.disabled = true;
//update the text on the submit button, and show upload progress if form has files
this.submit.value = 'Processing...';
if (this.files && this.files.length > 0) {
xhr.onloadstart = () => {
this.submit.value = '0%';
};
xhr.upload.onprogress = (ev) => {
const progress = Math.floor((ev.loaded / ev.total) * 100);
this.submit.value = `${progress}%`;
};
xhr.onload = () => {
this.submit.value = this.originalSubmitText;
};
if (this.submit) {
//disable submit button to prevent submitting while one in progress
this.submit.disabled = true;
//update the text on the submit button, and show upload progress if form has files
this.submit.value = 'Processing...';
if (this.files && this.files.length > 0) {
xhr.onloadstart = () => {
this.submit.value = '0%';
};
xhr.upload.onprogress = (ev) => {
const progress = Math.floor((ev.loaded / ev.total) * 100);
this.submit.value = `${progress}%`;
};
xhr.onload = () => {
this.submit.value = this.originalSubmitText;
};
}
}
xhr.onreadystatechange = () => {
@ -281,8 +346,10 @@ class postFormHandler {
}
//re-enable the submit button now, and set the submit button text back to original value
this.submit.disabled = false;
this.submit.value = this.originalSubmitText;
if (this.submit) {
this.submit.disabled = false;
this.submit.value = this.originalSubmitText;
}
//try and parse json from the response if there is a body
let json;
@ -389,7 +456,9 @@ class postFormHandler {
});
}
this.submit.value = this.originalSubmitText;
if (this.submit) {
this.submit.value = this.originalSubmitText;
}
}
};
@ -401,8 +470,10 @@ class postFormHandler {
'title': 'Error',
'message': 'Something broke'
});
this.submit.disabled = false;
this.submit.value = this.originalSubmitText;
if (this.submit) {
this.submit.disabled = false;
this.submit.value = this.originalSubmitText;
}
};
//open the request

@ -8,6 +8,9 @@ window.addEventListener('DOMContentLoaded', () => {
if (e) {
e.preventDefault();
}
if (!postForm) {
return;
}
history.replaceState({}, '', '#postform');
postForm.style.display = 'flex';
topPostButton.style.visibility = 'hidden';

@ -0,0 +1,8 @@
/* globals Web3 */
if (window.ethereum) {
window.jschanweb3 = new Web3(window.ethereum);
} else {
document.querySelectorAll('.web3')
.forEach(elem => elem.remove());
}

@ -426,6 +426,7 @@ async function custompages() {
early404Fraction: config.get.early404Fraction,
early404Replies: config.get.early404Replies,
meta: config.get.meta,
ethereumLinksURL: config.get.ethereumLinksURL,
archiveLinksURL: config.get.archiveLinksURL,
reverseImageLinksURL: config.get.reverseImageLinksURL,
enableWebring: config.get.enableWebring,
@ -439,6 +440,7 @@ async function custompages() {
yandexSiteKey: yandex ? yandex.siteKey : '',
globalAnnouncement: config.get.globalAnnouncement,
captchaOptions: config.get.captchaOptions,
enableWeb3: config.get.enableWeb3,
commit,
version,
globalLanguage: config.get.language,
@ -514,16 +516,15 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
fs.writeFileSync(`gulp/res/js/${templateName}.js`, compiledClient);
});
//symlink web3
await fs.symlink(__dirname+'/node_modules/web3/dist/web3.min.js', __dirname+'/gulp/res/js/web3.js', 'file')
.catch(e => { console.warn(e); });
//symlink socket.io file
fs.symlinkSync(__dirname+'/node_modules/socket.io/client-dist/socket.io.min.js', __dirname+'/gulp/res/js/socket.io.js', 'file');
await fs.symlink(__dirname+'/node_modules/socket.io/client-dist/socket.io.min.js', __dirname+'/gulp/res/js/socket.io.js', 'file')
.catch(e => { console.warn(e); });
} catch (e) {
//ignore EEXIST, probably the socket.io
if (e.code !== 'EEXIST') {
console.log(e);
}
console.log(e);
}
gulp.src([
@ -552,11 +553,19 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
`!${paths.scripts.src}/catalog.js`,
`!${paths.scripts.src}/time.js`,
`!${paths.scripts.src}/timezone.js`,
`!${paths.scripts.src}/renderweb3.js`,
])
.pipe(concat('all.js'))
.pipe(uglify({compress:true}))
.pipe(gulp.dest(paths.scripts.dest));
gulp.src([
`${paths.scripts.src}/web3.js`,
])
.pipe(concat('web3.js'))
// .pipe(uglify({compress:true})) //No need, we symlink from web3.min.js
.pipe(gulp.dest(paths.scripts.dest));
return gulp.src([
`${paths.scripts.src}/saveoverboard.js`,
`${paths.scripts.src}/hidefileinput.js`,
@ -567,6 +576,7 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
`${paths.scripts.src}/watchlist.js`,
`${paths.scripts.src}/catalog.js`,
`${paths.scripts.src}/time.js`,
`${paths.scripts.src}/renderweb3.js`,
])
.pipe(concat('render.js'))
.pipe(uglify({compress:true}))

@ -15,21 +15,23 @@ const { outputFile } = require('fs-extra')
, i18n = require(__dirname+'/../locale/locale.js')
, config = require(__dirname+'/../../lib/misc/config.js');
let { language, archiveLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement } = config.get
let { language, archiveLinksURL, ethereumLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get
, renderLocals = null;
const updateLocals = () => {
({ language, archiveLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement } = config.get);
({ language, archiveLinksURL, ethereumLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get);
renderLocals = {
Permissions,
cache: cacheTemplates,
ethereumLinksURL,
archiveLinksURL,
reverseImageLinksURL,
meta,
commit,
version,
enableWeb3,
defaultTheme: boardDefaults.theme,
defaultCodeTheme: boardDefaults.codeTheme,
postFilesSize: formatSize(globalLimits.postFilesSize.max),

@ -22,7 +22,7 @@ const { debugLogs } = require(__dirname+'/../../../configs/secrets.js')
});
}
, updateHandlers = () => {
const { globalLimits, filterFileNames, spaceFileNameReplacement } = require(__dirname+'/../../misc/config.js').get;
const { globalLimits, filterFileNames, spaceFileNameReplacement, uriDecodeFileNames } = require(__dirname+'/../../misc/config.js').get;
['flag', 'banner', 'asset', 'post'].forEach(fileType => {
const fileSizeLimit = globalLimits[`${fileType}FilesSize`];
const fileNumLimit = globalLimits[`${fileType}Files`];
@ -45,6 +45,7 @@ const { debugLogs } = require(__dirname+'/../../../configs/secrets.js')
safeFileNames: filterFileNames,
spaceFileNameReplacement,
preserveExtension: 4,
uriDecodeFileNames,
limits: {
totalSize: fileSizeLimit.max,
fileSize: fileSizeLimit.max,

@ -33,6 +33,10 @@ module.exports = (options) => {
processDateParam, processMessageLength, numberFields, numberArrays,
objectIdParams, objectIdFields, objectIdArrays } = options;
if (req.body && req.body.message) {
req.body.rawMessage = req.body.message;
}
/* check all body fields, body-parser prevents this array being too big, so no worry.
whitelist for fields that can be arrays, and convert singular of those fields to 1 length array */
const bodyFields = Object.keys(req.body || {});

@ -112,7 +112,7 @@ describe('paramconverter', () => {
out: { test: [ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa')] },
},
{
in: { options: { allowedArrays: ['test'],objectIdArrays: ['test'] }, body: { test: ['aaaaaaaaaaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaaaaaaaaaa'] } },
in: { options: { allowedArrays: ['test'], objectIdArrays: ['test'] }, body: { test: ['aaaaaaaaaaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaaaaaaaaaa'] } },
out: { test: [ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa'), ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa')] },
},
{
@ -121,16 +121,16 @@ describe('paramconverter', () => {
},
{
in: { options: { processMessageLength: true }, body: { message: 'asd' } },
out: { message: 'asd' },
out: { message: 'asd', rawMessage: 'asd' },
},
{
in: { options: { processMessageLength: true }, body: { message: 'asd\r\nasd\nasd' } },
out: { message: 'asd\r\nasd\nasd' },
out: { message: 'asd\r\nasd\nasd', rawMessage: 'asd\r\nasd\nasd' },
},
];
for(let i in bodyCases) {
test(`should output ${bodyCases[i].out} for an input of ${bodyCases[i].in}`, () => {
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') {
expect(() => {
@ -176,7 +176,7 @@ describe('paramconverter', () => {
];
for(let i in paramCases) {
test(`should output ${paramCases[i].out} for an input of ${paramCases[i].in}`, () => {
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') {
expect(() => {
@ -209,7 +209,7 @@ describe('paramconverter', () => {
];
for(let i in dateCases) {
test(`should output ${dateCases[i].out} for an input of ${dateCases[i].in}`, () => {
test(`${i} should output ${dateCases[i].out} for an input of ${dateCases[i].in}`, () => {
const converter = paramConverter(dateCases[i].in.options);
const locals = {locals:{}};
converter({ params: dateCases[i].in.params }, locals, noop);

@ -0,0 +1,15 @@
'use strict';
//NOTE: unused (for now)
const { Web3 } = require('web3')
, config = require(__dirname+'/../misc/config.js')
, { addCallback } = require(__dirname+'/../redis/redis.js')
, web3 = new Web3(config.get.ethereumNode);
const updateWeb3Provider = () => {
web3.setProvider(config.get.ethereumNode);
};
addCallback('config', updateWeb3Provider);
module.exports = web3;

@ -691,6 +691,7 @@
"Invalid account username": "Invalid account username",
"Invalid actions selected": "Invalid actions selected",
"Invalid archive links URL format, must be a link containing %s where the url param belongs.": "Invalid archive links URL format, must be a link containing %s where the url param belongs.",
"Invalid ethereum links URL format, must be a link containing %s where the url param belongs.": "Invalid ethereum links URL format, must be a link containing %s where the url param belongs.",
"Invalid assets selected": "Invalid assets selected",
"Invalid ban action": "Invalid ban action",
"Invalid ban duration": "Invalid ban duration",
@ -1410,5 +1411,10 @@
"Total number of filters would exceed global limit of %s": "Total number of filters would exceed global limit of %s",
"Filters FAQ": "Filters FAQ",
"e.g. Rule 1: No ad spam": "e.g. Rule 1: No ad spam",
"e.g. 3d": "e.g. 3d"
"e.g. 3d": "e.g. 3d",
"Username must not be an ethereum address": "Username must not be an ethereum address",
"Login timed out": "Login timed out",
"Missing nonce": "Missing nonce",
"Missing signature": "Missing signature",
"Invalid login signature": "Invalid login signature"
}

@ -690,6 +690,7 @@
"Invalid account username": "Nome utente dell'account non valido",
"Invalid actions selected": "Azioni selezionate non valide",
"Invalid archive links URL format, must be a link containing %s where the url param belongs.": "Formato URL dei link di archivio non valido, deve essere un link contenente %s dove il parametro url appartiene.",
"Invalid ethereum links URL format, must be a link containing %s where the url param belongs.": "Formato URL dei link di Ethereum non valido, deve essere un link contenente %s dove il parametro url appartiene.",
"Invalid assets selected": "Asset selezionati non validi",
"Invalid ban action": "Azione di ban non valida",
"Invalid ban duration": "Durata di ban non valida",
@ -1409,5 +1410,10 @@
"Total number of filters would exceed global limit of %s": "Total number of filters would exceed global limit of %s",
"Filters FAQ": "Filters FAQ",
"e.g. Rule 1: No ad spam": "e.g. Rule 1: No ad spam",
"e.g. 3d": "e.g. 3d"
"e.g. 3d": "e.g. 3d",
"Username must not be an ethereum address": "Username must not be an ethereum address",
"Login timed out": "Login timed out",
"Missing nonce": "Missing nonce",
"Missing signature": "Missing signature",
"Invalid login signature": "Invalid login signature"
}

@ -51,7 +51,7 @@
"other": "%s meses desde agora"
},
"%s replies": {
"one": "%s reposta",
"one": "%s resposta",
"other": "%s respostas"
},
"%s UIDs": {
@ -691,6 +691,7 @@
"Invalid account username": "Nome de usuário inválido",
"Invalid actions selected": "Ações inválidas selecionadas",
"Invalid archive links URL format, must be a link containing %s where the url param belongs.": "Formato de link de arquivo inválido, o link tem que conter %s onde o parâmetro de url está.",
"Invalid ethereum links URL format, must be a link containing %s where the url param belongs.": "Formato de link de Ethereum inválido, o link tem que conter %s onde o parâmetro de url está.",
"Invalid assets selected": "Recursos inválidos selecionados",
"Invalid ban action": "Ação de ban inválida",
"Invalid ban duration": "Duração de ban inválida",
@ -1410,5 +1411,10 @@
"Total number of filters would exceed global limit of %s": "Número total de filtros excede limite global de %s",
"Filters FAQ": "FAQ Filtros",
"e.g. Rule 1: No ad spam": "e.g. Regra 1: Proibido spam",
"e.g. 3d": "e.g. 3d"
"e.g. 3d": "e.g. 3d",
"Username must not be an ethereum address": "Username must not be an ethereum address",
"Login timed out": "Login timed out",
"Missing nonce": "Missing nonce",
"Missing signature": "Missing signature",
"Invalid login signature": "Invalid login signature"
}

@ -51,7 +51,7 @@
"other": "%s meses desde agora"
},
"%s replies": {
"one": "%s reposta",
"one": "%s resposta",
"other": "%s respostas"
},
"%s UIDs": {
@ -691,6 +691,7 @@
"Invalid account username": "Username inválido",
"Invalid actions selected": "Ações inválidas selecionadas",
"Invalid archive links URL format, must be a link containing %s where the url param belongs.": "Formato de link de arquivo inválido, o link tem que conter %s onde o parâmetro de url está.",
"Invalid ethereum links URL format, must be a link containing %s where the url param belongs.": "Formato de link de Ethereum inválido, o link tem que conter %s onde o parâmetro de url está.",
"Invalid assets selected": "Ativos inválidos selecionados",
"Invalid ban action": "Ação de ban inválida",
"Invalid ban duration": "Duração de ban inválida",
@ -1410,5 +1411,10 @@
"Total number of filters would exceed global limit of %s": "Número total de filtros excede limite global de %s",
"Filters FAQ": "FAQ Filtros",
"e.g. Rule 1: No ad spam": "e.g. Regra 1: Proibido spam",
"e.g. 3d": "e.g. 3d"
"e.g. 3d": "e.g. 3d",
"Username must not be an ethereum address": "Username must not be an ethereum address",
"Login timed out": "Login timed out",
"Missing nonce": "Missing nonce",
"Missing signature": "Missing signature",
"Invalid login signature": "Invalid login signature"
}

@ -743,6 +743,7 @@
"Invalid account username": "Неверное имя пользователя учетной записи",
"Invalid actions selected": "Выбранные недопустимые действия",
"Invalid archive links URL format, must be a link containing %s where the url param belongs.": "Iнедопустимый формат URL ссылок на архив, должна быть ссылка, содержащая %s, к которому относится параметр url.",
"Invalid ethereum links URL format, must be a link containing %s where the url param belongs.": "Iнедопустимый формат URL ссылок на Ethereum, должна быть ссылка, содержащая %s, к которому относится параметр url.",
"Invalid assets selected": "Выбраны недопустимые активы",
"Invalid ban action": "Недопустимое действие запрета",
"Invalid ban duration": "Недопустимая продолжительность запрета",
@ -1484,5 +1485,10 @@
"Total number of filters would exceed global limit of %s": "Общее количество фильтров превысило бы глобальный предел в %s",
"Filters FAQ": "Часто задаваемые вопросы по фильтрам",
"e.g. Rule 1: No ad spam": "например, отсутствие спама",
"e.g. 3d": "например, 3d"
"e.g. 3d": "например, 3d",
"Username must not be an ethereum address": "Username must not be an ethereum address",
"Login timed out": "Login timed out",
"Missing nonce": "Missing nonce",
"Missing signature": "Missing signature",
"Invalid login signature": "Invalid login signature"
}

@ -0,0 +1,15 @@
'use strict';
module.exports = async(db, redis) => {
console.log('Updating globalsettings to add uriDecodeFileNames');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
'uriDecodeFileNames': false,
},
});
console.log('Clearing globalsettings cache');
await redis.deletePattern('globalsettings');
};

@ -0,0 +1,25 @@
'use strict';
module.exports = async(db, redis) => {
console.log('Updating globalsettings to add web3 settings');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
'enableWeb3': false,
'ethereumLinksURL': 'https://etherscan.io/address/%s',
},
});
console.log('Updating boards to add web3 settings');
await db.collection('boards').updateMany({}, {
'$set': {
'enableWeb3': false,
},
});
console.log('Clearing globalsettings cache');
await redis.deletePattern('globalsettings');
console.log('Clearing boards cache');
await redis.deletePattern('board:*');
};

@ -31,6 +31,7 @@ const { Boards, Posts } = require(__dirname+'/../../db/')
'allowedFileTypes.image': ['board', 'threads', 'catalog'],
'enableTegaki': ['board', 'threads', 'catalog'],
'hideBanners': ['board', 'threads', 'catalog'],
'enableWeb3': ['board', 'threads', 'catalog'],
});
module.exports = async (req, res) => {
@ -73,6 +74,7 @@ module.exports = async (req, res) => {
'geoFlags': booleanSetting(req.body.geo_flags),
'customFlags': booleanSetting(req.body.custom_flags),
'enableTegaki': booleanSetting(req.body.enable_tegaki),
'enableWeb3': booleanSetting(req.body.enable_web3),
'forceAnon': booleanSetting(req.body.force_anon),
'sageOnlyEmail': booleanSetting(req.body.sage_only_email),
'userPostDelete': booleanSetting(req.body.user_post_delete),

@ -19,8 +19,10 @@ const { Boards } = require(__dirname+'/../../db/')
'meta.siteName': ['deletehtml', 'scripts', 'custompages'],
'meta.url': ['deletehtml', 'scripts', 'custompages'],
'archiveLinksURL': ['deletehtml', 'custompages'],
'ethereumLinksURL': ['deletehtml', 'custompages'],
'reverseImageLinksURL': ['deletehtml', 'custompages'],
'enableWebring': ['deletehtml', 'custompages'],
'enableWeb3': ['deletehtml'],
'thumbSize': ['deletehtml', 'css', 'scripts'],
'previewReplies': ['deletehtml', 'custompages'],
'stickyPreviewReplies': ['deletehtml', 'custompages'],
@ -129,18 +131,21 @@ module.exports = async (req, res) => {
allowCustomOverboard: booleanSetting(req.body.allow_custom_overboard, oldSettings.allowCustomOverboard),
archiveLinksURL: trimSetting(req.body.archive_links, oldSettings.archiveLinksURL),
reverseImageLinksURL: trimSetting(req.body.reverse_links, oldSettings.reverseImageLinksURL),
ethereumLinksURL: trimSetting(req.body.ethereum_links, oldSettings.ethereumLinksURL),
cacheTemplates: booleanSetting(req.body.cache_templates, oldSettings.cacheTemplates),
lockWait: numberSetting(req.body.lock_wait, oldSettings.lockWait),
pruneModlogs: numberSetting(req.body.prune_modlogs, oldSettings.pruneModlogs),
dontStoreRawIps: booleanSetting(req.body.dont_store_raw_ips, oldSettings.dontStoreRawIps),
pruneIps: numberSetting(req.body.prune_ips, oldSettings.pruneIps),
enableWebring: booleanSetting(req.body.enable_webring, oldSettings.enableWebring),
enableWeb3: booleanSetting(req.body.enable_web3, oldSettings.enableWeb3),
// ethereumNode: trimSetting(req.body.ethereum_node, oldSettings.ethereumNode),
following: arraySetting(req.body.webring_following, oldSettings.following),
blacklist: arraySetting(req.body.webring_blacklist, oldSettings.blacklist),
logo: arraySetting(req.body.webring_logos, oldSettings.logo),
proxy: {
enabled: booleanSetting(req.body.webring_proxy_enabled, oldSettings.proxy.enabled),
address: trimSetting(req.body.webring_proxy_address, oldSettings.proxy.address),
enabled: booleanSetting(req.body.proxy_enabled, oldSettings.proxy.enabled),
address: trimSetting(req.body.proxy_address, oldSettings.proxy.address),
},
thumbExtension: trimSetting(req.body.thumb_extension, oldSettings.thumbExtension),
highlightOptions: {
@ -192,6 +197,7 @@ module.exports = async (req, res) => {
maxRecentNews: numberSetting(req.body.max_recent_news, oldSettings.maxRecentNews),
filterFileNames: booleanSetting(req.body.filter_file_names, oldSettings.filterFileNames),
spaceFileNameReplacement: req.body.space_file_name_replacement,
uriDecodeFileNames: booleanSetting(req.body.uri_decode_file_names, oldSettings.uriDecodeFileNames),
globalLimits: {
customCss: {
enabled: booleanSetting(req.body.global_limits_custom_css_enabled, oldSettings.globalLimits.customCss.enabled),

@ -16,8 +16,8 @@ module.exports = async (req, res) => {
//fetch an account
const account = await Accounts.findOne(username);
//if the account doesnt exist, reject
if (!account) {
//if the account doesnt exist (or is web3 where password change would be impossible), reject
if (!account || account.web3 == true) {
return dynamicResponse(req, res, 403, 'message', {
'title': __('Forbidden'),
'message': __('Incorrect account credentials'),

@ -8,8 +8,15 @@ const bcrypt = require('bcrypt')
module.exports = async (req, res) => {
const { __ } = res.locals;
const username = req.body.username.toLowerCase();
const password = req.body.password;
let username, password;
if (res.locals.isWeb3) {
username = req.body.address.toLowerCase();
password = null;
} else {
username = req.body.username.toLowerCase();
password = req.body.password;
}
let goto = req.body.goto;
// we don't want to redirect the user to random sites
if (goto == null || !/^\/[0-9a-zA-Z][0-9a-zA-Z._/-]*$/.test(goto)) {
@ -29,23 +36,23 @@ module.exports = async (req, res) => {
});
}
// bcrypt compare input to saved hash
const passwordMatch = await bcrypt.compare(password, account.passwordHash);
//2fA (TOTP) validation
const delta = await doTwoFactor(username, account.twofactor, req.body.twofactor || '');
//if password was correct and 2fa valid (if enabled)
if (passwordMatch === false
|| (account.twofactor && delta === null)) {
return dynamicResponse(req, res, 403, 'message', {
'title': __('Forbidden'),
'message': __('Incorrect login credentials'),
'redirect': failRedirect
});
if (!account.web3 || account.passwordHash) {
// bcrypt compare input to saved hash
const passwordMatch = await bcrypt.compare(password, account.passwordHash);
//2fA (TOTP) validation
const delta = await doTwoFactor(username, account.twofactor, req.body.twofactor || '');
//if password was correct and 2fa valid (if enabled)
if (passwordMatch === false
|| (account.twofactor && delta === null)) {
return dynamicResponse(req, res, 403, 'message', {
'title': __('Forbidden'),
'message': __('Incorrect login credentials'),
'redirect': failRedirect
});
}
}
// add the account to the session and authenticate if password was correct
// add the account to the session and authenticate
req.session.user = account._id;
//successful login

@ -330,7 +330,7 @@ module.exports = async (req, res) => {
processedFile.hasThumb = audioThumbnails;
processedFile.geometry = { thumbwidth: thumbSize, thumbheight: thumbSize };
await saveFull();
if (!existsThumb) {
if (processedFile.hasThumb && !existsThumb) {
await audioThumbnail(processedFile);
}
}
@ -425,6 +425,14 @@ module.exports = async (req, res) => {
const nomarkup = prepareMarkdown(req.body.message, true);
const { message, quotes, crossquotes } = await messageHandler(nomarkup, req.params.board, req.body.thread, res.locals.permissions);
//web3 sig
let signature = null
, address = null;
if (res.locals.recoveredAddress) {
signature = req.body.signature;
address = res.locals.recoveredAddress;
}
//build post data for db. for some reason all the property names are lower case :^)
const now = Date.now();
const data = {
@ -443,6 +451,8 @@ module.exports = async (req, res) => {
password,
email,
spoiler,
signature,
address,
'banmessage': null,
userId,
'ip': res.locals.ip,

@ -7,9 +7,16 @@ const { Accounts } = require(__dirname+'/../../db/')
module.exports = async (req, res) => {
const { __ } = res.locals;
const original = req.body.username; //stored but not used yet
const username = original.toLowerCase(); //lowercase to prevent duplicates with mixed case
const password = req.body.password;
let original, username, password;
if (res.locals.isWeb3) {
original = req.body.address;
username = req.body.address.toLowerCase();
password = null;
} else {
original = req.body.username;
username = original.toLowerCase(); //lowercase to prevent duplicates with mixed case
password = req.body.password;
}
const account = await Accounts.findOne(username);
@ -22,8 +29,17 @@ module.exports = async (req, res) => {
});
}
// add account to db. password is hashed in db model func for easier tests
await Accounts.insertOne(original, username, password, roleManager.roles.ANON);
await Accounts.insertOne(original, username, password, roleManager.roles.ANON, res.locals.isWeb3);
if (res.locals.isWeb3) {
req.session.user = username;
await Accounts.updateLastActiveDate(username);
let goto = req.body.goto;
if (goto == null || !/^\/[0-9a-zA-Z][0-9a-zA-Z._/-]*$/.test(goto)) {
goto = '/account.html';
}
return res.redirect(goto);
}
return res.redirect('/login.html');

@ -18,6 +18,7 @@ module.exports = {
globalSettings: require(__dirname+'/globalsettings.js'),
customPage: require(__dirname+'/custompage.js'),
csrfPage: require(__dirname+'/csrf.js'),
noncePage: require(__dirname+'/nonce.js'),
randombanner: require(__dirname+'/randombanner.js'),
news: require(__dirname+'/news.js'),
captchaPage: require(__dirname+'/captchapage.js'),

@ -0,0 +1,16 @@
'use strict';
const { randomBytes } = require('crypto')
, cache = require(__dirname+'/../../lib/redis/redis.js');
module.exports = async (req, res) => {
const address = req.params.address;
const newNonce = (await randomBytes(32)).toString('base64');
await cache.set(`nonce:${address}:${newNonce}`, 1, 60);
res.json({
nonce: newNonce,
});
};

2533
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,11 +1,11 @@
{
"name": "jschan",
"version": "1.2.1",
"migrateVersion": "1.2.0",
"version": "1.3.0",
"migrateVersion": "1.3.0",
"description": "",
"main": "server.js",
"dependencies": {
"@fatchan/express-fileupload": "^1.4.2",
"@fatchan/express-fileupload": "^1.4.3",
"@fatchan/gm": "^1.3.2",
"@socket.io/redis-adapter": "^7.2.0",
"bcrypt": "^5.1.0",
@ -22,7 +22,6 @@
"file-type": "^16.5.4",
"fluent-ffmpeg": "^2.1.2",
"form-data": "^4.0.0",
"fs": "0.0.1-security",
"fs-extra": "^10.1.0",
"gulp": "^4.0.2",
"gulp-clean-css": "^4.3.0",
@ -42,22 +41,23 @@
"mongodb": "^4.16.0",
"node-fetch": "^2.6.12",
"otpauth": "^9.1.3",
"path": "^0.12.7",
"pm2": "^5.3.0",
"pug": "^3.0.2",
"pug-runtime": "^3.0.1",
"qrcode": "^1.5.3",
"redlock": "^4.2.0",
"sanitize-html": "^2.11.0",
"saslprep": "^1.0.3",
"semver": "^7.5.3",
"semver": "^7.5.4",
"socket.io": "^4.7.1",
"socks-proxy-agent": "^6.2.1",
"uid-safe": "^2.1.5",
"unix-crypt-td-js": "^1.1.4"
"unix-crypt-td-js": "^1.1.4",
"web3": "^4.1.0",
"web3-eth-accounts": "^4.0.4",
"web3-utils": "^4.0.4"
},
"devDependencies": {
"eslint": "^8.43.0",
"eslint": "^8.45.0",
"eslint-plugin-jest": "^26.9.0",
"jest": "^27.5.1",
"jest-cli": "^27.5.1",

@ -68,7 +68,7 @@ const config = require(__dirname+'/lib/misc/config.js')
const loadAppLocals = () => {
const { language, cacheTemplates, boardDefaults, globalLimits, captchaOptions, archiveLinksURL,
reverseImageLinksURL, meta, enableWebring, globalAnnouncement } = config.get;
reverseImageLinksURL, meta, enableWebring, globalAnnouncement, enableWeb3, ethereumLinksURL } = config.get;
//cache loaded templates
app.cache = {};
app[cacheTemplates === true ? 'enable' : 'disable']('view cache');
@ -77,9 +77,11 @@ const config = require(__dirname+'/lib/misc/config.js')
app.locals.defaultTheme = boardDefaults.theme;
app.locals.defaultCodeTheme = boardDefaults.codeTheme;
app.locals.globalLimits = globalLimits;
app.locals.ethereumLinksURL = ethereumLinksURL;
app.locals.archiveLinksURL = archiveLinksURL;
app.locals.reverseImageLinksURL = reverseImageLinksURL;
app.locals.enableWebring = enableWebring;
app.locals.enableWeb3 = enableWeb3;
app.locals.commit = commit;
app.locals.version = version;
app.locals.meta = meta;

@ -235,7 +235,7 @@ module.exports = () => describe('login and create test board', () => {
global_limits_asset_files_size_max: '10485760',
global_limits_asset_files_max: '10',
global_limits_asset_files_total: '50',
webring_proxy_address: '',
proxy_address: '',
webring_following: '',
webring_logos: '',
webring_blacklist: '',
@ -268,6 +268,7 @@ module.exports = () => describe('login and create test board', () => {
board_defaults_allowed_file_types_image: 'true',
board_defaults_allowed_file_types_animated_image: 'true',
board_defaults_allowed_file_types_audio: 'true',
ethereum_links: 'https://example.com/%s',
});
const response = await fetch('http://localhost/forms/global/settings', {
headers: {

@ -2,5 +2,5 @@ section.row
.label
span #{__('Captcha')}
span.required *
.col
.col.p-0
include ./captcha.pug

@ -39,6 +39,10 @@ script(src=`/js/lang/${pageLanguage}.js?v=${commit}`)
//- main script
script(src=`/js/all.js?v=${commit}&ct=${captchaOptions.type}`)
//- optional web3 script
if enableWeb3 || isBoard && board.settings.enableWeb3
script(src=`/js/web3.js?v=${commit}`)
//- additional scripts included only if hcaptcha, recaptcha, or yandex smartcaptcha are used
if captchaOptions.type === 'google'
script(src='https://www.google.com/recaptcha/api.js' async defer)

@ -83,6 +83,11 @@ section.form-wrapper.flex-center
each flag in boardFlags
option(value=flag[0] data-src=`/flag/${board._id}/${flag[1]}`) #{flag[0]}
img.jsonly#selected-flag
if enableWeb3 === true && board.settings.enableWeb3 === true
section.row.jsonly.web3
.label #{__('Web3')}
input.dummy-link.web3-sign(type='button' value=__('Sign') disabled=true)
textarea.ml-1.no-resize(name='signature', rows='1', autocomplete='off' readonly)
if ((board.settings.captchaMode === 1 && !isThread) || board.settings.captchaMode === 2) && !modview
if captchaOptions.type === 'text'
include ./captchasidelabel.pug

@ -0,0 +1,16 @@
.form-wrapper.flex-center
form.form-post(action='/forms/register' method='POST' data-captcha-preload='true')
.row
.label #{__('Username')}
input(type='text', name='username', maxlength='50' pattern='[a-zA-Z0-9]+' required title=__('alphanumeric only'))
.row
.label #{__('Password')}
input(type='password', name='password', maxlength='100' required)
.row
.label #{__('Confirm Password')}
input(type='password', name='passwordconfirm', maxlength='100' required)
if captchaOptions.type === 'text'
include ./captchasidelabel.pug
else
include ./captchafieldrow.pug
input(type='submit', value=__('Register'))

@ -0,0 +1,2 @@
include ../mixins/web3signature.pug
+web3signature(signature, address)

@ -14,6 +14,7 @@ mixin catalogtile(post, index, overboard=false)
data-tripcode=post.tripcode
data-subject=post.subject
data-filter=((post.subject||'')+(post.nomarkup||'')).toLowerCase()
data-flag=post.country && post.country.code
data-date=post.date
data-replies=post.replyposts
data-bump=post.bumped)
@ -42,6 +43,8 @@ mixin catalogtile(post, index, overboard=false)
option(value='single') #{__('Hide')}
if post.subject
option(value='fsub') #{__('Filter Subject')}
if post.country && post.country.code
option(value='fflag') #{__('Filter Flag')}
if (modview || manage || globalmanage)
option(value='edit') #{__('Edit')}
if !overboard

@ -1,5 +1,5 @@
mixin filters(filterArr)
- const filterTypeMap = { single: __('Single'), fid: __('ID'), fname: __('Name'), ftrip: __('Tripcode'), fnamer: __('Name'), ftripr: __('Tripcode'), fsub: __('Subject'), fsubr: __('Subject'), fmsg: __('Message'), fmsgr: __('Message') }
- const filterTypeMap = { single: __('Single'), fid: __('ID'), fname: __('Name'), ftrip: __('Tripcode'), fnamer: __('Name'), ftripr: __('Tripcode'), fsub: __('Subject'), fsubr: __('Subject'), fmsg: __('Message'), fmsgr: __('Message'), fflag: __('Flag'), fflagr: __('Flag') }
if filterArr.length > 0
each filter in filterArr
tr

@ -192,6 +192,7 @@ mixin modal(data)
option(value='ftrip') #{__('Tripcode')}
option(value='fsub') #{__('Subject')}
option(value='fmsg') #{__('Message')}
option(value='fflag') #{__('Flag')}
td
input#filter-value-input(required type='text' name='value')
td

@ -1,5 +1,6 @@
include ./report.pug
include ./banmessage.pug
include ./web3signature.pug
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'}`
@ -9,7 +10,8 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboar
data-name=post.name
data-tripcode=post.tripcode
data-subject=post.subject
data-email=post.email)
data-email=post.email
data-flag=post.country && post.country.code)
- const postURL = `/${post.board}/${(modview || manage || globalmanage) ? 'manage/' : ''}thread/${post.thread || post.postId}.html`;
.post-info
span
@ -69,10 +71,12 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboar
option(value='fid') #{__('Filter ID')}
if post.name
option(value='fname') #{__('Filter Name')}
if post.subject
option(value='fsub') #{__('Filter Subject')}
if post.tripcode
option(value='ftrip') #{__('Filter Tripcode')}
if post.subject
option(value='fsub') #{__('Filter Subject')}
if post.country && post.country.code
option(value='fflag') #{__('Filter Flag')}
if !overboard && !ban
option(value='moderate') #{__('Moderate')}
if !ban && (modview || manage || globalmanage)
@ -162,6 +166,8 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboar
| #{lastEdited[1]}
if post.banmessage
+banmessage(post.banmessage)
if post.signature && post.address
+web3signature(post.signature, post.address)
if truncatedMessage !== post.message
div.cb.mt-5.ml-5
| #{__('Message too long.')} #[a.viewfulltext(href=`${postURL}#${post.postId}`) #{__('View the full text')}]

@ -0,0 +1,5 @@
mixin web3signature(signature, address)
a.web3-address(href=ethereumLinksURL.replace('%s', encodeURIComponent(address)) rel='nofollow' referrerpolicy='same-origin' target='_blank') #{address.substring(0,5)}…#{address.substring(38, 42)}
details.dummy-link
summary #{__('Signature')}
.web3-signature #{signature}

@ -10,7 +10,7 @@ block content
hr(size=1)
h4.no-m-p #{__('General')}:
ul
if !user.twofactor
if !user.twofactor && !user.web3
li.bold: a(href='/twofactor.html') #{__('Setup 2FA (TOTP)')}
if permissions.get(Permissions.CREATE_BOARD)
li: a(href='/create.html') #{__('Create a board')}

@ -54,11 +54,13 @@ block content
li
a(href='#tab-7') #{__('Webring')}
li
a(href='#tab-8') #{__('Files & Thumbnails')}
a(href='#tab-8') #{__('Web3')}
li
a(href='#tab-9') #{__('Frontend Script Defaults')}
a(href='#tab-9') #{__('Files & Thumbnails')}
li
a(href='#tab-10') #{__('Board Defaults')}
a(href='#tab-10') #{__('Frontend Script Defaults')}
li
a(href='#tab-11') #{__('Board Defaults')}
.box-wrap
.tab.tab-1
.col
@ -170,6 +172,13 @@ block content
.row
.label #{__('Prune IPs Days')}
input(type='number', name='prune_ips' value=settings.pruneIps)
.row
.label #{__('Use Socks Proxy')}
label.postform-style.ph-5
input(type='checkbox', name='proxy_enabled', value='true' placeholder='socks5h://127.0.0.1:9050' checked=settings.proxy.enabled)
.row
.label #{__('Proxy Address')}
input(type='text', name='proxy_address', value=settings.proxy.address)
.tab.tab-3
.col
@ -508,13 +517,6 @@ block content
.label #{__('Enable')}
label.postform-style.ph-5
input(type='checkbox', name='enable_webring' value='true' checked=settings.enableWebring)
.row
.label #{__('Use Socks Proxy')}
label.postform-style.ph-5
input(type='checkbox', name='webring_proxy_enabled', value='true' placeholder='socks5h://127.0.0.1:9050' checked=settings.proxy.enabled)
.row
.label #{__('Proxy Address')}
input(type='text', name='webring_proxy_address', value=settings.proxy.address)
.row
.label #{__('Following')}
textarea(name='webring_following' placeholder=__('Newline separated')) #{settings.following.join('\n')}
@ -526,6 +528,31 @@ block content
textarea(name='webring_blacklist' placeholder=__('Newline separated')) #{settings.blacklist.join('\n')}
.tab.tab-8
.col
.row
.label #{__('Enable')}
label.postform-style.ph-5
input(type='checkbox', name='enable_web3' value='true' checked=settings.enableWeb3)
.row
.label
| #{__('Ethereum Links URL')}
|
small
| (
a(href='/faq.html#archive-reverse-url-format') ?
| )
input(type='text', name='ethereum_links', value=settings.ethereumLinksURL)
//- .row
.label
| #{__('Ethereum Node')}
|
small
| (
a(rel='nofollow' referrerpolicy='same-origin' target='_blank' href='https://ethereumnodes.com/') #{__('Nodes')}
| )
input(type='text' name='ethereum_node' value=settings.ethereumNode)
.tab.tab-9
.col
.row
.label #{__('Animated .gif Thumbnails')}
@ -559,6 +586,10 @@ block content
.row
.label #{__('Space File Name Replacement')}
input(type='text', name='space_file_name_replacement', value=settings.spaceFileNameReplacement)
.row
.label #{__('URI Decode File Names')}
label.postform-style.ph-5
input(type='checkbox', name='uri_decode_file_names', value='true' checked=settings.uriDecodeFileNames)
.row
.label #{__('Thumbnail File Extension')}
input(type='text' name='thumb_extension' value=settings.thumbExtension)
@ -569,7 +600,7 @@ block content
.label #{__('Video Max Resolution')}
input(type='text' name='global_limits_post_files_size_video_resolution' value=settings.globalLimits.postFilesSize.videoResolution)
.tab.tab-9
.tab.tab-10
.col
.row
.label #{__('Embeds Enabled')}
@ -666,7 +697,7 @@ block content
.label #{__('Tegaki Height')}
input(type='number', name='frontend_script_default_tegaki_height', value=settings.frontendScriptDefault.tegakiHeight)
.tab.tab-10
.tab.tab-11
.col
.row
.label #{__('SFW')}
@ -774,6 +805,10 @@ block content
.label #{__('Enable Tegaki')}
label.postform-style.ph-5
input(type='checkbox', name='board_defaults_enable_tegaki', value='true' checked=settings.boardDefaults.enableTegaki)
.row
.label #{__('Enable Web3')}
label.postform-style.ph-5
input(type='checkbox', name='board_defaults_enable_web3', value='true' checked=settings.boardDefaults.enableWeb3)
.row
.label #{__('User Post Deletion')}
label.postform-style.ph-5

@ -18,6 +18,18 @@ block content
.label #{__('2FA Code')}
input(type='number' name='twofactor' placeholder=__('if enabled'))
input(type='submit', value=__('Login'))
if enableWeb3 === true
.jsonly.web3.mv-10
.text-center OR
.form-wrapper.flex-center.mv-10
form.form-post(action='/forms/login' method='POST' data-captcha-preload='true')
input(type='hidden' name='nonce')
input(type='hidden' name='address')
input(type='hidden' name='signature')
.postform-style.dummy-link.web3-login.ct-r1.ph-5
img(src='/file/metamask-fox.svg' width=24 height=24)
| #{__('Login with MetaMask')}
.text-center.mt-10
p: a(href='/register.html') #{__('Register')}
p: a(href='/changepassword.html') #{__('Change Password')}

@ -118,6 +118,10 @@ block content
.label #{__('Enable Tegaki')}
label.postform-style.ph-5
input(type='checkbox', name='enable_tegaki', value='true' checked=board.settings.enableTegaki)
.row
.label #{__('Enable Web3')}
label.postform-style.ph-5
input(type='checkbox', name='enable_web3', value='true' checked=board.settings.enableWeb3)
.row
.label #{__('SFW')}
label.postform-style.ph-5

@ -5,21 +5,37 @@ block head
block content
h1.board-title #{__('Register')}
.form-wrapper.flex-center.mv-10
form.form-post(action='/forms/register' method='POST' data-captcha-preload='true')
.row
.label #{__('Username')}
input(type='text', name='username', maxlength='50' pattern='[a-zA-Z0-9]+' required title=__('alphanumeric only'))
.row
.label #{__('Password')}
input(type='password', name='password', maxlength='100' required)
.row
.label #{__('Confirm Password')}
input(type='password', name='passwordconfirm', maxlength='100' required)
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else
include ../includes/captchafieldrow.pug
input(type='submit', value=__('Register'))
.mv-10
if enableWeb3 === true
.sm#tab-1
.sm#tab-2
.tabbed-area.flexcenter
ul.tabs.group.web3
li
a(href='#tab-1') #{__('Username')}
li.jsonly
a(href='#tab-2') #{__('MetaMask')}
.box-wrap.mv-0.wm
.tab.tab-1
.form-wrapper.flex-center
include ../includes/registration.pug
.tab.tab-2.jsonly.web3
.form-wrapper.flex-center
form.form-post(action='/forms/register' method='POST' data-captcha-preload='true')
input(type='hidden' name='nonce')
input(type='hidden' name='address')
input(type='hidden' name='signature')
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else
include ../includes/captchafieldrow.pug
.postform-style.dummy-link.web3-login.ct-r1.ph-5
img(src='/file/metamask-fox.svg' width=24 height=24)
| #{__('Register with MetaMask')}
else
.form-wrapper.flex-center.mv-10
include ../includes/registration.pug
.text-center.mt-10
p: a(href='/login.html') #{__('Login')}
p: a(href='/changepassword.html') #{__('Change Password')}

Loading…
Cancel
Save