From fe01a9f15eba57f648f0ee41b1b4290c6ce5ec0f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:51:49 -0700 Subject: [PATCH 01/11] Additional max lengths for user properties --- core/config.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/config.js b/core/config.js index 9daa4e77..99572287 100644 --- a/core/config.js +++ b/core/config.js @@ -96,8 +96,16 @@ function getDefaultConfig() { usernameMin : 2, usernameMax : 16, // Note that FidoNet wants 36 max usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+]+$', + passwordMin : 6, passwordMax : 128, + + realNameMax : 32, + locationMax : 32, + affilsMax : 32, + emailMax : 255, + webMax : 255, + requireActivation : true, // require SysOp activation? invalidUsernames : [], From 67b0d1a683356317555c2a82431396c7868f57a4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:52:23 -0700 Subject: [PATCH 02/11] User configuration functional --- core/user_config.js | 185 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 164 insertions(+), 21 deletions(-) diff --git a/core/user_config.js b/core/user_config.js index ffda190b..5dfb85e5 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -3,6 +3,8 @@ var MenuModule = require('./menu_module.js').MenuModule; var ViewController = require('./view_controller.js').ViewController; +var theme = require('./theme.js'); +var sysValidate = require('./system_view_validate.js'); var async = require('async'); var assert = require('assert'); @@ -18,35 +20,134 @@ exports.moduleInfo = { }; var MciCodeIds = { - Email : 1, - Loc : 2, - Web : 3, - Affils : 4, - - BirthDate : 5, - Sex : 6, - - Theme : 10, - ScreenSize : 11, + RealName : 1, + BirthDate : 2, + Sex : 3, + Loc : 4, + Affils : 5, + Email : 6, + Web : 7, + TermHeight : 8, + Theme : 9, + Password : 10, + PassConfirm : 11, + ThemeInfo : 20, + ErrorMsg : 21, + + SaveCancel : 25, }; function UserConfigModule(options) { MenuModule.call(this, options); var self = this; + + self.getView = function(viewId) { + return self.viewControllers.menu.getView(viewId); + }; self.setViewText = function(viewId, text) { - var v = self.viewControllers.menu.getView(viewId); + var v = self.getView(viewId); if(v) { v.setText(text); } }; this.menuMethods = { - exitKeyPressed : function(formData, extraArgs) { - // :TODO: save/etc. - self.prevMenu(); - } + // + // Validation support + // + validateEmailAvail : function(data, cb) { + // + // If nothing changed, we know it's OK + // + if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) { + return cb(null); + } + + // Otherwise we can use the standard system method + return sysValidate.validateEmailAvail(data, cb); + }, + + validatePassword : function(data, cb) { + // + // Blank is OK - this means we won't be changing it + // + if(!data || 0 === data.length) { + return cb(null); + } + + // Otherwise we can use the standard system method + return sysValidate.validatePasswordSpec(data, cb); + }, + + validatePassConfirmMatch : function(data, cb) { + var passwordView = self.getView(MciCodeIds.Password); + cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); + }, + + viewValidationListener : function(err, cb) { + var errMsgView = self.getView(MciCodeIds.ErrorMsg); + var newFocusId; + if(errMsgView) { + if(err) { + errMsgView.setText(err.message); + + if(err.view.getId() === MciCodeIds.PassConfirm) { + newFocusId = MciCodeIds.Password; + var passwordView = self.getView(MciCodeIds.Password); + passwordView.clearText(); + err.view.clearText(); + } + } else { + errMsgView.clearText(); + } + } + cb(newFocusId); + }, + + saveChanges : function(formData, extraArgs) { + assert(formData.value.password === formData.value.passwordConfirm); + + var newProperties = { + real_name : formData.value.realName, + birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), + sex : formData.value.sex, + location : formData.value.location, + affiliation : formData.value.affils, + email_address : formData.value.email, + web_address : formData.value.web, + term_height : formData.value.termHeight.toString(), + theme_id : self.availThemeInfo[formData.value.theme].themeId, + }; + + self.client.user.persistProperties(newProperties, function persisted(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties'); + // :TODO: warn end user! + self.prevMenu(); + } else { + // + // New password if it's not empty + // + self.client.log.info('User updated properties'); + + if(formData.value.password.length > 0) { + self.client.user.setNewAuthCredentials(formData.value.password, function newAuthStored(err) { + if(err) { + // :TODO: warn the end user! + self.client.log.warn( { error : err.toString() }, 'Failed storing new authentication credentials'); + } else { + self.client.log.info('User changed authentication credentials'); + } + self.prevMenu(); + }); + } else { + self.prevMenu(); + } + } + }); + }, }; } @@ -55,6 +156,8 @@ require('util').inherits(UserConfigModule, MenuModule); UserConfigModule.prototype.mciReady = function(mciData, cb) { var self = this; var vc = self.viewControllers.menu = new ViewController( { client : self.client} ); + + var currentThemeIdIndex = 0; async.series( [ @@ -64,17 +167,57 @@ UserConfigModule.prototype.mciReady = function(mciData, cb) { function loadFromConfig(callback) { vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); }, + function prepareAvailableThemes(callback) { + self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) { + return { + themeId : themeId, + name : t.info.name, + author : t.info.author, + desc : _.isString(t.info.desc) ? t.info.desc : '', + group : _.isString(t.info.group) ? t.info.group : '', + }; + }), 'name'); + + currentThemeIdIndex = _.findIndex(self.availThemeInfo, function cmp(ti) { + return ti.themeId === self.client.user.properties.theme_id; + }); + + callback(null); + }, function populateViews(callback) { var user = self.client.user; - self.setViewText(MciCodeIds.Email, user.properties.email_address); - self.setViewText(MciCodeIds.Loc, user.properties.location); - self.setViewText(MciCodeIds.Web, user.properties.web_address); - self.setViewText(MciCodeIds.Affils, user.properties.affiliation); + self.setViewText(MciCodeIds.RealName, user.properties.real_name); self.setViewText(MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD')); self.setViewText(MciCodeIds.Sex, user.properties.sex); - + self.setViewText(MciCodeIds.Loc, user.properties.location); + self.setViewText(MciCodeIds.Affils, user.properties.affiliation); + self.setViewText(MciCodeIds.Email, user.properties.email_address); + self.setViewText(MciCodeIds.Web, user.properties.web_address); + self.setViewText(MciCodeIds.TermHeight, user.properties.term_height.toString()); + + + var themeView = self.getView(MciCodeIds.Theme); + if(themeView) { + themeView.setItems(_.map(self.availThemeInfo, 'name')); + themeView.setFocusItemIndex(currentThemeIdIndex); + } + + var realNameView = self.getView(MciCodeIds.RealName); + if(realNameView) { + realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix! + } + + callback(null); } - ] + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'User configuration failed to init'); + self.prevMenu(); + } else { + cb(null); + } + } ); }; From a2011ef39c8f0da51a32fb4a0fc58e963d0b3d13 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:53:34 -0700 Subject: [PATCH 03/11] setText() for MaskEditTextView --- core/mask_edit_text_view.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/mask_edit_text_view.js b/core/mask_edit_text_view.js index a25a2553..f99774e6 100644 --- a/core/mask_edit_text_view.js +++ b/core/mask_edit_text_view.js @@ -103,6 +103,14 @@ MaskEditTextView.maskPatternCharacterRegEx = { '&' : /[\w\d\s]/, // Any "printable" 32-126, 128-255 }; +MaskEditTextView.prototype.setText = function(text) { + MaskEditTextView.super_.prototype.setText.call(this, text); + + if(this.patternArray) { // :TODO: This is a hack - see TextView ctor note about setText() + this.patternArrayPos = this.patternArray.length; + } +}; + MaskEditTextView.prototype.setMaskPattern = function(pattern) { this.dimens.width = pattern.length; From d2c8bd90f088b9649a0bc573b24cb74f2cd78295 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:54:03 -0700 Subject: [PATCH 04/11] setFocusItemIndex() support --- core/spinner_menu_view.js | 6 ++++++ core/text_view.js | 5 ++++- core/vertical_menu_view.js | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/core/spinner_menu_view.js b/core/spinner_menu_view.js index 8050015b..65ac10af 100644 --- a/core/spinner_menu_view.js +++ b/core/spinner_menu_view.js @@ -64,6 +64,12 @@ SpinnerMenuView.prototype.setFocus = function(focused) { this.redraw(); }; +SpinnerMenuView.prototype.setFocusItemIndex = function(index) { + SpinnerMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + this.updateSelection(); // will redraw +}; + SpinnerMenuView.prototype.onKeyPress = function(ch, key) { if(key) { if(this.isKeyMapped('up', key.name)) { diff --git a/core/text_view.js b/core/text_view.js index bea8570c..96261ec5 100644 --- a/core/text_view.js +++ b/core/text_view.js @@ -89,6 +89,7 @@ function TextView(options) { return this.position.col + offset; }; + // :TODO: Whatever needs init here should be done separately from setText() since it redraws/etc. this.setText(options.text || ''); } @@ -97,7 +98,9 @@ util.inherits(TextView, View); TextView.prototype.redraw = function() { TextView.super_.prototype.redraw.call(this); - this.drawText(this.text); + if(_.isString(this.text)) { + this.drawText(this.text); + } }; TextView.prototype.setFocus = function(focused) { diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 72bc2a40..6e290969 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -146,6 +146,12 @@ VerticalMenuView.prototype.setFocus = function(focused) { this.redraw(); }; +VerticalMenuView.prototype.setFocusItemIndex = function(index) { + VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + this.redraw(); +}; + VerticalMenuView.prototype.onKeyPress = function(ch, key) { if(key) { From a9490d8fd25fc412e8ed2b337f66cc54374c5a41 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:54:38 -0700 Subject: [PATCH 05/11] Formatting --- core/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/view.js b/core/view.js index e5a9c502..1889c98e 100644 --- a/core/view.js +++ b/core/view.js @@ -103,7 +103,7 @@ function View(options) { this.restoreCursor = function() { //this.client.term.write(ansi.setCursorStyle(this.cursorStyle)); this.client.term.rawWrite('show' === this.cursor ? ansi.showCursor() : ansi.hideCursor()); - }; + }; } util.inherits(View, events.EventEmitter); From edcee5eb6a422b09207c4b1f090de2e28ecc7e68 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:54:55 -0700 Subject: [PATCH 06/11] setNewAuthCredentials() method --- core/user.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/core/user.js b/core/user.js index 54edc3af..ffc0b140 100644 --- a/core/user.js +++ b/core/user.js @@ -375,6 +375,25 @@ User.prototype.persistAllProperties = function(cb) { */ }; +User.prototype.setNewAuthCredentials = function(password, cb) { + var self = this; + + generatePasswordDerivedKeyAndSalt(password, function dkAndSalt(err, info) { + if(err) { + cb(err); + } else { + var newProperties = { + pw_pbkdf2_salt : info.salt, + pw_pbkdf2_dk : info.dk, + }; + + self.persistProperties(newProperties, function persisted(err) { + cb(err); + }); + } + }); +}; + User.prototype.getAge = function() { if(_.has(this.properties, 'birthdate')) { return moment().diff(this.properties.birthdate, 'years'); From 4b01cbc68adf1c08c40624d5688922dad88b02f4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:55:17 -0700 Subject: [PATCH 07/11] Missing ; --- mods/nua.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/nua.js b/mods/nua.js index ca33c970..c2a955f2 100644 --- a/mods/nua.js +++ b/mods/nua.js @@ -14,7 +14,7 @@ exports.getModule = NewUserAppModule; exports.moduleInfo = { name : 'NUA', desc : 'New User Application', -} +}; var MciViewIds = { userName : 1, From 37bba84cb41972006b83d2300513ad77f44c245a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:55:37 -0700 Subject: [PATCH 08/11] Missing ; --- core/system_view_validate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 5600e2b6..90fdab15 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -14,7 +14,7 @@ function validateNonEmpty(data, cb) { function validateMessageSubject(data, cb) { cb(data && data.length > 1 ? null : new Error('Subject too short')); -}; +} function validateUserNameAvail(data, cb) { if(data.length < Config.users.usernameMin) { From eca82b66d4829abb0ad747d75e6b63fc3eba53df Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:56:04 -0700 Subject: [PATCH 09/11] Better loading of themes & getAvailThemes() --- core/theme.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/core/theme.js b/core/theme.js index 8ada3fe1..ae6e1ca6 100644 --- a/core/theme.js +++ b/core/theme.js @@ -16,8 +16,10 @@ var async = require('async'); var _ = require('lodash'); var assert = require('assert'); + exports.loadTheme = loadTheme; exports.getThemeArt = getThemeArt; +exports.getAvailableThemes = getAvailableThemes; exports.getRandomTheme = getRandomTheme; exports.initAvailableThemes = initAvailableThemes; exports.displayThemeArt = displayThemeArt; @@ -72,7 +74,7 @@ function refreshThemeHelpers(theme) { return format; } - } + }; } function loadTheme(themeID, cb) { @@ -83,8 +85,11 @@ function loadTheme(themeID, cb) { if(err) { cb(err); } else { - if(!_.isObject(theme.info)) { - cb(new Error('Invalid theme or missing \'info\' section')); + if(!_.isObject(theme.info) || + !_.isString(theme.info.name) || + !_.isString(theme.info.author)) + { + cb(new Error('Invalid or missing "info" section!')); return; } @@ -128,6 +133,8 @@ function initAvailableThemes(cb) { }); Log.debug( { info : theme.info }, 'Theme loaded'); + } else { + Log.warn( { themeId : themeId, error : err.toString() }, 'Failed to load theme'); } }); @@ -146,6 +153,10 @@ function initAvailableThemes(cb) { ); } +function getAvailableThemes() { + return availableThemes; +} + function getRandomTheme() { if(Object.getOwnPropertyNames(availableThemes).length > 0) { var themeIds = Object.keys(availableThemes); From 3856a74ea782cdf43c835986c78c7589316625af Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:56:25 -0700 Subject: [PATCH 10/11] * Use new Config.user properties for max lengths * User configuration entry --- mods/menu.hjson | 98 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/mods/menu.hjson b/mods/menu.hjson index 6dbd0386..c00d4716 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -190,7 +190,7 @@ } ET2: { argName: realName - maxLength: 32 + maxLength: @config:users.realNameMax validate: @systemMethod:validateNonEmpty } MET3: { @@ -206,21 +206,21 @@ } ET5: { argName: location - maxLength: 32 + maxLength: @config:users.locationMax validate: @systemMethod:validateNonEmpty } ET6: { argName: affils - maxLength: 32 + maxLength: @config:users.affilsMax } ET7: { argName: email - maxLength: 255 + maxLength: @config:users.emailMax validate: @systemMethod:validateEmailAvail } ET8: { argName: web - maxLength: 255 + maxLength: @config:users.webMax } ET9: { argName: password @@ -287,7 +287,7 @@ } ET2: { argName: realName - maxLength: 32 + maxLength: @config:users.realNameMax validate: @systemMethod:validateNonEmpty } MET3: { @@ -303,21 +303,21 @@ } ET5: { argName: location - maxLength: 32 + maxLength: @config:users.locationMax validate: @systemMethod:validateNonEmpty } ET6: { argName: affils - maxLength: 32 + maxLength: @config:users.affilsMax } ET7: { argName: email - maxLength: 255 + maxLength: @config:users.emailMax validate: @systemMethod:validateEmailAvail } ET8: { argName: web - maxLength: 255 + maxLength: @config:users.webMax } ET9: { argName: password @@ -625,6 +625,7 @@ } } } + mainMenuUserConfig: { module: @systemModule:user_config art: CONFSCR @@ -632,30 +633,83 @@ 0: { mci: { ET1: { - argName: email + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + focus: true } - ET2: { - argName: location + ME2: { + argName: birthdate + maskPattern: "####/##/##" } - ET3: { - argName: webAddress + ME3: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty } ET4: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET5: { argName: affils + maxLength: @config:users.affilsMax } - ME5: { - maskPattern: "####/##/##" - argName: birthdate + ET6: { + argName: email + maxLength: @config:users.emailMax + validate: @method:validateEmailAvail } - ME11: { - maskPattern: "##x##" - argName: termSize + ET7: { + argName: web + maxLength: @config:users.webMax + } + ME8: { + maskPattern: "##" + argName: termHeight + validate: @systemMethod:validateNonEmpty + } + SM9: { + argName: theme + } + ET10: { + argName: password + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassword + } + ET11: { + argName: passwordConfirm + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassConfirmMatch + } + TM25: { + argName: submission + items: [ "save", "cancel" ] + submit: true } } + + submit: { + *: [ + { + value: { submission: 0 } + action: @method:saveChanges + } + { + value: { submission: 1 } + action: @systemMethod:prevMenu + } + ] + } + actionKeys: [ { keys: [ "escape" ] - action: @method:exitKeyPressed + action: @systemMethod:prevMenu } ] } From 0658d5af52642384c329e1f18a107fe2f3d9e0d7 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Dec 2015 11:57:00 -0700 Subject: [PATCH 11/11] User configuration in default theme --- mods/themes/luciano_blocktronics/CONFSCR.ANS | Bin 2219 -> 2529 bytes mods/themes/luciano_blocktronics/theme.hjson | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/mods/themes/luciano_blocktronics/CONFSCR.ANS b/mods/themes/luciano_blocktronics/CONFSCR.ANS index 0e9dc5c487cd9389f8dd27467bac78a8d5b1cfd1..0bf72430fa56c7339d52bed57a21c0c0277846bb 100644 GIT binary patch literal 2529 zcmb_d%Wm306eY{D?WQW5*<^2z!EVATK{Sd~+NvbouyTnhNDQd}iF{Vn-?63nlc?uD z#u!Musuv#5%zd1D?wN6C67{=LKgnFXGl_eH&Ll})RaM3q%=?MO#PAgJu61RJ0Ty^i zj%?t|)41PF)tvWW!CM}oN=zgchZUC+--sM`VD35-0uZ|jSZJ2gte-^4Du7OsAytN5 zJd%_^(gM#s;iIYUQ5~-UR+mO)!Nmu2L_mER)rU{13w!mN0$?HtcqYKDqv}B^0`dq0 zW&kBsWqDksuYCDhMy`+Gabm_oeQDIjLD3dpuccbGIWWVvY!FBCUlVzhz2&_xueHFKZ2vy}lyp)@ZK*&k;NCI0>$5bPUG_Rs=tRZB<+RVM@T>D@1CJY!>8C04%?jE<+RH3=51Qc&r0s<3FfxQU)@c0nf z+ykl!_@18wHz%cq4OChO1M*OPAOt~}PLd2v>r^CCt7B!P;l6V~IFb5rE z@*|at+_~B|Nbpd3iLr5dJyGlfX1#D|@o1@H-5jraqI>U77X1=I?^sFRsx9kYq{!6WkB8sP;LGQaw>Kfc3)fFN zq7`II>TGvc*PP7M*;d8V*31^g@_{o-Sn|b0Ua+gL7?&If&*cEHPHFpCkyE@N0wxg z5GcAPZBvSzRT&9~dw*Kw3^6Kb3K3OGJtiT=(#TSq_?`qPnKI9T=7VEMEo|zQ4=G=3 zxY=;a(EpXaDL!`zi`W+v-+n*7x*rUqh*bKY9vj8IIEv$^H?7v$+1J*)7C&!quSe$l J&6UjR_a9D55pDng literal 2219 zcmb_dyKdV+5R|(hK&qsQnrqyoI#PVVQ6&?M;X+9e%WhQ3HU$gN*?=V=M!w4M?~uU# ziILgeJMt(gNfQ>*?LB5^X7|Y9T#pky9%=LL?QkBY#c)2#Ow%+Q0&8 z{}<~$4C#eV*iIu<4FXANTA+OJ!wa(V(5~G)Uf9cv&)b!g_KWp=j=Rhm;0xZ9B>{fL zr=U%RVVJvf+EuxAOLQylCG>7?xMo~N)o>y6qE=jFA%_BF1-G+Y1hSJ|CjQ{^{Oo<$ zli?SjiW(mTy-2Sc=h-#t2hy$$+lvuZI5;1ixiTzeT?vWz!m1urtTHs}f9*c#Vc5#u~5=ssZV>KmI^;gOlk zi}HsmzJ2|2eugP9$C`s-vZOv9u~R4rR%nb(v&ktpp!)2O-B(XrwOm!}+xEQ6$_y;A zksHgHafg{|T;)E$$}CoEheKE0Wy#EOIg4nXjLlsnZ3kPKU;<#)vAwHI6=Jw~5XK!k zzM4ilK$1{i(3l3LjxV|Lv$}4vPI`Re0HvHE`U9WM&0|&FtIg_{XXVWUn`s$^x+HX; z3KHh6BP#2Mo5gnZaPRn_P61Fk8};}g>cL1=31+ceuGX7YRMr!f^gs}KXX1$brLNFj z1z{I|OX$0@Tit^PPNd@0%s4@8u2+xSyW7RK^@fn#b-v=KC{JcLuovN#|iA3%1Z