diff --git a/core/bbs.js b/core/bbs.js index 3bf53391..3ac0c861 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -243,6 +243,10 @@ function initialize(cb) { function readyFileAreaWeb(callback) { return require('./file_area_web.js').startup(callback); }, + function readyPasswordReset(callback) { + const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; + return WebPasswordReset.startup(callback); + }, function readyEventScheduler(callback) { const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; EventSchedulerModule.loadAndStart( (err, modInst) => { diff --git a/core/button_view.js b/core/button_view.js index 007ca29f..570adc09 100644 --- a/core/button_view.js +++ b/core/button_view.js @@ -18,15 +18,26 @@ function ButtonView(options) { util.inherits(ButtonView, TextView); +ButtonView.prototype.onKeyPress = function(ch, key) { + if(this.isKeyMapped('accept', key.name) || ' ' === ch) { + this.submitData = 'accept'; + this.emit('action', 'accept'); + delete this.submitData; + } else { + ButtonView.super_.prototype.onKeyPress.call(this, ch, key); + } +}; +/* ButtonView.prototype.onKeyPress = function(ch, key) { // allow space = submit - if(' ' === ch) { + if(' ' === ch) { this.emit('action', 'accept'); } ButtonView.super_.prototype.onKeyPress.call(this, ch, key); }; +*/ ButtonView.prototype.getData = function() { - return null; + return this.submitData || null; }; diff --git a/core/config.js b/core/config.js index f529304a..6a5405b9 100644 --- a/core/config.js +++ b/core/config.js @@ -230,7 +230,26 @@ function getDefaultConfig() { domain : 'another-fine-enigma-bbs.org', staticRoot : paths.join(__dirname, './../www'), - + + resetPassword : { + // + // The following templates have these variables available to them: + // + // * %BOARDNAME% : Name of BBS + // * %USERNAME% : Username of whom to reset password + // * %TOKEN% : Reset token + // * %RESET_URL% : In case of email, the link to follow for reset. In case of landing page, + // URL to POST submit reset form. + + // templates for pw reset *email* + resetPassEmailText : paths.join(__dirname, '../misc/reset_password_email.template.txt'), // plain text version + resetPassEmailHtml : paths.join(__dirname, '../misc/reset_password_email.template.html'), // HTML version + + // tempalte for pw reset *landing page* + // + resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'), + }, + http : { enabled : false, port : 8080, @@ -563,6 +582,12 @@ function getDefaultConfig() { // - @execute:/path/to/something/executable.sh // action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', + }, + + forgotPasswordMaintenance : { + schedule : 'every 24 hours', + action : '@method:core/web_password_reset.js:performMaintenanceTask', + args : [ '24 hours' ] // items older than this will be removed } } }, diff --git a/core/email.js b/core/email.js new file mode 100644 index 00000000..0daf06b2 --- /dev/null +++ b/core/email.js @@ -0,0 +1,31 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Config = require('./config.js').config; +const Errors = require('./enig_error.js').Errors; +const Log = require('./logger.js').log; + +// deps +const _ = require('lodash'); +const nodeMailer = require('nodemailer'); + +exports.sendMail = sendMail; + +function sendMail(message, cb) { + if(!_.has(Config, 'email.transport')) { + return cb(Errors.MissingConfig('Email "email::transport" configuration missing')); + } + + message.from = message.from || Config.email.defaultFrom; + + const transportOptions = Object.assign( {}, Config.email.transport, { + logger : Log, + }); + + const transport = nodeMailer.createTransport(transportOptions); + + transport.sendMail(message, (err, info) => { + return cb(err, info); + }); +} diff --git a/core/enig_error.js b/core/enig_error.js index 3fcff3bc..49627b9c 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -30,6 +30,7 @@ exports.Errors = { ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), + MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode), }; exports.ErrorReasons = { diff --git a/core/file_area_web.js b/core/file_area_web.js index ce62f83d..061af055 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -13,6 +13,7 @@ const StatLog = require('./stat_log.js'); const User = require('./user.js'); const Log = require('./logger.js').log; const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId; +const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; // deps const hashids = require('hashids'); @@ -23,8 +24,6 @@ const fs = require('fs'); const mimeTypes = require('mime-types'); const _ = require('lodash'); -const WEB_SERVER_PACKAGE_NAME = 'codes.l33t.enigma.web.server'; - /* :TODO: * Load temp download URLs @ startup & set expire timers via scheduler. @@ -51,9 +50,9 @@ class FileAreaWebAccess { return self.load(callback); }, function addWebRoute(callback) { - self.webServer = getServer(WEB_SERVER_PACKAGE_NAME); + self.webServer = getServer(webServerPackageName); if(!self.webServer) { - return callback(Errors.DoesNotExist(`Server with package name "${WEB_SERVER_PACKAGE_NAME}" does not exist`)); + return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`)); } if(self.isEnabled()) { @@ -173,6 +172,9 @@ class FileAreaWebAccess { buildTempDownloadLink(client, fileEntry, hashId) { hashId = hashId || this.getHashId(client, fileEntry); + + return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); + /* // // Create a URL such as @@ -200,6 +202,7 @@ class FileAreaWebAccess { return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`; } + */ } getExistingTempDownloadServeItem(client, fileEntry, cb) { @@ -246,7 +249,7 @@ class FileAreaWebAccess { } fileNotFound(resp) { - this.webServer.instance.respondWithError(resp, 404, 'File not found.', 'File Not Found'); + return this.webServer.instance.fileNotFound(resp); } routeWebRequestForFile(req, resp) { diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index b9db7659..a7e3c86b 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -47,8 +47,7 @@ class PacketHeader { point : 0, }; - this.packetVersion = version || '2+'; - + this.version = version || '2+'; this.origAddress = origAddr || EMPTY_ADDRESS; this.destAddress = destAddr || EMPTY_ADDRESS; this.created = createdMoment || moment(); @@ -234,7 +233,7 @@ function Packet(options) { // // :TODO: adjust values based on version discovered if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { - packetHeader.packetVersion = '2.2'; + packetHeader.version = '2.2'; // See FSC-0045 packetHeader.origPoint = packetHeader.year; @@ -254,14 +253,14 @@ function Packet(options) { 0 != packetHeader.capWord && packetHeader.capWord & 0x0001) { - packetHeader.packetVersion = '2+'; + packetHeader.version = '2+'; // See FSC-0048 if(-1 === packetHeader.origNet) { packetHeader.origNet = packetHeader.auxNet; } } else { - packetHeader.packetVersion = '2'; + packetHeader.version = '2'; // :TODO: should fill bytes be 0? } diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 8970aa86..c8733ea9 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -44,7 +44,9 @@ class Route { ); } - matchesRequest(req) { return req.method === this.method && this.pathRegExp.test(req.url); } + matchesRequest(req) { + return req.method === this.method && this.pathRegExp.test(req.url); + } getRouteKey() { return `${this.method}:${this.path}`; } } @@ -67,6 +69,35 @@ exports.getModule = class WebServerModule extends ServerModule { } } + buildUrl(pathAndQuery) { + // + // Create a URL such as + // https://l33t.codes:44512/ + |pathAndQuery| + // + // Prefer HTTPS over HTTP. Be explicit about the port + // only if non-standard. Allow users to override full prefix in config. + // + if(_.isString(Config.contentServers.web.overrideUrlPrefix)) { + return `${Config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`; + } + + let schema; + let port; + if(Config.contentServers.web.https.enabled) { + schema = 'https://'; + port = (443 === Config.contentServers.web.https.port) ? + '' : + `:${Config.contentServers.web.https.port}`; + } else { + schema = 'http://'; + port = (80 === Config.contentServers.web.http.port) ? + '' : + `:${Config.contentServers.web.http.port}`; + } + + return `${schema}${Config.contentServers.web.domain}${port}${pathAndQuery}`; + } + isEnabled() { return this.enableHttp || this.enableHttps; } @@ -126,7 +157,7 @@ exports.getModule = class WebServerModule extends ServerModule { } routeRequest(req, resp) { - const route = _.find(this.routes, r => r.matchesRequest(req) ); + const route = _.find(this.routes, r => r.matchesRequest(req) ); return route ? route.handler(req, resp) : this.accessDenied(resp); } @@ -161,6 +192,10 @@ exports.getModule = class WebServerModule extends ServerModule { return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied'); } + fileNotFound(resp) { + return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); + } + routeStaticFile(req, resp) { const fileName = req.url.substr(req.url.indexOf('/', 1)); const filePath = paths.join(Config.contentServers.web.staticRoot, fileName); @@ -168,7 +203,7 @@ exports.getModule = class WebServerModule extends ServerModule { fs.stat(filePath, (err, stats) => { if(err) { - return self.respondWithError(resp, 404, 'File not found.', 'File Not Found'); + return self.fileNotFound(resp); } const headers = { @@ -181,4 +216,28 @@ exports.getModule = class WebServerModule extends ServerModule { return readStream.pipe(resp); }); } + + routeTemplateFilePage(templatePath, preprocessCallback, resp) { + const self = this; + + fs.readFile(templatePath, 'utf8', (err, templateData) => { + if(err) { + return self.fileNotFound(resp); + } + + preprocessCallback(templateData, (err, finalPage, contentType) => { + if(err || !finalPage) { + return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error'); + } + + const headers = { + 'Content-Type' : contentType || mimeTypes.contentType('.html'), + 'Content-Length' : finalPage.length, + }; + + resp.writeHead(200, headers); + return resp.end(finalPage); + }); + }); + } }; diff --git a/core/system_menu_method.js b/core/system_menu_method.js index 2439d7df..f968a493 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -5,20 +5,21 @@ const removeClient = require('./client_connections.js').removeClient; const ansiNormal = require('./ansi_term.js').normal; const userLogin = require('./user_login.js').userLogin; -const messageArea = require('./message_area.js'); +const messageArea = require('./message_area.js'); // deps const _ = require('lodash'); const iconv = require('iconv-lite'); -exports.login = login; -exports.logoff = logoff; -exports.prevMenu = prevMenu; -exports.nextMenu = nextMenu; -exports.prevConf = prevConf; -exports.nextConf = nextConf; -exports.prevArea = prevArea; -exports.nextArea = nextArea; +exports.login = login; +exports.logoff = logoff; +exports.prevMenu = prevMenu; +exports.nextMenu = nextMenu; +exports.prevConf = prevConf; +exports.nextConf = nextConf; +exports.prevArea = prevArea; +exports.nextArea = nextArea; +exports.sendForgotPasswordEmail = sendForgotPasswordEmail; function login(callingMenu, formData, extraArgs, cb) { @@ -152,3 +153,21 @@ function nextArea(callingMenu, formData, extraArgs, cb) { return reloadMenu(callingMenu, cb); }); } + +function sendForgotPasswordEmail(callingMenu, formData, extraArgs, cb) { + const username = formData.value.username || callingMenu.client.user.username; + + const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; + + WebPasswordReset.sendForgotPasswordEmail(username, err => { + if(err) { + callingMenu.client.log.warn( { err : err.message }, 'Failed sending forgot password email'); + } + + if(extraArgs.next) { + return callingMenu.gotoMenu(extraArgs.next, cb); + } + + return logoff(callingMenu, formData, extraArgs, cb); + }); +} diff --git a/core/web_password_reset.js b/core/web_password_reset.js new file mode 100644 index 00000000..56e6b598 --- /dev/null +++ b/core/web_password_reset.js @@ -0,0 +1,314 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Config = require('./config.js').config; +const Errors = require('./enig_error.js').Errors; +const getServer = require('./listening_server.js').getServer; +const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +const User = require('./user.js'); +const userDb = require('./database.js').dbs.user; +const getISOTimestampString = require('./database.js').getISOTimestampString; +const Log = require('./logger.js').log; + +// deps +const async = require('async'); +const _ = require('lodash'); +const crypto = require('crypto'); +const fs = require('fs'); +const url = require('url'); +const querystring = require('querystring'); + +const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = + `%USERNAME%: +a password reset has been requested for your account on %BOARDNAME%. + + * If this was not you, please ignore this email. + * Otherwise, follow this link: %RESET_URL% +`; + +function getWebServer() { + return getServer(webServerPackageName); +} + +class WebPasswordReset { + + static startup(cb) { + WebPasswordReset.registerRoutes( err => { + return cb(err); + }); + } + + static sendForgotPasswordEmail(username, cb) { + const webServer = getServer(webServerPackageName); + if(!webServer || !webServer.instance.isEnabled()) { + return cb(Errors.General('Web server is not enabled')); + } + + async.waterfall( + [ + function getEmailAddress(callback) { + if(!username) { + return callback(Errors.MissingParam('Missing "username"')); + } + + User.getUserIdAndName(username, (err, userId) => { + if(err) { + return callback(err); + } + + User.getUser(userId, (err, user) => { + if(err || !user.properties.email_address) { + return callback(Errors.DoesNotExist('No email address associated with this user')); + } + + return callback(null, user); + }); + }); + }, + function generateAndStoreResetToken(user, callback) { + // + // Reset "token" is simply HEX encoded cryptographically generated bytes + // + crypto.randomBytes(256, (err, token) => { + if(err) { + return callback(err); + } + + token = token.toString('hex'); + + const newProperties = { + email_password_reset_token : token, + email_password_reset_token_ts : getISOTimestampString(), + }; + + // we simply place the reset token in the user's properties + user.persistProperties(newProperties, err => { + return callback(err, user); + }); + }); + + }, + function getEmailTemplates(user, callback) { + fs.readFile(Config.contentServers.web.resetPassword.resetPassEmailText, 'utf8', (err, textTemplate) => { + if(err) { + textTemplate = PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT; + } + + fs.readFile(Config.contentServers.web.resetPassword.resetPassEmailHtml, 'utf8', (err, htmlTemplate) => { + return callback(null, user, textTemplate, htmlTemplate); + }); + }); + }, + function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) { + const sendMail = require('./email.js').sendMail; + + const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties.email_password_reset_token}`); + + function replaceTokens(s) { + return s + .replace(/%BOARDNAME%/g, Config.general.boardName) + .replace(/%USERNAME%/g, user.username) + .replace(/%TOKEN%/g, user.properties.email_password_reset_token) + .replace(/%RESET_URL%/g, resetUrl) + ; + } + + textTemplate = replaceTokens(textTemplate); + if(htmlTemplate) { + htmlTemplate = replaceTokens(htmlTemplate); + } + + const message = { + to : `${user.properties.display_name||user.username} <${user.properties.email_address}>`, + // from will be filled in + subject : 'Forgot Password', + text : textTemplate, + html : htmlTemplate, + }; + + sendMail(message, (err, info) => { + // :TODO: Log me! + + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + static scheduleEvents(cb) { + // :TODO: schedule ~daily cleanup task + return cb(null); + } + + static registerRoutes(cb) { + const webServer = getWebServer(); + if(!webServer) { + return cb(null); // no webserver enabled + } + + if(!webServer.instance.isEnabled()) { + return cb(null); // no error, but we're not serving web stuff + } + + [ + { + // this is the page displayed to user when they GET it + method : 'GET', + path : '^\\/reset_password\\?token\\=[a-f0-9]+$', // Config.contentServers.web.forgotPasswordPageTemplate + handler : WebPasswordReset.routeResetPasswordGet, + }, + // POST handler for performing the actual reset + { + method : 'POST', + path : '^\\/reset_password$', + handler : WebPasswordReset.routeResetPasswordPost, + } + ].forEach(r => { + webServer.instance.addRoute(r); + }); + + return cb(null); + } + + + static fileNotFound(webServer, resp) { + return webServer.instance.fileNotFound(resp); + } + + static accessDenied(webServer, resp) { + return webServer.instance.accessDenied(resp); + } + + static getUserByToken(token, cb) { + async.waterfall( + [ + function validateToken(callback) { + User.getUserIdsWithProperty('email_password_reset_token', token, (err, userIds) => { + if(userIds && userIds.length === 1) { + return callback(null, userIds[0]); + } + + return callback(Errors.Invalid('Invalid password reset token')); + }); + }, + function getUser(userId, callback) { + User.getUser(userId, (err, user) => { + return callback(null, user); + }); + }, + ], + (err, user) => { + return cb(err, user); + } + ); + } + + static routeResetPasswordGet(req, resp) { + const webServer = getWebServer(); // must be valid, we just got a req! + + const urlParts = url.parse(req.url, true); + const token = urlParts.query && urlParts.query.token; + + if(!token) { + return WebPasswordReset.accessDenied(webServer, resp); + } + + WebPasswordReset.getUserByToken(token, (err, user) => { + if(err) { + // assume it's expired + return webServer.instance.respondWithError(resp, 410, 'Invalid or expired reset link.', 'Expired Link'); + } + + const postResetUrl = webServer.instance.buildUrl('/reset_password'); + + return webServer.instance.routeTemplateFilePage( + Config.contentServers.web.resetPassword.resetPageTemplate, + (templateData, preprocessFinished) => { + + const finalPage = templateData + .replace(/%BOARDNAME%/g, Config.general.boardName) + .replace(/%USERNAME%/g, user.username) + .replace(/%TOKEN%/g, token) + .replace(/%RESET_URL%/g, postResetUrl) + ; + + return preprocessFinished(null, finalPage); + }, + resp + ); + }); + } + + static routeResetPasswordPost(req, resp) { + const webServer = getWebServer(); // must be valid, we just got a req! + + let bodyData = ''; + req.on('data', data => { + bodyData += data; + }); + + function badRequest() { + return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request'); + } + + req.on('end', () => { + const formData = querystring.parse(bodyData); + + if(!formData.token || !formData.password || !formData.confirm_password || + formData.password !== formData.confirm_password || + formData.password.length < Config.users.passwordMin || formData.password.length > Config.users.passwordMax) + { + return badRequest(); + } + + WebPasswordReset.getUserByToken(formData.token, (err, user) => { + if(err) { + return badRequest(); + } + + user.setNewAuthCredentials(formData.password, err => { + if(err) { + return badRequest(); + } + + // delete assoc properties - no need to wait for completion + user.removeProperty('email_password_reset_token'); + user.removeProperty('email_password_reset_token_ts'); + + resp.writeHead(200); + return resp.end('Password changed successfully'); + }); + }); + }); + } +} + +function performMaintenanceTask(args, cb) { + + const forgotPassExpireTime = args[0] || '24 hours'; + + // remove all reset token associated properties older than |forgotPassExpireTime| + userDb.run( + `DELETE FROM user_property + WHERE user_id IN ( + SELECT user_id + FROM user_property + WHERE prop_name = "email_password_reset_token_ts" + AND DATETIME("now") >= DATETIME(prop_value, "+${forgotPassExpireTime}") + ) AND prop_name IN ("email_password_reset_token_ts", "email_password_reset_token");`, + err => { + if(err) { + Log.warn( { error : err.message }, 'Failed deleting old email reset tokens'); + } + return cb(err); + } + ); +} + +exports.WebPasswordReset = WebPasswordReset; +exports.performMaintenanceTask = performMaintenanceTask; \ No newline at end of file diff --git a/misc/reset_password_email.template.html b/misc/reset_password_email.template.html new file mode 100644 index 00000000..7691816c --- /dev/null +++ b/misc/reset_password_email.template.html @@ -0,0 +1,7 @@ +%USERNAME%:
+
+A password reset request has been generated for your account on %BOARDNAME% BBS:
+
+If you did not make this request, you may safely ignore this email. Otherwise, please follow this link to reset your password. If that does not work, you can copy the link below:
+
+%RESET_URL%
diff --git a/misc/reset_password_email.template.txt b/misc/reset_password_email.template.txt new file mode 100644 index 00000000..97ec722b --- /dev/null +++ b/misc/reset_password_email.template.txt @@ -0,0 +1,7 @@ +%USERNAME%: + +A password reset request has been generated for your account on %BOARDNAME% BBS: + +If you did not make this request, you may safely ignore this email. Otherwise, please follow the link below to reset your password: + +%RESET_URL% diff --git a/mods/last_callers.js b/mods/last_callers.js index 27326970..1ff6dbd8 100644 --- a/mods/last_callers.js +++ b/mods/last_callers.js @@ -27,7 +27,7 @@ exports.moduleInfo = { name : 'Last Callers', desc : 'Last callers to the system', author : 'NuSkooler', - packageName : 'codes.l33t.enigma.lastcallers' // :TODO: concept idea for mods + packageName : 'codes.l33t.enigma.lastcallers' }; const MciCodeIds = { diff --git a/mods/prompt.hjson b/mods/prompt.hjson index cfeb8fbc..83f01ec5 100644 --- a/mods/prompt.hjson +++ b/mods/prompt.hjson @@ -133,6 +133,24 @@ } }, + forgotPasswordPrompt: { + art: FORGOTPW + mci: { + ET1: { + argName: username + maxLength: @config:users.usernameMax + width: 32 + focus: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + /////////////////////////////////////////////////////////////////////// // File Base Related /////////////////////////////////////////////////////////////////////// @@ -167,6 +185,15 @@ ] } + fileBaseTagEntryPrompt: { + art: TAGFILE + mci: { + ET1: { + argName: tags + } + } + } + /////////////////////////////////////////////////////////////////////// // Standard / Required // diff --git a/mods/themes/luciano_blocktronics/FORGOTPW.ANS b/mods/themes/luciano_blocktronics/FORGOTPW.ANS new file mode 100644 index 00000000..1d69cb11 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FORGOTPW.ANS differ diff --git a/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS new file mode 100644 index 00000000..99219c10 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS differ diff --git a/package.json b/package.json index 11a3cbc8..4e8046c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "enigma-bbs", - "version": "0.0.4-alpha", + "version": "0.0.5-alpha", "description": "ENiGMA½ Bulletin Board System", "author": "Bryan Ashby ", "license": "BSD-2-Clause", @@ -40,7 +40,8 @@ "sqlite3": "^3.1.1", "ssh2": "^0.5.1", "temptmp" : "^1.0.0", - "sanitize-filename" : "^1.6.1" + "sanitize-filename" : "^1.6.1", + "nodemailer" : "^3.1.3" }, "devDependencies": { }, diff --git a/www/reset_password.template.html b/www/reset_password.template.html new file mode 100644 index 00000000..b2303d34 --- /dev/null +++ b/www/reset_password.template.html @@ -0,0 +1,35 @@ + + + + + Password Reset — ENiGMA½ BBS + + + + + +
+ Password Reset + + + + +
+ + \ No newline at end of file