diff --git a/WHATSNEW.md b/WHATSNEW.md index f85f3a88..3472426c 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -13,6 +13,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * New users now have randomly generated avatars assigned to them that can be served up via the new System General [Web Handler](/docs/_docs/servers/contentservers/web-handlers.md). * CombatNet has shut down, so the module (`combatnet.js`) has been removed. * New `NewUserPrePersist` system event available to developers to 'hook' account creation and add their own properties/etc. +* The signature for `viewValidationListener`'s callback has changed: It is now `(err, newFocusId)`. To ignore a validation error, implementors can simply call the callback with a `null` error, else they should forward it on. ## 0.0.13-beta * **Note for contributors**: ENiGMA has switched to [Prettier](https://prettier.io) for formatting/style. Please see [CONTRIBUTING](CONTRIBUTING.md) and the Prettier website for more information. diff --git a/art/themes/luciano_blocktronics/activitypub_user_config_main.ans b/art/themes/luciano_blocktronics/activitypub_user_config_main.ans index a0d31ac3..bd591ead 100644 Binary files a/art/themes/luciano_blocktronics/activitypub_user_config_main.ans and b/art/themes/luciano_blocktronics/activitypub_user_config_main.ans differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 731a83ee..361f7c3e 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -400,6 +400,7 @@ TL1: { width: 19, textOverflow: "..." } ET2: { width: 19, textOverflow: "..." } ET3: { width: 19, textOverflow: "..." } + ET4: { width: 21, textOverflow: "..." } } } 1: { @@ -491,15 +492,15 @@ width: 10 } TL5: { - width: 4 + width: 4 } TL6: { - width: 4 + width: 4 } MT7: { focus: true width: 69 - height: 3 + height: 3 mode: preview } BT8: { @@ -773,6 +774,7 @@ TL1: { width: 19, textOverflow: "..." } ET2: { width: 19, textOverflow: "..." } ET3: { width: 19, textOverflow: "..." } + ET4: { width: 21, textOverflow: "..." } //TL4: { width: 25 } } } diff --git a/core/activitypub/activity.js b/core/activitypub/activity.js index 07a25e14..d5db4112 100644 --- a/core/activitypub/activity.js +++ b/core/activitypub/activity.js @@ -94,6 +94,7 @@ module.exports = class Activity extends ActivityPubObject { return postJson(actorUrl, activityJson, reqOpts, cb); } + // :TODO: we need dp/support a bit more here... recipientIds() { const ids = []; diff --git a/core/bbs_list.js b/core/bbs_list.js index f87349fd..b4e45994 100644 --- a/core/bbs_list.js +++ b/core/bbs_list.js @@ -80,13 +80,13 @@ exports.getModule = class BBSListModule extends MenuModule { const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error); if (errMsgView) { if (err) { - errMsgView.setText(err.message); + errMsgView.setText(err.friendlyText); } else { errMsgView.clearText(); } } - return cb(null); + return cb(err, null); }, // @@ -119,7 +119,7 @@ exports.getModule = class BBSListModule extends MenuModule { } self.database.run( - `DELETE FROM bbs_list + `DELETE FROM bbs_list WHERE id=?;`, [entry.id], err => { @@ -162,7 +162,7 @@ exports.getModule = class BBSListModule extends MenuModule { } self.database.run( - `INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes) + `INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes) VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, [ formData.value.name, diff --git a/core/config_default.js b/core/config_default.js index 87b586c2..a50d78b7 100644 --- a/core/config_default.js +++ b/core/config_default.js @@ -413,6 +413,18 @@ module.exports = () => { }, }, + // General ActivityPub integration configuration + activityPub: { + // Mimics Mastodon max 500 characters for *outgoing* Notes + // (messages destined for ActivityPub); This is a soft limit; + // Implementations including Mastodon should still display + // longer messages, but this keeps us as a "good citizen" + autoSignatures: false, + + // by default, don't include auto-signatures in AP outgoing + maxMessageLength: 500, + }, + infoExtractUtils: { Exiftool2Desc: { cmd: `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x diff --git a/core/enig_error.js b/core/enig_error.js index c5e9f065..a84c6a30 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -61,6 +61,8 @@ exports.Errors = { new EnigError('Bad or missing form data', -32016, reason, reasonCode), Duplicate: (reason, reasonCode) => new EnigError('Duplicate', -32017, reason, reasonCode), + ValidationFailed: (reason, reasonCode) => + new EnigError('Validation failed', -32018, reason, reasonCode), }; exports.ErrorReasons = { @@ -76,4 +78,11 @@ exports.ErrorReasons = { Locked: 'LOCKED', NotAllowed: 'NOTALLOWED', Invalid2FA: 'INVALID2FA', + + ValueTooShort: 'VALUE_TOO_SHORT', + ValueTooLong: 'VALUE_TOO_LONG', + ValueInvalid: 'VALUE_INVALID', + + NotAvailable: 'NOT_AVAILABLE', + DoesNotExist: 'EEXIST', }; diff --git a/core/file_area_filter_edit.js b/core/file_area_filter_edit.js index 28a8b38d..9fc1fb01 100644 --- a/core/file_area_filter_edit.js +++ b/core/file_area_filter_edit.js @@ -146,18 +146,17 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { const errorView = this.viewControllers.editor.getView( MciViewIds.editor.error ); - let newFocusId; if (errorView) { if (err) { - errorView.setText(err.message); + errorView.setText(err.friendlyText); err.view.clearText(); // clear out the invalid data } else { errorView.clearText(); } } - return cb(newFocusId); + return cb(err, null); }, }; } diff --git a/core/fse.js b/core/fse.js index fd0f6aac..9a9c1f18 100644 --- a/core/fse.js +++ b/core/fse.js @@ -18,6 +18,7 @@ const { stripMciColorCodes, controlCodesToAnsi } = require('./color_codes.js'); const Config = require('./config.js').get; const { getAddressedToInfo, + messageInfoFromAddressedToInfo, setExternalAddressedToInfo, copyExternalAddressedToInfo, } = require('./mail_util.js'); @@ -37,6 +38,7 @@ const fse = require('fs-extra'); const fs = require('graceful-fs'); const paths = require('path'); const sanatizeFilename = require('sanitize-filename'); +const { ErrorReasons } = require('./enig_error.js'); exports.moduleInfo = { name: 'Full Screen Editor (FSE)', @@ -164,23 +166,35 @@ exports.FullScreenEditorModule = // // Validation stuff // - viewValidationListener: function (err, cb) { - var errMsgView = self.viewControllers.header.getView( + viewValidationListener: (err, cb) => { + if ( + err && + err.view.getId() === MciViewIds.header.subject && + err.reasonCode === ErrorReasons.ValueTooShort + ) { + // Ignore validation errors if this is the subject field + // and it's optional + const toView = this.getView('header', MciViewIds.header.to); + const msgInfo = messageInfoFromAddressedToInfo( + getAddressedToInfo(toView.getData()) + ); + if (true === msgInfo.subjectOptional) { + return cb(null, null); + } + } + + const errMsgView = this.viewControllers.header.getView( MciViewIds.header.errorMsg ); - var newFocusViewId; if (errMsgView) { if (err) { - errMsgView.setText(err.message); - - if (MciViewIds.header.subject === err.view.getId()) { - // :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel) - } + errMsgView.setText(err.friendlyText); } else { errMsgView.clearText(); } } - cb(newFocusViewId); + + return cb(err, null); }, headerSubmit: function (formData, extraArgs, cb) { self.switchToBody(); @@ -424,10 +438,15 @@ exports.FullScreenEditorModule = // // Append auto-signature, if enabled for the area & the user has one // - if (false != area.autoSignatures) { - const sig = this.client.user.getProperty(UserProps.AutoSignature); - if (sig) { - messageBody += `\r\n-- \r\n${sig}`; + const msgInfo = messageInfoFromAddressedToInfo( + getAddressedToInfo(headerValues.to) + ); + if (false !== msgInfo.autoSignatures) { + if (false !== area.autoSignatures) { + const sig = this.client.user.getProperty(UserProps.AutoSignature); + if (sig) { + messageBody += `\r\n-- \r\n${sig}`; + } } } @@ -1391,6 +1410,13 @@ exports.FullScreenEditorModule = } switchToBody() { + const to = this.getView('header', MciViewIds.header.to).getData(); + const msgInfo = messageInfoFromAddressedToInfo(getAddressedToInfo(to)); + if (msgInfo.maxMessageLength > 0) { + const bodyView = this.getView('body', MciViewIds.body.message); + bodyView.maxLength = msgInfo.maxMessageLength; + } + this.viewControllers.header.setFocus(false); this.viewControllers.body.switchFocus(1); diff --git a/core/mail_util.js b/core/mail_util.js index 3ab6bb9f..aba0771f 100644 --- a/core/mail_util.js +++ b/core/mail_util.js @@ -5,10 +5,15 @@ const EnigmaAssert = require('./enigma_assert.js'); const Address = require('./ftn_address.js'); const MessageConst = require('./message_const'); const { getQuotePrefix } = require('./ftn_util'); +const Config = require('./config').get; + +// deps +const { get } = require('lodash'); exports.getAddressedToInfo = getAddressedToInfo; exports.setExternalAddressedToInfo = setExternalAddressedToInfo; exports.copyExternalAddressedToInfo = copyExternalAddressedToInfo; +exports.messageInfoFromAddressedToInfo = messageInfoFromAddressedToInfo; exports.getQuotePrefixFromName = getQuotePrefixFromName; const EMAIL_REGEX = @@ -148,6 +153,24 @@ function copyExternalAddressedToInfo(fromMessage, toMessage) { toMessage.setExternalFlavor(fromMessage.meta.System[sm.ExternalFlavor]); } +function messageInfoFromAddressedToInfo(addressInfo) { + switch (addressInfo.flavor) { + case MessageConst.AddressFlavor.ActivityPub: { + const config = Config(); + const maxMessageLength = get(config, 'activityPub.maxMessageLength', 500); + const autoSignatures = get(config, 'activityPub.autoSignatures', false); + + // Additionally, it's ot necessary to supply a subject + // (aka summary) with a 'Note' Activity + return { subjectOptional: true, maxMessageLength, autoSignatures }; + } + + default: + // autoSignatures: null = varies by additional config + return { subjectOptional: false, maxMessageLength: 0, autoSignatures: null }; + } +} + function getQuotePrefixFromName(name) { const addrInfo = getAddressedToInfo(name); return getQuotePrefix(addrInfo.name || name); diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 025f14d4..5fddbb1b 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -113,6 +113,7 @@ function MultiLineEditTextView(options) { this.textLines = []; this.topVisibleIndex = 0; this.mode = options.mode || 'edit'; // edit | preview | read-only + this.maxLength = 0; // no max by default if ('preview' === this.mode) { this.autoScroll = options.autoScroll || true; @@ -317,6 +318,15 @@ function MultiLineEditTextView(options) { return text; }; + this.getCharacterLength = function () { + // :TODO: FSE needs re-write anyway, but this should just be known all the time vs calc. Too much of a mess right now... + let len = 0; + this.textLines.forEach(tl => { + len += tl.text.length; + }); + return len; + }; + this.replaceCharacterInText = function (c, index, col) { self.textLines[index].text = strUtil.replaceAt( self.textLines[index].text, @@ -664,6 +674,10 @@ function MultiLineEditTextView(options) { }; this.keyPressCharacter = function (c) { + if (this.maxLength > 0 && this.getCharacterLength() + 1 >= this.maxLength) { + return; + } + var index = self.getTextLinesIndex(); // @@ -1170,6 +1184,12 @@ MultiLineEditTextView.prototype.setPropertyValue = function (propName, value) { this.specialKeyMap.next = this.specialKeyMap.next || []; this.specialKeyMap.next.push('tab'); break; + + case 'maxLength': + if (_.isNumber(value)) { + this.maxLength = value; + } + break; } MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); diff --git a/core/nua.js b/core/nua.js index 4f6f355d..198264f8 100644 --- a/core/nua.js +++ b/core/nua.js @@ -49,10 +49,10 @@ exports.getModule = class NewUserAppModule extends MenuModule { viewValidationListener: function (err, cb) { const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg); - let newFocusId; + let newFocusId; if (err) { - errMsgView.setText(err.message); + errMsgView.setText(err.friendlyText); err.view.clearText(); if (err.view.getId() === MciViewIds.confirm) { @@ -65,7 +65,7 @@ exports.getModule = class NewUserAppModule extends MenuModule { errMsgView.clearText(); } - return cb(newFocusId); + return cb(err, newFocusId); }, // diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 763e0c47..13d1568f 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -2,11 +2,12 @@ 'use strict'; // ENiGMA½ -const User = require('./user.js'); -const Config = require('./config.js').get; -const Log = require('./logger.js').log; -const { getAddressedToInfo } = require('./mail_util.js'); -const Message = require('./message.js'); +const User = require('./user'); +const Config = require('./config').get; +const Log = require('./logger').log; +const { getAddressedToInfo } = require('./mail_util'); +const Message = require('./message'); +const { Errors, ErrorReasons } = require('./enig_error'); // note: Only use ValidationFailed in this module! // deps const fs = require('graceful-fs'); @@ -22,36 +23,66 @@ exports.validateBirthdate = validateBirthdate; exports.validatePasswordSpec = validatePasswordSpec; function validateNonEmpty(data, cb) { - return cb(data && data.length > 0 ? null : new Error('Field cannot be empty')); + return cb( + data && data.length > 0 + ? null + : Errors.ValidationFailed('Field cannot be empty', ErrorReasons.ValueTooShort) + ); } function validateMessageSubject(data, cb) { - return cb(data && data.length > 1 ? null : new Error('Subject too short')); + return cb( + data && data.length > 1 + ? null + : Errors.ValidationFailed('Subject too short', ErrorReasons.ValueTooShort) + ); } function validateUserNameAvail(data, cb) { const config = Config(); if (!data || data.length < config.users.usernameMin) { - cb(new Error('Username too short')); + cb(Errors.ValidationFailed('Username too short', ErrorReasons.ValueTooShort)); } else if (data.length > config.users.usernameMax) { // generally should be unreached due to view restraints - return cb(new Error('Username too long')); + return cb( + Errors.ValidationFailed('Username too long', ErrorReasons.ValueTooLong) + ); } else { const usernameRegExp = new RegExp(config.users.usernamePattern); const invalidNames = config.users.newUserNames + config.users.badUserNames; if (!usernameRegExp.test(data)) { - return cb(new Error('Username contains invalid characters')); + return cb( + Errors.ValidationFailed( + 'Username contains invalid characters', + ErrorReasons.ValueInvalid + ) + ); } else if (invalidNames.indexOf(data.toLowerCase()) > -1) { - return cb(new Error('Username is blacklisted')); + return cb( + Errors.ValidationFailed( + 'Username is blacklisted', + ErrorReasons.NotAllowed + ) + ); } else if (/^[0-9]+$/.test(data)) { - return cb(new Error('Username cannot be a number')); + return cb( + Errors.ValidationFailed( + 'Username cannot be a number', + ErrorReasons.ValueInvalid + ) + ); } else { // a new user name cannot be an existing user name or an existing real name User.getUserIdAndNameByLookup(data, function userIdAndName(err) { if (!err) { // err is null if we succeeded -- meaning this user exists already - return cb(new Error('Username unavailable')); + return cb( + Errors.ValidationFailed( + 'Username unavailable', + ErrorReasons.NotAvailable + ) + ); } return cb(null); @@ -60,25 +91,41 @@ function validateUserNameAvail(data, cb) { } } -const invalidUserNameError = () => new Error('Invalid username'); - function validateUserNameExists(data, cb) { if (0 === data.length) { - return cb(invalidUserNameError()); + return cb( + Errors.ValidationFailed('Invalid username', ErrorReasons.ValueTooShort) + ); } User.getUserIdAndName(data, err => { - return cb(err ? invalidUserNameError() : null); + return cb( + err + ? Errors.ValidationFailed( + 'Failed to find username', + err.reasonCode || ErrorReasons.DoesNotExist + ) + : null + ); }); } function validateUserNameOrRealNameExists(data, cb) { if (0 === data.length) { - return cb(invalidUserNameError()); + return cb( + Errors.ValidationFailed('Invalid username', ErrorReasons.ValueTooShort) + ); } User.getUserIdAndNameByLookup(data, err => { - return cb(err ? invalidUserNameError() : null); + return cb( + err + ? Errors.ValidationFailed( + 'Failed to find user', + err.reasonCode || ErrorReasons.DoesNotExist + ) + : null + ); }); } @@ -112,7 +159,9 @@ function validateEmailAvail(data, cb) { // const emailRegExp = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/; if (!emailRegExp.test(data)) { - return cb(new Error('Invalid email address')); + return cb( + Errors.ValidationFailed('Invalid email address', ErrorReasons.ValueInvalid) + ); } User.getUserIdsWithProperty( @@ -120,9 +169,19 @@ function validateEmailAvail(data, cb) { data, function userIdsWithEmail(err, uids) { if (err) { - return cb(new Error('Internal system error')); + return cb( + Errors.ValidationFailed( + err.message, + err.reasonCode || ErrorReasons.DoesNotExist + ) + ); } else if (uids.length > 0) { - return cb(new Error('Email address not unique')); + return cb( + Errors.ValidationFailed( + 'Email address not unique', + ErrorReasons.NotAvailable + ) + ); } return cb(null); @@ -132,25 +191,36 @@ function validateEmailAvail(data, cb) { function validateBirthdate(data, cb) { // :TODO: check for dates in the future, or > reasonable values - return cb(isNaN(Date.parse(data)) ? new Error('Invalid birthdate') : null); + return cb( + isNaN(Date.parse(data)) + ? Errors.ValidationFailed('Invalid birthdate', ErrorReasons.ValueInvalid) + : null + ); } function validatePasswordSpec(data, cb) { const config = Config(); if (!data || data.length < config.users.passwordMin) { - return cb(new Error('Password too short')); + return cb( + Errors.ValidationFailed('Password too short', ErrorReasons.ValueTooShort) + ); } // check badpass, if avail fs.readFile(config.users.badPassFile, 'utf8', (err, passwords) => { if (err) { - Log.warn({ error: err.message }, 'Cannot read bad pass file'); + Log.warn( + { error: err.message, path: config.users.badPassFile }, + 'Cannot read bad pass file' + ); return cb(null); } passwords = passwords.toString().split(/\r\n|\n/g); if (passwords.includes(data)) { - return cb(new Error('Password is too common')); + return cb( + Errors.ValidationFailed('Password is too common', ErrorReasons.NotAllowed) + ); } return cb(null); diff --git a/core/upload.js b/core/upload.js index ec73a233..d27e9d2e 100644 --- a/core/upload.js +++ b/core/upload.js @@ -120,13 +120,13 @@ exports.getModule = class UploadModule extends MenuModule { ); if (errView) { if (err) { - errView.setText(err.message); + errView.setText(err.friendlyText); } else { errView.clearText(); } } - return cb(null); + return cb(err, null); }, }; } diff --git a/core/user_config.js b/core/user_config.js index 859c63e8..e5bbff6f 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -90,7 +90,7 @@ exports.getModule = class UserConfigModule extends MenuModule { var newFocusId; if (errMsgView) { if (err) { - errMsgView.setText(err.message); + errMsgView.setText(err.friendlyText); if (err.view.getId() === MciCodeIds.PassConfirm) { newFocusId = MciCodeIds.Password; @@ -102,7 +102,8 @@ exports.getModule = class UserConfigModule extends MenuModule { errMsgView.clearText(); } } - cb(newFocusId); + + return cb(err, newFocusId); }, // diff --git a/core/view.js b/core/view.js index fe0c4a88..ed8f08d0 100644 --- a/core/view.js +++ b/core/view.js @@ -150,7 +150,7 @@ View.prototype.setPosition = function (pos) { this.position.col = parseInt(arguments[1], 10); } - // sanatize + // sanitize this.position.row = Math.max(this.position.row, 1); this.position.col = Math.max(this.position.col, 1); this.position.row = Math.min(this.position.row, this.client.term.termHeight); diff --git a/core/view_controller.js b/core/view_controller.js index 6a0597e7..420d4b2b 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -385,19 +385,18 @@ function ViewController(options) { this.validateView = function (view, cb) { if (view && _.isFunction(view.validate)) { view.validate(view.getData(), function validateResult(err) { - var viewValidationListener = + const viewValidationListener = self.client.currentMenuModule.menuMethods.viewValidationListener; if (_.isFunction(viewValidationListener)) { if (err) { err.view = view; // pass along the view that failed + err.friendlyText = err.reason || err.message; } - viewValidationListener( - err, - function validationComplete(newViewFocusId) { - cb(err, newViewFocusId); - } - ); + viewValidationListener(err, (err, newFocusedViewId) => { + // validator may have updated |err| + return cb(err, newFocusedViewId); + }); } else { cb(err); } diff --git a/core/wfc.js b/core/wfc.js index 3f127424..13ef6cde 100644 --- a/core/wfc.js +++ b/core/wfc.js @@ -226,6 +226,7 @@ exports.getModule = class WaitingForCallerModule extends MenuModule { enter() { this.client.stopIdleMonitor(); this._applyOpVisibility(); + Events.on( Events.getSystemEvents().ClientDisconnected, this._clientDisconnected.bind(this) @@ -240,7 +241,7 @@ exports.getModule = class WaitingForCallerModule extends MenuModule { Events.removeListener( Events.getSystemEvents().ClientDisconnected, - this._clientDisconnected + this._clientDisconnected.bind(this) ); this._restoreOpVisibility();