diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 56b50fae500..29bf0a52223 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -19,11 +19,11 @@ const config = { db: { url: process.env.API_DB_URL, }, + tests: { + domain: process.env.TESTDOMAIN || 'mailtrap.freesewing.dev', + }, static: process.env.API_STATIC, storage: process.env.API_STORAGE, - hashing: { - saltRounds: 10, - }, encryption: { key: process.env.API_ENC_KEY, }, @@ -37,6 +37,11 @@ const config = { aws: { ses: { region: 'us-east-1', + from: 'FreeSewing ', + replyTo: ['FreeSewing '], + feedback: 'bounce@freesewing.org', + cc: [], + bcc: ['records@freesewing.org'], }, }, sanity: { diff --git a/sites/backend/src/controllers/admin.js b/sites/backend/src/controllers/admin.js deleted file mode 100644 index 047be91bf87..00000000000 --- a/sites/backend/src/controllers/admin.js +++ /dev/null @@ -1,177 +0,0 @@ -import { User, Person, Pattern, Newsletter } from '../models' -import jwt from 'jsonwebtoken' -import config from '../config' -import { ehash } from '../utils' - -function AdminController() {} - -AdminController.prototype.search = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.find({ - $or: [ - { handle: { $regex: `.*${req.body.query}.*` } }, - { username: { $regex: `.*${req.body.query}.*` } }, - { ehash: ehash(req.body.query) }, - ], - }) - .sort('username') - .exec((err, users) => { - if (err) return res.sendStatus(400) - Person.find({ handle: { $regex: `.*${req.body.query}.*` } }) - .sort('handle') - .exec((err, people) => { - if (err) return res.sendStatus(400) - if (users === null && people === null) return res.sendStatus(404) - return res.send({ - users: users.map((user) => user.adminProfile()), - people: people.map((person) => person.info()), - }) - }) - }) - }) -} - -AdminController.prototype.setPatronStatus = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.findOne({ handle: req.body.handle }, (err, user) => { - if (err) return res.sendStatus(400) - if (user === null) return res.sendStatus(404) - user.patron = req.body.patron - return saveAndReturnAccount(res, user) - }) - }) -} - -AdminController.prototype.setRole = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.findOne({ handle: req.body.handle }, (err, user) => { - if (err) return res.sendStatus(400) - if (user === null) return res.sendStatus(404) - user.role = req.body.role - return saveAndReturnAccount(res, user) - }) - }) -} - -AdminController.prototype.unfreeze = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.findOne({ handle: req.body.handle }, (err, user) => { - if (err) return res.sendStatus(400) - if (user === null) return res.sendStatus(404) - user.status = 'active' - return saveAndReturnAccount(res, user) - }) - }) -} - -AdminController.prototype.impersonate = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.findOne({ handle: req.body.handle }, (err, user) => { - if (err) return res.sendStatus(400) - if (user === null) return res.sendStatus(404) - 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 })) - }) - }) - }) - }) -} - -AdminController.prototype.patronList = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.find({ patron: { $gt: 0 } }, (err, patronList) => { - if (err) return res.sendStatus(500) - return res.send(patronList) - }) - }) -} - -AdminController.prototype.subscriberList = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.find({ newsletter: true }, (err, subscribedUsers) => { - if (err) return res.sendStatus(500) - let subscribers = subscribedUsers.map((user) => ({ - ehash: user.ehash, - email: user.email, - })) - Newsletter.find({}, (err, subs) => { - if (err) return res.sendStatus(500) - return res.send(subscribers.concat(subs)) - }) - }) - }) -} - -AdminController.prototype.stats = function (req, res) { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, admin) => { - if (err || admin === null) return res.sendStatus(400) - if (admin.role !== 'admin') return res.sendStatus(403) - User.find({ 'consent.profile': true }, (err, users) => { - if (err) return res.sendStatus(500) - Person.find({}, (err, people) => { - if (err) return res.sendStatus(500) - Pattern.find({}, (err, patterns) => { - return res.send({ - users: users.length, - people: people.length, - patterns: patterns.length, - }) - }) - }) - }) - }) -} - -function saveAndReturnAccount(res, user) { - user.save(function (err, updatedUser) { - if (err) { - return res.sendStatus(500) - } else return res.send({ account: updatedUser.account() }) - }) -} - -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 - ) -} - -export default AdminController diff --git a/sites/backend/src/controllers/auth.js b/sites/backend/src/controllers/auth.js deleted file mode 100644 index 79c7dcbff19..00000000000 --- a/sites/backend/src/controllers/auth.js +++ /dev/null @@ -1,225 +0,0 @@ -import { User, Person, Pattern, Confirmation } from '../models' -import { - createUrl, - getHash, - getToken, - getHandle, - createHandle, - imageType, - saveAvatarFromBase64, -} from '../utils' -import config from '../config' -import queryString from 'query-string' -import axios from 'axios' - -/** This is essentially part of the user controller, but - * it seemed best to keep all this authentication stuff - * somewhat apart - */ - -function AuthController() {} - -AuthController.prototype.initOauth = function (req, res) { - if (!req.body) return res.sendStatus(400) - let confirmation = new Confirmation({ - type: 'oauth', - data: { - language: req.body.language, - provider: req.body.provider, - }, - }) - confirmation.save(function (err) { - if (err) return res.sendStatus(500) - return res.send({ state: confirmation._id }) - }) -} - -AuthController.prototype.loginOauth = function (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) - if (String(confirmation.data.validation) !== String(req.body.validation)) - return res.sendStatus(401) - let signup = confirmation.data.signup - 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) - 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 - let patterns = {} - Pattern.find({ user: user.handle }, (err, patternList) => { - if (err) return res.sendStatus(400) - for (let pattern of patternList) patterns[pattern.handle] = pattern - confirmation.remove((err) => { - if (err !== null) return res.sendStatus(500) - user.updateLoginTime(() => res.send({ account, people, token, signup })) - }) - }) - }) - }) - }) -} - -AuthController.prototype.providerCallback = function (req, res) { - let language, token, email, avatarUri, username - let provider = req.params.provider - let conf = config.oauth[provider] - let signup = false - - // Verify state - Confirmation.findById(req.query.state, (err, confirmation) => { - if (err) return res.sendStatus(400) - if (confirmation === null) return res.sendStatus(401) - if (String(confirmation._id) !== String(req.query.state)) return res.sendStatus(401) - if (confirmation.data.provider !== provider) return res.sendStatus(401) - - language = confirmation.data.language - // Get access token - const go = axios.create({ baseURL: '', timeout: 5000 }) - go.post(conf.tokenUri, { - client_id: conf.clientId, - client_secret: conf.clientSecret, - code: req.query.code, - accept: 'json', - grant_type: 'authorization_code', - redirect_uri: config.api + '/oauth/callback/from/' + provider, - }) - .then((result) => { - if (result.status !== 200) return res.sendStatus(401) - if (provider === 'github') token = queryString.parse(result.data).access_token - else if (provider === 'google') token = result.data.access_token - // Contact API for user info - const headers = (token) => ({ headers: { Authorization: 'Bearer ' + token } }) - go.get(conf.dataUri, headers(token)) - .then(async (result) => { - if (provider === 'github') { - ;(email = await getGithubEmail(result.data.email, go, conf.emailUri, headers(token))), - (avatarUri = result.data.avatar_url) - username = result.data.login - } else if (provider === 'google') { - for (let address of result.data.emailAddresses) { - if (address.metadata.primary === true) email = address.value - } - for (let photo of result.data.photos) { - if (photo.metadata.primary === true) avatarUri = photo.url - } - for (let name of result.data.names) { - if (name.metadata.primary === true) username = name.displayName - } - } - User.findOne({ ehash: getHash(email) }, (err, user) => { - if (err) return res.sendStatus(400) - if (user === null) { - // New user: signup - signup = true - let handle = getHandle() - go.get(avatarUri, { responseType: 'arraybuffer' }).then((avatar) => { - let type = imageType(avatar.headers['content-type']) - saveAvatarFromBase64( - new Buffer(avatar.data, 'binary').toString('base64'), - handle, - type - ) - let userData = { - picture: handle + '.' + type, - email: email, - initial: email, - ehash: getHash(email), - handle, - username: username, - settings: { language: language }, - social: { - github: '', - twitter: '', - instagram: '', - }, - time: { - created: new Date(), - login: new Date(), - }, - } - if (provider === 'github') { - userData.social.github = result.data.login - userData.bio = result.data.bio - } - let user = new User(userData) - user.save(function (err) { - if (err) return res.sendStatus(500) - let validation = createHandle(20) - confirmation.data.handle = user.handle - confirmation.data.validation = validation - confirmation.data.signup = true - confirmation.save(function (err) { - if (err) return res.sendStatus(500) - return res.redirect( - createUrl( - language, - signup - ? '/confirm/signup/' + req.query.state + '/' - : '/login/callback/' + confirmation._id + '/' + validation - ) - ) - }) - }) - }) - } else { - // Existing user - if (provider === 'github') { - if (user.bio === '') user.bio = result.data.bio - user.social.github = result.data.login - } - user.save(function (err) { - let validation = createHandle(20) - confirmation.data.handle = user.handle - confirmation.data.validation = validation - confirmation.data.signup = false - confirmation.save(function (err) { - if (err) return res.sendStatus(500) - return res.redirect( - // Watch out for pending users - createUrl( - language, - user.status === 'pending' - ? '/confirm/signup/' + req.query.state + '/' - : '/login/callback/' + confirmation._id + '/' + validation - ) - ) - }) - }) - } - }) - }) - .catch((err) => { - console.log('api token error', err) - res.sendStatus(401) - }) - }) - .catch((err) => { - console.log('post token error', err) - res.sendStatus(401) - }) - }) -} - -/* - * Github does not always return the email address - * See https://github.com/freesewing/backend/issues/162 - */ -const getGithubEmail = async (email, client, uri, headers) => { - if (email === null) { - return client.get(uri, headers).then((result) => { - for (let e of result.data) { - if (e.primary) return e.email - } - }) - } else return email -} - -export default AuthController diff --git a/sites/backend/src/controllers/github.js b/sites/backend/src/controllers/github.js deleted file mode 100644 index 349b38792d4..00000000000 --- a/sites/backend/src/controllers/github.js +++ /dev/null @@ -1,103 +0,0 @@ -import axios from 'axios' -import config from '../config' - -function GithubController() {} - -// Create a gist -GithubController.prototype.createGist = function (req, res) { - if (!req.body.data) return res.sendStatus(400) - let client = GithubClient() - client - .post('/gists', { - public: true, - description: `An open source sewing pattern from freesewing.org`, - files: { - 'pattern.yaml': { content: req.body.data }, - }, - }) - .then((result) => { - let id = result.data.id - client - .post(`/gists/${id}/comments`, { - body: `👉 https://freesewing.org/recreate/gist/${id} 👀`, - }) - .then((result) => res.send({ id })) - .catch((err) => res.sendStatus(500)) - }) - .catch((err) => res.sendStatus(500)) -} - -GithubController.prototype.createIssue = function (req, res) { - if (!req.body.data) return res.sendStatus(400) - if (!req.body.design) return res.sendStatus(400) - let client = GithubClient() - client - .post('/gists', { - public: true, - description: `A FreeSewing crash report`, - files: { - 'pattern.yaml': { content: req.body.data }, - 'settings.yaml': { content: req.body.patternProps.settings }, - 'events.yaml': { content: req.body.patternProps.events }, - 'errors.md': { content: req.body.traces }, - 'parts.json': { content: req.body.patternProps.parts }, - }, - }) - .then((gist) => { - client - .post('/repos/freesewing/freesewing/issues', { - title: `Error while drafting ${req.body.design}`, - body: `An error occured while drafting ${req.body.design} and a [crash report](https://gist.github.com/${gist.data.id}) was generated.`, - labels: [`:package: ${req.body.design}`, ':robot: robot'], - }) - .then((issue) => { - let notify = - typeof config.github.notify.specific[req.body.design] === 'undefined' - ? config.github.notify.dflt - : config.github.notify.specific[req.body.design] - let id = issue.data.number - let path = `/recreate/gist/${gist.data.id}` - let body = 'Ping ' - for (const user of notify) body += `@${user} ` - if (req.body.userGithub) body += `@${req.body.userGithub} ` - body += ' 👋 \nRecreate this:\n\n' - body += - `- **Lab**: 👉 https://lab.freesewing.dev/v/next/` + - `${req.body.design}?from=github&preload=${gist.data.id}` - body += '\n\n' - body += `- **Production**: 👉 https://freesewing.org${path}` - body += '\n\n' - if (req.body.userHandle) body += `(user handle: ${req.body.userHandle})` - client - .post(`/repos/freesewing/freesewing/issues/${id}/comments`, { body }) - .then((result) => res.send({ id })) - .catch((err) => { - console.log(err) - res.sendStatus(500) - }) - }) - .catch((err) => { - console.log(err) - res.sendStatus(500) - }) - }) - .catch((err) => { - console.log(err) - res.sendStatus(500) - }) -} - -const GithubClient = () => - axios.create({ - baseURL: config.github.api, - timeout: 5000, - auth: { - username: config.github.bot.user, - password: config.github.token, - }, - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }) - -export default GithubController diff --git a/sites/backend/src/controllers/newsletter.js b/sites/backend/src/controllers/newsletter.js deleted file mode 100644 index 0f4fe57dd1c..00000000000 --- a/sites/backend/src/controllers/newsletter.js +++ /dev/null @@ -1,93 +0,0 @@ -import { Newsletter, Confirmation, User } from '../models' -import { log, email, ehash } from '../utils' -import path from 'path' - -const bail = (res, page = 'index') => - res.sendFile(path.resolve(__dirname, '..', 'landing', `${page}.html`)) - -function NewsletterController() {} - -NewsletterController.prototype.subscribe = function (req, res, subscribe = true) { - if (!req.body || !req.body.email) return res.sendStatus(400) - let confirmation = new Confirmation({ - type: 'newsletter', - data: { email: req.body.email }, - }) - confirmation.save(function (err) { - if (err) return res.sendStatus(500) - log.info('newsletterSubscription', { - email: req.body.email, - confirmation: confirmation._id, - }) - email.subscribe(req.body.email, confirmation._id) - return res.send({ status: 'subscribed' }) - }) -} - -NewsletterController.prototype.confirm = function (req, res, subscribe = true) { - if (!req.params.token) return bail(res, 'invalid') - Confirmation.findById(req.params.token, (err, confirmation) => { - if (err) return bail(res) - if (confirmation === null) return bail(res) - Newsletter.findOne( - { - ehash: ehash(confirmation.data.email), - }, - (err, reader) => { - if (err) return bail(res) - // Already exists? - if (reader !== null) return bail(res, 'already-subscribed') - let hash = ehash(confirmation.data.email) - - let sub = new Newsletter({ - email: confirmation.data.email, - ehash: hash, - time: { - created: new Date(), - }, - }) - sub.save(function (err) { - if (err) { - log.error('newsletterSubscriptionFailed', sub) - console.log(err) - return res.sendStatus(500) - } else { - console.log(`Subscribed ${reader.email} to the newsletter`) - email.newsletterWelcome(confirmation.data.email, hash) - - return bail(res, 'subscribe') - } - }) - } - ) - }) -} - -NewsletterController.prototype.unsubscribe = function (req, res) { - if (!req.params.ehash) return bail(res, 'invalid') - - Newsletter.findOne({ ehash: req.params.ehash }, (err, reader) => { - if (reader) { - Newsletter.deleteOne({ id: reader.id }, (err, result) => { - if (!err) { - console.log(`Unsubscribed ${reader.email} from the newsletter`) - return bail(res, 'unsubscribe') - } else return bail(res, 'oops') - }) - } else { - User.findOne({ ehash: req.params.ehash }, (err, user) => { - if (user) { - user.newsletter = false - user.save(function (err, updatedUser) { - if (err) { - log.error('accountUpdateFailed', err) - return res.sendStatus(500) - } else return bail(res, 'unsubscribe') - }) - } else return bail(res, 'oops') - }) - } - }) -} - -export default NewsletterController diff --git a/sites/backend/src/controllers/og.js b/sites/backend/src/controllers/og.js deleted file mode 100644 index ced08fae27e..00000000000 --- a/sites/backend/src/controllers/og.js +++ /dev/null @@ -1,225 +0,0 @@ -import config from '../config' -import { capitalize } from '../utils' -import sharp from 'sharp' -import fs from 'fs' -import path from 'path' -import axios from 'axios' -import remark from 'remark' -import remarkParse from 'remark-parse' -import remarkFrontmatter from 'remark-frontmatter' -import toString from 'mdast-util-to-string' -import { Buffer } from 'buffer' -import yaml from 'yaml' - -// Sites for which we generate images -const sites = ['dev', 'org'] -// Langauges for which we generate images -const languages = ['en', 'fr', 'de', 'es', 'nl'] - -// Load template once at startup -const template = fs.readFileSync(path.resolve(...config.og.template), 'utf-8') - -/* Helper method to extract intro from strapi markdown */ -const introFromStrapiMarkdown = async (md, slug) => { - const tree = await remark().use(remarkParse).parse(md) - if (tree.children[0].type !== 'paragraph') - console.log('Markdown does not start with paragraph', slug) - - return toString(tree.children[0]) -} - -/* Helper method to extract title from markdown frontmatter */ -const titleAndIntroFromLocalMarkdown = async (md, slug) => { - const tree = await remark().use(remarkParse).use(remarkFrontmatter, ['yaml']).parse(md) - - if (tree.children[0].type !== 'yaml') - console.log('Markdown does not start with frontmatter', slug) - else - return { - title: titleAsLines(yaml.parse(tree.children[0].value).title), - intro: introAsLines(toString(tree.children.slice(1, 2))), - } - - return false -} - -/* Helper method to load dev blog post */ -const loadDevBlogPost = async (slug) => { - const result = await axios.get( - `${config.strapi}/blogposts?_locale=en&dev_eq=true&slug_eq=${slug}` - ) - if (result.data) - return { - title: titleAsLines(result.data[0].title), - intro: introAsLines(await introFromStrapiMarkdown(result.data[0].body, slug)), - sub: [ - result.data[0].author.displayname, - new Date(result.data[0].published_at).toString().split(' ').slice(0, 4).join(' '), - ], - lead: 'Developer Blog', - } - - return false -} - -/* Helper method to load markdown file from disk */ -const loadMarkdownFile = async (page, site, lang) => - fs.promises - .readFile(path.resolve('..', '..', 'markdown', site, ...page.split('/'), `${lang}.md`), 'utf-8') - .then(async (md) => - md - ? { - ...(await titleAndIntroFromLocalMarkdown(md, page)), - sub: ['freesewing.dev/', page], - lead: capitalize(page.split('/').shift()), - } - : false - ) - -/* Find longest possible place to split a string */ -const splitLine = (line, chars) => { - const words = line.split(' ') - if (words[0].length > chars) { - // Force a word break - return [line.slice(0, chars - 1) + '-', line.slice(chars - 1)] - } - // Glue chunks together until it's too long - let firstLine = '' - let max = false - for (const word of words) { - if (!max && `${firstLine}${word}`.length <= chars) firstLine += `${word} ` - else max = true - } - - return [firstLine, words.join(' ').slice(firstLine.length)] -} - -/* Divide title into lines to fit on image */ -const titleAsLines = (title) => { - // Does it fit on one line? - if (title.length <= config.og.chars.title_1) return [title] - // Does it fit on two lines? - let lines = splitLine(title, config.og.chars.title_1) - if (lines[1].length <= config.og.chars.title_2) return lines - // Three lines it is - return [lines[0], ...splitLine(lines[1], config.og.chars.title_2)] -} - -/* Divive intro into lines to fit on image */ -const introAsLines = (intro) => { - // Does it fit on one line? - if (intro.length <= config.og.chars.intro) return [intro] - // Two lines it is - return splitLine(intro, config.og.chars.intro) -} - -// Get title and intro -const getMetaData = { - dev: async (page) => { - const chunks = page.split('/') - // Home page - if (chunks.length === 1 && chunks[0] === '') - return { - title: ['FreeSewing.dev'], - intro: introAsLines( - 'FreeSewing API documentation and tutorials for developers and contributors' - ), - sub: ['Also featuring', ' our developers blog'], - lead: '.dev', - } - // Blog index page - if (chunks.length === 1 && chunks[0] === 'blog') - return { - title: titleAsLines('FreeSewing Developer Blog'), - intro: introAsLines( - 'Contains no sewing news whatsover. Only posts for (aspiring) developers :)' - ), - sub: ['freesewing.dev', '/blog'], - lead: 'Developer Blog', - } - // Blog post - if (chunks.length === 2 && chunks[0] === 'blog') { - return await loadDevBlogPost(chunks[1]) - } - // Other (MDX) page - const md = await loadMarkdownFile(page, 'dev', 'en') - - // Return markdown info or default generic data - return md - ? md - : { - title: titleAsLines('FreeSewing.dev'), - intro: introAsLines( - 'Documentation, guides, and howtos for contributors and developers alike' - ), - sub: ['https://freesewing.dev/', '<== Check it out'], - lead: 'freesewing.dev', - } - }, - org: async (page, site, lang) => ({}), -} - -/* Hide unused placeholders */ -const hidePlaceholders = (list) => { - let svg = template - for (const i of list) { - svg = svg.replace(`${i}title_1`, '').replace(`${i}title_2`, '').replace(`${i}title_3`, '') - } - - return svg -} - -/* Place text in SVG template */ -const decorateSvg = (data) => { - let svg - // Single title line - if (data.title.length === 1) { - svg = hidePlaceholders([2, 3]).replace(`1title_1`, data.title[0]) - } - // Double title line - else if (data.title.length === 2) { - svg = hidePlaceholders([1, 3]) - .replace(`2title_1`, data.title[0]) - .replace(`2title_2`, data.title[1]) - } - // Triple title line - else if (data.title.length === 3) { - svg = hidePlaceholders([1, 2]) - .replace(`3title_1`, data.title[0]) - .replace(`3title_2`, data.title[1]) - .replace(`3title_3`, data.title[2]) - } - - return svg - .replace('sub_1', data.sub[0] || '') - .replace('sub_2', data.sub[1] || '') - .replace(`intro_1`, data.intro[0] || '') - .replace(`intro_2`, data.intro[1] || '') - .replace('lead_1', data.lead || '') -} - -/* This generates open graph images */ - -function OgController() {} - -OgController.prototype.image = async function (req, res) { - // Extract path parameters - const { lang = 'en', site = 'dev' } = req.params - const page = req.params['0'] - if (sites.indexOf(site) === -1) return res.send({ error: 'sorry' }) - if (languages.indexOf(lang) === -1) return res.send({ error: 'sorry' }) - - // Load meta data - const data = await getMetaData[site](page, site, lang) - // Inject into SVG - const svg = decorateSvg(data) - // Turn into PNG - sharp(Buffer.from(svg, 'utf-8')) - .resize({ width: 1200 }) - .toBuffer((err, data, info) => { - if (err) console.log(err) - return res.type('png').send(data) - }) -} - -export default OgController diff --git a/sites/backend/src/controllers/pattern.js b/sites/backend/src/controllers/pattern.js deleted file mode 100644 index be3bab9025c..00000000000 --- a/sites/backend/src/controllers/pattern.js +++ /dev/null @@ -1,127 +0,0 @@ -import { User, Pattern } from '../models' -import { log } from '../utils' - -function PatternController() {} - -// CRUD basics -PatternController.prototype.create = (req, res) => { - if (!req.body) return res.sendStatus(400) - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, user) => { - if (err || user === null) return res.sendStatus(403) - let handle = uniqueHandle() - let pattern = new Pattern({ - handle, - user: user.handle, - person: req.body.person, - name: req.body.name, - notes: req.body.notes, - data: req.body.data, - created: new Date(), - }) - pattern.save(function (err) { - if (err) { - log.error('patternCreationFailed', user) - console.log(err) - return res.sendStatus(500) - } - log.info('patternCreated', { handle: pattern.handle }) - return res.send(pattern.anonymize()) - }) - }) -} - -PatternController.prototype.read = (req, res) => { - Pattern.findOne({ handle: req.params.handle }, (err, pattern) => { - if (err) return res.sendStatus(400) - if (pattern === null) return res.sendStatus(404) - return res.send(pattern.anonymize()) - }) -} - -PatternController.prototype.update = (req, res) => { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, async (err, user) => { - if (err || user === null) return res.sendStatus(400) - Pattern.findOne({ handle: req.params.handle }, (err, pattern) => { - if (err || pattern === null) return res.sendStatus(400) - if (typeof req.body.name === 'string') pattern.name = req.body.name - if (typeof req.body.notes === 'string') pattern.notes = req.body.notes - return saveAndReturnPattern(res, pattern) - }) - }) -} - -PatternController.prototype.delete = (req, res) => { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, async (err, user) => { - if (err || user === null) return res.sendStatus(400) - Pattern.deleteOne({ handle: req.params.handle, user: user.handle }, (err) => { - if (err) return res.sendStatus(400) - else return res.sendStatus(204) - }) - }) -} - -// Delete multiple -PatternController.prototype.deleteMultiple = function (req, res) { - if (!req.body) return res.sendStatus(400) - if (!req.body.patterns) return res.sendStatus(400) - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, user) => { - if (err || user === null) return res.sendStatus(400) - let patterns = req.body.patterns - if (patterns.length < 1) return res.sendStatus(400) - let handles = [] - for (let handle of patterns) handles.push({ handle }) - Pattern.deleteMany( - { - user: user.handle, - $or: handles, - }, - (err) => { - if (err) return res.sendStatus(500) - const patterns = {} - Patterns.find({ user: user.handle }, (err, patternList) => { - if (err) return res.sendStatus(400) - for (let pattern of patternList) patterns[pattern.handle] = pattern - res.send({ patterns }) - }) - } - ) - }) -} - -function saveAndReturnPattern(res, pattern) { - pattern.save(function (err, updatedPattern) { - if (err) { - log.error('patternUpdateFailed', updatedPattern) - return res.sendStatus(500) - } - return res.send(updatedPattern.info()) - }) -} - -const newHandle = (length = 5) => { - let handle = '' - let possible = 'abcdefghijklmnopqrstuvwxyz' - for (let i = 0; i < length; i++) - handle += possible.charAt(Math.floor(Math.random() * possible.length)) - - return handle -} - -const uniqueHandle = () => { - let handle, exists - do { - exists = false - handle = newHandle() - Pattern.findOne({ handle: handle }, (err, pattern) => { - if (pattern !== null) exists = true - }) - } while (exists !== false) - - return handle -} - -export default PatternController diff --git a/sites/backend/src/controllers/person.js b/sites/backend/src/controllers/person.js deleted file mode 100644 index 5abc165e964..00000000000 --- a/sites/backend/src/controllers/person.js +++ /dev/null @@ -1,115 +0,0 @@ -import { User, Person } from '../models' -import { log } from '../utils' - -function PersonController() {} - -// CRUD basics -PersonController.prototype.create = function (req, res) { - if (!req.body) return res.sendStatus(400) - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, user) => { - if (err || user === null) return res.sendStatus(400) - let handle = uniqueHandle() - let person = new Person({ - handle, - user: user.handle, - name: req.body.name, - units: req.body.units, - breasts: req.body.breasts, - picture: handle + '.svg', - created: new Date(), - }) - person.createAvatar() - person.save(function (err) { - if (err) return res.sendStatus(400) - log.info('personCreated', { handle: handle }) - return res.send({ person: person.info() }) - }) - }) -} - -PersonController.prototype.read = function (req, res) { - if (!req.body) return res.sendStatus(400) - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, (err, user) => { - if (err || user === null) return res.sendStatus(400) - Person.findOne({ handle: req.params.handle }, (err, person) => { - if (err) return res.sendStatus(400) - if (person === null) return res.sendStatus(404) - return res.send({ person: person.info() }) - }) - }) -} - -PersonController.prototype.update = (req, res) => { - var async = 0 - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, async (err, user) => { - if (err || user === null) return res.sendStatus(400) - Person.findOne({ handle: req.params.handle }, (err, person) => { - if (err || person === null) return res.sendStatus(400) - let data = req.body - if (typeof data.name === 'string') person.name = data.name - if (typeof data.notes === 'string') person.notes = data.notes - if (typeof data.units === 'string') person.units = data.units - if (typeof data.breasts === 'string') person.breasts = data.breasts === 'true' ? true : false - if (typeof data.measurements !== 'undefined') - person.measurements = { - ...person.measurements, - ...data.measurements, - } - if (typeof data.picture !== 'undefined') person.saveAvatar(data.picture) - - return saveAndReturnPerson(res, person) - }) - }) -} - -PersonController.prototype.delete = (req, res) => { - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, async (err, user) => { - if (err || user === null) return res.sendStatus(400) - Person.deleteOne({ handle: req.params.handle, user: user.handle }, (err) => { - if (err) return res.sendStatus(400) - else return res.sendStatus(204) - }) - }) -} - -// Clone -PersonController.prototype.clone = function (req, res) {} - -function saveAndReturnPerson(res, person) { - person.save(function (err, updatedPerson) { - if (err) { - log.error('personUpdateFailed', updatedPerson) - return res.sendStatus(500) - } - - return res.send({ person: updatedPerson.info() }) - }) -} - -const newHandle = (length = 5) => { - let handle = '' - let possible = 'abcdefghijklmnopqrstuvwxyz' - for (let i = 0; i < length; i++) - handle += possible.charAt(Math.floor(Math.random() * possible.length)) - - return handle -} - -const uniqueHandle = () => { - let handle, exists - do { - exists = false - handle = newHandle() - Person.findOne({ handle: handle }, (err, person) => { - if (person !== null) exists = true - }) - } while (exists !== false) - - return handle -} - -export default PersonController diff --git a/sites/backend/src/controllers/strapi.js b/sites/backend/src/controllers/strapi.js deleted file mode 100644 index 218afe89e90..00000000000 --- a/sites/backend/src/controllers/strapi.js +++ /dev/null @@ -1,129 +0,0 @@ -import axios from 'axios' -import config from '../config' -import asBuffer from 'data-uri-to-buffer' -import FormData from 'form-data' -import fs from 'fs' - -const getToken = async () => { - let result - try { - result = await axios.post( - `${config.strapi.protocol}://${config.strapi.host}:${config.strapi.port}/auth/local`, - { - identifier: config.strapi.username, - password: config.strapi.password, - } - ) - } catch (err) { - console.log('ERROR: Failed to load strapi token') - return false - } - - return result.data.jwt -} - -const withToken = (token) => ({ - headers: { - Authorization: `Bearer ${token}`, - }, -}) - -const ext = (type) => { - switch (type.toLowerCase()) { - case 'image/jpg': - case 'image/jpeg': - return 'jpg' - break - case 'image/png': - return 'png' - break - case 'image/webp': - return 'webp' - break - default: - return false - } -} - -const api = (path) => - `${config.strapi.protocol}://${config.strapi.host}:${config.strapi.port}${path}` - -// Uploads a picture to Strapi -const uploadPicture = async (img, name, token) => { - const form = new FormData() - const buff = asBuffer(img) - const extention = ext(buff.type) - if (!extention) return [false, { error: `Filetype ${buff.type} is not supported` }] - - // I hate you strapi, because this hack is the only way I can get your shitty upload to work - const filename = `${config.strapi.tmp}/viaBackend.${extention}` - const file = fs.createReadStream(filename) - form.append('files', file) - form.append( - 'fileInfo', - JSON.stringify({ - alternativeText: `The picture/avatar for maker ${name}`, - caption: `Maker: ${name}`, - }) - ) - - let result - try { - result = await axios.post(api('/upload'), form, { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - }) - } catch (err) { - console.log('ERROR: Failed to upload picture') - return [false, { error: 'Upload failed' }] - } - - return [true, result.data] -} - -const validRequest = (body) => - body && - body.displayname && - body.about && - body.picture && - typeof body.displayname === 'string' && - typeof body.about === 'string' && - typeof body.picture === 'string' - -// Creates a maker or author in Strapi -const createPerson = async (type, data, token) => { - let result - try { - result = await axios.post(api(`/${type}s`), data, withToken(token)) - } catch (err) { - console.log('ERROR: Failed to create', type) - return [false, { error: 'Creation failed' }] - } - - return [true, result.data] -} -function StrapiController() {} - -StrapiController.prototype.addPerson = async function (req, res, type) { - if (!validRequest(req.body)) return res.sendStatus(400) - const token = await getToken() - const [upload, picture] = await uploadPicture(req.body.picture, req.body.displayname, token) - if (!upload) return res.status(400).send(picture) - - const [create, person] = await createPerson( - type, - { - picture: picture[0].id, - displayname: req.body.displayname, - about: req.body.about, - }, - token - ) - if (!create) return res.status(400).send(person) - - return res.send(person) -} - -export default StrapiController diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/user.mjs index 376aa647a3c..76dcfcc8dcc 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -10,6 +10,8 @@ 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 @@ -47,6 +49,17 @@ const getToken = (user, config) => { 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' @@ -59,99 +72,10 @@ export function UserController() {} * See: https://freesewing.dev/reference/backend/api */ UserController.prototype.signup = async (req, res, tools) => { - if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result }) - if (!req.body.email) return res.status(400).json({ error: 'emailMissing', result }) - if (!req.body.password) return res.status(400).json({ error: 'passwordMissing', result }) - if (!req.body.language) return res.status(400).json({ error: 'languageMissing', result }) + const User = new UserModel(tools) + await User.create(req.body) - // Destructure what we need from tools - const { prisma, config, encrypt, email } = tools - - // Requests looks ok - does the user exist? - const emailhash = hash(clean(req.body.email)) - if (await prisma.user.findUnique({ where: { ehash: emailhash } })) { - return res.status(400).json({ error: 'emailExists', result }) - } - // It does not. Creating user entry - let record - try { - const username = clean(randomString()) // Temporary username, - record = await prisma.user.create({ - data: { - data: asJson({ settings: { language: req.body.language } }), - ehash: emailhash, - email: encrypt(clean(req.body.email)), - ihash: emailhash, - initial: encrypt(clean(req.body.email)), - password: asJson(hashPassword(req.body.password)), - username, - lusername: username.toLowerCase(), - }, - }) - } catch (err) { - log.warn(err, 'Could not create user record') - return res.status(500).send({ error: 'createAccountFailed', result }) - } - // Now set username to user-ID - let updated - try { - updated = await prisma.user.update({ - where: { id: record.id }, - data: { - username: `user-${record.id}`, - lusername: `user-${record.id}`, - }, - }) - } catch (err) { - log.warn(err, 'Could not update username on created user record') - return res.status(500).send({ result: 'error', error: 'updateCreatedAccountUsernameFailed' }) - } - log.info({ user: updated.id }, 'Account created') - - // Create confirmation - let confirmation - try { - confirmation = await prisma.confirmation.create({ - data: { - type: 'signup', - data: encrypt({ - language: req.body.language, - email: req.body.email, - id: record.id, - ehash: emailhash, - }), - }, - }) - } catch (err) { - log.warn(err, 'Unable to create confirmation at signup') - return res.status(500).send({ result: 'error', error: 'updateCreatedAccountUsernameFailed' }) - } - - // Send out signup email - let sent - try { - sent = await email.send( - req.body.to, - ...emailTemplate.signup(req.body.email, req.body.language, confirmation.id) - ) - } catch (err) { - log.warn(err, 'Unable to send email') - return res.status(500).send({ error: 'failedToSendSignupEmail', result }) - } - - if (req.body.unittest && req.body.email.split('@').pop() === 'mailtrap.freesewing.dev') { - // Unit test, return confirmation code in response - return res.status(201).send({ - result: 'success', - status: 'created', - email: req.body.email, - confirmation: confirmation.id, - }) - } - - return result - ? res.status(201).send({ result: 'success', status: 'created', email: req.body.email }) - : res.status(500).send({ error: 'unableToSendSignupEmail', result }) + return User.sendResponse(res) } /* @@ -359,64 +283,105 @@ UserController.prototype.update = async (req, res, tools) => { } // Account loaded - Handle various updates - const data = req.body - if (typeof data.avatar !== 'undefined') { - // Catch people submitting without uploading an avatar - if (data.avatar) user.saveAvatar(data.avatar) - return saveAndReturnAccount(res, user) + 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() } - /* - var async = 0 - if (!req.user._id) return res.sendStatus(400) - User.findById(req.user._id, async (err, user) => { - if (err || user === null) { - return res.sendStatus(400) - } - let data = req.body + // 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) - if (typeof data.settings !== 'undefined') { - user.settings = { - ...user.settings, - ...data.settings, - } - return saveAndReturnAccount(res, user) - } else if (data.newsletter === true || data.newsletter === false) { - user.newsletter = data.newsletter - if (data.newsletter === true) email.newsletterWelcome(user.email, user.ehash) + // Commit + prisma.user.update({ + where: { id: account.id }, + data, + }) - return saveAndReturnAccount(res, user) - } else if (typeof data.bio === 'string') { - user.bio = data.bio - return saveAndReturnAccount(res, user) - } else if (typeof data.social === 'object') { - if (typeof data.social.github === 'string') user.social.github = data.social.github - if (typeof data.social.twitter === 'string') user.social.twitter = data.social.twitter - if (typeof data.social.instagram === 'string') user.social.instagram = data.social.instagram - return saveAndReturnAccount(res, user) - } else if (typeof data.consent === 'object') { - user.consent = { - ...user.consent, - ...data.consent, - } - return saveAndReturnAccount(res, user) - } else if (typeof data.avatar !== 'undefined') { - // Catch people submitting without uploading an avatar - if (data.avatar) user.saveAvatar(data.avatar) - return saveAndReturnAccount(res, user) - } else if (typeof data.password === 'string') { - user.password = data.password - return saveAndReturnAccount(res, user) - } else if (typeof data.username === 'string') { - User.findOne({ username: data.username }, (err, userExists) => { - if (userExists !== null && data.username !== user.username) - return res.status(400).send('usernameTaken') - else { - user.username = data.username - return saveAndReturnAccount(res, user) + // 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 }) + } + } } - // Email change requires confirmation + } + // 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) => { diff --git a/sites/backend/src/landing/already-subscribed.html b/sites/backend/src/landing/already-subscribed.html deleted file mode 100644 index 2df1ecce436..00000000000 --- a/sites/backend/src/landing/already-subscribed.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - You are already subscribed - - - - -
-

Love the enthusiasm

-

But you were already subscribed

-
- - - diff --git a/sites/backend/src/landing/oops.html b/sites/backend/src/landing/oops.html deleted file mode 100644 index 905c82e3b41..00000000000 --- a/sites/backend/src/landing/oops.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - Oops - - - - -
-

Oops

-

That did not go as planned

-
- - - diff --git a/sites/backend/src/landing/subscribe.html b/sites/backend/src/landing/subscribe.html deleted file mode 100644 index 57d8f6f488d..00000000000 --- a/sites/backend/src/landing/subscribe.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - You are subscribed - - - - -
-

Done

-

You are now subscribed to the FreeSewing newsletter

-
- - - diff --git a/sites/backend/src/landing/unsubscribe.html b/sites/backend/src/landing/unsubscribe.html deleted file mode 100644 index b7ed6ad1a97..00000000000 --- a/sites/backend/src/landing/unsubscribe.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - You are unsubscribed - - - - -
-

Gone

-

You are no longer subscribed to the FreeSewing newsletter

-
- - - diff --git a/sites/backend/src/models/confirmation.mjs b/sites/backend/src/models/confirmation.mjs new file mode 100644 index 00000000000..f92b219f967 --- /dev/null +++ b/sites/backend/src/models/confirmation.mjs @@ -0,0 +1,69 @@ +import { log } from '../utils/log.mjs' +import { hash } from '../utils/crypto.mjs' + +export function ConfirmationModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + + return this +} + +ConfirmationModel.prototype.load = async function (where) { + this.record = await this.prisma.confirmation.findUnique({ where }) + + return this.setExists() +} + +ConfirmationModel.prototype.setExists = function () { + this.exists = this.record ? true : false + + return this +} + +ConfirmationModel.prototype.setResponse = function (status = 200, error = false) { + this.response = { + status, + body: { + result: 'success', + }, + } + if (status > 201) { + this.response.body.error = error + this.response.body.result = 'error' + this.error = true + } else this.error = false + + return this.setExists() +} + +ConfirmationModel.prototype.setResponse = function ( + status = 200, + result = 'success', + error = false +) { + this.response = { + status: this.status, + body: { + error: this.error, + result: this.result, + }, + } + if (error) { + this.response.body.error = error + this.error = true + } else this.error = false + + return this.setExists() +} + +ConfirmationModel.prototype.create = async function (data = {}) { + try { + this.record = await this.prisma.confirmation.create({ data }) + } catch (err) { + log.warn(err, 'Could not create confirmation record') + return this.setResponse(500, 'createConfirmationFailed') + } + log.info({ confirmation: this.record.id }, 'Confirmation created') + + return this.setResponse(201) +} diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs new file mode 100644 index 00000000000..ddb70032f0a --- /dev/null +++ b/sites/backend/src/models/user.mjs @@ -0,0 +1,149 @@ +//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 { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' +import { clean, asJson } from '../utils/index.mjs' +import { ConfirmationModel } from './confirmation.mjs' +import { emailTemplate } from '../utils/email.mjs' +// import { emailTemplate } from '../utils/email.mjs' +//import set from 'lodash.set' + +export function UserModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + this.decrypt = tools.decrypt + this.encrypt = tools.encrypt + this.mailer = tools.email + this.Confirmation = new ConfirmationModel(tools) + + return this +} + +UserModel.prototype.load = async function (where) { + this.record = await this.prisma.user.findUnique({ where }) + if (this.record?.email) this.email = this.decrypt(this.record.email) + + return this.setExists() +} + +UserModel.prototype.setExists = function () { + this.exists = this.record ? true : false + + return this +} + +UserModel.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() +} + +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') + + const ehash = hash(clean(body.email)) + await this.load({ ehash }) + if (this.exists) return this.setResponse(400, 'emailExists') + + try { + this.email = clean(body.email) + this.language = body.language + const email = this.encrypt(this.email) + const username = clean(randomString()) // Temporary username + this.record = await this.prisma.user.create({ + data: { + ehash, + ihash: ehash, + email, + initial: email, + username, + lusername: username, + data: asJson({ settings: { language: this.language } }), + password: asJson(hashPassword(body.password)), + }, + }) + } catch (err) { + log.warn(err, 'Could not create user record') + return this.setResponse(500, 'createAccountFailed') + } + + // Update username + try { + await this.update({ + username: `user-${this.record.id}`, + lusername: `user-${this.record.id}`, + }) + } catch (err) { + log.warn(err, 'Could not update username after user creation') + return this.setResponse(500, 'error', 'usernameUpdateAfterUserCreationFailed') + } + log.info({ user: this.record.id }, 'Account created') + + // Create confirmation + this.confirmation = await this.Confirmation.create({ + type: 'signup', + data: this.encrypt({ + language: this.language, + email: this.email, + id: this.record.id, + ehash: ehash, + }), + }) + + // Send signup email + 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 }) + : this.setResponse(201, false, { email: this.email }) +} + +UserModel.prototype.sendSignupEmail = async function () { + try { + this.confirmationSent = await this.mailer.send( + this.email, + ...emailTemplate.signup(this.email, this.language, this.confirmation) + ) + } catch (err) { + log.warn(err, 'Unable to send signup email') + return this.setResponse(500, 'error', 'unableToSendSignupEmail') + } + + return this.setResponse(200) +} + +UserModel.prototype.update = async function (data) { + try { + this.record = await this.prisma.user.update({ + where: { id: this.record.id }, + data, + }) + } catch (err) { + log.warn(err, 'Could not update user record') + process.exit() + return this.setResponse(500, 'error', 'updateUserFailed') + } + + return this.setResponse(200) +} + +UserModel.prototype.sendResponse = async function (res) { + return res.status(this.response.status).send(this.response.body) +} diff --git a/sites/backend/src/routes/admin.js b/sites/backend/src/routes/admin.js deleted file mode 100644 index 87db357b78f..00000000000 --- a/sites/backend/src/routes/admin.js +++ /dev/null @@ -1,23 +0,0 @@ -import Controller from '../controllers/admin' - -const Admin = new Controller() - -export default (app, passport) => { - // Users - app.post('/admin/search', passport.authenticate('jwt', { session: false }), Admin.search) - app.put('/admin/patron', passport.authenticate('jwt', { session: false }), Admin.setPatronStatus) - app.put('/admin/role', passport.authenticate('jwt', { session: false }), Admin.setRole) - app.post( - '/admin/impersonate', - passport.authenticate('jwt', { session: false }), - Admin.impersonate - ) - app.put('/admin/unfreeze', passport.authenticate('jwt', { session: false }), Admin.unfreeze) - app.get('/admin/patrons', passport.authenticate('jwt', { session: false }), Admin.patronList) - app.get( - '/admin/subscribers', - passport.authenticate('jwt', { session: false }), - Admin.subscriberList - ) - app.get('/admin/stats', passport.authenticate('jwt', { session: false }), Admin.stats) -} diff --git a/sites/backend/src/routes/github.js b/sites/backend/src/routes/github.js deleted file mode 100644 index 3a290efa55e..00000000000 --- a/sites/backend/src/routes/github.js +++ /dev/null @@ -1,8 +0,0 @@ -import Controller from '../controllers/github' - -const Github = new Controller() - -export default (app, passport) => { - app.post('/github/issue', Github.createIssue) - app.post('/github/gist', Github.createGist) -} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index fed859ac6d5..fc9b449735f 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -1,17 +1,5 @@ -//import pattern from './pattern' -//import person from './person' import { userRoutes } from './user.mjs' -//import oauth from './auth.mjs' -//import github from './github' -//import admin from './admin' -//import newsletter from './newsletter' export const routes = { userRoutes, -// pattern, -// person, -// oauth, -// github, -// admin, -// newsletter, } diff --git a/sites/backend/src/routes/newsletter.js b/sites/backend/src/routes/newsletter.js deleted file mode 100644 index 52cf6e69cad..00000000000 --- a/sites/backend/src/routes/newsletter.js +++ /dev/null @@ -1,12 +0,0 @@ -import Controller from '../controllers/newsletter' - -const Nws = new Controller() - -export default (app, passport) => { - // Email subscribe - app.post('/newsletter/subscribe', (req, res) => Nws.subscribe(req, res, true)) - // Email unsubscribe - app.post('/newsletter/unsubscribe', (req, res) => Nws.subscribe(req, res, false)) - app.get('/newsletter/confirm/:token', (req, res) => Nws.confirm(req, res)) - app.get('/newsletter/unsubscribe/:ehash', (req, res) => Nws.unsubscribe(req, res)) -} diff --git a/sites/backend/src/routes/oauth.mjs b/sites/backend/src/routes/oauth.mjs deleted file mode 100644 index 6f51b99b7f2..00000000000 --- a/sites/backend/src/routes/oauth.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import Controller from '../controllers/oauth.mjs' - -const OAuth = new Controller() - -export default (app, passport) => { - // Oauth - app.post('/oauth/init', OAuth.initOAuth) - app.get('/oauth/callback/from/:provider', OAuth.providerCallback) - app.post('/oauth/login', OAuth.loginOAuth) -} diff --git a/sites/backend/src/routes/og.js b/sites/backend/src/routes/og.js deleted file mode 100644 index 72f1b7d7413..00000000000 --- a/sites/backend/src/routes/og.js +++ /dev/null @@ -1,9 +0,0 @@ -import Controller from '../controllers/og' - -// Note: Og = Open graph. See https://ogp.me/ -const Og = new Controller() - -export default (app, passport) => { - // Load open graph image (requires no authentication) - app.get('/og-img/:lang/:site/*', Og.image) -} diff --git a/sites/backend/src/routes/pattern.js b/sites/backend/src/routes/pattern.js deleted file mode 100644 index b19223cbd65..00000000000 --- a/sites/backend/src/routes/pattern.js +++ /dev/null @@ -1,10 +0,0 @@ -import Controller from '../controllers/pattern' - -const Pattern = new Controller() - -export default (app, passport) => { - app.get('/patterns/:handle', Pattern.read) // Anomymous read - app.post('/patterns', passport.authenticate('jwt', { session: false }), Pattern.create) // Create - app.put('/patterns/:handle', passport.authenticate('jwt', { session: false }), Pattern.update) // Update - app.delete('/patterns/:handle', passport.authenticate('jwt', { session: false }), Pattern.delete) // Delete -} diff --git a/sites/backend/src/routes/person.js b/sites/backend/src/routes/person.js deleted file mode 100644 index d651b2b8210..00000000000 --- a/sites/backend/src/routes/person.js +++ /dev/null @@ -1,10 +0,0 @@ -import Controller from '../controllers/person' - -const Person = new Controller() - -export default (app, passport) => { - app.post('/people', passport.authenticate('jwt', { session: false }), Person.create) // Create - app.get('/people/:handle', passport.authenticate('jwt', { session: false }), Person.read) // Read - app.put('/people/:handle', passport.authenticate('jwt', { session: false }), Person.update) // Update - app.delete('/people/:handle', passport.authenticate('jwt', { session: false }), Person.delete) // Delete -} diff --git a/sites/backend/src/routes/strapi.js b/sites/backend/src/routes/strapi.js deleted file mode 100644 index a0a8049b9e0..00000000000 --- a/sites/backend/src/routes/strapi.js +++ /dev/null @@ -1,9 +0,0 @@ -import Controller from '../controllers/strapi' - -const Strapi = new Controller() - -export default (app, passport) => { - // Email subscribe - app.post('/strapi/maker', (req, res) => Strapi.addPerson(req, res, 'maker')) - app.post('/strapi/author', (req, res) => Strapi.addPerson(req, res, 'author')) -} diff --git a/sites/backend/src/utils/email.mjs b/sites/backend/src/utils/email.mjs index 2b4c529fd21..252dd7db212 100644 --- a/sites/backend/src/utils/email.mjs +++ b/sites/backend/src/utils/email.mjs @@ -7,20 +7,15 @@ import fr from '../../../../packages/i18n/dist/en/email.mjs' import es from '../../../../packages/i18n/dist/en/email.mjs' import de from '../../../../packages/i18n/dist/en/email.mjs' import { i18nUrl } from './index.mjs' -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2" +import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2' const i18n = { en, nl, fr, es, de } export const emailTemplate = { signup: (to, language, uuid) => [ i18n[language].signupTitle, - templates.signup( - i18n[language], - to, - i18nUrl(language, `/confirm/signup/${uuid}`) - )], - emailChange: (to, cc, language, uuid) => [ - ] + templates.signup(i18n[language], to, i18nUrl(language, `/confirm/signup/${uuid}`)), + ], } emailTemplate.emailchange = (newAddress, currentAddress, language, id) => { @@ -189,7 +184,6 @@ emailTemplate.newsletterWelcome = async (recipient, ehash) => { }) } - /* * Exporting this closure that makes sure we have access to the * instantiated config @@ -197,7 +191,7 @@ emailTemplate.newsletterWelcome = async (recipient, ehash) => { export const mailer = (config) => ({ email: { send: (...params) => sendEmailViaAwsSes(config, ...params), - } + }, }) /* @@ -222,16 +216,15 @@ async function sendEmailViaAwsSes(config, to, subject, text) { }, }, Destination: { - ToAddresses: [ to ], - BccAddresses: [ 'tracking@freesewing.org' ], + ToAddresses: [to], + CcAddresses: config.aws.ses.cc || [], + BccAddresses: config.aws.ses.bcc || [], }, - FeedbackForwardingEmailAddress: 'bounce@freesewing.org', - FromEmailAddress: 'info@freesewing.org', - ReplyToAddresses: [ 'info@freesewing.org' ], + FeedbackForwardingEmailAddress: config.aws.ses.feedback, + FromEmailAddress: config.aws.ses.from, + ReplyToAddresses: config.aws.ses.replyTo || [], }) const result = await client.send(command) - return (result['$metadata']?.httpStatusCode === 200) + return result['$metadata']?.httpStatusCode === 200 } - - diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs index bf8a7ad4d72..1c59fe82bed 100644 --- a/sites/backend/tests/user.test.mjs +++ b/sites/backend/tests/user.test.mjs @@ -260,7 +260,7 @@ describe(`${user} Signup flow and authentication`, () => { }) 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) => { chai .request(config.api) @@ -278,6 +278,27 @@ describe(`${user} Account management`, () => { 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(typeof res.body.account.id).to.equal(`number`) + done() + }) + }) */ })