diff --git a/src/config/database.config.js b/config/database.config.js similarity index 100% rename from src/config/database.config.js rename to config/database.config.js diff --git a/src/config/email.config.js b/config/email.config.js similarity index 65% rename from src/config/email.config.js rename to config/email.config.js index b7ee8de..5c7c67c 100644 --- a/src/config/email.config.js +++ b/config/email.config.js @@ -7,5 +7,7 @@ export const MailgunAccount = { }; export const EmailConfig = { - from: "Black Tide 🌊🖤 " + from: "Black Tide ", + upperText: "Greetings from HPN!", + bottomText: "® Black Tide 🌊🖤, 2022" }; diff --git a/config/server.config.js b/config/server.config.js new file mode 100644 index 0000000..27c9928 --- /dev/null +++ b/config/server.config.js @@ -0,0 +1,6 @@ + +export const ServerConfig = { + port: 3001, + host: '127.0.0.1', + client: 'https://example.com' +}; diff --git a/src/config/server.config.js b/src/config/server.config.js deleted file mode 100644 index a382545..0000000 --- a/src/config/server.config.js +++ /dev/null @@ -1,5 +0,0 @@ - -export const ServerConfig = { - port: 3001, - host: '127.0.0.1' -}; diff --git a/src/email/templates.js b/src/email/templates.js new file mode 100644 index 0000000..d924f5a --- /dev/null +++ b/src/email/templates.js @@ -0,0 +1,26 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { EmailConfig } from '../../config/email.config.js'; +import { minutes } from '../utils/common.utils.js'; +import { TemplateFile, TranslationFile } from '../utils/localization.utils.js'; + +export const RestorePasswordEmail = ({username, locale, url}) => { + const subject = translation.get('email.restore.subject', locale); + const bodyText = translation.get('email.restore.body', locale, {username, url}); + return { + subject, + text: bodyText, + html: templates.wrapper.generate({...common, body: bodyText}) + }; +}; + +const common = { + title: EmailConfig.upperText, + company: EmailConfig.bottomText +}; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const refreshRate = 60*minutes; +const translation = new TranslationFile(__dirname+'/texts.json', refreshRate); +const templates = { + wrapper: new TemplateFile(__dirname+'/wrapper.html', refreshRate, {html: true}) +}; diff --git a/src/email/texts.json b/src/email/texts.json new file mode 100644 index 0000000..d29a789 --- /dev/null +++ b/src/email/texts.json @@ -0,0 +1,10 @@ +{ + "en": { + "email.restore.subject": "Account recovery.", + "email.restore.body": "Hello, ${username}.\nWe received a request to reset your password.\nFollow the link below to proceed:\n${url}\nIf you didn't request this change, ignore this email." + }, + "ru": { + "email.restore.subject": "Восстановление аккаунта.", + "email.restore.body": "Здравствуйте, ${username}.\nМы получили запрос на сброс пароля на вашем аккаунте.\nНажмите на ссылку для подтверждения:\n${url}\nЕсли это были не вы, проигнорируйте это письмо." + } +} \ No newline at end of file diff --git a/src/email/wrapper - Copy.html b/src/email/wrapper - Copy.html new file mode 100644 index 0000000..03a0400 --- /dev/null +++ b/src/email/wrapper - Copy.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+ + + + + + + +
+

Creating Email Magic

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. In tempus adipiscing felis, sit amet blandit ipsum volutpat sed. Morbi porttitor, eget accumsan et dictum, nisi libero ultricies ipsum, posuere neque at erat.

+

In tempus felis blandit

+
+ + + + + + +
+

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. In tempus adipiscing felis, sit amet blandit ipsum volutpat sed. Morbi porttitor, eget accumsan dictum, est nisi libero ultricies ipsum, in posuere mauris neque at erat.

+

Blandit ipsum volutpat sed

+
  +

+

Morbi porttitor, eget est accumsan dictum, nisi libero ultricies ipsum, in posuere mauris neque at erat. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In tempus adipiscing felis, sit amet blandit ipsum volutpat sed.

+

In tempus felis blandit

+
+
+
+ + + + + +
+

+ ® Someone, Somewhere 2021
Unsubscribe +

+
+ + + + + +
+ Twitter + + Facebook +
+
+
+
+ + \ No newline at end of file diff --git a/src/email/wrapper.html b/src/email/wrapper.html new file mode 100644 index 0000000..6dc06bd --- /dev/null +++ b/src/email/wrapper.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ ${title} +
+ ${body} +
+ + + + + +
+

