From 0313bb45723bad571607d2bc0f2f3b1e40bdff22 Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sat, 5 Nov 2022 18:55:59 +0100 Subject: [PATCH] wip(backend): Work on routes, auth, and email templates --- sites/backend/README.md | 29 + sites/backend/package.json | 1 + sites/backend/prisma/schema.prisma | 13 + sites/backend/prisma/schema.sqlite | Bin 69632 -> 77824 bytes sites/backend/src/config.mjs | 13 + sites/backend/src/controllers/admin.mjs | 745 ++++++++++++++++++ sites/backend/src/controllers/apikey.mjs | 50 ++ sites/backend/src/controllers/user.mjs | 2 +- sites/backend/src/index.mjs | 13 +- sites/backend/src/middleware.mjs | 13 +- sites/backend/src/models/apikey.mjs | 119 +++ sites/backend/src/models/user.mjs | 62 +- sites/backend/src/routes/apikey.mjs | 30 + sites/backend/src/routes/index.mjs | 2 + sites/backend/src/routes/user.mjs | 14 +- sites/backend/src/templates/email.mjs | 38 - sites/backend/src/templates/email/blocks.mjs | 269 +++++++ .../src/templates/email/emailchange.mjs | 23 + sites/backend/src/templates/email/goodbye.mjs | 38 + sites/backend/src/templates/email/index.mjs | 15 + .../backend/src/templates/email/loginlink.mjs | 39 + .../src/templates/email/newslettersub.mjs | 28 + .../src/templates/email/passwordreset.mjs | 41 + sites/backend/src/templates/email/signup.mjs | 28 + sites/backend/src/utils/crypto.mjs | 2 +- sites/backend/src/utils/email.mjs | 2 +- sites/backend/tests/user.test.mjs | 297 +------ sites/email/build_local/call-to-action.html | 231 ------ sites/email/build_local/emailchange.html | 24 +- sites/email/build_local/goodbye.html | 24 +- sites/email/build_local/login-link.html | 22 - .../build_local/newsletter-subscribe.html | 24 +- sites/email/build_local/password-reset.html | 24 +- sites/email/build_local/signup.html | 24 +- sites/email/build_local/text-only.html | 254 ------ sites/email/src/templates/call-to-action.html | 71 -- sites/email/src/templates/text-only.html | 54 -- 37 files changed, 1610 insertions(+), 1068 deletions(-) create mode 100644 sites/backend/src/controllers/admin.mjs create mode 100644 sites/backend/src/controllers/apikey.mjs create mode 100644 sites/backend/src/models/apikey.mjs create mode 100644 sites/backend/src/routes/apikey.mjs delete mode 100644 sites/backend/src/templates/email.mjs create mode 100644 sites/backend/src/templates/email/blocks.mjs create mode 100644 sites/backend/src/templates/email/emailchange.mjs create mode 100644 sites/backend/src/templates/email/goodbye.mjs create mode 100644 sites/backend/src/templates/email/index.mjs create mode 100644 sites/backend/src/templates/email/loginlink.mjs create mode 100644 sites/backend/src/templates/email/newslettersub.mjs create mode 100644 sites/backend/src/templates/email/passwordreset.mjs create mode 100644 sites/backend/src/templates/email/signup.mjs delete mode 100644 sites/email/build_local/call-to-action.html delete mode 100644 sites/email/build_local/text-only.html delete mode 100644 sites/email/src/templates/call-to-action.html delete mode 100644 sites/email/src/templates/text-only.html diff --git a/sites/backend/README.md b/sites/backend/README.md index 3edf11f2e2d..f9f92c8dac1 100644 --- a/sites/backend/README.md +++ b/sites/backend/README.md @@ -4,3 +4,32 @@ This is a work in process to port the v2 backend to a new v3 backend. It will be based on Express using Prisma with a SQLite database. Watch this space. + +## Permission levels + +There are two different models to authenticate, as user, or with an API key. + +The API keys have more granularity, their permission levels are: + + - `0`: No permissions. Can only login but not do anything (used for testing) + - `1`: Read access to own people/patterns data + - `2`: Read access to all account data + - `3`: Write access to own people/pattern data + - `4`: Write access to all own account data (this is the `user` role) + - `5`: Read access to people/pattern data of all users (this is the `bughunter` role) + - `6`: Read access to all account data of all users + - `7`: Read access to all account data of all users + Write access for specific support functions (this is the `support` role) + - `8`: Write access to all account data of all users (this is the `admin` role) + +User roles map to these permission levels as such: + +- `user`: 4 (this is everybody) +- `bughunter`: 5 (a small group of people, less than 10) +- `support`: 7 (a small number of trusted collaborators, less than 5) +- `admin`: 8 (joost) + +When using an API key above level 4, you need the following roles: + +- 5: Requires bughunter, support, or admin +- 6,7,: Requires support or admin +- 8: Requires admin diff --git a/sites/backend/package.json b/sites/backend/package.json index 53359a740b1..64144349f55 100644 --- a/sites/backend/package.json +++ b/sites/backend/package.json @@ -16,6 +16,7 @@ "bcryptjs": "^2.4.3", "crypto": "^1.0.1", "express": "4.18.2", + "passport-http": "^0.3.0", "pino": "^8.7.0" }, "devDependencies": { diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 000c42019aa..d0f7e10d7ca 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -7,6 +7,18 @@ datasource db { url = env("API_DB_URL") } +model Apikey { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + expiresAt DateTime + name String @default("") + level Int @default(0) + role String @default("user") + secret String + user User @relation(fields: [userId], references: [id]) + userId Int +} + model Confirmation { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -36,6 +48,7 @@ model User { newsletter Boolean @default(false) password String patron Int @default(0) + apikeys Apikey[] people Person[] patterns Pattern[] role String @default("user") diff --git a/sites/backend/prisma/schema.sqlite b/sites/backend/prisma/schema.sqlite index abc9f57a94a7ff7c42cce511daef775a944354fe..8b6e62c999a0aa50489dc22389a88cc9ad11b126 100644 GIT binary patch delta 3404 zcmZozz|!!5WrDPz6$1lR6w!_OfcS++^lsS;JD!QodPGfSJ*TWew94aYaSOR{fI1q@2{e)be=8 zg3RpHN(779Imp#9#8n~0(aFbEK?xx-iC=bdHwWkBUVaz$)QW=4qSWHa8#qL{b+{B1 z6cm*55_3~0C$dZOszU{GQp-|vCfhS9Pd>)1#hRL2lv*;Gky&gqKc|ER%NoW)kUN^v zRN2KPB^g`Q!R~>$1wx{^10qO8H?VPOGB7ZhPQJ)0<$~~Jydi?kqpij!ZZFBmkeQc~ zT2Wk>lUb4)pIBOw59UJyF5VF0GM>p%yaAJiSa)x}z&ML(@_%;A$*TOfC-buvOy0;< zx7mV2fN63TOZ4V?wkpQSXIV`*ui_P9ob16B#KX(Lz`(?n#lUOIm9<$=Ac(7;=_UgQ zt7ju`lWik!s|S-f11F5vWXsCY%+Ddp$to$y*y17ZYHzhtacW6PW?p)+l2x@*PGVkq zX<~Y+l9f_wo>FaXVv>cCfw5(hrFl|{v5AGDnPsA7im`E;rIBfxfuVtgnMG=1vax}& zxk;jFYO0xGqPcmJNqtJ9g<+bpfkm=GaS^XPVm!v!Qtu(~>UyO2U}|J)ZefvZk(`)jmXd6eVq#%zVwq@S zU{IfGWM-OZl$ercY;KuimTF;cnrLF4mXeleq@<%%oS0LhWTj+cVPVPR>RWM*cNnq;9=TUwl21Wu#ZK`dP(2-)hvh$lU-IaaMyT#qL~o0}(@ z85)>aSR^MKBqb+Wnj57ho0_Lum?ave8CWJ6r^F$K^%VhJ^B=e-yWP@ZwP<~1_GBq|xOg1+&Gfh#dMe z^9XBtHZxB$F*YzXHnadGTZ0rs3lqykgZktoi$rtd)U>1&VM1r8?u*9`pM_+M`p6u7~!A!=Zpnw*%z#VpAP_PY@i zC$l6cm}v;oI;i|Ff$V>f>lg$WK!AC(puiXYMFs+s3|Kgrc;+$i`SZ-%%yVH9Pkpl< z6I&>2YptY#xrM2vVUn?~QA)C@u1TV0if*D=Vxq2jl4+VjQevu!p+PbzS-(05El*O+ zOwCL!%*;|v(@f2h4U8=1AQ%kdCBa2j1 z^HfV?Q%hsRG|NPDizGvnl+;v9L&KCL12cnUGZPC-<1|B)q*Mz7vqZ~OBTIus!_-vs z6a$bg7HNrz#;K;}21X{vi6)k2mWd|ysmaEsi56)lW~s&&W+_Q#28JdkCT7Xz#;Hc; zW=RI-hKZn}H_hBUB`rD8z$i62CCMVu&@w5-%q+#gBH7Ty)F3e}Ey*y=EHOFB)Y2r; z)F{QB7N$w2#)(D-=E+9MCYFh*NomPuNv5ghO0~tA>3O9E&3cTGII}cM zHZe#uOVUkFPBzdru{1T(wM;QG)ipCWO*Kt3NlCP{Gz7)jn%mGgGc-3fPqH*KH#18y zOEph4PpLOBF;7cNv$RMxHA^*4N%qHAu@yG6fZ+$tflVDW;aG#ula) zmgY&8<|#=g2FWId$;n10re+4FY37E>CdP@$$(D(hmX?O*7A7f{MyAQBrYT0LCaFdy z$rdISrpcy81{TJN#%AUQ#wjU@Nl7NjNvW_nTeLx8k^>6|6aRV!{`LGfHVX7H~-(`_zuwffK{oNp)htqzoB70f*?%(dGO)?AF@V72Nt^|nU-MKkvT@B~U=x>~T*qO# zxq(Z7Y4SS$AXRRC261swQN}KPuzNvX1mPM9g$v0U7HqYV{VdP?9U`Q8NRb_0ppL~!*mXT@leGYSu z#sq10aa~=;$=f+@ZxdkT7U1P!U|`^8U|;~LXWJ~O@PdDn0gJ+776kzwX8s2Z{BQUl z@PFsOyjf7;Fh4K ({ + id: user.id, + consent: user.consent, + createdAt: user.createdAt, + data: user.data, + email: decrypt(user.email), + initial: decrypt(user.initial), + lastLogin: user.lastLogin, + newsletter: user.newsletter, + patron: user.patron, + role: user.role, + status: user.status, + updatedAt: user.updatedAt, + username: user.username, + lusername: user.lusername, +}) + +const getToken = (user, config) => + jwt.sign( + { + _id: user.id, + username: user.username, + role: user.role, + status: user.status, + aud: config.jwt.audience, + iss: config.jwt.issuer, + }, + config.jwt.secretOrKey, + { expiresIn: config.jwt.expiresIn } + ) + +const isUsernameAvailable = async (username, prisma) => { + const user = await prisme.user.findUnique({ + where: { + lusername: username.toLowerCase(), + }, + }) + + if (user === null) return true + return false +} + +// We'll send this result unless it goes ok +const result = 'error' + +export function UserController() {} + +/* + * Signup + * + * This is the endpoint that handles account signups + * See: https://freesewing.dev/reference/backend/api + */ +UserController.prototype.signup = async (req, res, tools) => { + const User = new UserModel(tools) + await User.create(req.body) + + return User.sendResponse(res) +} + +/* + * Confirm account (after signup) + * + * This is the endpoint that fully unlocks the account if the user gives their consent + * See: https://freesewing.dev/reference/backend/api + */ +UserController.prototype.confirm = async (req, res, tools) => { + if (!req.params.id) return res.status(404).send({ error: 'missingConfirmationId', result }) + if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result }) + if (!req.body.consent || req.body.consent < 1) + return res.status(400).send({ error: 'consentRequired', result }) + + // Destructure what we need from tools + const { prisma, config, decrypt } = tools + + // Retrieve confirmation record + let confirmation + try { + confirmation = await prisma.confirmation.findUnique({ + where: { + id: req.params.id, + }, + }) + } catch (err) { + log.warn(err, `Could not lookup confirmation id ${req.params.id}`) + return res.status(404).send({ error: 'failedToRetrieveConfirmationId', result }) + } + if (!confirmation) { + log.warn(err, `Could not find confirmation id ${req.params.id}`) + return res.status(404).send({ error: 'failedToFindConfirmationId', result }) + } + if (confirmation.type !== 'signup') { + log.warn(err, `Confirmation mismatch; ${req.params.id} is not a signup id`) + return res.status(404).send({ error: 'confirmationIdTypeMismatch', result }) + } + const data = decrypt(confirmation.data) + + // Retrieve user account + let account + try { + account = await prisma.user.findUnique({ + where: { + id: data.id, + }, + }) + } catch (err) { + log.warn(err, `Could not lookup user id ${data.id} from confirmation data`) + return res.status(404).send({ error: 'failedToRetrieveUserIdFromConfirmationData', result }) + } + if (!account) { + log.warn(err, `Could not find user id ${data.id} from confirmation data`) + return res.status(404).send({ error: 'failedToLoadUserFromConfirmationData', result }) + } + + // Update user consent and status + let updateUser + try { + updateUser = await prisma.user.update({ + where: { + id: account.id, + }, + data: { + status: 1, + consent: req.body.consent, + lastLogin: new Date(), + }, + }) + } catch (err) { + log.warn(err, `Could not update user id ${data.id} after confirmation`) + return res.status(404).send({ error: 'failedToUpdateUserAfterConfirmation', result }) + } + + // Account is now active, let's return a passwordless login + return res.status(200).send({ + result: 'success', + token: getToken(account, config), + account: asAccount({ ...account, status: 1, consent: req.body.consent }, decrypt), + }) +} + +/* + * Login (with username and password) + * + * This is the endpoint that provides traditional username/password login + * See: https://freesewing.dev/reference/backend/api + */ +UserController.prototype.login = async function (req, res, tools) { + if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result }) + if (!req.body.username) return res.status(400).json({ error: 'usernameMissing', result }) + if (!req.body.password) return res.status(400).json({ error: 'passwordMissing', result }) + + // Destructure what we need from tools + const { prisma, config, decrypt } = tools + + // Retrieve user account + let account + try { + account = await prisma.user.findFirst({ + where: { + OR: [ + { lusername: { equals: clean(req.body.username) } }, + { ehash: { equals: hash(clean(req.body.username)) } }, + { id: { equals: parseInt(req.body.username) || -1 } }, + ], + }, + }) + } catch (err) { + log.warn(err, `Error while trying to find username: ${req.body.username}`) + return res.status(401).send({ error: 'loginFailed', result }) + } + if (!account) { + log.warn(`Login attempt for non-existing user: ${req.body.username} from ${req.ip}`) + return res.status(401).send({ error: 'loginFailed', result }) + } + + // Account found, check password + const [valid, updatedPasswordField] = verifyPassword(req.body.password, account.password) + if (!valid) { + log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`) + return res.status(401).send({ error: 'loginFailed', result }) + } + + // Login success + log.info(`Login by user ${account.id} (${account.username})`) + if (updatedPasswordField) { + // Update the password field with a v3 hash + let updateUser + try { + updateUser = await prisma.user.update({ + where: { + id: account.id, + }, + data: { + password: updatedPasswordField, + }, + }) + } catch (err) { + log.warn( + err, + `Could not update password field with v3 hash for user id ${account.id} (${account.username})` + ) + } + } + + return res.status(200).send({ + result: 'success', + token: getToken(account, config), + account: asAccount({ ...account }, decrypt), + }) +} + +UserController.prototype.readAccount = async (req, res, tools) => { + if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result }) + + // Destructure what we need from tools + const { prisma, decrypt } = tools + + // Retrieve user account + let account + try { + account = await prisma.user.findUnique({ + where: { + id: req.user._id, + }, + }) + } catch (err) { + log.warn(err, `Could not lookup user id ${req.user._id} from token data`) + return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result }) + } + if (!account) { + log.warn(err, `Could not find user id ${req.user._id} from token data`) + return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result }) + } + + // Return account data + return res.status(200).send({ + result: 'success', + account: asAccount({ ...account }, decrypt), + }) +} + +UserController.prototype.update = async (req, res, tools) => { + if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result }) + + // Destructure what we need from tools + const { prisma, decrypt } = tools + + // Retrieve user account + let account + try { + account = await prisma.user.findUnique({ + where: { + id: req.user._id, + }, + }) + } catch (err) { + log.warn(err, `Could not lookup user id ${req.user._id} from token data`) + return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result }) + } + if (!account) { + log.warn(err, `Could not find user id ${req.user._id} from token data`) + return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result }) + } + + // Account loaded - Handle various updates + const data = {} + // Username + if (req.body.username) { + if (!isUsernameAvailable(req.body.username, prisma)) { + return res.status(400).send({ error: 'usernameTaken', result }) + } + data.username = req.body.username + data.lusername = data.username.toLowerCase() + } + // Newsletter + if (req.body.newsletter === false) data.newsletter = false + if (req.body.newsletter === true) data.newsletter = true + // Consent + if (typeof req.body.consent !== 'undefined') data.consent = req.body.consent + // Bio + if (typeof req.body.bio === 'string') userData.bio = req.body.bio + // Password + if (typeof req.body.password === 'string') + userData.password = asJson(hashPassword(req.body.password)) + // Data + const userData = JSON.parse(account.data) + const uhash = hash(account.data) + if (typeof req.body.language === 'string') set(userData, 'settings.language', req.body.language) + if (typeof req.body.units === 'string') set(userData, 'settings.units', req.body.units) + if (typeof req.body.github === 'string') set(userData, 'settings.social.github', req.body.github) + if (typeof req.body.twitter === 'string') + set(userData, 'settings.social.twitter', req.body.twitter) + if (typeof req.body.instagram === 'string') + set(userData, 'settings.social.instagram', req.body.instagram) + // Did data change? + if (uhash !== hash(userData)) data.data = JSON.stringify(userData) + + // Commit + prisma.user.update({ + where: { id: account.id }, + data, + }) + + // Email change requires confirmation + if (typeof req.body.email === 'string') { + const currentEmail = decrypt(account.email) + if (req.body.email !== currentEmail) { + if (req.body.confirmation) { + // Find confirmation + let confirmation + try { + prisma.confirmation.findUnique({ + where: { id: req.body.confirmation }, + }) + } catch (err) { + log.warn(err, `Failed to find confirmation for email change`) + return res.status(500).send({ error: 'failedToFindEmailChangeConfirmation', result }) + } + if (!confirmation) { + log.warn(err, `Missing confirmation for email change`) + return res.status(400).send({ error: 'missingEmailChangeConfirmation', result }) + } + } else { + // Create confirmation + let confirmation + try { + confirmation = prisma.confirmation.create({ + data: { + type: 'emailchange', + data: encrypt({ + language: userData.settings.language || 'en', + email: { + new: req.body.email, + current: currentEmail, + }, + }), + }, + }) + } catch (err) { + log.warn(err, `Failed to create confirmation for email change`) + return res.status(500).send({ error: 'failedToCreateEmailChangeConfirmation', result }) + } + // Send out confirmation email + let sent + try { + sent = await email.send( + req.body.email, + currentEmail, + ...emailTemplate.emailchange( + req.body.email, + currentEmail, + userData.settings.language, + confirmation.id + ) + ) + } catch (err) { + log.warn(err, 'Unable to send email') + return res.status(500).send({ error: 'failedToSendEmailChangeConfirmationEmail', result }) + } + } + } + } + // Now handle the + /* + else if (typeof data.email === 'string' && data.email !== user.email) { + if (typeof data.confirmation === 'string') { + Confirmation.findById(req.body.confirmation, (err, confirmation) => { + if (err) return res.sendStatus(400) + if (confirmation === null) return res.sendStatus(401) + if (confirmation.data.email.new === req.body.email) { + user.ehash = ehash(req.body.email) + user.email = req.body.email + return saveAndReturnAccount(res, user) + } else return res.sendStatus(400) + }) + } else { + let confirmation = new Confirmation({ + type: 'emailchange', + data: { + handle: user.handle, + language: user.settings.language, + email: { + new: req.body.email, + current: user.email, + }, + }, + }) + confirmation.save(function (err) { + if (err) return res.sendStatus(500) + log.info('emailchangeRequest', { + newEmail: req.body.email, + confirmation: confirmation._id, + }) + email.emailchange(req.body.email, user.email, user.settings.language, confirmation._id) + return saveAndReturnAccount(res, user) + }) + } + } + }) + */ + return res.status(200).send({}) +} + +/* + +// For people who have forgotten their password, or password-less logins +UserController.prototype.confirmationLogin = function (req, res) { + if (!req.body || !req.body.id) return res.sendStatus(400) + Confirmation.findById(req.body.id, (err, confirmation) => { + if (err) return res.sendStatus(400) + if (confirmation === null) return res.sendStatus(401) + User.findOne({ handle: confirmation.data.handle }, (err, user) => { + if (err) return res.sendStatus(400) + if (user === null) { + return res.sendStatus(401) + } + if (user.status !== 'active') return res.sendStatus(403) + else { + log.info('confirmationLogin', { user, req }) + let account = user.account() + let token = getToken(account) + let people = {} + Person.find({ user: user.handle }, (err, personList) => { + if (err) return res.sendStatus(400) + for (let person of personList) people[person.handle] = person.info() + let patterns = {} + Pattern.find({ user: user.handle }, (err, patternList) => { + if (err) return res.sendStatus(400) + for (let pattern of patternList) patterns[pattern.handle] = pattern + return user.updateLoginTime(() => res.send({ account, people, patterns, token })) + }) + }) + } + }) + }) +} + +// CRUD basics + +// Note that the user is already crearted (in signup) +// we just need to active the account +UserController.prototype.create = (req, res) => { + if (!req.body) return res.sendStatus(400) + if (!req.body.consent || !req.body.consent.profile) return res.status(400).send('consentRequired') + Confirmation.findById(req.body.id, (err, confirmation) => { + if (err) return res.sendStatus(400) + if (confirmation === null) return res.sendStatus(401) + User.findOne({ handle: confirmation.data.handle }, (err, user) => { + if (err) return res.sendStatus(400) + if (user === null) return res.sendStatus(401) + user.status = 'active' + user.consent = req.body.consent + user.time.login = new Date() + log.info('accountActivated', { handle: user.handle }) + let account = user.account() + let token = getToken(account) + user.save(function (err) { + if (err) return res.sendStatus(400) + Confirmation.findByIdAndDelete(req.body.id, (err, confirmation) => { + return res.send({ account, people: {}, patterns: {}, token }) + }) + }) + }) + }) +} + +UserController.prototype.readProfile = (req, res) => { + User.findOne({ username: req.params.username }, (err, user) => { + if (err) return res.sendStatus(404) + if (user === null) return res.sendStatus(404) + else res.send(user.profile()) + }) +} + +function saveAndReturnAccount(res, user) { + user.save(function (err, updatedUser) { + if (err) { + log.error('accountUpdateFailed', err) + return res.sendStatus(500) + } else return res.send({ account: updatedUser.account() }) + }) +} + +function temporaryStoragePath(dir) { + return path.join(config.storage, 'tmp', dir) +} + +UserController.prototype.isUsernameAvailable = (req, res) => { + if (!req.user._id) return res.sendStatus(400) + let username = req.body.username.toLowerCase().trim() + if (username === '') return res.sendStatus(400) + User.findOne({ username: username }, (err, user) => { + if (err) return res.sendStatus(400) + if (user === null) return res.sendStatus(200) + if (user._id + '' === req.user._id) return res.sendStatus(200) + else return res.sendStatus(400) + }) +} + +// // Re-send activation email +UserController.prototype.resend = (req, res) => { + if (!req.body) return res.sendStatus(400) + if (!req.body.email) return res.status(400).send('emailMissing') + if (!req.body.language) return res.status(400).send('languageMissing') + User.findOne( + { + ehash: ehash(req.body.email), + }, + (err, user) => { + if (err) return res.sendStatus(500) + if (user === null) return res.status(404).send('noSuchUser') + else { + let confirmation = new Confirmation({ + type: 'signup', + data: { + language: req.body.language, + email: user.email, + handle: user.handle, + }, + }) + confirmation.save(function (err) { + if (err) return res.sendStatus(500) + log.info('resendActivationRequest', { + email: req.body.email, + confirmation: confirmation._id, + }) + email.signup(req.body.email, req.body.language, confirmation._id) + return res.sendStatus(200) + }) + } + } + ) +} + +UserController.prototype.resetPassword = (req, res) => { + if (!req.body) return res.sendStatus(400) + User.findOne( + { + $or: [ + { username: req.body.username.toLowerCase().trim() }, + { ehash: ehash(req.body.username) }, + ], + }, + (err, user) => { + if (err) { + console.log(err) + return res.sendStatus(400) + } + if (user === null) return res.sendStatus(401) + let confirmation = new Confirmation({ + type: 'passwordreset', + data: { + handle: user.handle, + }, + }) + confirmation.save(function (err) { + if (err) return res.sendStatus(500) + log.info('passwordresetRequest', { user: user.handle, confirmation: confirmation._id }) + email.passwordreset(user.email, user.settings.language, confirmation._id) + return res.sendStatus(200) + }) + } + ) +} + +UserController.prototype.setPassword = (req, res) => { + if (!req.body) return res.sendStatus(400) + Confirmation.findById(req.body.confirmation, (err, confirmation) => { + if (err) return res.sendStatus(400) + if (confirmation === null) return res.sendStatus(401) + User.findOne({ handle: req.body.handle }, (err, user) => { + if (err) return res.sendStatus(400) + if (user === null) return res.sendStatus(401) + if (confirmation.type === 'passwordreset' && confirmation.data.handle === user.handle) { + user.password = req.body.password + user.save(function (err) { + log.info('passwordSet', { user, req }) + let account = user.account() + let token = getToken(account) + return user.updateLoginTime(() => res.send({ account, token })) + }) + } else return res.sendStatus(401) + }) + }) + + return +} + +UserController.prototype.confirmChangedEmail = (req, res) => { + if (!req.body || !req.body.id || !req.user._id) return res.sendStatus(400) + Confirmation.findById(req.body.id, (err, confirmation) => { + if (err || confirmation === null) return res.sendStatus(401) + User.findById(req.user._id, async (err, user) => { + if (err || confirmation.data.handle !== user.handle) return res.sendStatus(401) + user.ehash = ehash(confirmation.data.email.new) + user.email = confirmation.data.email.new + return saveAndReturnAccount(res, user) + }) + }) +} + +// // Other +UserController.prototype.patronList = (req, res) => { + User.find({ patron: { $gte: 2 } }) + .sort('username') + .exec((err, users) => { + if (err || users === null) return res.sendStatus(400) + let patrons = { + 2: [], + 4: [], + 8: [], + } + for (let key of Object.keys(users)) { + let user = users[key].profile() + patrons[user.patron].push({ + handle: user.handle, + username: user.username, + bio: user.bio, + picture: user.picture, + social: user.social, + pictureUris: user.pictureUris, + }) + } + return res.send(patrons) + }) +} + +UserController.prototype.export = (req, res) => { + if (!req.user._id) return res.sendStatus(400) + User.findById(req.user._id, (err, user) => { + if (user === null) return res.sendStatus(400) + let dir = createTempDir() + if (!dir) return res.sendStatus(500) + let zip = new Zip() + zip.file('account.json', asJson(user.export(), null, 2)) + loadAvatar(user).then((avatar) => { + if (avatar) zip.file(user.picture, data) + zip + .generateAsync({ + type: 'uint8array', + comment: 'freesewing.org', + streamFiles: true, + }) + .then(function (data) { + let file = path.join(dir, 'export.zip') + fs.writeFile(file, data, (err) => { + log.info('dataExport', { user, req }) + return res.send({ export: uri(file) }) + }) + }) + }) + }) +} + +const loadAvatar = async (user) => { + if (user.picture) + await fs.readFile(path.join(user.storagePath(), user.picture), (err, data) => data) + else return false +} + +// restrict processing of data, aka freeze account +UserController.prototype.restrict = (req, res) => { + if (!req.user._id) return res.sendStatus(400) + User.findById(req.user._id, (err, user) => { + if (user === null) return res.sendStatus(400) + user.status = 'frozen' + user.save(function (err) { + if (err) { + log.error('accountFreezeFailed', user) + return res.sendStatus(500) + } + return res.sendStatus(200) + }) + }) +} + +// Remove account +UserController.prototype.remove = (req, res) => { + if (!req.user._id) return res.sendStatus(400) + User.findById(req.user._id, (err, user) => { + if (user === null) return res.sendStatus(400) + rimraf(user.storagePath(), (err) => { + if (err) { + console.log('rimraf', err) + log.error('accountRemovalFailed', { err, user, req }) + return res.sendStatus(500) + } + user.remove((err, usr) => { + if (err !== null) { + log.error('accountRemovalFailed', { err, user, req }) + return res.sendStatus(500) + } else return res.sendStatus(200) + }) + }) + }) +} + +const getToken = (account) => { + return jwt.sign( + { + _id: account._id, + handle: account.handle, + role: account.role, + aud: config.jwt.audience, + iss: config.jwt.issuer, + }, + config.jwt.secretOrKey + ) +} + +const createTempDir = () => { + let path = temporaryStoragePath(newHandle(10)) + fs.mkdir(path, { recursive: true }, (err) => { + if (err) { + log.error('mkdirFailed', err) + path = false + } + }) + + return path +} + +const uri = (path) => config.static + path.substring(config.storage.length) + +*/ diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikey.mjs new file mode 100644 index 00000000000..a178cefe1f6 --- /dev/null +++ b/sites/backend/src/controllers/apikey.mjs @@ -0,0 +1,50 @@ +import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' +import { clean, asJson } from '../utils/index.mjs' +import { log } from '../utils/log.mjs' +import { ApikeyModel } from '../models/apikey.mjs' +import { UserModel } from '../models/user.mjs' + +export function ApikeyController() {} + +/* + * Create API key + * + * This is the endpoint that handles creation of API keys/tokens + * See: https://freesewing.dev/reference/backend/api + */ +ApikeyController.prototype.create = async (req, res, tools) => { + const Apikey = new ApikeyModel(tools) + await Apikey.create(req) + + return Apikey.sendResponse(res) +} + +/* + * Read API key + * + * This is the endpoint that handles reading of API keys/tokens + * See: https://freesewing.dev/reference/backend/api + */ +ApikeyController.prototype.whoami = async (req, res, tools) => { + const User = new UserModel(tools) + const Apikey = new ApikeyModel(tools) + + // Load user making the call + await User.loadAuthenticatedUser(req.user) + + const key = User.user.apikeys.filter((key) => key.id === req.user.id) + + if (key.length === 1) + Apikey.setResponse(200, 'success', { + apikey: { + key: key[0].id, + level: key[0].level, + expiresAt: key[0].expiresAt, + name: key[0].name, + userId: key[0].userId, + }, + }) + else Apikey.setResponse(404, 'notFound') + + return Apikey.sendResponse(res) +} diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/user.mjs index 76dcfcc8dcc..70443cf69c8 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -229,7 +229,7 @@ UserController.prototype.login = async function (req, res, tools) { }) } -UserController.prototype.readAccount = async (req, res, tools) => { +UserController.prototype.whoami = async (req, res, tools) => { if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result }) // Destructure what we need from tools diff --git a/sites/backend/src/index.mjs b/sites/backend/src/index.mjs index 9cfe1ddaf96..4c600542937 100644 --- a/sites/backend/src/index.mjs +++ b/sites/backend/src/index.mjs @@ -23,11 +23,7 @@ const app = express() app.use(express.json()) app.use(express.static('public')) -// Load middleware -loadExpressMiddleware(app) -loadPassportMiddleware(passport, config) - -const params = { +const tools = { app, passport, prisma, @@ -35,8 +31,13 @@ const params = { ...mailer(config), config, } + +// Load middleware +loadExpressMiddleware(app) +loadPassportMiddleware(passport, tools) + // Load routes -for (const type in routes) routes[type](params) +for (const type in routes) routes[type](tools) // Catch-all route (Load index.html once instead of at every request) const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html')) diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 09acfcfb354..47130cc74a0 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -1,6 +1,8 @@ //import bodyParser from 'body-parser' import cors from 'cors' +import http from 'passport-http' import jwt from 'passport-jwt' +import { ApikeyModel } from './models/apikey.mjs' function loadExpressMiddleware(app) { // FIXME: Is this still needed in FreeSewing v3? @@ -8,12 +10,19 @@ function loadExpressMiddleware(app) { app.use(cors()) } -function loadPassportMiddleware(passport, config) { +function loadPassportMiddleware(passport, tools) { + passport.use( + new http.BasicStrategy(async (key, secret, done) => { + const Apikey = new ApikeyModel(tools) + await Apikey.verify(key, secret) + return Apikey.verified ? done(null, { ...Apikey.record, apikey: true }) : done(false) + }) + ) passport.use( new jwt.Strategy( { jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), - ...config.jwt, + ...tools.config.jwt, }, (jwt_payload, done) => { return done(null, jwt_payload) diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs new file mode 100644 index 00000000000..292c43a5166 --- /dev/null +++ b/sites/backend/src/models/apikey.mjs @@ -0,0 +1,119 @@ +import { log } from '../utils/log.mjs' +import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' +import { clean, asJson } from '../utils/index.mjs' +import { UserModel } from './user.mjs' + +export function ApikeyModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + this.User = new UserModel(tools) + + return this +} + +ApikeyModel.prototype.setExists = function () { + this.exists = this.record ? true : false + + return this +} + +ApikeyModel.prototype.setResponse = function (status = 200, error = false, data = {}) { + this.response = { + status, + body: { + result: 'success', + ...data, + }, + } + if (status > 201) { + this.response.body.error = error + this.response.body.result = 'error' + this.error = true + } else this.error = false + + return this.setExists() +} + +ApikeyModel.prototype.sendResponse = async function (res) { + return res.status(this.response.status).send(this.response.body) +} + +ApikeyModel.prototype.verify = async function (key, secret) { + await this.read({ id: key }) + const [valid] = await verifyPassword(secret, this.record.secret) + this.verified = valid + + return this +} + +ApikeyModel.prototype.read = async function (where) { + this.record = await this.prisma.apikey.findUnique({ where }) + + return this +} + +ApikeyModel.prototype.create = async function ({ body, user }) { + if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') + if (!body.name) return this.setResponse(400, 'nameMissing') + if (!body.level) return this.setResponse(400, 'levelMissing') + if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric') + if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel') + if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing') + if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresInNotNumeric') + if (body.expiresIn > this.config.apikeys.maxExpirySeconds) + return this.setResponse(400, 'expiresInHigherThanMaximum') + + // Load user making the call + await this.User.loadAuthenticatedUser(user) + if (body.level > this.config.roles.levels[this.User.user.role]) + return this.setResponse(400, 'keyLevelExceedsRoleLevel') + + // Generate api secret + const secret = randomString(32) + const expiresAt = new Date(Date.now() + body.expiresIn * 1000) + + try { + this.record = await this.prisma.apikey.create({ + data: { + expiresAt, + name: body.name, + level: body.level, + secret: asJson(hashPassword(secret)), + userId: user._id, + }, + }) + } catch (err) { + log.warn(err, 'Could not create apikey') + return this.setResponse(500, 'createApikeyFailed') + } + + return this.setResponse(200, 'success', { + apikey: { + key: this.record.id, + secret, + level: this.record.level, + expiresAt: this.record.expiresAt, + name: this.record.name, + userId: this.record.userId, + }, + }) +} + +ApikeyModel.prototype.___read = async function ({ user, params }) { + // Load user making the call + await this.User.loadAuthenticatedUser(user) + + const key = this.User.user.apikeys.filter((key) => key.id === params.id) + + return key.length === 1 + ? this.setResponse(200, 'success', { + apikey: { + key: key[0].id, + level: key[0].level, + expiresAt: key[0].expiresAt, + name: key[0].name, + userId: key[0].userId, + }, + }) + : this.setResponse(404, 'notFound') +} diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index ddb70032f0a..5d2737f15a0 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -29,6 +29,19 @@ UserModel.prototype.load = async function (where) { return this.setExists() } +UserModel.prototype.loadAuthenticatedUser = async function (user) { + if (!user) return this + const where = user?.apikey ? { id: user.userId } : { id: user._id } + this.user = await this.prisma.user.findUnique({ + where, + include: { + apikeys: true, + }, + }) + + return this +} + UserModel.prototype.setExists = function () { this.exists = this.record ? true : false @@ -108,7 +121,7 @@ UserModel.prototype.create = async function (body) { }) // Send signup email - await this.sendSignupEmail() + //await this.sendSignupEmail() return body.unittest && this.email.split('@').pop() === this.config.tests.domain ? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id }) @@ -147,3 +160,50 @@ UserModel.prototype.update = async function (data) { UserModel.prototype.sendResponse = async function (res) { return res.status(this.response.status).send(this.response.body) } + +UserModel.prototype.createApikey = async function ({ body, user }) { + if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') + if (!body.name) return this.setResponse(400, 'nameMissing') + if (!body.level) return this.setResponse(400, 'levelMissing') + if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric') + if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel') + if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing') + if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresInNotNumeric') + if (body.expiresIn > this.config.apikeys.maxExpirySeconds) + return this.setResponse(400, 'expiresInHigherThanMaximum') + + // Load user making the call + await this.loadAuthenticatedUser(user) + if (body.level > this.config.roles.levels[this.user.role]) + return this.setResponse(400, 'keyLevelExceedsRoleLevel') + + // Generate api secret + const secret = randomString(32) + const expiresAt = new Date(Date.now() + body.expiresIn * 1000) + + try { + this.record = await this.prisma.apikey.create({ + data: { + expiresAt, + name: body.name, + level: body.level, + secret: asJson(hashPassword(secret)), + userId: user._id, + }, + }) + } catch (err) { + log.warn(err, 'Could not create apikey') + return this.setResponse(500, 'createApikeyFailed') + } + + return this.setResponse(200, 'success', { + apikey: { + key: this.record.id, + secret, + level: this.record.level, + expiresAt: this.record.expiresAt, + name: this.record.name, + userId: this.record.userId, + }, + }) +} diff --git a/sites/backend/src/routes/apikey.mjs b/sites/backend/src/routes/apikey.mjs new file mode 100644 index 00000000000..582169dfb06 --- /dev/null +++ b/sites/backend/src/routes/apikey.mjs @@ -0,0 +1,30 @@ +import { ApikeyController } from '../controllers/apikey.mjs' + +const Apikey = new ApikeyController() +const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] + +export function apikeyRoutes(tools) { + const { app, passport } = tools + + // Create Apikey + app.post('/apikey/jwt', passport.authenticate(...jwt), (req, res) => + Apikey.create(req, res, tools) + ) + app.post('/apikey/key', passport.authenticate(...bsc), (req, res) => + Apikey.create(req, res, tools) + ) + + // Read Apikey + app.get('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) => + Apikey.read(req, res, tools) + ) + app.get('/apikey/:id/key', passport.authenticate(...bsc), (req, res) => + Apikey.read(req, res, tools) + ) + + // Read current Apikey + app.get('/whoami/key', passport.authenticate(...bsc), (req, res) => + Apikey.whoami(req, res, tools) + ) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index fc9b449735f..b7723d0e4db 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -1,5 +1,7 @@ +import { apikeyRoutes } from './apikey.mjs' import { userRoutes } from './user.mjs' export const routes = { + apikeyRoutes, userRoutes, } diff --git a/sites/backend/src/routes/user.mjs b/sites/backend/src/routes/user.mjs index dd9c06880f8..06c6b4d46fe 100644 --- a/sites/backend/src/routes/user.mjs +++ b/sites/backend/src/routes/user.mjs @@ -2,9 +2,11 @@ import { UserController } from '../controllers/user.mjs' const User = new UserController() const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] export function userRoutes(tools) { const { app, passport } = tools + // Sign up app.post('/signup', (req, res) => User.signup(req, res, tools)) @@ -14,13 +16,15 @@ export function userRoutes(tools) { // Login app.post('/login', (req, res) => User.login(req, res, tools)) - // Read account (own data) - app.get('/account', passport.authenticate(...jwt), (req, res) => - User.readAccount(req, res, tools) - ) + // Read current jwt + + app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools)) + app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools)) + app.get('/account/key', passport.authenticate(...bsc), (req, res) => User.whoami(req, res, tools)) // Update account - app.put('/account', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools)) + app.put('/account/jwt', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools)) + app.put('/account/key', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools)) /* diff --git a/sites/backend/src/templates/email.mjs b/sites/backend/src/templates/email.mjs deleted file mode 100644 index 6bb4aedc69c..00000000000 --- a/sites/backend/src/templates/email.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// Line breaks -const nl = "\n" -const nl2 = "\n\n" - -export const templates = { - emailChange: t => [ - t.emailchangeTitle, - t.emailchangeCopy1, - t.emailchangeActionLink, - t.questionsJustReply, - ].join(nl2), - goodbye: t => [ - goodbyeTitle, - goodbyeCopy1, - ].join(nl2), - newsletterSubscribe: t => `Confirm your newsletter subscription.${nl} -Somebody asked to subscribe this email address to the FreeSewing newsletter. -If it was you, please click below to confirm your subscription:${nl} -${t.newsletterConfirmationLink}${nl}`, - newsletterWelcome: t => `You are now subscribed to the FreeSewing newsletter${nl} -If you'd like to catch up, we keep an online archive of previous editions at: https://freesewing.org/newsletter/${nl} -You can unsubscribe at any time by visiting this link: ${t.newsletterUnsubscribeLink}${nl}`, - passwordReset: t => [ - t.passwordresetTitle, - t.passwordresetCopy1, - t.passwordresetActionLink, - t.questionsJustReply, - ].join(nl2), - signup: (t, to, url) => [ - t.signupCopy1, - t.signupCopy2, - url, - t.questionsJustReply, - 'joost', - '--', - `${t.signupWhy} [${to}]`, - ].join(nl2), -} diff --git a/sites/backend/src/templates/email/blocks.mjs b/sites/backend/src/templates/email/blocks.mjs new file mode 100644 index 00000000000..046a9e85e77 --- /dev/null +++ b/sites/backend/src/templates/email/blocks.mjs @@ -0,0 +1,269 @@ +/* + * buttonRow uses the following replacements: + * - actionUrl + * - button + */ +export const buttonRow = { + html: ` + + + + + + + + + `, + text: `{{ actionUrl }}`, +} + +/* + * closingRow uses the following replacements: + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const closingRow = { + html: ` + + +

