diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index ade2d347e11..17d1ca62998 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -39,27 +39,29 @@ model Subscriber { model User { id Int @id @default(autoincrement()) + apikeys Apikey[] + bio String @default("") + confirmations Confirmation[] consent Int @default(0) createdAt DateTime @default(now()) - data String @default("{}") ehash String @unique email String + github String @default("") ihash String initial String + imperial Boolean @default(false) + language String @default("en") lastLogin DateTime? + lusername String @unique newsletter Boolean @default(false) password String patron Int @default(0) - apikeys Apikey[] - confirmations Confirmation[] - people Person[] patterns Pattern[] + people Person[] role String @default("user") status Int @default(0) updatedAt DateTime? @updatedAt username String - lusername String @unique - @@index([ihash]) } diff --git a/sites/backend/prisma/schema.sqlite b/sites/backend/prisma/schema.sqlite index f1e61d7f74f..103a26d7ed8 100644 Binary files a/sites/backend/prisma/schema.sqlite and b/sites/backend/prisma/schema.sqlite differ diff --git a/sites/backend/src/controllers/admin.mjs b/sites/backend/src/controllers/admin.mjs deleted file mode 100644 index 76dcfcc8dcc..00000000000 --- a/sites/backend/src/controllers/admin.mjs +++ /dev/null @@ -1,745 +0,0 @@ -//import { log, email, ehash, newHandle, uniqueHandle } from '../utils' -import jwt from 'jsonwebtoken' -import axios from 'axios' -//import path from 'path' -//import fs from 'fs' -import Zip from 'jszip' -//import rimraf from 'rimraf' -import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { clean, asJson } from '../utils/index.mjs' -import { getUserAvatar } from '../utils/sanity.mjs' -import { log } from '../utils/log.mjs' -import { emailTemplate } from '../utils/email.mjs' -import set from 'lodash.set' -import { UserModel } from '../models/user.mjs' - -/* - * Prisma is not an ORM and we can't attach methods to the model - * So here's a bunch of helper methods that expect a user object - * as input - */ -const asAccount = (user, decrypt) => ({ - 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/user.mjs b/sites/backend/src/controllers/user.mjs index cd2bf037e6b..09e55e0c964 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -1,26 +1,5 @@ -import jwt from 'jsonwebtoken' -import axios from 'axios' -import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { clean, asJson } from '../utils/index.mjs' -import { getUserAvatar } from '../utils/sanity.mjs' -import { log } from '../utils/log.mjs' -import set from 'lodash.set' import { UserModel } from '../models/user.mjs' -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() {} /* @@ -62,6 +41,11 @@ UserController.prototype.login = async function (req, res, tools) { return User.sendResponse(res) } +/* + * Returns the account of the authenticated user (with JWT) + * + * See: https://freesewing.dev/reference/backend/api + */ UserController.prototype.whoami = async (req, res, tools) => { const User = new UserModel(tools) await User.readAsAccount({ id: req.user.uid }) @@ -69,433 +53,15 @@ UserController.prototype.whoami = async (req, res, tools) => { return User.sendResponse(res) } +/* + * Updates the account of the authenticated user + * + * See: https://freesewing.dev/reference/backend/api + */ UserController.prototype.update = async (req, res, tools) => { const User = new UserModel(tools) await User.read({ id: req.user.uid }) + await User.unsafeUpdate(req.body) - // Commit - //await User.update({ id: req.user.uid }, req.body) - // 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({}) + return User.sendResponse(res) } - -/* - -// 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/models/user.mjs b/sites/backend/src/models/user.mjs index d825e7dea3f..a97822f6819 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -39,7 +39,8 @@ UserModel.prototype.read = async function (where) { UserModel.prototype.reveal = async function (where) { this.clear = {} if (this.record) { - this.clear.data = JSON.parse(this.decrypt(this.record.data)) + this.clear.bio = this.decrypt(this.record.bio) + this.clear.github = this.decrypt(this.record.github) this.clear.email = this.decrypt(this.record.email) this.clear.initial = this.decrypt(this.record.initial) } @@ -47,6 +48,18 @@ UserModel.prototype.reveal = async function (where) { return this } +/* + * Helper method to encrypt at-rest data + */ +UserModel.prototype.cloak = function (data) { + for (const field of ['bio', 'github', 'email']) { + if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) + } + if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password)) + + return data +} + /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data @@ -125,8 +138,9 @@ UserModel.prototype.setExists = function () { UserModel.prototype.create = async function ({ body }) { if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (!body.email) return this.setResponse(400, 'emailMissing') - if (!body.password) return this.setResponse(400, 'passwordMissing') if (!body.language) return this.setResponse(400, 'languageMissing') + if (!this.config.languages.includes(body.language)) + return this.setResponse(400, 'unsupportedLanguage') const ehash = hash(clean(body.email)) await this.read({ ehash }) @@ -145,8 +159,10 @@ UserModel.prototype.create = async function ({ body }) { initial: email, username, lusername: username, - data: this.encrypt(asJson({ settings: { language: this.language } })), - password: asJson(hashPassword(body.password)), + language: body.language, + password: asJson(hashPassword(randomString())), // We'll change this later + github: this.encrypt(''), + bio: this.encrypt(''), } this.record = await this.prisma.user.create({ data }) } catch (err) { @@ -300,41 +316,92 @@ UserModel.prototype.safeUpdate = async function (data) { */ UserModel.prototype.unsafeUpdate = async function (body) { const data = {} - // Update consent + const notes = [] + // Bio + if (typeof body.bio === 'string') data.bio = body.bio + // Consent if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent - // Update newsletter + // Github + if (typeof body.github === 'string') data.github = body.github.split('@').pop() + // Imperial + if ([true, false].includes(body.imperial)) data.imperial = body.imperial + // Language + if (this.config.languages.includes(body.language)) data.language = body.language + // Newsletter if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter - // Update username + // Password + if (typeof body.password === 'string') data.password = body.password // Will be cloaked below + // Patron + if ([0, 2, 4, 8].includes(body.patron)) data.patron = body.patron + // Username if (typeof body.username === 'string') { - if (await this.isAvailableUsername(body.username)) { + const available = await this.isLusernameAvailable(body.username) + if (available) { data.username = body.username.trim() data.lusername = clean(body.username) + } else { + log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`) + notes.push('usernameChangeRejected') } } - // Update password - if (typeof body.password === 'string') { - data.password = asJson(hashPassword(body.password)) - } - // Update data - if (typeof body.data === 'object') { - data.data = { ...this.record.data } + + // Now update the record + await this.safeUpdate(this.cloak(data)) + + // Email change requires confirmation + if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { + if (typeof body.confirmation === 'string') { + // Retrieve confirmation record + await this.Confirmation.read({ id: body.confirmation }) + + if (!this.Confirmation.exists) { + log.warn(err, `Could not find confirmation id ${params.id}`) + return this.setResponse(404, 'failedToFindConfirmationId') + } + + if (this.Confirmation.record.type !== 'emailchange') { + log.warn(err, `Confirmation mismatch; ${params.id} is not an emailchange id`) + return this.setResponse(404, 'confirmationIdTypeMismatch') + } + + const data = this.Confirmation.clear.data + if (data.email.current === this.clear.email && typeof data.email.new === 'string') { + await this.saveUpdate({ + email: this.encrypt(data.email), + }) + } + } else { + // Create confirmation for email change + this.confirmation = await this.Confirmation.create({ + type: 'emailchange', + data: { + language: this.record.language, + email: { + current: this.clear.email, + new: body.email, + }, + }, + userId: this.record.id, + }) + // Send confirmation email + await this.mailer.send({ + template: 'emailchange', + language: this.record.language, + to: body.email, + cc: this.clear.email, + replacements: { + actionUrl: i18nUrl(this.language, `/confirm/emailchange/${this.Confirmation.record.id}`), + whyUrl: i18nUrl(this.language, `/docs/faq/email/why-emailchange`), + supportUrl: i18nUrl(this.language, `/patrons/join`), + }, + }) + } } - /* - data String @default("{}") - ehash String @unique - email String - */ - - try { - this.record = await this.prisma.user.update({ where, data }) - } catch (err) { - log.warn(err, 'Could not update user record') - process.exit() - return this.setResponse(500, 'updateUserFailed') - } - - return this.setResponse(200) + return this.setResponse(200, false, { + result: 'success', + account: this.asAccount(), + }) } /* @@ -441,3 +508,21 @@ UserModel.prototype.loginOk = function () { account: this.asAccount(), }) } + +/* + * Check to see if a (lowercase) username is available + * as well as making sure username is not something we + * do not allow + */ +UserModel.prototype.isLusernameAvailable = async function (lusername) { + if (lusername.length < 2) return false + if (lusername.slice(0, 5) === 'user-') return false + let found + try { + found = await this.prisma.user.findUnique({ where: { lusername } }) + } catch (err) { + log.warn({ err, where }, 'Could not search for free username') + } + + return true +} diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index a7659968c4f..ea40ce6c81f 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -9,7 +9,8 @@ export const setup = async function (config, store, chai) { .request(config.api) .post('/signup') .send({ - ...store.account, + email: store.account.email, + language: store.account.language, unittest: true, }) .end((err, res) => { @@ -65,6 +66,28 @@ export const setup = async function (config, store, chai) { done() }) }) + + step(`${store.icon('user')} Should set the initial password`, (done) => { + chai + .request(config.api) + .put('/account/jwt') + .set('Authorization', 'Bearer ' + store.account.token) + .send({ + password: store.account.password, + }) + .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(store.account.email) + expect(res.body.account.username).to.equal(store.account.username) + expect(res.body.account.lusername).to.equal(store.account.username.toLowerCase()) + expect(typeof res.body.account.id).to.equal(`number`) + store.token = res.body.token + done() + }) + }) }) } diff --git a/sites/backend/tests/user.mjs b/sites/backend/tests/user.mjs index e85c7f851ff..a0fa2fe0368 100644 --- a/sites/backend/tests/user.mjs +++ b/sites/backend/tests/user.mjs @@ -19,7 +19,6 @@ export const userTests = async (config, store, chai) => { const fields = { email: 'test@freesewing.dev', - password: 'test', language: 'fr', } Object.keys(fields).map((key) => { @@ -193,7 +192,6 @@ export const userTests = async (config, store, chai) => { done() }) }) - step(`${store.icon('user', 'jwt')} Should load account (jwt)`, (done) => { chai .request(config.api)