+ ${company}
Unsubscribe +

+
+ + + + +
+ Twitter +
+
+
+
+ + \ No newline at end of file diff --git a/src/index.js b/src/index.js index 3d54095..6885943 100644 --- a/src/index.js +++ b/src/index.js @@ -3,8 +3,8 @@ import Fastify from 'fastify' import formBodyPlugin from 'fastify-formbody'; import fastifyMultipart from 'fastify-multipart'; import Sequelize from 'sequelize'; -import { DatabaseConfig } from './config/database.config.js'; -import { ServerConfig } from './config/server.config.js'; +import { DatabaseConfig } from '../config/database.config.js'; +import { ServerConfig } from '../config/server.config.js'; import { importAll } from './utils/module.utils.js'; const app = Fastify({logger: false}); diff --git a/src/logic/cache.js b/src/logic/cache.js index 40022f3..a7f4b31 100644 --- a/src/logic/cache.js +++ b/src/logic/cache.js @@ -1,6 +1,6 @@ /** - * A map with a limited number of entries. (unspecified order) + * A regular map, except with a limited number of entries. */ export class CacheTable{ @@ -35,7 +35,7 @@ export class CacheTable{ /** - * A cache table that keeps track of access within multiple time intervals. + * A cache table that keeps track of accesses within multiple time intervals. */ export class SpamCache extends CacheTable{ diff --git a/src/logic/email.js b/src/logic/email.js index 137212f..7a6d9c0 100644 --- a/src/logic/email.js +++ b/src/logic/email.js @@ -1,6 +1,7 @@ -import { EmailConfig, MailgunAccount } from "../config/email.config.js"; +import { EmailConfig, MailgunAccount } from "../../config/email.config.js"; import Mailgun from "mailgun-js"; -import { RestorePasswordEmail } from "../misc/email-templates.js"; +import { RestorePasswordEmail } from "../email/templates.js"; +import { ServerConfig } from "../../config/server.config.js"; const mailgun = Mailgun(MailgunAccount); @@ -16,15 +17,17 @@ function sendMail(opts){ }); } -export async function sendRestorationLink(user, app){ +export async function sendRestorationLink(user){ const template = RestorePasswordEmail({ username: user.username, - url: app.hostname+'/'+user.restoreCode + locale: user.locale, + url: ServerConfig.client+'/restore?code='+user.restoreCode }); return await sendMail({ from: EmailConfig.from, to: user.email, subject: template.subject, - text: template.text + text: template.text, + html: template.html }); } diff --git a/src/logic/session.js b/src/logic/session.js index 4d7c643..329f367 100644 --- a/src/logic/session.js +++ b/src/logic/session.js @@ -22,11 +22,11 @@ export class UserSession{ return userCache.get(token); } static onUpdate(user){ - const session = find(user); + const session = UserSession.find(user); if(session) session.user = user; } static onDestroy(user){ - const session = find(user); + const session = UserSession.find(user); if(session) session.kill(); } } diff --git a/src/misc/email-templates.js b/src/misc/email-templates.js deleted file mode 100644 index 092be91..0000000 --- a/src/misc/email-templates.js +++ /dev/null @@ -1,11 +0,0 @@ - -export const RestorePasswordEmail = ({username, url}) => ({ - subject: `Account recovery`, - text: ` - Hello, ${username}. - We received a request to reset your password. - Follow the link below to proceed: - ${url} - If you didn't request this change, ignore this email. - ` -}); diff --git a/src/model/user.d.ts b/src/model/user.d.ts index 6ce5f1d..45d8016 100644 --- a/src/model/user.d.ts +++ b/src/model/user.d.ts @@ -2,18 +2,22 @@ import { model } from "./model" import { UserRolesEnum } from "./user.model"; export type UserEntity = model & { - uuid: string, - token: string, - tokenExpiry: Date, + uuid: string, /** unique id */ + token: string, /** auth token */ + tokenExpiry: Date, /** expiration date */ email: string, emailConfirmed: boolean, username: string, - passwd: string, - role: typeof UserRolesEnum, + paswd: string, /** password hash */ + role: UserRolesEnum, firstIp: string, - firstLocale: string, - bannedUntil: Date, - banReason: string, - restoreCode: string, - restoreExpiry: Date + locale: string, /** accepted-language */ + bannedUntil: Date, /** null if not banned */ + banReason: string, /** can be null when banned */ + restoreCode: string, /** sent with the password restore email */ + restoreExpiry: Date, /** subtracted on unsuccessful attempt */ + createdAt: Date, /** registration date */ + meta: UserMeta /** JSON meta information */ }; + +type UserMeta = any; diff --git a/src/model/user.model.js b/src/model/user.model.js index fc1aeb7..599a5d9 100644 --- a/src/model/user.model.js +++ b/src/model/user.model.js @@ -15,25 +15,34 @@ const UserEntity = ({db}) => ( paswd: Sequelize.TEXT, role: {type: Sequelize.ENUM(UserRolesEnum), defaultValue: 'user'}, firstIp: Sequelize.TEXT, - firstLocale: Sequelize.TEXT, + locale: Sequelize.TEXT, bannedUntil: Sequelize.DATE, banReason: Sequelize.TEXT, restoreCode: Sequelize.TEXT, - restoreExpiry: Sequelize.DATE + restoreExpiry: Sequelize.DATE, + createdAt: {type: Sequelize.DATE, defaultValue: Sequelize.NOW}, + meta: Sequelize.JSON }, { indexes: [ { fields: ['token'], unique: true }, { fields: ['email'], unique: true } ], hooks: { + //todo: consider whether a hook on select would be plausible beforeUpdate: UserSession.onUpdate, afterDestroy: UserSession.onDestroy } }) ); + +/** @readonly @enum {string} */ + export const UserRolesEnum = [ - 'user', 'admin', 'moderator' + // 'guest', + 'user', + 'moderator', + 'admin' ]; export default UserEntity; diff --git a/src/route/auth.controller.js b/src/route/auth.controller.js index 0bf1f23..d71d012 100644 --- a/src/route/auth.controller.js +++ b/src/route/auth.controller.js @@ -1,4 +1,4 @@ -import { notYet, randomElement, reverseString, hours, minutes } from '../utils/common.utils.js'; +import { hasDateHappenedYet, randomElement, reverseString, hours, minutes } from '../utils/common.utils.js'; import { checkStringParam, errorOut, ipAddress, localeFromHeader } from '../utils/web.utils.js'; import { Animals } from '../misc/animals.js'; import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch, isEndpointAllowedForBannedUsers, isEndpointProtected, generateRestoreCode, restoreValidity, restoreAttempts } from '../logic/security.js'; @@ -30,8 +30,8 @@ function AuthController({app, db}){ session = new UserSession(userData, request); } } - const tokenNotExpired = notYet(session?.user.tokenExpiry); - const userNotBanned = (!notYet(session?.user.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); + const tokenNotExpired = hasDateHappenedYet(session?.user.tokenExpiry); + const userNotBanned = (!hasDateHappenedYet(session?.user.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); if(session?.user && tokenNotExpired && userNotBanned){ request.session = session; }else{ @@ -102,7 +102,8 @@ function AuthController({app, db}){ tokenExpiry: newTokenExpiry(), paswd: hashPassword(reverseString(paswd)), firstIp: ipAddress(request), - firstLocale: localeFromHeader(request.headers['accept-language']) + locale: localeFromHeader(request.headers['accept-language']), + meta: {} }; await Users.create(newUser); //todo: email notify @@ -117,14 +118,14 @@ function AuthController({app, db}){ const user = await Users.findOne({where: {email: fixedEmail}}); if(!user) return errorOut(reply, 'restore.not_found'); - if(user.restoreExpiry && notYet(user.restoreExpiry)){ + if(user.restoreExpiry && hasDateHappenedYet(user.restoreExpiry)){ return errorOut(reply, 'restore.already_sent'); } const restoreCode = generateRestoreCode(); const restoreExpiry = new Date(Date.now() + restoreValidity); await user.update({restoreCode, restoreExpiry}); - const success = await sendRestorationLink(user, app); + const success = await sendRestorationLink(user); return {success, lifetime: restoreValidity}; }); @@ -144,7 +145,7 @@ function AuthController({app, db}){ const user = await Users.findOne({where: {email: fixedEmail}}); if(!user) return errorOut(reply, 'restore.not_found'); - if((!notYet(user.restoreExpiry)) || (!user.restoreCode)){ + if((!hasDateHappenedYet(user.restoreExpiry)) || (!user.restoreCode)){ return errorOut(reply, 'restore.expired'); } diff --git a/src/route/user.controller.js b/src/route/user.controller.js index 2d1dab7..d2796dc 100644 --- a/src/route/user.controller.js +++ b/src/route/user.controller.js @@ -1,4 +1,5 @@ import { reissueToken, tokenLifetime, tokenReissue } from '../logic/security.js'; +import { hasDateHappenedYet } from '../utils/common.utils.js'; /** @param {import('./route').props} */ @@ -11,7 +12,7 @@ function UserController({app, db}){ let newToken; { // issue a new token if near expired - const needsReissue = ((user.tokenExpiry - new Date()) < (tokenLifetime - tokenReissue)); + const needsReissue = hasDateHappenedYet(user.tokenExpiry - (tokenLifetime - tokenReissue)); if(needsReissue) newToken = await reissueToken(user); } diff --git a/src/utils/common.utils.js b/src/utils/common.utils.js index 9b2897a..d56f818 100644 --- a/src/utils/common.utils.js +++ b/src/utils/common.utils.js @@ -11,7 +11,7 @@ export const minutes = 60*1000; export const hours = 3600*1000; export const days = 24*hours; -export function notYet(date){ +export function hasDateHappenedYet(date){ if(!date) return false; return (new Date() <= date); } diff --git a/src/utils/localization.utils.js b/src/utils/localization.utils.js new file mode 100644 index 0000000..84e5db1 --- /dev/null +++ b/src/utils/localization.utils.js @@ -0,0 +1,57 @@ +import fs from 'fs'; + +/** + * Plain-text data with auto-refresh. + */ +export class TemplateFile{ + + constructor(path, interval, opts){ + this.path = path; + this.opts = opts; + this.reload(); + if(interval){ + setInterval(() => this.reload(), interval); + } + } + reload(){ + fs.stat(this.path, (err, stats) => { + if(err) throw err; + if((!this.lastModified) || (stats.mtime > this.lastModified)){ + this.lastModified = stats.mtime; + fs.readFile(this.path, {encoding: 'utf-8'}, (err, data) => { + if(err) throw err; + this.text = data; + this.onChange(); + }); + } + }); + } + onChange(){ // "virtual" + } + generate(variables, srcText){ + return (srcText || this.text).replace(/\${(.*?)}/g, (full, name) => { + const res = variables[name] ?? full; + return this.opts?.html ? res.replace("\n","
\n") : res; + }); + } +} + +/** + * JSON internationalization table. + */ +export class TranslationFile extends TemplateFile{ + + onChange(){ + this.data = JSON.parse(this.text); + } + get(key, locale, variables){ + let lang; + for(lang in this.data){ + if(locale.substring(0, lang.length) == lang){ + break; + } + } + const entry = this.data[lang]?.[key]; + return variables ? this.generate(variables, entry) : entry; + } +} diff --git a/src/utils/module.utils.js b/src/utils/module.utils.js index 75d0b74..fff3897 100644 --- a/src/utils/module.utils.js +++ b/src/utils/module.utils.js @@ -2,20 +2,22 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const srcRootDirectory = path.dirname(fileURLToPath(import.meta.url))+'/..'; function defaultFileFilter(filename){ return !!filename.match(/\.js$/); } +/** + * Import and invoke all modules in a subfolder + */ export async function importAll(folder, opts){ const controllerPaths = ( - fs.readdirSync(path.join(__dirname, '../'+folder)) + fs.readdirSync(path.join(srcRootDirectory, folder)) .filter(defaultFileFilter) - .map(file => path.join(__dirname, '../'+folder, file)) + .map(file => path.join(srcRootDirectory, folder, file)) ); - for (const path of controllerPaths) { + for(const path of controllerPaths){ const submodule = await import('file://'+path); submodule.default(opts); } diff --git a/src/utils/web.utils.js b/src/utils/web.utils.js index a346408..85c6bf0 100644 --- a/src/utils/web.utils.js +++ b/src/utils/web.utils.js @@ -19,5 +19,5 @@ export function ipAddress(request){ export function localeFromHeader(input){ //not sure what to do with this yet. //https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language - return checkStringParam(input, 1, 64) ? input : 'en'; + return checkStringParam(input, 2, 64) ? input : 'en'; }