+ {{ closing }}. +

+ {{ greeting }}, +
+ joost +

+ + PS: {{ ps-pre-link}} + + {{ ps-link}} + {{ ps-post-link }}. + +

+ + `, + text: ` +{{ closing }} + +{{ greeting }} +joost + +PS: {{ text-ps }} : {{ text-ps-link }}`, +} + +/* + * headingRow uses the following replacements: + * - actionUrl + * - heading + */ +export const headingRow = { + html: ` + + +

+ + {{ heading }} + +

+ + `, + text: ` +{{ heading }} +`, +} + +/* + * lead1Row uses the following replacements: + * - actionUrl + * - lead + */ +export const lead1Row = { + html: ` + + +

+ + {{ lead }} + +

+ + `, + text: `{{ textLead }} + {{ actionUrl }} + `, +} + +/* + * Helper methods to wrap the body with all it takes + * Uses the following replacements: + * - title + * - intro + * - body + * - urlWebsite + * - urlWhy + * - whyDidIGetThis + */ +export const wrap = { + html: (body) => ` + + + + + + + + + {{ title }} + + + + +
+ {{ intro }} + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ +
+
+ + + + + + + + +
+ +`, + text: (body) => ` +${body} + +-- +FreeSewing +Plantin en Moretuslei 69 +Antwerp +Belgium + +{{ website }} : {{ urlWebsite }} +Github : https://github.com/fresewing/freesewing +Discord : https://discord.freesewing.org/ +Twitter : https://twitter.com/freesewing_org +{{ whyDidIGetThis }} : {{ urlWhy }} +`, +} diff --git a/sites/backend/src/templates/email/emailchange.mjs b/sites/backend/src/templates/email/emailchange.mjs new file mode 100644 index 00000000000..7b6ed39e0f3 --- /dev/null +++ b/sites/backend/src/templates/email/emailchange.mjs @@ -0,0 +1,23 @@ +import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - actionUrl + * - heading + * - lead + * - button + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const emailChange = { + html: wrap.html(` + ${headingRow.html} + ${lead1Row.html} + ${buttonRow.html} + ${closingRow.html} + `), + text: wrap.text(`${headingRow.text}${lead1Row.text}${buttonRow.text}${closingRow.text}`), +} diff --git a/sites/backend/src/templates/email/goodbye.mjs b/sites/backend/src/templates/email/goodbye.mjs new file mode 100644 index 00000000000..6375ee0e154 --- /dev/null +++ b/sites/backend/src/templates/email/goodbye.mjs @@ -0,0 +1,38 @@ +import { headingRow, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - heading + * - lead1 + * - lead2 + * - greeting + * - ps + */ +export const goodbye = { + html: wrap.html(` + ${headingRow.html} + + +

+ {{ lead1 }} +
+ {{ lead2 }} +

+ {{ greeting }}, +
+ joost +

+ PS: {{ ps }}. +

+ + +`), + text: wrap.text(`${headingRow.text} +{{lead1}} +{{lead2}} + +{{greeting}} +joost + +PS: {{ps}}`), +} diff --git a/sites/backend/src/templates/email/index.mjs b/sites/backend/src/templates/email/index.mjs new file mode 100644 index 00000000000..5c454b3bb19 --- /dev/null +++ b/sites/backend/src/templates/email/index.mjs @@ -0,0 +1,15 @@ +import { emailChange } from './emailchange.mjs' +import { goodbye } from './goodbye.mjs' +import { loginLink } from './loginlink.mjs' +import { newsletterSub } from './newslettersub.mjs' +import { passwordReset } from './passwordreset.mjs' +import { signup } from './signup.mjs' + +export const templates = { + emailChange, + goodbye, + loginLink, + newsletterSub, + passwordReset, + signup, +} diff --git a/sites/backend/src/templates/email/loginlink.mjs b/sites/backend/src/templates/email/loginlink.mjs new file mode 100644 index 00000000000..556bbe810c3 --- /dev/null +++ b/sites/backend/src/templates/email/loginlink.mjs @@ -0,0 +1,39 @@ +import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - actionUrl + * - heading + * - lead + * - button + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const loginLink = { + html: wrap.html(` + ${headingRow} + + +

+ {{ prelead }} +
+
+ + {{ lead }}: + +

+ + + ${buttonRow.text} + ${closingRow.text} +`), + text: wrap.text(`${headingRow.text} +{{ prelead }} +{{lead }} +${buttonRow.text} +${closingRow.text} +`), +} diff --git a/sites/backend/src/templates/email/newslettersub.mjs b/sites/backend/src/templates/email/newslettersub.mjs new file mode 100644 index 00000000000..1f1b456d910 --- /dev/null +++ b/sites/backend/src/templates/email/newslettersub.mjs @@ -0,0 +1,28 @@ +import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - actionUrl + * - heading + * - lead + * - button + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const newsletterSub = { + html: wrap.html(` + ${headingRow.html} + ${lead1Row.html} + ${buttonRow.html} + ${closingRow.html} +`), + text: wrap.text(` +${headingRow.text} +${lead1Row.text} +${buttonRow.text} +${closingRow.text} +`), +} diff --git a/sites/backend/src/templates/email/passwordreset.mjs b/sites/backend/src/templates/email/passwordreset.mjs new file mode 100644 index 00000000000..4c81b14fd3d --- /dev/null +++ b/sites/backend/src/templates/email/passwordreset.mjs @@ -0,0 +1,41 @@ +import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - actionUrl + * - heading + * - lead + * - button + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const passwordReset = { + html: wrap.html(` + ${headingRow.html} + + +

+ You forgot your FreeSewing password and that's fine. +
+
+ + To re-gain access to your account, click the big black rectangle below: + +

+ + + ${buttonRow.html} + ${closingRow.html} +`), + test: wrap.text(`${headingRow.text} +You forgot your FreeSewing password and that's fine. + +To re-gain access to your account, click the link below: + +${buttonRow.text} +${closingRow.text} +`), +} diff --git a/sites/backend/src/templates/email/signup.mjs b/sites/backend/src/templates/email/signup.mjs new file mode 100644 index 00000000000..fd8839b4ece --- /dev/null +++ b/sites/backend/src/templates/email/signup.mjs @@ -0,0 +1,28 @@ +import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' + +/* + * Used the following replacements: + * - actionUrl + * - heading + * - lead + * - button + * - closing + * - greeting + * - ps-pre-link + * - ps-link + * - ps-post-link + */ +export const signup = { + html: wrap.html(` + ${headingRow.html} + ${lead1Row.html} + ${buttonRow.html} + ${closingRow.html} +`), + text: wrap.text(` + ${headingRow.text} + ${lead1Row.text} + ${buttonRow.text} + ${closingRow.text} +`), +} diff --git a/sites/backend/src/utils/crypto.mjs b/sites/backend/src/utils/crypto.mjs index 6fe57d1db17..d3bbebec76b 100644 --- a/sites/backend/src/utils/crypto.mjs +++ b/sites/backend/src/utils/crypto.mjs @@ -12,7 +12,7 @@ export const hash = (string) => createHash('sha256').update(string).digest('hex' * Generates a random string * * This is not used in anything cryptographic. It is only used as a temporary - * username to avoid username collisions + * username to avoid username collisions or to generate (long) API key secrets */ export const randomString = (bytes = 8) => randomBytes(bytes).toString('hex') diff --git a/sites/backend/src/utils/email.mjs b/sites/backend/src/utils/email.mjs index 252dd7db212..e709105b07a 100644 --- a/sites/backend/src/utils/email.mjs +++ b/sites/backend/src/utils/email.mjs @@ -1,5 +1,5 @@ import axios from 'axios' -import { templates } from '../templates/email.mjs' +import { templates } from '../templates/email/index.mjs' // FIXME: Update this after we re-structure the i18n package import en from '../../../../packages/i18n/dist/en/email.mjs' import nl from '../../../../packages/i18n/dist/en/email.mjs' diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs index 1c59fe82bed..7283d28cd1a 100644 --- a/sites/backend/tests/user.test.mjs +++ b/sites/backend/tests/user.test.mjs @@ -243,7 +243,7 @@ describe(`${user} Signup flow and authentication`, () => { step(`${user} Should load account with JWT`, (done) => { chai .request(config.api) - .get('/account') + .get('/account/jwt') .set('Authorization', 'Bearer ' + store.token) .end((err, res) => { expect(res.status).to.equal(200) @@ -257,297 +257,62 @@ describe(`${user} Signup flow and authentication`, () => { done() }) }) -}) -describe(`${user} Account management`, () => { - /* TODO: Need to do this once we have a UI to kick the tires - step(`${user} Should update the account avatar`, (done) => { + step(`${user} Should load account with JWT (whoami)`, (done) => { chai .request(config.api) - .put('/account') + .get('/whoami/jwt') .set('Authorization', 'Bearer ' + store.token) - .send({ - avatar: data.avatar, - }) - .end((err, res) => { - console.log(res) - expect(res.status).to.equal(200) - expect(res.type).to.equal('application/json') - expect(res.charset).to.equal('utf-8') - expect(res.body.result).to.equal(`success`) - done() - }) - }) - - it(`${user} Should update the account username`, (done) => { - chai - .request(config.api) - .put('/account') - .set('Authorization', 'Bearer ' + store.token) - .send({ - username: data..username + '_updated', - }) .end((err, res) => { expect(res.status).to.equal(200) expect(res.type).to.equal('application/json') expect(res.charset).to.equal('utf-8') expect(res.body.result).to.equal(`success`) expect(res.body.account.email).to.equal(data.email) - expect(res.body.account.username).to.equal(store.username+'_updated') - expect(res.body.account.lusername).to.equal(store.username.toLowerCase()+'_updated') + expect(res.body.account.username).to.equal(store.username) + expect(res.body.account.lusername).to.equal(store.username.toLowerCase()) expect(typeof res.body.account.id).to.equal(`number`) done() }) }) - */ -}) -/* - - -describe('Account management', () => { - it('should update the account avatar', (done) => { + step(`${user} Create API Key`, (done) => { chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) + .request(config.api) + .post('/apikey/jwt') + .set('Authorization', 'Bearer ' + store.token) .send({ - avatar: config.avatar, + name: 'Test API key', + level: 4, + expiresIn: 60, }) .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.pictureUris.l.slice(-4).should.equal('.png') - done() - }) - }) - it('should update the account username', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - username: config.user.username + '_updated', - }) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.username.should.equal(config.user.username + '_updated') + expect(res.status).to.equal(200) + expect(res.type).to.equal('application/json') + expect(res.charset).to.equal('utf-8') + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.apikey.key).to.equal('string') + expect(typeof res.body.apikey.secret).to.equal('string') + expect(typeof res.body.apikey.expiresAt).to.equal('string') + expect(res.body.apikey.level).to.equal(4) + store.apikey = res.body.apikey done() }) }) - it('should restore the account username', (done) => { + step(`${user} Read API Key with api key (whoami)`, (done) => { chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - username: config.user.username, - }) + .request(config.api) + .get(`/whoami/key`) + .auth(store.apikey.key, store.apikey.secret) .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.username.should.equal(config.user.username) - done() - }) - }) - - it('should not update the account username if that username is taken', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - username: 'admin', - }) - .end((err, res) => { - res.should.have.status(400) - res.text.should.equal('usernameTaken') - done() - }) - }) - - it('should update the account bio', (done) => { - let bio = 'This is the test bio ' - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - bio: bio, - }) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.bio.should.equal(bio) - done() - }) - }) - - it('should update the account language', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - settings: { - language: 'nl', - }, - }) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.settings.language.should.equal('nl') - done() - }) - }) - - it('should update the account units', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - settings: { - units: 'imperial', - }, - }) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.account.settings.units.should.equal('imperial') - done() - }) - }) - - for (let network of ['github', 'twitter', 'instagram']) { - it(`should update the account's ${network} username`, (done) => { - let data = { social: {} } - data.social[network] = network - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send(data) - .end((err, res) => { - res.should.have.status(200) - JSON.parse(res.text).account.social[network].should.equal(network) - done() - }) - }) - } - - it('should update the account password', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - password: 'changeme', - }) - .end((err, res) => { - res.should.have.status(200) - done() - }) - }) - - it('should login with the new password', (done) => { - chai - .request(config.backend) - .post('/login') - .send({ - username: config.user.username, - password: 'changeme', - }) - .end((err, res) => { - res.should.have.status(200) - done() - }) - }) - - it('should restore the account password', (done) => { - chai - .request(config.backend) - .put('/account') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - password: config.user.password, - }) - .end((err, res) => { - res.should.have.status(200) + expect(res.status).to.equal(200) + expect(res.type).to.equal('application/json') + expect(res.charset).to.equal('utf-8') + expect(res.body.result).to.equal(`success`) + const checks = ['key', 'level', 'expiresAt', 'name', 'userId'] + checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i])) done() }) }) }) - -describe('Other user endpoints', () => { - it("should load a user's profile", (done) => { - chai - .request(config.backend) - .get('/users/admin') - .set('Authorization', 'Bearer ' + config.user.token) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.username.should.equal('admin') - done() - }) - }) - - it('should confirm that a username is available', (done) => { - chai - .request(config.backend) - .post('/available/username') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - username: Date.now() + ' ' + Date.now(), - }) - .end((err, res) => { - res.should.have.status(200) - done() - }) - }) - - it('should confirm that a username is not available', (done) => { - chai - .request(config.backend) - .post('/available/username') - .set('Authorization', 'Bearer ' + config.user.token) - .send({ - username: 'admin', - }) - .end((err, res) => { - res.should.have.status(400) - done() - }) - }) - - it('should load the patron list', (done) => { - chai - .request(config.backend) - .get('/patrons') - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data['2'].should.be.an('array') - data['4'].should.be.an('array') - data['8'].should.be.an('array') - done() - }) - }) - - it('should export the user data', (done) => { - chai - .request(config.backend) - .get('/account/export') - .set('Authorization', 'Bearer ' + config.user.token) - .end((err, res) => { - res.should.have.status(200) - let data = JSON.parse(res.text) - data.export.should.be.a('string') - store.exportLink = data.export - done() - }) - }) - */ diff --git a/sites/email/build_local/call-to-action.html b/sites/email/build_local/call-to-action.html deleted file mode 100644 index c601800abf4..00000000000 --- a/sites/email/build_local/call-to-action.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - - - - - - A Responsive Email Template - - - - - - - -
- Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through... - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ -
-
- - - - - - - - - - - -
- - - diff --git a/sites/email/build_local/emailchange.html b/sites/email/build_local/emailchange.html index 3c73a2f94c7..172bbe9fba1 100644 --- a/sites/email/build_local/emailchange.html +++ b/sites/email/build_local/emailchange.html @@ -37,19 +37,9 @@ .border { border-width: 1px !important } -.p-15px { - padding: 15px !important -} -.py-40px { - padding-top: 40px !important; - padding-bottom: 40px !important -} .pt-7 { padding-top: 28px !important } -.pt-5px { - padding-top: 5px !important -} /* Your custom utility classes */ /* * Here is where you can define your custom utility classes. @@ -88,22 +78,10 @@ .sm-p-15px { padding: 15px !important } - .sm-py-30px { - padding-top: 30px !important; - padding-bottom: 30px !important - } .sm-py-10px { padding-top: 10px !important; padding-bottom: 10px !important } - .sm-py-50px { - padding-top: 50px !important; - padding-bottom: 50px !important - } - .sm-px-15px { - padding-left: 15px !important; - padding-right: 15px !important - } } - - - A Responsive Email Template - - - - - - - -
- Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through... - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ -
-
- - - - - - - - - - - -
- - - diff --git a/sites/email/src/templates/call-to-action.html b/sites/email/src/templates/call-to-action.html deleted file mode 100644 index 81d7d935dfd..00000000000 --- a/sites/email/src/templates/call-to-action.html +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "A Responsive Email Template" -preheader: "Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through..." ---- - - - - - - - - -
- - - - - -
- - - - - - - -
- - - - - - - - -
-

{{{ main_title }}}

-
-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est. Aenean at mollis ipsum. -

-
-
- - - - - -
- - - - -
- Learn More   → -
-
-
-
- -
-
-
diff --git a/sites/email/src/templates/text-only.html b/sites/email/src/templates/text-only.html deleted file mode 100644 index a7bfae88854..00000000000 --- a/sites/email/src/templates/text-only.html +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "A Responsive Email Template" -preheader: "Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through..." ---- - - - - - - - - -
- - - - - -
- - - - -
- - - - - -
-

- Yo, -

- Message goes here dog. -

- Cheers, -
- The Team -

-
-
-
- -
-
-