From ca8ceb1a30c1ef71637ea26d74fdb8aacdcb4765 Mon Sep 17 00:00:00 2001 From: Joost De Cock Date: Sat, 1 Jan 2022 15:18:27 +0100 Subject: [PATCH] fix(backend): Proper import of archived repo Not really sure what happened, but clearly when I imported our backend code into our monorepo, it was not the latest version. So I'm fixing this now by putting in the latest code. I will re-apply the changes since later. --- packages/backend/example.env | 61 +- packages/backend/package.json | 48 +- packages/backend/src/cli/data.js | 120 +++ packages/backend/src/cli/index.js | 43 + packages/backend/src/cli/lib.js | 97 ++ packages/backend/src/cli/options.js | 34 + packages/backend/src/config/index.js | 77 +- packages/backend/src/config/verify.js | 83 +- packages/backend/src/controllers/admin.js | 180 ++++ packages/backend/src/controllers/auth.js | 433 ++++----- packages/backend/src/controllers/comment.js | 16 - .../backend/src/controllers/confirmation.js | 21 - packages/backend/src/controllers/draft.js | 131 --- packages/backend/src/controllers/github.js | 99 ++ packages/backend/src/controllers/model.js | 179 ---- .../backend/src/controllers/newsletter.js | 97 ++ packages/backend/src/controllers/og.js | 230 ----- packages/backend/src/controllers/pattern.js | 127 +++ packages/backend/src/controllers/person.js | 115 +++ packages/backend/src/controllers/referral.js | 6 - packages/backend/src/controllers/strapi.js | 145 +++ packages/backend/src/controllers/user.js | 854 +++++++++--------- packages/backend/src/env.js | 2 + packages/backend/src/i18n/de.js | 25 - packages/backend/src/i18n/en.js | 40 - packages/backend/src/i18n/es.js | 25 - packages/backend/src/i18n/fr.js | 25 - packages/backend/src/i18n/index.js | 7 - packages/backend/src/i18n/nl.js | 25 - packages/backend/src/index.js | 65 +- .../src/landing/already-subscribed.html | 46 + packages/backend/src/landing/index.html | 113 +-- packages/backend/src/landing/invalid.html | 52 ++ packages/backend/src/landing/oops.html | 52 ++ packages/backend/src/landing/subscribe.html | 46 + packages/backend/src/landing/unsubscribe.html | 46 + .../src/middleware/express/bodyParser.js | 8 +- .../backend/src/middleware/express/cors.js | 6 +- .../backend/src/middleware/express/index.js | 4 +- .../backend/src/middleware/passport/index.js | 2 +- .../backend/src/middleware/passport/jwt.js | 14 +- packages/backend/src/models/comment.js | 39 - packages/backend/src/models/confirmation.js | 20 +- packages/backend/src/models/draft.js | 46 - packages/backend/src/models/index.js | 21 +- packages/backend/src/models/model.js | 101 --- packages/backend/src/models/newsletter.js | 34 + packages/backend/src/models/pattern.js | 62 ++ packages/backend/src/models/person.js | 171 ++++ packages/backend/src/models/user.js | 405 +++++---- packages/backend/src/routes/admin.js | 41 +- packages/backend/src/routes/auth.js | 27 +- packages/backend/src/routes/comment.js | 35 - packages/backend/src/routes/confirmation.js | 6 - packages/backend/src/routes/draft.js | 41 - packages/backend/src/routes/github.js | 8 + packages/backend/src/routes/index.js | 18 +- packages/backend/src/routes/model.js | 38 - packages/backend/src/routes/newsletter.js | 12 + packages/backend/src/routes/og.js | 11 - packages/backend/src/routes/pattern.js | 10 + packages/backend/src/routes/person.js | 10 + packages/backend/src/routes/referral.js | 11 - packages/backend/src/routes/strapi.js | 9 + packages/backend/src/routes/user.js | 115 +-- packages/backend/src/signup.html | 140 --- packages/backend/src/templates/avatar.js | 68 ++ packages/backend/src/templates/emailchange.js | 14 +- packages/backend/src/templates/footer.js | 26 +- packages/backend/src/templates/goodbye.js | 10 +- packages/backend/src/templates/header.js | 6 +- packages/backend/src/templates/index.js | 18 +- .../src/templates/newsletter-subscribe.js | 62 ++ .../src/templates/newsletter-welcome.js | 44 + .../backend/src/templates/passwordreset.js | 14 +- packages/backend/src/templates/signup.js | 12 +- packages/backend/src/utils/email/index.js | 281 +++--- packages/backend/src/utils/email/relays.js | 12 + packages/backend/src/utils/email/sendgrid.js | 19 + packages/backend/src/utils/email/smtp.js | 18 + packages/backend/src/utils/index.js | 171 ++-- packages/backend/src/utils/log/index.js | 51 +- packages/backend/tests/admin.test.js | 31 + packages/backend/tests/config.js | 31 + packages/backend/tests/index.js | 31 + packages/backend/tests/oauth.test.js | 40 + packages/backend/tests/pattern.test.js | 96 ++ packages/backend/tests/person.test.js | 195 ++++ packages/backend/tests/user.all.test.js | 201 +++++ packages/backend/tests/user.test.js | 349 +++++++ 90 files changed, 4337 insertions(+), 2693 deletions(-) create mode 100644 packages/backend/src/cli/data.js create mode 100644 packages/backend/src/cli/index.js create mode 100644 packages/backend/src/cli/lib.js create mode 100644 packages/backend/src/cli/options.js create mode 100644 packages/backend/src/controllers/admin.js delete mode 100644 packages/backend/src/controllers/comment.js delete mode 100644 packages/backend/src/controllers/confirmation.js delete mode 100644 packages/backend/src/controllers/draft.js create mode 100644 packages/backend/src/controllers/github.js delete mode 100644 packages/backend/src/controllers/model.js create mode 100644 packages/backend/src/controllers/newsletter.js delete mode 100644 packages/backend/src/controllers/og.js create mode 100644 packages/backend/src/controllers/pattern.js create mode 100644 packages/backend/src/controllers/person.js delete mode 100644 packages/backend/src/controllers/referral.js create mode 100644 packages/backend/src/controllers/strapi.js create mode 100644 packages/backend/src/env.js delete mode 100644 packages/backend/src/i18n/de.js delete mode 100644 packages/backend/src/i18n/en.js delete mode 100644 packages/backend/src/i18n/es.js delete mode 100644 packages/backend/src/i18n/fr.js delete mode 100644 packages/backend/src/i18n/index.js delete mode 100644 packages/backend/src/i18n/nl.js create mode 100644 packages/backend/src/landing/already-subscribed.html create mode 100644 packages/backend/src/landing/invalid.html create mode 100644 packages/backend/src/landing/oops.html create mode 100644 packages/backend/src/landing/subscribe.html create mode 100644 packages/backend/src/landing/unsubscribe.html delete mode 100644 packages/backend/src/models/comment.js delete mode 100644 packages/backend/src/models/draft.js delete mode 100644 packages/backend/src/models/model.js create mode 100644 packages/backend/src/models/newsletter.js create mode 100644 packages/backend/src/models/pattern.js create mode 100644 packages/backend/src/models/person.js delete mode 100644 packages/backend/src/routes/comment.js delete mode 100644 packages/backend/src/routes/confirmation.js delete mode 100644 packages/backend/src/routes/draft.js create mode 100644 packages/backend/src/routes/github.js delete mode 100644 packages/backend/src/routes/model.js create mode 100644 packages/backend/src/routes/newsletter.js delete mode 100644 packages/backend/src/routes/og.js create mode 100644 packages/backend/src/routes/pattern.js create mode 100644 packages/backend/src/routes/person.js delete mode 100644 packages/backend/src/routes/referral.js create mode 100644 packages/backend/src/routes/strapi.js delete mode 100644 packages/backend/src/signup.html create mode 100644 packages/backend/src/templates/avatar.js create mode 100644 packages/backend/src/templates/newsletter-subscribe.js create mode 100644 packages/backend/src/templates/newsletter-welcome.js create mode 100644 packages/backend/src/utils/email/relays.js create mode 100644 packages/backend/src/utils/email/sendgrid.js create mode 100644 packages/backend/src/utils/email/smtp.js create mode 100644 packages/backend/tests/admin.test.js create mode 100644 packages/backend/tests/config.js create mode 100644 packages/backend/tests/index.js create mode 100644 packages/backend/tests/oauth.test.js create mode 100644 packages/backend/tests/pattern.test.js create mode 100644 packages/backend/tests/person.test.js create mode 100644 packages/backend/tests/user.all.test.js create mode 100644 packages/backend/tests/user.test.js diff --git a/packages/backend/example.env b/packages/backend/example.env index 8bf7ec9fc2e..ad6c4b9f46f 100644 --- a/packages/backend/example.env +++ b/packages/backend/example.env @@ -1,15 +1,9 @@ -# If you're behind a proxy, set it here: -http_proxy="http://my.proxy.acme.net:8080" -https_proxy="http://my.proxy.acme.net:8080" -no_proxy="acme.net" - -# Location of the freesewing backend +# Where to find things FS_BACKEND=http://localhost:3000 +FS_STATIC=https://static.freesewing.org +FS_STORAGE=/some/folder/on/my/disk -# Location of the freesewing frontend (website) -FS_SITE=http://localhost:8000 - -# Connection string for the mongo database +# Database FS_MONGO_URI="mongodb://localhost/freesewing" # If you're using docker-compose, use 'mongo' as hostname: #FS_MONGO_URI="mongodb://mongo/freesewing" @@ -17,38 +11,23 @@ FS_MONGO_URI="mongodb://localhost/freesewing" # Secret to encrypt data in mongo FS_ENC_KEY=secretKeyThatYouShouldChange -# The JSON web token issuer -FS_JWT_ISSUER=freesewing.org +# SMTP (email) +FS_SMTP_USER=user +FS_SMTP_PASS=password +FS_SMTP_HOST=localhost +FS_SMTP_PORT=1025 -# SMTP gateway to send outgoing email through -FS_SMTP_HOST=smtp.google.com +# Signing on/Logging in via Github or Google requires you to configure Oauth: +# +# - For Github: https://github.com/settings/developers +# - For Google: https://console.developers.google.com/apis/credentials -# Username for the SMTP gateway -FS_SMTP_USER="your.user@gmail.com" - -# Password for the SMTP gateway -FS_SMTP_PASS=yourPassword - -# Signing on/Logging in via Github or Google requires -# you to configure API access: -# For Github: https://github.com/settings/developers -# For Google: https://console.developers.google.com/apis/credentials - -# Github client ID -FS_GITHUB_CLIENT_ID=yourGithubClientID -# Github client secret -FS_GITHUB_CLIENT_SECRET=yourGithubClientSecret - -# Google client ID -FS_GOOGLE_CLIENT_ID=yourGoogleClientID -# Google client secret -FS_GOOGLE_CLIENT_SECRET=yourGoogleClientSecret - -# If you want to use docker-compose to spin up the backend -# and a mongo database, you'll need these too: -# Mongo root user -MONGO_INITDB_ROOT_USERNAME=root -# Mongo root password -MONGO_INITDB_ROOT_PASSWORD=changeMe +# Oauth +FS_GITHUB_CLIENT_ID=someLongID +FS_GITHUB_CLIENT_SECRET=someEvenLongerSecret +FS_GOOGLE_CLIENT_ID=someLongId +FS_GOOGLE_CLIENT_SECRET=someEvenLongerSecret +# Github token (for creating issues) +FS_GITHUB_TOKEN=yourTokenHere diff --git a/packages/backend/package.json b/packages/backend/package.json index 7133b2fe6e7..00aab34747b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -6,28 +6,40 @@ "main": "build/main.js", "module": "build/main.mjs", "scripts": { + "cli": "babel-node --presets '@babel/preset-env' src/cli/index.js --help", + "clear:users": "babel-node --presets '@babel/preset-env' src/cli/index.js --clearUsers", + "clear:models": "babel-node --presets '@babel/preset-env' src/cli/index.js --clearModels", + "clear:patterns": "babel-node --presets '@babel/preset-env' src/cli/index.js --clearPatterns", + "clear:confirmations": "babel-node --presets '@babel/preset-env' src/cli/index.js --clearConfirmations", + "clear:all": "babel-node --presets '@babel/preset-env' src/cli/index.js --clearAll", + "clear:reboot": "babel-node --presets '@babel/preset-env' src/cli/index.js --reboot", "precommit": "npm run pretty && lint-staged", "patch": "npm version patch -m ':bookmark: v%s' && npm run build", "minor": "npm version minor -m ':bookmark: v%s' && npm run build", "major": "npm version major -m ':bookmark: v%s' && npm run build", - "test": "echo \"Error: no test specified\" && exit 0", + "test": "SEND_TEST_EMAILS=0 ./node_modules/.bin/mocha tests/index.js", + "testall": "SEND_TEST_EMAILS=1 ./node_modules/.bin/mocha tests/index.js", "clean": "rimraf dist", - "pretty": "npx prettier --write \"src/*.js\"", + "prettier": "npx prettier --write 'src/**' 'tests/**'", "lint": "eslint --fix \"src/*.js\"", - "dev": "backpack", + "develop": "backpack", "build": "backpack build", - "start": "backpack build && pm2 start build/main.js --name freesewing-backend" + "start:prod": "backpack build && pm2 start build/main.js --name freesewing-backend", + "start:canary": "backpack build && pm2 start build/main.js --name canary-backend", + "upgrade:freesewing": "git checkout main && npm run build && pm2 stop freesewing-backend && pm2 delete freesewing-backend && pm2 start build/main.js --name freesewing-backend", + "upgrade:next": "git checkout develop && npm run build && pm2 stop canary-backend && pm2 delete canary-backend && pm2 start build/main.js --name canary-backend" }, "repository": { "type": "git", - "url": "git+https://github.com/freesewing/backend.git" + "url": "git+https://github.com/freesewing/freesewing.git" }, "author": "Joost De Cock", "license": "MIT", "bugs": { - "url": "https://github.com/freesewing/backend/issues" + "url": "https://github.com/freesewing/freesewing/issues" }, - "homepage": "https://github.com/freesewing/backend#readme", + "homepage": "https://github.com/freesewing/freesewing#readme", + "prettier": "@freesewing/prettier-config", "husky": { "hooks": { "pre-commit": "lint-staged" @@ -42,18 +54,26 @@ "dependencies": { "@freesewing/i18n": "latest", "axios": "0.18.0", + "body-parser": "^1.19.0", + "buffer-to-stream": "^1.0.0", + "buffer-to-uint8array": "^1.1.0", + "chalk": "^4.1.1", + "cors": "^2.8.5", + "data-uri-to-buffer": "^3.0.1", + "data-uri-to-file": "^0.1.8", "body-parser": "1.18.3", "chalk": "2.4.1", "cors": "2.8.4", "dateformat": "3.0.3", "express": "4.16.4", + "form-data": "^4.0.0", "formidable": "1.2.1", "jsonwebtoken": "8.3.0", "jszip": "3.1.5", "mdast-util-to-string": "2", - "mongoose": "5.3.3", - "mongoose-bcrypt": "1.6.0", - "mongoose-encryption": "2.0.1", + "mongoose": "^5.12.13", + "mongoose-bcrypt": "^1.8.1", + "mongoose-encryption": "^2.1.0", "nodemailer": "4.6.8", "passport": "0.4.0", "passport-jwt": "4.0.0", @@ -64,9 +84,15 @@ "remark-plain-text": "^0.2.0", "rimraf": "2.6.2", "sharp": "^0.29.3", + "chai": "^4.3.4", + "command-line-args": "^5.1.1", + "chai-http": "^4.3.0", + "mocha": "^8.3.2", + "tlds": "^1.221.1", "yaml": "^1.10.2" }, "devDependencies": { - "backpack-core": "0.7.0" + "backpack-core": "0.7.0", + "prettier": "^2.3.1" } } diff --git a/packages/backend/src/cli/data.js b/packages/backend/src/cli/data.js new file mode 100644 index 00000000000..9dc7d21c756 --- /dev/null +++ b/packages/backend/src/cli/data.js @@ -0,0 +1,120 @@ +import { withBreasts, withoutBreasts } from '@freesewing/models'; + +export default { + users: [ + { + email: 'test1@freesewing.org', + username: 'test1_user', + handle: 'tusr1', + password: 'test1', + role: 'user', + settings: { + language: 'nl', + units: 'imperial' + }, + patron: 2, + consent: { + profile: true, + measurements: true, + openData: true + }, + status: 'active' + }, + { + email: 'test@freesewing.org', + username: 'test_user', + handle: 'tuser', + password: 'test', + role: 'user', + settings: { + language: 'nl', + units: 'imperial' + }, + patron: 4, + consent: { + profile: true, + measurements: true, + openData: true + }, + status: 'active' + }, + { + email: 'admin@freesewing.org', + username: 'admin', + password: 'admin', + role: 'admin', + handle: 'admin', + social: { + github: 'freesewing-bot', + twitter: 'freesewing_org', + instagram: 'freesewing_org', + }, + patron: 8, + settings: { + language: 'en', + units: 'metric' + }, + consent: { + profile: true, + measurements: true, + openData: true + }, + newsletter: true, + status: 'active' + } + ], + people: [ + { + handle: 'persa', + picture: 'persa.svg', + user: 'tuser', + name: 'Example person - No breasts', + breasts: false, + units: 'metric', + notes: 'This is an example person', + measurements: withoutBreasts.size42 + }, + { + handle: 'persb', + picture: 'persb.svg', + user: 'tuser', + name: 'Example person - With breasts', + breasts: true, + units: 'metric', + notes: 'This is an example person', + measurements: { + ...withBreasts.size36, + doesNotExist: 234 + } + }, + ], + patterns: [ + { + handle: "recip", + name: "Example pattern", + notes: "These are the pattern notes", + data: { + settings: { + sa: 10, + complete: true, + paperless: false, + units: "imperial", + measurements: { + biceps: 335, + hpsToWaist: 520, + chest: 1080, + waistToHips: 145, + neck: 420, + shoulderSlope: 13, + shoulderToShoulder: 465, + hips: 990 + } + }, + design: "aaron", + }, + created: "2019-08-14T09:47:27.163Z", + user: 'tuser', + person:"persa" + } + ] +} diff --git a/packages/backend/src/cli/index.js b/packages/backend/src/cli/index.js new file mode 100644 index 00000000000..c70e88644a5 --- /dev/null +++ b/packages/backend/src/cli/index.js @@ -0,0 +1,43 @@ +import '../env' +import mongoose from 'mongoose' +import config from '../config/index' +import chalk from 'chalk' +import verifyConfig from '../config/verify' +import optionDefinitions from './options' +import commandLineArgs from 'command-line-args' +import { showHelp, loadSampleData, runTasks } from './lib' + +const options = commandLineArgs(optionDefinitions) +if (options.help) { + showHelp() + process.exit() +} + +// Verify configuration +verifyConfig(config, chalk) + +// Connecting to the database +mongoose.Promise = global.Promise +mongoose + .connect(config.db.uri, { + useNewUrlParser: true + }) + .then(() => { + console.log(chalk.green('Successfully connected to the database')) + console.log() + runTasks(options).then(() => { + if (options.reboot) { + loadSampleData().then(() => { + console.log('⚡ Data loaded') + process.exit() + }) + } else { + console.log() + process.exit() + } + }) + }) + .catch(err => { + console.log(chalk.red('Could not connect to the database. Exiting now...'), err) + process.exit() + }) diff --git a/packages/backend/src/cli/lib.js b/packages/backend/src/cli/lib.js new file mode 100644 index 00000000000..83222574381 --- /dev/null +++ b/packages/backend/src/cli/lib.js @@ -0,0 +1,97 @@ +import chalk from 'chalk' +import { User, Person, Pattern, Confirmation, Newsletter } from '../models' +import { ehash } from '../utils' +import data from './data' + +export const showHelp = () => { + console.log() + console.log(chalk.yellow('Use one of the following:')) + console.log() + console.log(' ', chalk.bold.blue('npm run clear:users'), '👉 Truncate the users collection') + console.log(' ', chalk.bold.blue('npm run clear:people'), '👉 Truncate the people collection') + console.log(' ', chalk.bold.blue('npm run clear:patterns'), '👉 Truncate the patterns collection') + console.log( + ' ', + chalk.bold.blue('npm run clear:confirmations'), + '👉 Truncate the confirmations collection' + ) + console.log(' ', chalk.bold.blue('npm run clear:all'), '👉 Empty the entire database') + console.log( + ' ', + chalk.bold.blue('npm run clear:reboot'), + '👉 Empty database, then load sample data' + ) + console.log() + process.exit() +} + +export const clearUsers = async () => { + await User.deleteMany().then(result => { + if (result.ok) console.log('🔥 Users deleted') + else console.log('🚨 Could not remove users', result) + }) +} +export const clearPeople = async () => { + await Person.deleteMany().then(result => { + if (result.ok) console.log('🔥 People removed') + else console.log('🚨 Could not remove people', result) + }) +} +export const clearPatterns = async () => { + await Pattern.deleteMany().then(result => { + if (result.ok) console.log('🔥 Patterns deleted') + else console.log('🚨 Could not remove patterns', result) + }) +} +export const clearConfirmations = async () => { + await Confirmation.deleteMany().then(result => { + if (result.ok) console.log('🔥 Confirmations deleted') + else console.log('🚨 Could not remove confirmations', result) + }) +} +export const clearNewsletterSubscribers = async () => { + await Newsletter.deleteMany().then(result => { + if (result.ok) console.log('🔥 Newsletter subscriptions deleted') + else console.log('🚨 Could not remove newsletter subscriptions', result) + }) +} + +export const loadSampleData = async () => { + let promises = [] + for (let sample of data.users) { + let user = new User({ + ...sample, + initial: sample.email, + ehash: ehash(sample.email), + picture: sample.handle + '.svg', + time: { + created: new Date() + } + }) + user.createAvatar() + promises.push(user.save()) + } + for (let sample of data.people) { + let person = new Person(sample) + person.createAvatar() + promises.push(person.save()) + } + for (let sample of data.patterns) { + promises.push(new Pattern(sample).save()) + } + + return Promise.all(promises) +} + +export const runTasks = options => { + let promises = [] + if (options.clearAll || options.reboot || options.clearUsers) promises.push(clearUsers()) + if (options.clearAll || options.reboot || options.clearPeople) promises.push(clearPeople()) + if (options.clearAll || options.reboot || options.clearPatterns) promises.push(clearPatterns()) + if (options.clearAll || options.reboot || options.clearConfirmations) + promises.push(clearConfirmations()) + if (options.clearAll || options.reboot || options.clearNewsletterSubscriptions) + promises.push(clearNewsletterSubscribers()) + + return Promise.all(promises) +} diff --git a/packages/backend/src/cli/options.js b/packages/backend/src/cli/options.js new file mode 100644 index 00000000000..24a8c85da74 --- /dev/null +++ b/packages/backend/src/cli/options.js @@ -0,0 +1,34 @@ +export default [ + { + name: 'clearUsers', + type: Boolean + }, + { + name: 'clearModels', + type: Boolean + }, + { + name: 'clearPatterns', + type: Boolean + }, + { + name: 'clearConfirmations', + type: Boolean + }, + { + name: 'clearNewsletterSubscribers', + type: Boolean + }, + { + name: 'clearAll', + type: Boolean + }, + { + name: 'reboot', + type: Boolean + }, + { + name: 'help', + type: Boolean + } +] diff --git a/packages/backend/src/config/index.js b/packages/backend/src/config/index.js index 802d5b9f973..bc28dad6f79 100644 --- a/packages/backend/src/config/index.js +++ b/packages/backend/src/config/index.js @@ -1,11 +1,9 @@ -// Load environment variables -import dotenv from 'dotenv' -dotenv.config() - -// Construct config object -const config = { +module.exports = { api: process.env.FS_BACKEND, - website: process.env.FS_SITE, + website: { + domain: 'freesewing.org', + scheme: 'https' + }, static: process.env.FS_STATIC, storage: process.env.FS_STORAGE, avatar: { @@ -17,53 +15,70 @@ const config = { } }, db: { - uri: process.env.FS_MONGO_URI || 'mongodb://localhost/freesewing', + uri: process.env.FS_MONGO_URI || 'mongodb://localhost/freesewing' }, hashing: { saltRounds: 10 }, encryption: { - key: process.env.FS_ENC_KEY || '', // Prevent mongoose plugin from throwing an error + key: process.env.FS_ENC_KEY || '' // Prevent mongoose plugin from throwing an error }, jwt: { secretOrKey: process.env.FS_ENC_KEY, - issuer: process.env.FS_JWT_ISSUER, - audience: process.env.FS_JWT_ISSUER, - expiresIn: "36 days", - + issuer: 'freesewing.org', + audience: 'freesewing.org', + expiresIn: '36 days' }, - languages: ["en", "de", "es", "fr", "nl"], + languages: ['en', 'de', 'es', 'fr', 'nl'], + sendEmailWith: 'smtp', + sendgrid: process.env.FS_SENDGRID_API_KEY, smtp: { host: process.env.FS_SMTP_HOST, + port: process.env.FS_SMTP_PORT, user: process.env.FS_SMTP_USER, - pass: process.env.FS_SMTP_PASS, - + pass: process.env.FS_SMTP_PASS }, oauth: { github: { clientId: process.env.FS_GITHUB_CLIENT_ID, clientSecret: process.env.FS_GITHUB_CLIENT_SECRET, - tokenUri: "https://github.com/login/oauth/access_token", - dataUri: "https://api.github.com/user" + tokenUri: 'https://github.com/login/oauth/access_token', + dataUri: 'https://api.github.com/user', + emailUri: 'https://api.github.com/user/emails' }, google: { clientId: process.env.FS_GOOGLE_CLIENT_ID, clientSecret: process.env.FS_GOOGLE_CLIENT_SECRET, - tokenUri: "https://oauth2.googleapis.com/token", - dataUri: "https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos" + tokenUri: 'https://oauth2.googleapis.com/token', + dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos' } }, - og: { - template: ["..", "..", "artwork", "og", "template.svg"], - chars: { - title_1: 18, - title_2: 19, - title_3: 20, - intro: 34, - sub: 42 + github: { + token: process.env.FS_GITHUB_TOKEN, + api: 'https://api.github.com', + bot: { + user: 'freesewing-robot', + name: 'Freesewing bot', + email: 'bot@freesewing.org' + }, + notify: { + specific: { + benjamin: [ 'woutervdub' ], + penelope: [ 'woutervdub' ], + waralee: [ 'woutervdub' ], + diana: [ 'alfalyr' ], + holmes: [ 'alfalyr' ], + sandy: [ 'alfalyr' ], + }, + dflt: [ 'joostdecock' ] } }, - strapi: 'https://posts.freesewing.org', + strapi: { + protocol: process.env.FS_STRAPI_PROTOCOL, + host: process.env.FS_STRAPI_HOST, + port: process.env.FS_STRAPI_PORT, + username: process.env.FS_STRAPI_USERNAME, + password: process.env.FS_STRAPI_PASSWORD, + tmp: process.env.FS_STRAPI_TMP, + } } - -export default config diff --git a/packages/backend/src/config/verify.js b/packages/backend/src/config/verify.js index b703930365a..235dfa7b9c9 100644 --- a/packages/backend/src/config/verify.js +++ b/packages/backend/src/config/verify.js @@ -1,92 +1,79 @@ const verifyConfig = (config, chalk) => { const nonEmptyString = input => { - if (typeof input === "string" && input.length > 0) return true; - return false; + if (typeof input === 'string' && input.length > 0) return true + return false } - const warnings = []; - const errors = []; + const warnings = [] + const errors = [] // Required (error when missing) // // Database - if (!nonEmptyString(config.db.uri)) - errors.push({ e: "FS_MONGO_URI", i: "mongo" }); + if (!nonEmptyString(config.db.uri)) errors.push({ e: 'FS_MONGO_URI', i: 'mongo' }) // Encryption - if (!nonEmptyString(config.encryption.key)) - errors.push({ e: "FS_ENC_KEY", i: "encryption" }); - - // JWT - if (!nonEmptyString(config.jwt.issuer)) - errors.push({ e: "FS_JWT_ISSUER", i: "jwt" }); - + if (!nonEmptyString(config.encryption.key)) errors.push({ e: 'FS_ENC_KEY', i: 'encryption' }) // Wanted (warning when missing) // // API - if (!nonEmptyString(config.api)) - warnings.push({ e: "FS_BACKEND", i: "api" }); + if (!nonEmptyString(config.api)) warnings.push({ e: 'FS_BACKEND', i: 'api' }) // Site - if (!nonEmptyString(config.api)) - warnings.push({ e: "FS_SITE", i: "site" }); + if (!nonEmptyString(config.api)) warnings.push({ e: 'FS_SITE', i: 'site' }) // SMTP - if (!nonEmptyString(config.smtp.host)) - warnings.push({ e: "FS_SMTP_HOST", i: "smtp" }); + if (!nonEmptyString(config.smtp.host)) warnings.push({ e: 'FS_SMTP_HOST', i: 'smtp' }) - if (!nonEmptyString(config.smtp.user)) - warnings.push({ e: "FS_SMTP_USER", i: "smtp" }); + if (!nonEmptyString(config.smtp.user)) warnings.push({ e: 'FS_SMTP_USER', i: 'smtp' }) - if (!nonEmptyString(config.smtp.pass)) - warnings.push({ e: "FS_SMTP_PASS", i: "smtp" }); + if (!nonEmptyString(config.smtp.pass)) warnings.push({ e: 'FS_SMTP_PASS', i: 'smtp' }) // OAUTH if (!nonEmptyString(config.oauth.github.clientId)) - warnings.push({ e: "FS_GITHUB_CLIENT_ID", i: "oauth" }); + warnings.push({ e: 'FS_GITHUB_CLIENT_ID', i: 'oauth' }) if (!nonEmptyString(config.oauth.github.clientSecret)) - warnings.push({ e: "FS_GITHUB_CLIENT_SECRET", i: "oauth" }); + warnings.push({ e: 'FS_GITHUB_CLIENT_SECRET', i: 'oauth' }) if (!nonEmptyString(config.oauth.google.clientId)) - warnings.push({ e: "FS_GOOGLE_CLIENT_ID", i: "oauth" }); + warnings.push({ e: 'FS_GOOGLE_CLIENT_ID', i: 'oauth' }) if (!nonEmptyString(config.oauth.google.clientSecret)) - warnings.push({ e: "FS_GOOGLE_CLIENT_SECRET", i: "oauth" }); + warnings.push({ e: 'FS_GOOGLE_CLIENT_SECRET', i: 'oauth' }) - for (let {e, i} of warnings) { + for (let { e, i } of warnings) { console.log( - chalk.yellow("Warning:"), - "Missing", + chalk.yellow('Warning:'), + 'Missing', chalk.yellow(e), "environment variable. Some features won't be available.", - "\n", - chalk.yellow("See: "), - chalk.yellow.bold("https://dev.freesewing.org/backend/configuration#"+i), - "\n", - ); + '\n', + chalk.yellow('See: '), + chalk.yellow.bold('https://dev.freesewing.org/backend/configuration#' + i), + '\n' + ) } - for (let {e, i} of errors) { + for (let { e, i } of errors) { console.log( - chalk.redBright("Error:"), - "Required environment variable", + chalk.redBright('Error:'), + 'Required environment variable', chalk.redBright(e), "is missing. The backend won't start without it.", - "\n", - chalk.yellow("See: "), - chalk.yellow.bold("https://dev.freesewing.org/backend/configuration#"+i), - "\n", - ); + '\n', + chalk.yellow('See: '), + chalk.yellow.bold('https://dev.freesewing.org/backend/configuration#' + i), + '\n' + ) } if (errors.length > 0) { - console.log(chalk.redBright("Invalid configuration. Stopping here...")); - return process.exit(1); + console.log(chalk.redBright('Invalid configuration. Stopping here...')) + return process.exit(1) } - return true; + return true } -export default verifyConfig; - +export default verifyConfig diff --git a/packages/backend/src/controllers/admin.js b/packages/backend/src/controllers/admin.js new file mode 100644 index 00000000000..e0022e705d5 --- /dev/null +++ b/packages/backend/src/controllers/admin.js @@ -0,0 +1,180 @@ +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/packages/backend/src/controllers/auth.js b/packages/backend/src/controllers/auth.js index 115d1d24d30..ff16bff8fe7 100644 --- a/packages/backend/src/controllers/auth.js +++ b/packages/backend/src/controllers/auth.js @@ -1,273 +1,226 @@ -import { User, Model, Draft, Confirmation } from "../models"; -import { getHash, getToken, getHandle, createHandle, imageType, saveAvatarFromBase64 } from "../utils"; -import config from "../config"; -import queryString from "query-string"; -import axios from "axios"; -import { log } from "../utils"; +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() { } +function AuthController() {} -AuthController.prototype.initOauth = function (req, res) { - if (!req.body) return res.sendStatus(400); +AuthController.prototype.initOauth = function(req, res) { + if (!req.body) return res.sendStatus(400) let confirmation = new Confirmation({ - type: "oauth", + type: 'oauth', data: { language: req.body.language, - provider: req.body.provider, + provider: req.body.provider } - }); - confirmation.save(function (err) { - if (err) return res.sendStatus(500); - return res.send({ state: confirmation._id }); - }); + }) + 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); +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._id) !== String(req.body.confirmation)) return res.sendStatus(401); - if (String(confirmation.data.validation) !== String(req.body.validation)) return res.sendStatus(401); - let signup = confirmation.data.signup; + 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 (err) return res.sendStatus(400) + if (user === null) return res.sendStatus(401) - if(user.status !== "active") res.sendStatus(403); - let account = user.account(); - let token = getToken(account); - let models = {}; - Model.find({user: user.handle}, (err, modelList) => { - if(err) return res.sendStatus(400); - for ( let model of modelList ) models[model.handle] = model; - let drafts = {}; - Draft.find({user: user.handle}, (err, draftList) => { - if(err) return res.sendStatus(400); - for ( let draft of draftList ) drafts[draft.handle] = draft; - confirmation.remove((err) => { - if(err !== null) return res.sendStatus(500); - user.updateLoginTime(() => res.send({account, models, token, signup})); - }); - }); - }); - }); - }); + 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.callbackFromGithub = function (req, res) { - let language; - let conf = config.oauth; - - // Is this a follow-up on an Oauth init? - 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 !== "github") return res.sendStatus(401); - - language = confirmation.data.language; - // Fetch access token from Github - // Fetch user info from Github - const github = axios.create({ - baseURL: "https://github.com", - timeout: 5000 - }); - - github.post("/login/oauth/access_token", { - client_id: conf.clientId, - client_secret: conf.clientSecret, - code: req.query.code, - accent: "json" - }).then(result => { - if (result.status !== 200) return res.sendStatus(401); - let token = queryString.parse(result.data).access_token; - const api = axios.create({ - baseURL: "", - timeout: 5000 - }); - const headers = token => ({ - headers: { - Authorization: "Bearer " + token, - } - }); - api.get("https://api.github.com/user", headers(token)).then(result => { - User.findOne({ ehash: getHash(result.data.email) }, (err, user) => { - if (err) return res.sendStatus(400); - if(user === null) { - // New user: signup - let handle = getHandle(); - api.get(result.data.avatar_url, { responseType: 'arraybuffer' }).then(avatar => { - let type = imageType(avatar.headers["content-type"]); - saveAvatarFromBase64(new Buffer(avatar.data, 'binary').toString('base64'), handle, type); - let user = new User({ - picture: handle + "." + type, - email: result.data.email, - initial: result.data.email, - ehash: getHash(result.data.email), - handle, - username: result.data.login, - settings: { language: language }, - social: { github: result.data.login }, - bio: result.data.bio, - time: { - created: new Date(), - login: new Date(), - } - }); - 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(config.website+"/login/callback/"+confirmation._id+"/"+validation); - }); - }); - }); - } else { - // Existing user - if(user.status !== "active") res.sendStatus(403); - 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(config.website+"/login/callback/"+confirmation._id+"/"+validation); - }); - }); - } - }); - }).catch(err => res.sendStatus(401)); - }).catch(err => res.sendStatus(401)); - }); -} - -AuthController.prototype.providerCallback = function (req, res) { - let language, token, email, avatarUri, username; - let provider = req.params.provider; +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); + 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; + language = confirmation.data.language // Get access token - const go = axios.create({ baseURL: "", timeout: 5000 }); + 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 + "/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(result => { - if (provider === "github") { - email = result.data.email; - 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 - 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 }, - time: { - created: new Date(), - login: new Date(), - } + 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 } - if (provider === "github") { - userData.ocial = { github: result.data.login }; - userData.bio = result.data.bio + 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 } - console.log('user data is', userData); - 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(config.website+"/login/callback/"+confirmation._id+"/"+validation); - }); - }); - }); - } else { - // Existing user - if(user.status !== "active") res.sendStatus(403); - 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(config.website+"/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); - }); - }); + 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) + }) + }) } -export default AuthController; +/* +* 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/packages/backend/src/controllers/comment.js b/packages/backend/src/controllers/comment.js deleted file mode 100644 index f74c49489c4..00000000000 --- a/packages/backend/src/controllers/comment.js +++ /dev/null @@ -1,16 +0,0 @@ -const comment = {}; - -// CRUD basics -comment.create = (req, res) => { } -comment.read = (req, res) => { } -comment.update = (req, res) => { } -comment.delete = (req, res) => { } - -// Page or recent comments -comment.pageComments = (req, res) => { } -comment.recentComments = (req, res) => { } - -// Webhook -comment.replyFromEmail = (req, res) => { } - -export default comment; diff --git a/packages/backend/src/controllers/confirmation.js b/packages/backend/src/controllers/confirmation.js deleted file mode 100644 index df799966535..00000000000 --- a/packages/backend/src/controllers/confirmation.js +++ /dev/null @@ -1,21 +0,0 @@ -import { User, Confirmation } from "../models"; -import crypto from "crypto"; -import bcrypt from "bcryptjs"; -import { log, email } from "../utils"; -import jwt from "jsonwebtoken"; -import config from "../config"; - -function ConfirmationController() { } - -ConfirmationController.prototype.confirm = function (req, res) { - if (!req.body) return res.sendStatus(400); - Confirmation.findById(req.body.id, (err, confirmation) => { - if (err) return res.sendStatus(400); - if(confirmation === null) return res.sendStatus(401); - return res.send(confirmation); - }); -} - - - -export default ConfirmationController; diff --git a/packages/backend/src/controllers/draft.js b/packages/backend/src/controllers/draft.js deleted file mode 100644 index a5a5150dd88..00000000000 --- a/packages/backend/src/controllers/draft.js +++ /dev/null @@ -1,131 +0,0 @@ -import { User, Draft } from "../models"; -import { log } from "../utils"; - -function DraftController() { } - -// CRUD basics -DraftController.prototype.read = (req, res) => { } - -DraftController.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(400); - let handle = uniqueHandle(); - let draft = new Draft({ - handle, - user: user.handle, - name: handle, - gist: req.body.gist, - created: new Date(), - }); - draft.save(function (err) { - if (err) { - log.error('draftCreationFailed', user); - console.log(err); - return res.sendStatus(500); - } - log.info('draftCreated', { handle: draft.handle }); - const drafts ={}; - Draft.find({user: user.handle}, (err, draftList) => { - if(err) return res.sendStatus(400); - for ( let draft of draftList ) drafts[draft.handle] = draft; - return res.send({ handle, drafts }); - }); - }); - }); -} - -DraftController.prototype.readGist = (req, res) => { - Draft.findOne({ handle: req.params.handle }, (err, draft) => { - if(err || draft === null) return res.sendStatus(400); - return res.send(draft.asGist()); - }); -} - -DraftController.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); - Draft.findOne({ handle: req.params.handle }, (err, draft) => { - if(err || draft === null) return res.sendStatus(400); - if(typeof req.body.name === 'string') draft.name = req.body.name; - if(typeof req.body.notes === 'string') draft.notes = req.body.notes; - return saveAndReturnDraft(res, draft); - }); - }); -} - -DraftController.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); - Draft.deleteOne({ handle: req.params.handle, user: user.handle }, (err) => { - if (err) return res.sendStatus(400); - else return res.sendStatus(204); - }); - }); -} - -// Delete multiple -DraftController.prototype.deleteMultiple = function (req, res) { - if (!req.body) return res.sendStatus(400); - if (!req.body.drafts) 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 drafts = req.body.drafts; - if(drafts.length < 1) return res.sendStatus(400); - let handles = []; - for(let handle of drafts) handles.push({ handle }); - Draft.deleteMany({ - user: user.handle, - $or: handles - }, (err) => { - if(err) return res.sendStatus(500); - const drafts ={}; - Draft.find({user: user.handle}, (err, draftList) => { - if(err) return res.sendStatus(400); - for ( let draft of draftList ) drafts[draft.handle] = draft; - res.send({ drafts }); - }); - }); - }); -} - - - - -function saveAndReturnDraft(res, draft) { - draft.save(function (err, updatedDraft) { - if (err) { - log.error('draftUpdateFailed', updatedDraft); - return res.sendStatus(500); - } - return res.send({ draft: updatedDraft.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(); - Draft.findOne({ handle: handle }, (err, draft) => { - if(draft !== null) exists = true; - }); - } while (exists !== false); - - return handle; -} - -export default DraftController; diff --git a/packages/backend/src/controllers/github.js b/packages/backend/src/controllers/github.js new file mode 100644 index 00000000000..121f00a94fa --- /dev/null +++ b/packages/backend/src/controllers/github.js @@ -0,0 +1,99 @@ +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 += `- **Workbench**: 👉 https://${req.body.design}.freesewing.dev${path}` + body += "\n" + body += `- **Next**: 👉 https://next.freesewing.org${path}` + body += "\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/packages/backend/src/controllers/model.js b/packages/backend/src/controllers/model.js deleted file mode 100644 index 60169e84a13..00000000000 --- a/packages/backend/src/controllers/model.js +++ /dev/null @@ -1,179 +0,0 @@ -import { User, Model } from "../models"; -import { log } from "../utils"; -import fs from "fs"; -import path from "path"; -import config from "../config"; -import sharp from "sharp"; - -function ModelController() { } - -// CRUD basics -ModelController.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 model = new Model({ - handle, - user: user.handle, - name: req.body.name, - units: req.body.units, - breasts: req.body.breasts, - created: new Date(), - }); - model.save(function (err) { - if (err) { - log.error('modelCreationFailed', user); - console.log(err); - return res.sendStatus(500); - } - log.info('modelCreated', { handle: model.handle }); - return res.send({ model }); - }); - }); -} - -ModelController.prototype.read = function (req, res) { } - -ModelController.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); - Model.findOne({ handle: req.params.handle }, (err, model) => { - if(err || model === null) return res.sendStatus(400); - let data = req.body; - if(typeof data.name === 'string') model.name = data.name; - if(typeof data.notes === 'string') model.notes = data.notes; - if(typeof data.units === 'string') model.units = data.units; - if(typeof data.breasts === 'string' ) model.breasts = (data.breasts === "true") ? true : false; - if(typeof data.measurements !== 'undefined' ) model.measurements = { - ...model.measurements, - ...data.measurements - }; - if(typeof data.picture !== "undefined") { - let type = imageType(data.picture); - saveAvatar(data.picture, model.handle, type); - model.picture = model.handle+"."+type; - } - - return saveAndReturnModel(res, model); - }); - }); -} - -ModelController.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); - Model.deleteOne({ handle: req.params.handle, user: user.handle }, (err) => { - if (err) return res.sendStatus(400); - else return res.sendStatus(204); - }); - }); -} - - -function imageType(uri) { - let type = uri.split(';').shift(); - type = type.split('/').pop(); - - return type; -} - -function saveAvatar(picture, handle, type) { - let b64 = picture.split(';base64,').pop(); - fs.mkdir(userStoragePath(handle)+"/models", {recursive: true}, (err) => { - if(err) log.error("mkdirFailed", err); - let imgBuffer = Buffer.from(b64, 'base64'); - for(let size of Object.keys(config.avatar.sizes)) { - sharp(imgBuffer) - .resize(config.avatar.sizes[size], config.avatar.sizes[size]) - .toFile(avatarPath(size, handle, type), (err, info) => { - if(err) log.error("avatarNotSaved", err); - }); - } - }); -} - -function saveAndReturnModel(res,model) { - model.save(function (err, updatedModel) { - if (err) { - log.error('modelUpdateFailed', updatedModel); - return res.sendStatus(500); - } - return res.send({ model: updatedModel.info() }); - }) -} - -function avatarPath(size, handle, ext, type="user") { - let dir = userStoragePath(handle); - if(type === "model") dir += "/models"; - if(size === "l") return path.join(dir, handle+"."+ext); - else return path.join(dir, size+"-"+handle+"."+ext); -} - -function userStoragePath(handle) { - return path.join( - config.storage, - handle.substring(0,1), - handle); -} - - - -// Delete multiple -ModelController.prototype.deleteMultiple = function (req, res) { - if (!req.body) return res.sendStatus(400); - if (!req.body.models) 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 models = req.body.models; - if(models.length < 1) return res.sendStatus(400); - let handles = []; - for(let handle of models) handles.push({ handle }); - Model.deleteMany({ - user: user.handle, - $or: handles - }, (err) => { - if(err) return res.sendStatus(500); - const models ={}; - Model.find({user: user.handle}, (err, modelList) => { - if(err) return res.sendStatus(400); - for ( let model of modelList ) models[model.handle] = model; - res.send({ models }); - }); - }); - }); -} - - -// Clone -ModelController.prototype.clone = function (req, res) { } - -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(); - Model.findOne({ handle: handle }, (err, model) => { - if(model !== null) exists = true; - }); - } while (exists !== false); - - return handle; -} - - -export default ModelController; diff --git a/packages/backend/src/controllers/newsletter.js b/packages/backend/src/controllers/newsletter.js new file mode 100644 index 00000000000..364c9c42694 --- /dev/null +++ b/packages/backend/src/controllers/newsletter.js @@ -0,0 +1,97 @@ +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/packages/backend/src/controllers/og.js b/packages/backend/src/controllers/og.js deleted file mode 100644 index 3a0afdb2024..00000000000 --- a/packages/backend/src/controllers/og.js +++ /dev/null @@ -1,230 +0,0 @@ -import config from "../config"; -import { capitalize, log } 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 data = {} - 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/packages/backend/src/controllers/pattern.js b/packages/backend/src/controllers/pattern.js new file mode 100644 index 00000000000..1c2a91bd3fd --- /dev/null +++ b/packages/backend/src/controllers/pattern.js @@ -0,0 +1,127 @@ +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/packages/backend/src/controllers/person.js b/packages/backend/src/controllers/person.js new file mode 100644 index 00000000000..1dfb1247aa3 --- /dev/null +++ b/packages/backend/src/controllers/person.js @@ -0,0 +1,115 @@ +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/packages/backend/src/controllers/referral.js b/packages/backend/src/controllers/referral.js deleted file mode 100644 index f17c1c68898..00000000000 --- a/packages/backend/src/controllers/referral.js +++ /dev/null @@ -1,6 +0,0 @@ -const referral = {}; - -// CRUD basics -referral.create = (req, res) => { } - -export default referral; diff --git a/packages/backend/src/controllers/strapi.js b/packages/backend/src/controllers/strapi.js new file mode 100644 index 00000000000..22e5e3c0918 --- /dev/null +++ b/packages/backend/src/controllers/strapi.js @@ -0,0 +1,145 @@ +import axios from 'axios' +import config from '../config' +import asBuffer from 'data-uri-to-buffer' +import FormData from 'form-data' +import fs from 'fs' +import toFile from 'data-uri-to-file' +import toStream from 'buffer-to-stream' +import tou8 from 'buffer-to-uint8array' + +const auth = { + identifier: config.strapi.username, + password: config.strapi.password, +} + +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 onDisk = await fs.promises.writeFile(filename, asBuffer(img)) + 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/packages/backend/src/controllers/user.js b/packages/backend/src/controllers/user.js index c711a7e1024..615ea5ad950 100644 --- a/packages/backend/src/controllers/user.js +++ b/packages/backend/src/controllers/user.js @@ -1,52 +1,96 @@ -import { User, Confirmation, Model, Draft } from "../models"; -import crypto from "crypto"; -import bcrypt from "bcryptjs"; -import { log, email } from "../utils"; -import jwt from "jsonwebtoken"; -import config from "../config"; -import formidable from "formidable"; -import sharp from "sharp"; -import path from "path"; -import fs from "fs"; -import Zip from "jszip"; -import rimraf from "rimraf"; +import { User, Confirmation, Person, Pattern } from '../models' +import { + log, + email, + ehash, + newHandle, + uniqueHandle, +} from '../utils' +import jwt from 'jsonwebtoken' +import config from '../config' +import path from 'path' +import fs from 'fs' +import Zip from 'jszip' +import rimraf from 'rimraf' -function UserController() { } +function UserController() {} -UserController.prototype.login = function (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) return res.sendStatus(400); - if(user === null) return res.sendStatus(401); - user.verifyPassword(req.body.password, (err, valid) => { - if (err) return res.sendStatus(400); - else if (valid) { - if(user.status !== "active") res.sendStatus(403); - log.info('login', { user, req }); - let account = user.account(); - let token = getToken(account); - let models = {}; - Model.find({user: user.handle}, (err, modelList) => { - if(err) return res.sendStatus(400); - for ( let model of modelList ) models[model.handle] = model.info(); - let drafts = {}; - Draft.find({user: user.handle}, (err, draftList) => { - if(err) return res.sendStatus(400); - for ( let draft of draftList ) drafts[draft.handle] = draft; - user.updateLoginTime(() => res.send({account, models, recipes: drafts, token})); - }); - }); - } else { - log.warning('wrongPassword', { user, req }); - return res.sendStatus(401); +UserController.prototype.login = function(req, res) { + if (!req.body || !req.body.username) return res.sendStatus(400) + User.findOne( + { + $or: [ + { username: req.body.username.toLowerCase().trim() }, + { username: req.body.username.trim() }, + { ehash: ehash(req.body.username) } + ] + }, + (err, user) => { + if (err) return res.sendStatus(400) + if (user === null) return res.sendStatus(401) + user.verifyPassword(req.body.password, (err, valid) => { + if (err) return res.sendStatus(400) + else if (valid) { + if (user.status !== 'active') return res.sendStatus(403) + else { + log.info('login', { 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 }) + ) + }) + }) + } + } else { + log.warning('wrongPassword', { user, req }) + return res.sendStatus(401) + } + }) + } + ) +} + +// 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 @@ -54,465 +98,453 @@ UserController.prototype.login = function (req, res) { // 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) 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); + 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); + 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,token}); - }); - }); - }); - }); + return res.send({ account, people: {}, patterns: {}, token }) + }) + }) + }) + }) } UserController.prototype.readAccount = (req, res) => { - if (!req.user._id) return res.sendStatus(400); + if (!req.user._id) return res.sendStatus(400) User.findById(req.user._id, (err, user) => { - if(user !== null) { - log.info('ping', { user, req }); - const models ={}; - Model.find({user: user.handle}, (err, modelList) => { - if(err) return res.sendStatus(400); - for ( let model of modelList ) models[model.handle] = model.info(); - const drafts ={}; - Draft.find({user: user.handle}, (err, draftList) => { - if(err) return res.sendStatus(400); - for ( let draft of draftList ) drafts[draft.handle] = draft; - res.send({account: user.account(), models, drafts}); - }); - }); + if (user !== null) { + log.info('ping', { user, req }) + const people = {} + Person.find({ user: user.handle }, (err, personList) => { + if (err) return res.sendStatus(400) + for (let person of personList) people[person.handle] = person.info() + const patterns = {} + Pattern.find({ user: user.handle }, (err, patternList) => { + if (err) return res.sendStatus(400) + for (let pattern of patternList) patterns[pattern.handle] = pattern.export() + return res.send({ account: user.account(), people, patterns }) + }) + }) } else { - return res.sendStatus(400); + return res.sendStatus(400) } - }); + }) } 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({ profile: user.profile() }); - }); + if (err) return res.sendStatus(404) + if (user === null) return res.sendStatus(404) + else res.send(user.profile()) + }) } UserController.prototype.update = (req, res) => { - var async = 0; - if (!req.user._id) return res.sendStatus(400); + 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; - if(typeof data.settings !== 'undefined') { + if (err || user === null) { + return res.sendStatus(400) + } + let data = req.body + + if (typeof data.settings !== 'undefined') { user.settings = { ...user.settings, ...data.settings } - } - if(typeof data.username === 'string') user.username = data.username; - if(typeof data.bio === 'string') user.bio = data.bio; - 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; - } - if(typeof data.consent === 'object') { + 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) + + 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 } - } - if(typeof data.avatar !== "undefined" && data.avatar) { - let type = imageTypeFromDataUri(data.avatar); - saveAvatar(data.avatar, user.handle, type); - user.avatar = user.handle+"."+type; - } - - // Below are async ops, need to watch out when to save - - if(typeof data.newPassword === 'string' && typeof data.currentPassword === 'string') { - user.verifyPassword(data.currentPassword, (err, valid) => { - if (err) return res.sendStatus(400); + 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 { - if (!valid) return res.sendStatus(403); - user.password = data.newPassword; - return saveAndReturnAccount(res, user); + user.username = data.username + return saveAndReturnAccount(res, user) } - }); + }) } - // Email change requires confirmation - else if(typeof data.email === 'string' && data.email !== user.email) { - if(typeof data.confirmation === 'string') { + 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); - }); + 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", + 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); - }); + }) + 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) + }) } } - - else return saveAndReturnAccount(res, user); - }); -} - -function imageTypeFromDataUri(uri) { - let type = uri.split(';').shift(); - type = type.split('/').pop(); - - return type; -} - - -function saveAndReturnAccount(res,user) { - user.save(function (err, updatedUser) { - if (err) { - log.error('accountUpdateFailed', updatedUser); - return res.sendStatus(500); - } - return res.send({ account: updatedUser.account() }); }) } -function saveAvatar(picture, handle, type) { - let b64 = picture.split(';base64,').pop(); - fs.mkdir(userStoragePath(handle), {recursive: true}, (err) => { - if(err) log.error("mkdirFailed", err); - let imgBuffer = Buffer.from(b64, 'base64'); - for(let size of Object.keys(config.avatar.sizes)) { - sharp(imgBuffer) - .resize(config.avatar.sizes[size], config.avatar.sizes[size]) - .toFile(avatarPath(size, handle, type), (err, info) => { - if(err) log.error("avatarNotSaved", err); - }); - } - }); -} - -function userStoragePath(handle) { - return path.join( - config.storage, - handle.substring(0,1), - handle); +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); -} - -function avatarPath(size, handle, ext, type="user") { - let dir = userStoragePath(handle); - if(size === "l") return path.join(dir, handle+"."+ext); - else return path.join(dir, size+"-"+handle+"."+ext); -} - -function imageType(contentType) { - if (contentType === "image/png") return "png"; - if (contentType === "image/jpeg") return "jpg"; - if (contentType === "image/gif") return "gif"; - if (contentType === "image/bmp") return "bmp"; - if (contentType === "image/webp") return "webp"; + 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); + 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); - }); + 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) + }) } - - - - -// userController.delete = (req, res) => { } - - // // Signup flow +// // Signup flow UserController.prototype.signup = (req, res) => { - if (!req.body) return res.sendStatus(400); - User.findOne({ - ehash: ehash(req.body.email) - }, (err, user) => { - if (err) return res.sendStatus(500); - if(user !== null) return res.status(400).send('userExists'); - else { - // FROM HERE - let handle = uniqueHandle(); - let username = "user-"+handle; - let user = new User({ - email: req.body.email, - initial: req.body.email, - ehash: ehash(req.body.email), - handle, - username, - password: req.body.password, - settings: { language: req.body.language }, - status: "pending", - time: { - created: new Date(), - } - }); - user.save(function (err) { - if (err) { - log.error('accountCreationFailed', user); - console.log(err); - return res.sendStatus(500); - } - log.info('accountCreated', { handle: user.handle }); + if (!req.body) return res.sendStatus(400) + if (!req.body.email) return res.status(400).send('emailMissing') + if (!req.body.password) return res.status(400).send('passwordMissing') + 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(400).send('userExists') + else { + let handle = uniqueHandle() + let username = 'user-' + handle + let user = new User({ + email: req.body.email, + initial: req.body.email, + ehash: ehash(req.body.email), + handle, + username, + password: req.body.password, + settings: { language: req.body.language }, + status: 'pending', + picture: handle + '.svg', + time: { + created: new Date() + } + }) + user.save(function(err) { + if (err) { + log.error('accountCreationFailed', user) + console.log(err) + return res.sendStatus(500) + } + log.info('accountCreated', { handle: user.handle }) + user.createAvatar(handle) + let confirmation = new Confirmation({ + type: 'signup', + data: { + language: req.body.language, + email: req.body.email, + password: req.body.password, + handle + } + }) + confirmation.save(function(err) { + if (err) return res.sendStatus(500) + log.info('signupRequest', { email: req.body.email, confirmation: confirmation._id }) + email.signup(req.body.email, req.body.language, confirmation._id) + return res.sendStatus(200) + }) + }) + } + } + ) +} + +// // 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", + type: 'signup', data: { language: req.body.language, - email: req.body.email, - password: req.body.password, - handle + email: user.email, + handle: user.handle } - }); - confirmation.save(function (err) { - if (err) return res.sendStatus(500); - log.info('signupRequest', { email: req.body.email, confirmation: confirmation._id }); - email.signup(req.body.email, req.body.language, confirmation._id); - return res.sendStatus(200); - }); - }); - } - }); -} - // userController.confirmSignupEmail = (req, res) => { } - // userController.removeConfirmation = (req, res) => { } - // userController.resendActivationEmail = (req, res) => { } - - // // Reset/recover/change email - // userController.recoverPassword = (req, res) => { } -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('resendActivationRequest', { email: req.body.email, confirmation: confirmation._id }) + email.signup(req.body.email, req.body.language, confirmation._id) + return res.sendStatus(200) + }) } - }); - 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.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); + 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); - user.updateLoginTime(() => res.send({account,token})); - }) - } else return res.sendStatus(401); + 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; + 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) + }) + }) +} -// userController.confirmChangedEmail = (req, res) => { } - - // // Other - // userController.patronList = (req, res) => { } +// // 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); + 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", JSON.stringify(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)}); - }); - }); - }); - }); + if (user === null) return res.sendStatus(400) + let dir = createTempDir() + if (!dir) return res.sendStatus(500) + let zip = new Zip() + zip.file('account.json', JSON.stringify(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; + 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); + 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); - }); - }); + 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); + 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); + 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); - }); - }); - }); + 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, - aud: config.jwt.audience, - iss: config.jwt.issuer, - }, config.jwt.secretOrKey); -} - -const clean = (email) => email.toLowerCase().trim(); - -const ehash = (email) => { - let hash = crypto.createHash("sha256"); - hash.update(clean(email)); - return hash.digest("hex"); -} - -const passwordMatches = async (password, hash) => { - let match = await bcrypt.compare(password, hash); - - return match; -} - -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(); - User.findOne({ handle: handle }, (err, user) => { - if(user !== null) exists = true; - }); - } while (exists !== false); - - return handle; +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; + let path = temporaryStoragePath(newHandle(10)) + fs.mkdir(path, { recursive: true }, err => { + if (err) { + log.error('mkdirFailed', err) + path = false } - }); + }) - return path; + return path } -const uri = path => config.static+path.substring(config.storage.length); +const uri = path => config.static + path.substring(config.storage.length) -const loadModels = user => { - const models ={}; - Model.find({user: user.handle}, (err, modelList) => { - if(err) console.log('models err', err, models); - for ( let model of modelList ) models[model.user] = model; - return models; - }); -} - -export default UserController; +export default UserController diff --git a/packages/backend/src/env.js b/packages/backend/src/env.js new file mode 100644 index 00000000000..ca5ac26a577 --- /dev/null +++ b/packages/backend/src/env.js @@ -0,0 +1,2 @@ +import * as dotenv from 'dotenv' +dotenv.config() diff --git a/packages/backend/src/i18n/de.js b/packages/backend/src/i18n/de.js deleted file mode 100644 index 41018ce8b9f..00000000000 --- a/packages/backend/src/i18n/de.js +++ /dev/null @@ -1,25 +0,0 @@ -export default { - chatWithUs: "Chatte mit uns", - footerCredits: "Gemacht von Joost De Cock & den Mitwirkenden, mit der finanziellen Unterstützung unserer Gönner ❤️", - footerSlogan: "Freesewing ist eine Open Source Plattform für Schnittmuster nach Maß", - joostFromFreesewing: "Joost von Freesewing", - questionsJustReply: "Wenn Sie Fragen haben, antworten Sie einfach auf diese E-Mail. Ich bin immer gerne bereit zu helfen. 🙂", - signature: "mit Liebe,", - - signupActionText: "Bestätigen Sie Ihre E-Mail-Adresse", - signupCopy1: "Danke, dass Sie sich bei freesewing.org angemeldet haben.

Bevor wir beginnen, müssen Sie Ihre E-Mail-Adresse bestätigen. Bitte klicken Sie auf den folgenden Link, um das zu tun:", - signupHeaderOpeningLine: "Wir freuen uns sehr, dass Sie der Freesewing Community beitreten.", - signupHiddenIntro: "Lassen Sie uns Ihre E-Mail-Adresse bestätigen", - signupSubject: "Willkommen bei freesewing.org", - signupTitle: "Willkommen an Bord", - signupWhy: "Du hast diese E-Mail erhalten, weil du dich gerade auf freesewing.org angemeldet hast", - - emailchangeActionText: "Bestätigen Sie Ihre neue E-Mail-Adresse", - emailchangeCopy1: "Sie haben um die Änderung der E-Mail-Adresse gebeten, die mit Ihrem Konto unter freesewing.org verknüpft ist.

Bevor Sie dies tun, müssen Sie Ihre neue E-Mail-Adresse bestätigen. Bitte klicken Sie auf den Link unten, um dies zu tun:", - emailchangeHeaderOpeningLine: "Stellen Sie nur sicher, dass wir Sie bei Bedarf erreichen können", - emailchangeHiddenIntro: "Bestätigen Sie Ihre neue E-Mail-Adresse", - emailchangeSubject: "Bitte bestätigen Sie Ihre neue E-Mail-Adresse", - emailchangeTitle: "Bitte bestätigen Sie Ihre neue E-Mail-Adresse", - emailchangeWhy: "Sie haben diese E-Mail erhalten, weil Sie die mit dem Konto auf freesewing.org verknüpfte E-Mail-Adresse geändert haben", -}; - diff --git a/packages/backend/src/i18n/en.js b/packages/backend/src/i18n/en.js deleted file mode 100644 index 246500f0599..00000000000 --- a/packages/backend/src/i18n/en.js +++ /dev/null @@ -1,40 +0,0 @@ -export default { - chatWithUs: "Chat with us", - footerCredits: "Made by Joost De Cock & contributors with the financial support of our patrons ❤️ ", - footerSlogan: "Freesewing is an open source platform for made-to-measure sewing patterns", - joostFromFreesewing: "Joost from Freesewing", - questionsJustReply: "If you have any questions, just reply to this E-mail. I'm always happy to help out. 🙂", - signature: "Love,", - - signupActionText: "Confirm your E-mail address", - signupCopy1: "Thank you for signing up at freesewing.org.

Before we get started, you need to confirm your E-mail address. Please click the link below to do that:", - signupHeaderOpeningLine: "We're really happy you're joining the freesewing community.", - signupHiddenIntro: "Let's confirm your E-mail address", - signupSubject: "Welcome to freesewing.org", - signupTitle: "Welcome aboard", - signupWhy: "You received this E-mail because you just signed up for an account on freesewing.org", - - emailchangeActionText: "Confirm your new E-mail address", - emailchangeCopy1: "You requested to change the E-mail address linked to your account at freesewing.org.

Before we do that, you need to confirm your new E-mail address. Please click the link below to do that:", - emailchangeHeaderOpeningLine: "Just making sure we can reach you when needed", - emailchangeHiddenIntro: "Let's confirm your new E-mail address", - emailchangeSubject: "Please confirm your new E-mail address", - emailchangeTitle: "Please confirm your new E-mail address", - emailchangeWhy: "You received this E-mail because you changed the E-mail address linked to account on freesewing.org", - - passwordresetActionText: "Re-gain access to your account", - passwordresetCopy1: "You forgot your password for your account at freesewing.org.

Click click the link below to reset your password:", - passwordresetHeaderOpeningLine: "Don't worry, these things happen to all of us", - passwordresetHiddenIntro: "Re-gain access to your account", - passwordresetSubject: "Re-gain access to your account on freesewing.org", - passwordresetTitle: "Reset your password, and re-gain access to your account", - passwordresetWhy: "You received this E-mail because you requested to reset your password on freesewing.org", - - goodbyeCopy1: "If you'd like to share why you're leaving, you can reply to this message.
From our side, we won't bother you again.", - goodbyeHeaderOpeningLine: "Just know that you can always come back", - goodbyeHiddenIntro: "Thank you for giving freesewing a chance", - goodbyeSubject: "Farewell 👋", - goodbyeTitle: "Thank you for giving freesewing a chance", - goodbyeWhy: "You received this E-mail as a final adieu after removing your account on freesewing.org", -}; - diff --git a/packages/backend/src/i18n/es.js b/packages/backend/src/i18n/es.js deleted file mode 100644 index 541f13d818a..00000000000 --- a/packages/backend/src/i18n/es.js +++ /dev/null @@ -1,25 +0,0 @@ -export default { - chatWithUs: "Habla con nosotros", - footerCredits: "Hecho por Joost De Cock y colaboradores con el apoyo económico de nuestros patrocinadores ❤️ ", - footerSlogan: "Freesewing es una plataforma open source para patrones de costura a medida", - joostFromFreesewing: "Joost de Freesewing", - questionsJustReply: "Si tiene alguna pregunta, simplemente responda a este correo electrónico. Siempre feliz de ayudar. 🙂", - signature: "Con amor,", - - signupActionText: "Confirme su dirección de correo electrónico", - signupCopy1: "Gracias por registrarse en freesewing.org.

Antes de comenzar, debe confirmar su dirección de correo electrónico. Por favor haga clic en el enlace de abajo para hacer eso:", - signupHeaderOpeningLine: "Estamos muy contentos de que te unas a la comunidad de freesewing.", - signupHiddenIntro: "Confirmemos tu dirección de correo electrónico", - signupSubject: "Bienvenido a freesewing.org", - signupTitle: "Bienvenido a bordo", - signupWhy: "Recibió este correo electrónico porque acaba de registrarse para una cuenta en freesewing.org", - - emailchangeActionText: "Confirme su nueva dirección de correo electrónico", - emailchangeCopy1: "Solicitó cambiar la dirección de correo electrónico vinculada a su cuenta en freesewing.org .

Antes de hacerlo, debe confirmar su nueva dirección de correo electrónico. Por favor haga clic en el enlace de abajo para hacer eso:", - emailchangeHeaderOpeningLine: "Solo asegurándonos de que podamos contactarlo cuando sea necesario", - emailchangeHiddenIntro: "Confirmemos tu nueva dirección de correo electrónico", - emailchangeSubject: "Por favor confirme su nueva dirección de correo electrónico", - emailchangeTitle: "Por favor confirme su nueva dirección de correo electrónico", - emailchangeWhy: "Recibió este correo electrónico porque cambió la dirección de correo electrónico vinculada a la cuenta en freesewing.org", -}; - diff --git a/packages/backend/src/i18n/fr.js b/packages/backend/src/i18n/fr.js deleted file mode 100644 index 0a059d1579c..00000000000 --- a/packages/backend/src/i18n/fr.js +++ /dev/null @@ -1,25 +0,0 @@ -export default { - chatWithUs: "Chattez avec nous", - footerCredits: "Fait par Joost De Cock et ses contributeurs avec le soutien financier de mécènes ❤️ ", - footerSlogan: "Freesewing est une plate-forme open source pour des patrons de couture sur mesure", - joostFromFreesewing: "Joost de Freesewing", - questionsJustReply: "Si vous avez des questions, répondez simplement à cet e-mail. Je suis toujours heureux d'aider. 🙂", - signature: "Bise,", - - signupActionText: "Confirmez votre adresse mail", - signupCopy1: "Merci de votre inscription sur freesewing.org.

Avant de commencer, vous devez confirmer votre adresse e-mail. S'il vous plaît cliquez sur le lien ci-dessous pour le faire:", - signupHeaderOpeningLine: "Nous sommes vraiment heureux que vous rejoigniez la communauté freesewing.", - signupHiddenIntro: "Confirmons votre adresse mail", - signupSubject: "Bienvenue sur freesewing.org", - signupTitle: "Bienvenue à bord", - signupWhy: "Vous avez reçu cet e-mail parce que vous venez de créer un compte sur freesewing.org", - - emailchangeActionText: "Confirmez votre nouvelle adresse mail", - emailchangeCopy1: "Vous avez demandé de modifier l'adresse e-mail associée à votre compte sur freesewing.org .

Avant de procéder, vous devez confirmer votre nouvelle adresse e-mail. S'il vous plaît cliquez sur le lien ci-dessous pour le faire:", - emailchangeHeaderOpeningLine: "Assurez-vous simplement que nous pouvons vous joindre en cas de besoin", - emailchangeHiddenIntro: "Confirmons votre nouvelle adresse e-mail", - emailchangeSubject: "Merci de confirmer votre nouvelle adresse e-mail", - emailchangeTitle: "Merci de confirmer votre nouvelle adresse e-mail", - emailchangeWhy: "Vous avez reçu cet e-mail parce que vous avez changé l'adresse e-mail associée au compte sur freesewing.org", -}; - diff --git a/packages/backend/src/i18n/index.js b/packages/backend/src/i18n/index.js deleted file mode 100644 index ea203cbba75..00000000000 --- a/packages/backend/src/i18n/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import en from "./en"; -import de from "./de"; -import es from "./es"; -import fr from "./fr"; -import nl from "./nl"; - -export default { en, de, es, fr, nl }; diff --git a/packages/backend/src/i18n/nl.js b/packages/backend/src/i18n/nl.js deleted file mode 100644 index 7b1d4d3471e..00000000000 --- a/packages/backend/src/i18n/nl.js +++ /dev/null @@ -1,25 +0,0 @@ -export default { - chatWithUs: "Chat met ons", - footerCredits: "Een project van Joost De Cock & vrijwillige medewerkers met de financiële steun van onze sponsors ❤️", - footerSlogan: "Freesewing is een open source platform voor naaipatronen op maat", - joostFromFreesewing: "Joost van Freesewing", - questionsJustReply: "Zit je met vragen? Stuur ze dan als antwoord op deze E-mail. Ik ben steeds bereid om een handje te helpen. 🙂", - signature: "Liefs,", - - signupActionText: "Bevestig je E-mail adres", - signupCopy1: "Leuk dat je je hebt ingeschreven op freesewing.org.

Vooraleer we aan de slag kunnen, moeten we eerst je E-mail adres bevestigen. Klik op onderstaande link om dat te doen:", - signupHeaderOpeningLine: "We zijn verheugd dat je deel wil uitmaken van de freesewing gemeenschap", - signupHiddenIntro: "Nu gewoon nog even je E-mail adres bevestigen", - signupSubject: "Welkom bij freesewing.org", - signupTitle: "Welkom aan boord", - signupWhy: "Je ontving deze E-mail omdat je je zonet ingeschreven hebt op freesewing.org", - - emailchangeActionText: "Bevestig uw nieuwe e-mailadres", - emailchangeCopy1: "U heeft verzocht het e-mailadres dat aan uw account is gekoppeld te wijzigen op freesewing.org .

Voordat we dat doen, moet u uw nieuwe e-mailadres bevestigen. Klik op de onderstaande link om dat te doen:", - emailchangeHeaderOpeningLine: "We zorgen ervoor dat we u kunnen bereiken wanneer dat nodig is", - emailchangeHiddenIntro: "Laten we uw nieuwe e-mailadres bevestigen", - emailchangeSubject: "Bevestig uw nieuwe e-mailadres", - emailchangeTitle: "Bevestig uw nieuwe e-mailadres", - emailchangeWhy: "Je hebt deze e-mail ontvangen omdat je het e-mailadres hebt gewijzigd dat is gekoppeld aan account op freesewing.org", -}; - diff --git a/packages/backend/src/index.js b/packages/backend/src/index.js index 899934f5295..8c64ff717a8 100644 --- a/packages/backend/src/index.js +++ b/packages/backend/src/index.js @@ -1,56 +1,51 @@ -import express from "express"; -import mongoose from "mongoose"; -import chalk from "chalk"; -import passport from "passport"; -import config from "./config/index"; -import verifyConfig from "./config/verify"; -import expressMiddleware from "./middleware/express"; -import passportMiddleware from "./middleware/passport"; -import routes from "./routes"; -import path from "path"; +import './env' +import express from 'express' +import mongoose from 'mongoose' +import config from './config/index' +import chalk from 'chalk' +import passport from 'passport' +import verifyConfig from './config/verify' +import expressMiddleware from './middleware/express' +import passportMiddleware from './middleware/passport' +import routes from './routes' +import path from 'path' // Verify configuration -verifyConfig(config, chalk); +verifyConfig(config, chalk) // Start Express -const app = express(); +const app = express() // Load Express middleware -for (let type of Object.keys(expressMiddleware)) expressMiddleware[type](app); +for (let type of Object.keys(expressMiddleware)) expressMiddleware[type](app) // Load Passport middleware -for (let type of Object.keys(passportMiddleware)) passportMiddleware[type](passport); +for (let type of Object.keys(passportMiddleware)) passportMiddleware[type](passport) // Load routes -for (let type of Object.keys(routes)) routes[type](app, passport); +for (let type of Object.keys(routes)) routes[type](app, passport) // Connecting to the database -mongoose.Promise = global.Promise; +mongoose.Promise = global.Promise mongoose - .connect( - config.db.uri, - { - useNewUrlParser: true - } - ) + .connect(config.db.uri, { + useNewUrlParser: true + }) .then(() => { - console.log(chalk.green("Successfully connected to the database")); + console.log(chalk.green('Successfully connected to the database')) }) .catch(err => { - console.log( - chalk.red("Could not connect to the database. Exiting now..."), - err - ); - process.exit(); - }); + console.log(chalk.red('Could not connect to the database. Exiting now...'), err) + process.exit() + }) // Catch-all route -app.get("/", async (req, res) => res.sendFile(path.resolve(__dirname, "landing", "index.html"))); +app.get('/', async (req, res) => res.sendFile(path.resolve(__dirname, 'landing', 'index.html'))) -const port = process.env.PORT || 3000; +const port = process.env.PORT || 3000 app.listen(port, err => { - if (err) console.error(chalk.red('Error occured'), err); - if (__DEV__) console.log(chalk.yellow("> in development")); - console.log(chalk.green(`> listening on port ${port}`)); -}); + if (err) console.error(chalk.red('Error occured'), err) + if (__DEV__) console.log(chalk.yellow('> in development')) + console.log(chalk.green(`> listening on port ${port}`)) +}) diff --git a/packages/backend/src/landing/already-subscribed.html b/packages/backend/src/landing/already-subscribed.html new file mode 100644 index 00000000000..f30ffe9bbae --- /dev/null +++ b/packages/backend/src/landing/already-subscribed.html @@ -0,0 +1,46 @@ + + + + + You are already subscribed + + + + +
+

Love the enthusiasm

+

But you were already subscribed

+
+ + + diff --git a/packages/backend/src/landing/index.html b/packages/backend/src/landing/index.html index fb33b19cd16..d250e843eed 100644 --- a/packages/backend/src/landing/index.html +++ b/packages/backend/src/landing/index.html @@ -1,72 +1,57 @@ - - Freesewing backend - - - - -
- - - -

FreeSewing

-

A JavaScript library for made-to-measure sewing patterns

-

This is the backend for FreeSewing.org

+ + You found our backend + + + + +
+

Hi

- I'm lost -  |  - I'm curious + This is the FreeSewing backend. +
+ Try freesewing.org instead. +

+

For questions, join us at + discord.freesewing.org

+ - diff --git a/packages/backend/src/landing/invalid.html b/packages/backend/src/landing/invalid.html new file mode 100644 index 00000000000..a3baa1d32ce --- /dev/null +++ b/packages/backend/src/landing/invalid.html @@ -0,0 +1,52 @@ + + + + + Your request is invalid + + + + +
+

What are you doing?

+

+ Try freesewing.org instead. +

+
+ + + diff --git a/packages/backend/src/landing/oops.html b/packages/backend/src/landing/oops.html new file mode 100644 index 00000000000..264ca143291 --- /dev/null +++ b/packages/backend/src/landing/oops.html @@ -0,0 +1,52 @@ + + + + + Oops + + + + +
+

Oops

+

+ That did not go as planned +

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

Done

+

You are now subscribed to the FreeSewing newsletter

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

Gone

+

You are no longer subscribed to the FreeSewing newsletter

+
+ + + diff --git a/packages/backend/src/middleware/express/bodyParser.js b/packages/backend/src/middleware/express/bodyParser.js index ca1abe0db2e..13ad583df26 100644 --- a/packages/backend/src/middleware/express/bodyParser.js +++ b/packages/backend/src/middleware/express/bodyParser.js @@ -1,6 +1,6 @@ -import bodyParser from "body-parser"; +import bodyParser from 'body-parser' -export default (app) => { - app.use(bodyParser.json({ limit: '20mb' })); - app.use(bodyParser.urlencoded({ extended: true })); +export default app => { + app.use(bodyParser.json({ limit: '20mb' })) + app.use(bodyParser.urlencoded({ extended: true })) } diff --git a/packages/backend/src/middleware/express/cors.js b/packages/backend/src/middleware/express/cors.js index 670453649bf..c285a89523b 100644 --- a/packages/backend/src/middleware/express/cors.js +++ b/packages/backend/src/middleware/express/cors.js @@ -1,5 +1,5 @@ -import cors from "cors"; +import cors from 'cors' -export default (app) => { - app.use(cors()); +export default app => { + app.use(cors()) } diff --git a/packages/backend/src/middleware/express/index.js b/packages/backend/src/middleware/express/index.js index a7eaf52de6f..4051efc9e48 100644 --- a/packages/backend/src/middleware/express/index.js +++ b/packages/backend/src/middleware/express/index.js @@ -1,4 +1,4 @@ -import bodyParser from "./bodyParser"; -import cors from "./cors"; +import bodyParser from './bodyParser' +import cors from './cors' export default { bodyParser, cors } diff --git a/packages/backend/src/middleware/passport/index.js b/packages/backend/src/middleware/passport/index.js index 3cb5a0133c4..cfa6f74b433 100644 --- a/packages/backend/src/middleware/passport/index.js +++ b/packages/backend/src/middleware/passport/index.js @@ -1,3 +1,3 @@ -import jwt from "./jwt"; +import jwt from './jwt' export default { jwt } diff --git a/packages/backend/src/middleware/passport/jwt.js b/packages/backend/src/middleware/passport/jwt.js index 9cf75a09c09..7488eebca43 100644 --- a/packages/backend/src/middleware/passport/jwt.js +++ b/packages/backend/src/middleware/passport/jwt.js @@ -1,13 +1,15 @@ -import jwt from "passport-jwt"; -import config from "../../config"; +import jwt from 'passport-jwt' +import config from '../../config' const options = { jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), ...config.jwt } -export default (passport) => { - passport.use(new jwt.Strategy(options, (jwt_payload, done) => { - return done(null, jwt_payload); - })); +export default passport => { + passport.use( + new jwt.Strategy(options, (jwt_payload, done) => { + return done(null, jwt_payload) + }) + ) } diff --git a/packages/backend/src/models/comment.js b/packages/backend/src/models/comment.js deleted file mode 100644 index 965e4cb4627..00000000000 --- a/packages/backend/src/models/comment.js +++ /dev/null @@ -1,39 +0,0 @@ -import mongoose, { Schema } from "mongoose"; - -const CommentSchema = new Schema({ - id: { - type: Number, - required: true, - unique: true, - index: true - }, - user: { - type: String, - required: true, - lowercase: true, - trim: true, - index: true - }, - page: { - type: String, - required: true, - lowercase: true, - index: true, - trim: true - }, - comment: { - type: String, - trim: true - }, - parent: Number, - time: Date, - status: { - type: String, - enum: ["active", "removed", "restricted"], - default: "active" - } -},{ timestamps: true }); - -CommentSchema.index({ id: 1, user: 1 , page: 1}); - -export default mongoose.model('Comment', CommentSchema); diff --git a/packages/backend/src/models/confirmation.js b/packages/backend/src/models/confirmation.js index ba3a33ae826..508bc61097f 100644 --- a/packages/backend/src/models/confirmation.js +++ b/packages/backend/src/models/confirmation.js @@ -1,7 +1,7 @@ -import mongoose, { Schema } from "mongoose"; -import bcrypt from 'mongoose-bcrypt'; -import encrypt from 'mongoose-encryption'; -import config from "../config"; +import mongoose, { Schema } from 'mongoose' +import bcrypt from 'mongoose-bcrypt' +import encrypt from 'mongoose-encryption' +import config from '../config' const ConfirmationSchema = new Schema({ created: { @@ -10,18 +10,18 @@ const ConfirmationSchema = new Schema({ }, type: { type: String, - enum: ["signup", "emailchange", "passwordreset", "oauth"], - required: true, + enum: ['signup', 'emailchange', 'passwordreset', 'oauth', 'newsletter'], + required: true }, data: {} -}); +}) -ConfirmationSchema.plugin(bcrypt); +ConfirmationSchema.plugin(bcrypt) ConfirmationSchema.plugin(encrypt, { secret: config.encryption.key, encryptedFields: ['data'], decryptPostSave: false -}); +}) -export default mongoose.model('Confirmation', ConfirmationSchema); +export default mongoose.model('Confirmation', ConfirmationSchema) diff --git a/packages/backend/src/models/draft.js b/packages/backend/src/models/draft.js deleted file mode 100644 index bba9c1f871f..00000000000 --- a/packages/backend/src/models/draft.js +++ /dev/null @@ -1,46 +0,0 @@ -import mongoose, { Schema } from "mongoose"; - -const DraftSchema = new Schema({ - handle: { - type: String, - required: true, - lowercase: true, - unique: true, - trim: true, - index: true - }, - user: { - type: String, - required: true, - lowercase: true, - trim: true, - index: true - }, - name: { - type: String, - required: true, - trim: true - }, - created: Date, - notes: { - type: String, - trim: true - }, - gist: {}, -},{ timestamps: true }); - -DraftSchema.index({ user: 1 , handle: 1}); - -DraftSchema.methods.info = function() { - return this.toObject(); -} - -DraftSchema.methods.asGist = function() { - let draft = this.toObject(); - for(let field of ["_id", "user", "createdAt", "updatedAt", "_v"]) - delete draft[field]; - - return draft; -} - -export default mongoose.model('Draft', DraftSchema); diff --git a/packages/backend/src/models/index.js b/packages/backend/src/models/index.js index 042e68fdb80..e05c9e04e05 100644 --- a/packages/backend/src/models/index.js +++ b/packages/backend/src/models/index.js @@ -1,12 +1,11 @@ -import mongoose from "mongoose"; -import CommentModel from "./comment"; -import ConfirmationModel from "./confirmation"; -import ModelModel from "./model"; -import UserModel from "./user"; -import DraftModel from "./draft"; +import ConfirmationModel from './confirmation' +import PersonModel from './person' +import UserModel from './user' +import PatternModel from './pattern' +import NewsletterModel from './newsletter' -export const Comment = CommentModel; -export const Confirmation = ConfirmationModel; -export const Model = ModelModel; -export const User = UserModel; -export const Draft = DraftModel; +export const Confirmation = ConfirmationModel +export const Person = PersonModel +export const User = UserModel +export const Pattern = PatternModel +export const Newsletter = NewsletterModel diff --git a/packages/backend/src/models/model.js b/packages/backend/src/models/model.js deleted file mode 100644 index 4b1a4cdb487..00000000000 --- a/packages/backend/src/models/model.js +++ /dev/null @@ -1,101 +0,0 @@ -import mongoose, { Schema } from "mongoose"; -import config from "../config"; -import path from "path"; - -const ModelSchema = new Schema({ - handle: { - type: String, - required: true, - lowercase: true, - unique: true, - trim: true, - index: true - }, - user: { - type: String, - required: true, - lowercase: true, - trim: true, - index: true - }, - name: { - type: String, - required: true, - trim: true - }, - breasts: { - type: Boolean, - default: false - }, - picture: String, - units: { - type: String, - enum: ["metric", "imperial"], - default: "metric" - }, - created: Date, - notes: { - type: String, - trim: true - }, - measurements: { - bicepsCircumference: Number, - bustSpan: Number, - centerBackNeckToWaist: Number, - chestCircumference: Number, - headCircumference: Number, - highBust: Number, - highPointShoulderToBust: Number, - hipsCircumference: Number, - hipsToUpperLeg: Number, - inseam: Number, - kneeCircumference: Number, - naturalWaist: Number, - naturalWaistToFloor: Number, - naturalWaistToHip: Number, - naturalWaistToKnee: Number, - naturalWaistToSeat: Number, - naturalWaistToUnderbust: Number, - neckCircumference: Number, - seatCircumference: Number, - seatDepth: Number, - shoulderSlope: Number, - shoulderToElbow: Number, - shoulderToShoulder: Number, - shoulderToWrist: Number, - underbust: Number, - upperLegCircumference: Number, - wristCircumference: Number - } -},{ timestamps: true }); - -ModelSchema.index({ user: 1 , handle: 1}); - -ModelSchema.methods.info = function() { - let model = this.toObject(); - model.pictureUris = { - l: this.avatarUri(), - m: this.avatarUri("m"), - s: this.avatarUri("s"), - xs: this.avatarUri("xs"), - } - - return model; -} - -ModelSchema.methods.avatarUri = function(size = "l") { - if (this.picture === "") return config.static + "/avatar.svg"; - - let prefix = (size === "l") ? "" : size+"-"; - return config.static - +"/models/" - +this.handle.substring(0,1) - +"/" - +this.handle - +"/" - +prefix - +this.picture; -} - - -export default mongoose.model('Model', ModelSchema); diff --git a/packages/backend/src/models/newsletter.js b/packages/backend/src/models/newsletter.js new file mode 100644 index 00000000000..f08661ad93f --- /dev/null +++ b/packages/backend/src/models/newsletter.js @@ -0,0 +1,34 @@ +import mongoose, { Schema } from 'mongoose' +import bcrypt from 'mongoose-bcrypt' +import encrypt from 'mongoose-encryption' +import config from '../config' + +const NewsletterSchema = new Schema({ + created: { + type: Date, + default: Date.now + }, + email: { + type: String, + required: true + }, + ehash: { + type: String, + required: true, + unique: true, + index: true + }, + data: {} +}) + +NewsletterSchema.plugin(bcrypt) + +NewsletterSchema.index({ ehash: 1 }) + +NewsletterSchema.plugin(encrypt, { + secret: config.encryption.key, + encryptedFields: ['email'], + decryptPostSave: false +}) + +export default mongoose.model('Newsletter', NewsletterSchema) diff --git a/packages/backend/src/models/pattern.js b/packages/backend/src/models/pattern.js new file mode 100644 index 00000000000..72d7e613fd2 --- /dev/null +++ b/packages/backend/src/models/pattern.js @@ -0,0 +1,62 @@ +import mongoose, { Schema } from 'mongoose' + +const PatternSchema = new Schema( + { + handle: { + type: String, + required: true, + lowercase: true, + unique: true, + trim: true, + index: true + }, + user: { + type: String, + required: true, + lowercase: true, + trim: true, + index: true + }, + person: { + type: String, + required: true, + lowercase: true, + trim: true, + index: true + }, + name: { + type: String, + required: true, + trim: true + }, + created: Date, + notes: { + type: String, + trim: true + }, + data: {} + }, + { timestamps: true } +) + +PatternSchema.index({ user: 1, handle: 1 }) + +PatternSchema.methods.info = function() { + return this.toObject() +} + +PatternSchema.methods.export = function() { + let pattern = this.toObject() + for (let field of ['__v', '_id', '_v', 'created']) delete pattern[field] + + return pattern +} + +PatternSchema.methods.anonymize = function() { + let pattern = this.toObject() + for (let field of ['__v', '_id', 'user', 'createdAt', 'updatedAt', '_v']) delete pattern[field] + + return pattern +} + +export default mongoose.model('Pattern', PatternSchema) diff --git a/packages/backend/src/models/person.js b/packages/backend/src/models/person.js new file mode 100644 index 00000000000..6bffe443e3a --- /dev/null +++ b/packages/backend/src/models/person.js @@ -0,0 +1,171 @@ +import mongoose, { Schema } from 'mongoose' +import config from '../config' +import fs from 'fs' +import { log, randomAvatar } from '../utils' +import path from 'path' +import sharp from 'sharp' + +const PersonSchema = new Schema( + { + handle: { + type: String, + required: true, + lowercase: true, + unique: true, + trim: true, + index: true + }, + user: { + type: String, + required: true, + lowercase: true, + trim: true, + index: true + }, + name: { + type: String, + required: true, + trim: true + }, + breasts: { + type: Boolean, + default: false + }, + picture: { + type: String, + trim: true, + default: '' + }, + units: { + type: String, + enum: ['metric', 'imperial'], + default: 'metric' + }, + notes: { + type: String, + trim: true + }, + measurements: { + ankle: Number, + biceps: Number, + bustFront: Number, + bustPointToUnderbust: Number, + bustSpan: Number, + chest: Number, + crossSeam: Number, + crossSeamFront: Number, + crotchDepth: Number, + head: Number, + heel: Number, + highBust: Number, + highBustFront: Number, + hips: Number, + hpsToBust: Number, + hpsToWaistBack: Number, + hpsToWaistFront: Number, + inseam: Number, + knee: Number, + neck: Number, + seat: Number, + seatBack: Number, + shoulderSlope: Number, + shoulderToElbow: Number, + shoulderToShoulder: Number, + shoulderToWrist: Number, + underbust: Number, + upperLeg: Number, + waist: Number, + waistBack: Number, + waistToFloor: Number, + waistToHips: Number, + waistToKnee: Number, + waistToSeat: Number, + waistToUnderbust: Number, + waistToUpperLeg: Number, + wrist: Number, + }, + }, + { timestamps: true } +) + +PersonSchema.index({ user: 1, handle: 1 }) + +PersonSchema.methods.info = function() { + let person = this.toObject() + delete person.__v + delete person._id + person.pictureUris = { + l: this.avatarUri(), + m: this.avatarUri('m'), + s: this.avatarUri('s'), + xs: this.avatarUri('xs') + } + + return person +} + +PersonSchema.methods.avatarName = function(size = 'l') { + let prefix = size === 'l' ? '' : size + '-' + if (this.picture.slice(-4).toLowerCase() === '.svg') prefix = '' + + return prefix + this.picture +} + +PersonSchema.methods.avatarUri = function(size = 'l') { + return ( + config.static + + '/users/' + + this.user.substring(0, 1) + + '/' + + this.user + + '/people/' + + this.handle + + '/' + + this.avatarName(size) + ) +} + +PersonSchema.methods.storagePath = function() { + return ( + config.storage + + '/users/' + + this.user.substring(0, 1) + + '/' + + this.user + + '/people/' + + this.handle + + '/' + ) +} + +PersonSchema.methods.createAvatar = function() { + let dir = this.storagePath() + fs.mkdir(dir, { recursive: true }, err => { + if (err) console.log('mkdirFailed', dir, err) + fs.writeFile(path.join(dir, this.handle) + '.svg', randomAvatar(), err => { + if (err) console.log('writeFileFailed', dir, err) + }) + }) +} + +PersonSchema.methods.saveAvatar = function(picture) { + let type = picture.split(';').shift() + type = type.split('/').pop() + this.picture = this.handle + '.' + type + + let dir = this.storagePath() + let b64 = picture.split(';base64,').pop() + fs.mkdir(dir, { recursive: true }, err => { + if (err) log.error('mkdirFailed', err) + let imgBuffer = Buffer.from(b64, 'base64') + for (let size of Object.keys(config.avatar.sizes)) { + sharp(imgBuffer) + .resize(config.avatar.sizes[size], config.avatar.sizes[size]) + .toFile(path.join(dir, this.avatarName(size)), (err, info) => { + if (err) log.error('avatarNotSaved', err) + }) + } + }) +} + +export default mongoose.model('Person', PersonSchema) diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 9e788ad52e2..63c3278f989 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -1,216 +1,275 @@ -import mongoose, { Schema } from "mongoose"; -import bcrypt from 'mongoose-bcrypt'; -import { email, log } from "../utils"; -import encrypt from 'mongoose-encryption'; -import config from "../config"; -import path from "path"; +import mongoose, { Schema } from 'mongoose' +import bcrypt from 'mongoose-bcrypt' +import { email, randomAvatar } from '../utils' +import encrypt from 'mongoose-encryption' +import config from '../config' +import path from 'path' +import fs from 'fs' +import { log } from '../utils' +import sharp from 'sharp' -const UserSchema = new Schema({ - email: { - type: String, - required: true, - }, - ehash: { - type: String, - required: true, - unique: true, - index: true - }, - initial: { - type: String, - required: true - }, - username: { - type: String, - required: true, - unique: true, - index: true, - trim: true - }, - handle: { - type: String, - required: true, - lowercase: true, - trim: true, - index: true, - unique: true - }, - role: { - type: String, - enum: ["user", "moderator", "admin"], - required: true, - default: "user" - }, - patron: { - type: Number, - enum: [0, 2, 4, 8], - default: 0 - }, - bio: { - type: String, - default: "" - }, - picture: { - type: String, - trim: true, - }, - status: { - type: String, - enum: ["pending", "active", "blocked", "frozen"], - default: "pending", - required: true - }, - password: { - type: String, - bcrypt: true - }, - settings: { - language: { +const UserSchema = new Schema( + { + email: { type: String, - default: "en", - enum: config.languages, + required: true }, - units: { + ehash: { type: String, - enum: ["metric", "imperial"], - default: "metric" - } - }, - consent: { - profile: { + required: true, + unique: true, + index: true + }, + initial: { + type: String, + required: true + }, + username: { + type: String, + required: true, + unique: true, + index: true, + trim: true + }, + handle: { + type: String, + required: true, + lowercase: true, + trim: true, + index: true, + unique: true + }, + role: { + type: String, + enum: ['user', 'moderator', 'admin'], + required: true, + default: 'user' + }, + patron: { + type: Number, + enum: [0, 2, 4, 8], + default: 0 + }, + bio: { + type: String, + default: '' + }, + picture: { + type: String, + trim: true, + default: '' + }, + status: { + type: String, + enum: ['pending', 'active', 'blocked', 'frozen'], + default: 'pending', + required: true + }, + password: { + type: String, + bcrypt: true + }, + settings: { + language: { + type: String, + default: 'en', + enum: config.languages + }, + units: { + type: String, + enum: ['metric', 'imperial'], + default: 'metric' + } + }, + consent: { + profile: { + type: Boolean, + default: false + }, + measurements: { + type: Boolean, + default: false + }, + openData: { + type: Boolean, + default: true + } + }, + time: { + migrated: Date, + login: Date, + patron: Date + }, + social: { + twitter: String, + instagram: String, + github: String + }, + newsletter: { type: Boolean, default: false }, - model: { - type: Boolean, - default: false - }, - openData: { - type: Boolean, - default: true - } }, - time: { - created: Date, - migrated: Date, - login: Date, - patron: Date - }, - social: { - twitter: String, - instagram: String, - github: String - } -},{ timestamps: true }); + { timestamps: true } +) UserSchema.pre('remove', function(next) { - email.goodbye(this.email, this.settings.language) - .then(() => { next(); }) - .catch(err => { - logger.error(err); - next(); - }); -}); + email + .goodbye(this.email, this.settings.language) + .then(() => { + next() + }) + .catch(err => { + logger.error(err) + next() + }) +}) -UserSchema.plugin(bcrypt); -UserSchema.index({ ehash: 1, username: 1 , handle: 1}); +UserSchema.plugin(bcrypt) +UserSchema.index({ ehash: 1, username: 1, handle: 1 }) UserSchema.plugin(encrypt, { secret: config.encryption.key, - encryptedFields: [ - 'email', - 'initial', - 'social.twitter', - 'social.instagram', - 'social.github' - ], + encryptedFields: ['email', 'initial', 'social.twitter', 'social.instagram', 'social.github'], decryptPostSave: true -}); +}) UserSchema.methods.account = function() { - let account = this.toObject(); - delete account.password; - delete account.ehash; - delete account.pepper; - delete account.initial; - delete account._ac; - delete account._ct; + let account = this.toObject() + delete account.password + delete account.ehash + delete account.pepper + delete account.initial + delete account._ac + delete account._ct + account.pictureUris = { l: this.avatarUri(), - m: this.avatarUri("m"), - s: this.avatarUri("s"), - xs: this.avatarUri("xs"), + m: this.avatarUri('m'), + s: this.avatarUri('s'), + xs: this.avatarUri('xs') } - return account; + return account } UserSchema.methods.profile = function() { - let account = this.toObject(); - delete account.password; - delete account.ehash; - delete account.pepper; - delete account.email; - delete account.consent; - delete account.initial; - delete account.role; - delete account.status; - delete account.handle; - delete account.time.login; - delete account.picture; - delete account.__v; - delete account._id; - delete account._ac; - delete account._ct; - delete account._ct; + let account = this.toObject() + delete account.password + delete account.ehash + delete account.pepper + delete account.email + delete account.consent + delete account.initial + delete account.role + delete account.status + delete account.time + delete account.picture + delete account.__v + delete account._id + delete account._ac + delete account._ct + delete account._ct account.pictureUris = { l: this.avatarUri(), - m: this.avatarUri("m"), - s: this.avatarUri("s"), - xs: this.avatarUri("xs"), + m: this.avatarUri('m'), + s: this.avatarUri('s'), + xs: this.avatarUri('xs') } - return account; + return account +} + +UserSchema.methods.adminProfile = function() { + let account = this.toObject() + delete account.password + delete account.ehash + delete account.pepper + delete account.__v + delete account._id + delete account._ac + delete account._ct + delete account._ct + account.pictureUris = { + l: this.avatarUri(), + m: this.avatarUri('m'), + s: this.avatarUri('s'), + xs: this.avatarUri('xs') + } + + return account } UserSchema.methods.export = function() { - let exported = this.toObject(); - delete exported.password; - delete exported.ehash; - delete exported.pepper; - delete exported._ac; - delete exported._ct; + let exported = this.toObject() + delete exported.password + delete exported.ehash + delete exported.pepper + delete exported._ac + delete exported._ct - return exported; + return exported } UserSchema.methods.updateLoginTime = function(callback) { - this.set({time: {login: new Date()}}); + this.set({ time: { login: new Date() } }) this.save(function(err, user) { - return callback(); - }); + return callback() + }) +} + +UserSchema.methods.avatarName = function(size = 'l') { + let prefix = size === 'l' ? '' : size + '-' + if (this.picture.slice(-4).toLowerCase() === '.svg') prefix = '' + + return prefix + this.picture } UserSchema.methods.storagePath = function() { - return path.join( - config.storage, - this.handle.substring(0,1), - this.handle - ); + return path.join(config.storage, 'users', this.handle.substring(0, 1), this.handle) } -UserSchema.methods.avatarUri = function(size = "l") { - if (this.picture === "") return config.static + "/avatar.svg"; - - let prefix = (size === "l") ? "" : size+"-"; - return config.static - +"/" - +this.handle.substring(0,1) - +"/" - +this.handle - +"/" - +prefix - +this.picture; +UserSchema.methods.avatarUri = function(size = 'l') { + if (!this.picture || this.picture.length < 5) return "https://freesewing.org/avatar.svg" + return ( + config.static + + '/users/' + + this.handle.substring(0, 1) + + '/' + + this.handle + + '/' + + this.avatarName(size) + ) } -export default mongoose.model('User', UserSchema); +UserSchema.methods.saveAvatar = function(picture) { + let type = picture.split(';').shift() + type = type.split('/').pop() + this.picture = this.handle + '.' + type + + let dir = this.storagePath() + let b64 = picture.split(';base64,').pop() + fs.mkdir(dir, { recursive: true }, err => { + if (err) log.error('mkdirFailed', err) + let imgBuffer = Buffer.from(b64, 'base64') + for (let size of Object.keys(config.avatar.sizes)) { + sharp(imgBuffer) + .resize(config.avatar.sizes[size], config.avatar.sizes[size]) + .toFile(path.join(dir, this.avatarName(size)), (err, info) => { + if (err) log.error('avatarNotSaved', err) + }) + } + }) +} + +UserSchema.methods.createAvatar = function() { + let dir = this.storagePath() + fs.mkdirSync(dir, { recursive: true }, err => { + if (err) console.log('mkdirFailed', dir, err) + fs.writeFileSync(path.join(dir, this.handle) + '.svg', randomAvatar(), err => { + if (err) console.log('writeFileFailed', dir, err) + }) + }) +} + +export default mongoose.model('User', UserSchema) diff --git a/packages/backend/src/routes/admin.js b/packages/backend/src/routes/admin.js index b4681b603b3..6739041e003 100644 --- a/packages/backend/src/routes/admin.js +++ b/packages/backend/src/routes/admin.js @@ -1,20 +1,27 @@ -import admin from "../controllers/admin"; +import Controller from '../controllers/admin' -export default (app) => { +const Admin = new Controller() - // Impersonate user - app.get('/admin/impersonate/:handle', admin.impersonate); - - /* User cRUD endpoints */ - app.get('/admin/user/{handle}', admin.readUser); // Read - app.put('/admin/user/{handle}', admin.updateUser); // Update - app.delete('/admin/user/{handle}', admin.deleteUser); // Delete - - // Find users - app.get('/admin/find/users/:filter', admin.findUsers); +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/packages/backend/src/routes/auth.js b/packages/backend/src/routes/auth.js index 2090596cf11..97a8f3ec69e 100644 --- a/packages/backend/src/routes/auth.js +++ b/packages/backend/src/routes/auth.js @@ -1,25 +1,10 @@ -import Controller from "../controllers/auth"; +import Controller from '../controllers/auth' -const Auth = new Controller(); +const Auth = new Controller() export default (app, passport) => { - - // Init Oauth - app.post( - "/oauth/init", - Auth.initOauth - ); - - // Signup callback from Oauth provider - app.get( - '/callback/from/:provider', - Auth.providerCallback - ); - - // Login after Oauth - app.post( - '/oauth/login', - Auth.loginOauth - ); - + // Oauth + app.post('/oauth/init', Auth.initOauth) + app.get('/oauth/callback/from/:provider', Auth.providerCallback) + app.post('/oauth/login', Auth.loginOauth) } diff --git a/packages/backend/src/routes/comment.js b/packages/backend/src/routes/comment.js deleted file mode 100644 index c54144a51b7..00000000000 --- a/packages/backend/src/routes/comment.js +++ /dev/null @@ -1,35 +0,0 @@ -import comment from "../controllers/comment"; - -export default (app) => { - -/********************************************** - * * - * ANONYMOUS ROUTES * - * * - *********************************************/ - - // Webhook: Reply to comment via email - app.post('/webhook/comment/reply', comment.replyFromEmail); - - // Load page comments - app.get('/comments/page/:page', comment.pageComments); - - // Load recent comments - app.get('/comments/recent/:count', comment.recentComments); - - - -/********************************************** - * * - * AUTHENTICATED ROUTES * - * * - *********************************************/ - - /* CRUD endpoints */ - - app.post('/comment', comment.create); // Create - app.get('/comment/:id', comment.read); // Read - app.put('/comment/:id', comment.update); // Update - app.delete('/comment/:id', comment.delete); // Delete - -} diff --git a/packages/backend/src/routes/confirmation.js b/packages/backend/src/routes/confirmation.js deleted file mode 100644 index dba7edfee13..00000000000 --- a/packages/backend/src/routes/confirmation.js +++ /dev/null @@ -1,6 +0,0 @@ -import Controller from "../controllers/confirmation"; - -const Confirmation = new Controller(); -export default (app, passport) => { - app.post('/confirm', Confirmation.confirm); -} diff --git a/packages/backend/src/routes/draft.js b/packages/backend/src/routes/draft.js deleted file mode 100644 index cf17f7d7a9f..00000000000 --- a/packages/backend/src/routes/draft.js +++ /dev/null @@ -1,41 +0,0 @@ -import Controller from "../controllers/draft"; - -const Draft = new Controller(); - -export default (app, passport) => { - -/********************************************** - * * - * ANONYMOUS ROUTES * - * * - *********************************************/ - - // Load shared draft/gist - app.get('/gist/:handle', Draft.readGist); - - -/********************************************** - * * - * AUTHENTICATED ROUTES * - * * - *********************************************/ - - /* CRUD endpoints */ - - app.post('/draft', passport.authenticate('jwt', { session: false }), Draft.create); // Create - app.get('/draft/:handle', passport.authenticate('jwt', { session: false }), Draft.read); // Read - app.put('/draft/:handle', passport.authenticate('jwt', { session: false }), Draft.update); // Update - app.delete('/draft/:handle', passport.authenticate('jwt', { session: false }), Draft.delete); // Delete - - // Delete multiple - app.post( - '/remove/drafts', - passport.authenticate('jwt', {session: false }), - Draft.deleteMultiple - ); -} - - - - - diff --git a/packages/backend/src/routes/github.js b/packages/backend/src/routes/github.js new file mode 100644 index 00000000000..3a290efa55e --- /dev/null +++ b/packages/backend/src/routes/github.js @@ -0,0 +1,8 @@ +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/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index 838df0e94aa..68baad21789 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -1,10 +1,10 @@ -import comment from "./comment"; -import confirmation from "./confirmation"; -import draft from "./draft"; -import model from "./model"; -import referral from "./referral"; -import user from "./user"; -import auth from "./auth"; -import og from "./og"; +import pattern from './pattern' +import person from './person' +import user from './user' +import auth from './auth' +import github from './github' +import admin from './admin' +import newsletter from './newsletter' +import strapi from './strapi' -export default { comment, user, draft, model, referral, confirmation, auth, og } +export default { user, pattern, person, auth, github, admin, newsletter, strapi } diff --git a/packages/backend/src/routes/model.js b/packages/backend/src/routes/model.js deleted file mode 100644 index 0d030815104..00000000000 --- a/packages/backend/src/routes/model.js +++ /dev/null @@ -1,38 +0,0 @@ -import Controller from "../controllers/model"; - -const Model = new Controller(); - -export default (app, passport) => { - /* CRUD endpoints */ - app.post( - '/model', - passport.authenticate('jwt', {session: false }), - Model.create - ); // Create - app.get('/model/:handle', Model.read); // Read - app.put( - '/model/:handle', - passport.authenticate('jwt', {session: false }), - Model.update - ); // Update - app.delete( - '/model/:handle', - passport.authenticate('jwt', { session: false }), - Model.delete - ); // Delete - - // Delete multiple - app.post( - '/remove/models', - passport.authenticate('jwt', {session: false }), - Model.deleteMultiple - ); - - // Clone model - app.post('/clone/model/:handle', Model.clone); -} - - - - - diff --git a/packages/backend/src/routes/newsletter.js b/packages/backend/src/routes/newsletter.js new file mode 100644 index 00000000000..5884b3409cb --- /dev/null +++ b/packages/backend/src/routes/newsletter.js @@ -0,0 +1,12 @@ +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/packages/backend/src/routes/og.js b/packages/backend/src/routes/og.js deleted file mode 100644 index 8431f15e453..00000000000 --- a/packages/backend/src/routes/og.js +++ /dev/null @@ -1,11 +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/packages/backend/src/routes/pattern.js b/packages/backend/src/routes/pattern.js new file mode 100644 index 00000000000..b19223cbd65 --- /dev/null +++ b/packages/backend/src/routes/pattern.js @@ -0,0 +1,10 @@ +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/packages/backend/src/routes/person.js b/packages/backend/src/routes/person.js new file mode 100644 index 00000000000..d651b2b8210 --- /dev/null +++ b/packages/backend/src/routes/person.js @@ -0,0 +1,10 @@ +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/packages/backend/src/routes/referral.js b/packages/backend/src/routes/referral.js deleted file mode 100644 index 4fe49e5f20a..00000000000 --- a/packages/backend/src/routes/referral.js +++ /dev/null @@ -1,11 +0,0 @@ -import referral from "../controllers/referral"; - -export default (app) => { - // Log referral - app.post('/referral', referral.create); -} - - - - - diff --git a/packages/backend/src/routes/strapi.js b/packages/backend/src/routes/strapi.js new file mode 100644 index 00000000000..65a4767ec0c --- /dev/null +++ b/packages/backend/src/routes/strapi.js @@ -0,0 +1,9 @@ +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/packages/backend/src/routes/user.js b/packages/backend/src/routes/user.js index 2941f3dc8ae..fa3e310bd97 100644 --- a/packages/backend/src/routes/user.js +++ b/packages/backend/src/routes/user.js @@ -1,93 +1,34 @@ -import Controller from "../controllers/user"; +import Controller from '../controllers/user' -const User = new Controller(); +const User = new Controller() export default (app, passport) => { - //app.get('/user', userController.findOne); + // account + app.post('/account', User.create) + app.get('/account', passport.authenticate('jwt', { session: false }), User.readAccount) // Read account (own data) + app.put('/account', passport.authenticate('jwt', { session: false }), User.update) // Update + app.delete('/account', passport.authenticate('jwt', { session: false }), User.remove) + app.get('/account/export', passport.authenticate('jwt', { session: false }), User.export) + app.get('/account/restrict', passport.authenticate('jwt', { session: false }), User.restrict) + app.post('/account/recover', User.resetPassword) + app.post( + '/account/change/email', + passport.authenticate('jwt', { session: false }), + User.confirmChangedEmail + ) -/********************************************** - * * - * ANONYMOUS ROUTES * - * * - *********************************************/ - - /* Sign-up flow */ - - // Sign up user - app.post('/signup', User.signup); - - // Create account from confirmation / Consent for data processing given - app.post('/user', User.create); - - // Remove confirmation / No consent for data processing given - //app.delete('/remove/confirmation/:token', userController.removeConfirmation); - - - /* Login flow */ - - // User login - app.post('/login', User.login); - - // Recover user password - //app.post('/recover/password', userController.recoverPassword); - - // Reset user password - app.post('/reset/password', User.resetPassword); - - // Set user password after reset - app.post('/set/password', User.setPassword); - - - /* Email confirmation endpoints */ - // (these are always GET because they are links in an email) - - // Confirm email address at signup - //app.get('/confirm/signup/email/:token', userController.confirmSignupEmail); - - // Confirm user email change - //app.get('/confirm/changed/email:handle/:token', userController.confirmChangedEmail); - - - /* Email confirmation endpoints */ - // Load patron list - //app.get('/patrons/list', userController.patronList); - - -/********************************************** - * * - * AUTHENTICATED ROUTES * - * * - *********************************************/ - - /* CRUD endpoints */ - app.get('/account', passport.authenticate('jwt', { session: false }), User.readAccount); // Read account (own data) - //app.get('/account', function(req, res,next) { - // passport.authenticate('jwt', function(err, user, info) { - // console.log('In authenticate callback, arguments should be (err, user, info)', arguments); - // return next(res.send({error: err, user: user, info: info})); - // })(req, res, next) - //}); - - //app.get('/user', userController.readOwnProfile); // Read profile (own data) - app.get('/users/:username', User.readProfile); // Read profile (other user's data) - // Create is a non-authenticated route part of sign-up flow - app.put('/user', passport.authenticate('jwt', {session: false }), User.update); // Update - //app.delete('/user', userController.delete); // Delete - -// Export data -app.get('/export', passport.authenticate('jwt', { session: false }), User.export); - -// Restrict processing (freeze account) -app.get('/restrict', passport.authenticate('jwt', { session: false }), User.restrict); -// Remove account -app.get('/remove', passport.authenticate('jwt', { session: false }), User.remove); - - // Check whether username is available - app.post('/available/username', passport.authenticate('jwt', {session: false }), User.isUsernameAvailable); // Update + // Sign up & log in + app.post('/signup', User.signup) + app.post('/resend', User.resend) + app.post('/login', User.login) + app.post('/confirm/login', User.confirmationLogin) + // Users + app.get('/patrons', User.patronList) + app.get('/users/:username', User.readProfile) // Read profile (other user's data) + app.post( + '/available/username', + passport.authenticate('jwt', { session: false }), + User.isUsernameAvailable + ) } - - - - - diff --git a/packages/backend/src/signup.html b/packages/backend/src/signup.html deleted file mode 100644 index 53265215ed4..00000000000 --- a/packages/backend/src/signup.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - Welcome to [Product Name], {{name}}! - - - - - - - - - - - - diff --git a/packages/backend/src/templates/avatar.js b/packages/backend/src/templates/avatar.js new file mode 100644 index 00000000000..dbbc34ccb88 --- /dev/null +++ b/packages/backend/src/templates/avatar.js @@ -0,0 +1,68 @@ +export default ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` diff --git a/packages/backend/src/templates/emailchange.js b/packages/backend/src/templates/emailchange.js index 29a5eb63466..cf3456e36b3 100644 --- a/packages/backend/src/templates/emailchange.js +++ b/packages/backend/src/templates/emailchange.js @@ -1,10 +1,10 @@ const emailchange = { i18n: [ - "emailchangeTitle", - "emailchangeCopy1", - "emailchangeActionText", - "questionsJustReply", - "signature", + 'email.emailchangeTitle', + 'email.emailchangeCopy1', + 'email.emailchangeActionText', + 'email.questionsJustReply', + 'email.signature' ], html: ` @@ -72,6 +72,6 @@ __emailchangeCopy1__ __emailchangeActionLink__ __questionsJustReply__` -}; +} -export default emailchange; +export default emailchange diff --git a/packages/backend/src/templates/footer.js b/packages/backend/src/templates/footer.js index 05df0b30529..375f34694f4 100644 --- a/packages/backend/src/templates/footer.js +++ b/packages/backend/src/templates/footer.js @@ -1,9 +1,5 @@ const footer = { - i18n: [ - "chatWithUs", - "footerSlogan", - "footerCredits" - ], + i18n: [], html: `
@@ -14,25 +10,11 @@ const footer = {
- - - - @@ -47,7 +29,7 @@ const footer = {
-

- Discord - - Twitter - - Instagram - - GitHub -

-

__footerWhy__ -

- __footerSlogan__
- __footerCredits__

`, - text: "" -}; + text: '' +} -export default footer; +export default footer diff --git a/packages/backend/src/templates/goodbye.js b/packages/backend/src/templates/goodbye.js index 1d6b9507e45..469b8e4de2d 100644 --- a/packages/backend/src/templates/goodbye.js +++ b/packages/backend/src/templates/goodbye.js @@ -1,9 +1,5 @@ const goodbye = { - i18n: [ - "goodbyeTitle", - "goodbyeCopy1", - "signature", - ], + i18n: ['goodbyeTitle', 'goodbyeCopy1', 'signature'], html: `
@@ -44,6 +40,6 @@ const goodbye = { text: `__goodbyeTitle__ __goodbyeCopy1__` -}; +} -export default goodbye; +export default goodbye diff --git a/packages/backend/src/templates/header.js b/packages/backend/src/templates/header.js index 66085845458..1c4652df347 100644 --- a/packages/backend/src/templates/header.js +++ b/packages/backend/src/templates/header.js @@ -113,7 +113,7 @@ const header = {
`, - text: "" -}; + text: '' +} -export default header; +export default header diff --git a/packages/backend/src/templates/index.js b/packages/backend/src/templates/index.js index 1f721991577..72e80fb64e6 100644 --- a/packages/backend/src/templates/index.js +++ b/packages/backend/src/templates/index.js @@ -1,9 +1,11 @@ -import header from "./header"; -import footer from "./footer"; -import signup from "./signup"; -import emailchange from "./emailchange"; -import passwordreset from "./passwordreset"; -import goodbye from "./goodbye"; +import header from './header' +import footer from './footer' +import signup from './signup' +import emailchange from './emailchange' +import passwordreset from './passwordreset' +import goodbye from './goodbye' +import newsletterSubscribe from './newsletter-subscribe' +import newsletterWelcome from './newsletter-welcome' export default { header, @@ -12,4 +14,6 @@ export default { emailchange, passwordreset, goodbye, -}; + newsletterSubscribe, + newsletterWelcome, +} diff --git a/packages/backend/src/templates/newsletter-subscribe.js b/packages/backend/src/templates/newsletter-subscribe.js new file mode 100644 index 00000000000..e67a5acc871 --- /dev/null +++ b/packages/backend/src/templates/newsletter-subscribe.js @@ -0,0 +1,62 @@ +const subscribe = { + i18n: [], + html: ` + + + +
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + +
Confirm your newsletter subscription
+ Somebody asked to subscribe this email address to the FreeSewing newsletter. If it was you, please click below to confirm your subscription: +
+ + + + +
+ + + + +
Confirm your newsletter subscription
+
+
+ +
+
+
+
`, + text: `Confirm your newsletter subscription. + +Somebody asked to subscribe this email address to the FreeSewing newsletter. +If it was you, please click below to confirm your subscription: + +__newsletterConfirmationLink__ + +` +} + +export default subscribe diff --git a/packages/backend/src/templates/newsletter-welcome.js b/packages/backend/src/templates/newsletter-welcome.js new file mode 100644 index 00000000000..e6188ef8a94 --- /dev/null +++ b/packages/backend/src/templates/newsletter-welcome.js @@ -0,0 +1,44 @@ +const welcome = { + i18n: [], + html: ` + + + +
+ + + + +
+ + + + +
+ + + + + + + +
You are now subscribed to the FreeSewing newsletter
+ If you'd like to catch up, we keep an online archive of previous editions at: + https://freesewing.org/newsletter/ +

+ You can unsubscribe at any time by visiting this link: __newsletterUnsubscribeLink__ +

+
+
+
+
`, + text: `You are now subscribed to the FreeSewing newsletter + +If you'd like to catch up, we keep an online archive of previous editions at: https://freesewing.org/newsletter/ + +You can unsubscribe at any time by visiting this link: __newsletterUnsubscribeLink__ + +` +} + +export default welcome diff --git a/packages/backend/src/templates/passwordreset.js b/packages/backend/src/templates/passwordreset.js index 70bf98cd58e..c55e38a8fa1 100644 --- a/packages/backend/src/templates/passwordreset.js +++ b/packages/backend/src/templates/passwordreset.js @@ -1,10 +1,10 @@ const passwordreset = { i18n: [ - "passwordresetTitle", - "passwordresetCopy1", - "passwordresetActionText", - "questionsJustReply", - "signature", + 'passwordresetTitle', + 'passwordresetCopy1', + 'passwordresetActionText', + 'questionsJustReply', + 'signature' ], html: ` @@ -72,6 +72,6 @@ __passwordresetCopy1__ __passwordresetActionLink__ __questionsJustReply__` -}; +} -export default passwordreset; +export default passwordreset diff --git a/packages/backend/src/templates/signup.js b/packages/backend/src/templates/signup.js index a547a62034b..ba83ccaf269 100644 --- a/packages/backend/src/templates/signup.js +++ b/packages/backend/src/templates/signup.js @@ -1,11 +1,5 @@ const signup = { - i18n: [ - "signupTitle", - "signupCopy1", - "signupActionText", - "questionsJustReply", - "signature", - ], + i18n: ['signupTitle', 'signupCopy1', 'signupActionText', 'questionsJustReply', 'signature'], html: `
@@ -72,6 +66,6 @@ __signupCopy1__ __signupActionLink__ __questionsJustReply__` -}; +} -export default signup; +export default signup diff --git a/packages/backend/src/utils/email/index.js b/packages/backend/src/utils/email/index.js index 1e37e0eae95..3b902b123f6 100644 --- a/packages/backend/src/utils/email/index.js +++ b/packages/backend/src/utils/email/index.js @@ -1,170 +1,215 @@ -import nodemailer from "nodemailer"; -import fs from "fs"; -import path from "path"; -import { log } from "../../utils"; -import config from "../../config"; -import { email as i18n } from "@freesewing/i18n"; -import fo from "@freesewing/i18n"; -import templates from "../../templates"; +import config from '../../config' +import { strings as i18n } from '@freesewing/i18n' +import templates from '../../templates' +import { createUrl } from '../' +import sendEmailWith from './relays' -const email = {}; +const deliver = sendEmailWith(config.sendEmailWith) +const email = {} -const transporter = nodemailer.createTransport({ - host: config.smtp.host, - port: 587, - secure: false, // Only needed or SSL, not for TLS - auth: { - user: config.smtp.user, - pass: config.smtp.pass - } -}); - -const loadTemplate = (type, format, language) => { - let template = templates.header[format] - + templates[type][format] - + templates.footer[format]; - let toTranslate = templates[type].i18n - .concat(templates.footer.i18n); - let from = []; - let to = []; +const loadTemplate = (type, format, language='en') => { + let template = templates.header[format] + templates[type][format] + templates.footer[format] + let toTranslate = templates[type].i18n.concat(templates.footer.i18n) + let from = [] + let to = [] for (let key of toTranslate) { - from.push(`__${key}__`); - to.push(i18n[language][key] || key); + from.push(`__${key}__`) + to.push(i18n[language]['email.' + key] || key) } - for(let id in from) template = template.replace(from[id], to[id]); + for (let i = 0; i < from.length; i++) template = template.replace(from[i], to[i]) - return template; + return template } const replace = (text, from, to) => { - for(let id in from) text = text.replace(from[id], to[id] || from[id]); + for (let id in from) text = text.split(from[id]).join(to[id] || from[id]) - return text; + return text } - email.signup = (recipient, language, id) => { - let html = loadTemplate("signup", "html", language); - let text = loadTemplate("signup", "text", language); - let from = [ - '__signupActionLink__', - '__headerOpeningLine__', - '__hiddenIntro__', - '__footerWhy__', - ]; + let html = loadTemplate('signup', 'html', language) + let text = loadTemplate('signup', 'text', language) + let from = ['__signupActionLink__', '__headerOpeningLine__', '__hiddenIntro__', '__footerWhy__'] + let link = createUrl(language, `/confirm/signup/${id}`) let to = [ - `${config.website}/confirm/signup/${id}`, - i18n[language].signupHeaderOpeningLine, - i18n[language].signupHiddenIntro, - i18n[language].signupWhy, - ]; - html = replace(html, from, to); - text = replace(text, from, to); - - let options = { - from: `"${i18n[language].joostFromFreesewing}" `, + link, + i18n[language]['email.signupHeaderOpeningLine'], + i18n[language]['email.signupHiddenIntro'], + i18n[language]['email.signupWhy'] + ] + html = replace(html, from, to) + text = replace(text, from, to) + let options = { + from: `"${i18n[language]['email.joostFromFreesewing']}" `, to: recipient, - subject: i18n[language].signupSubject, + subject: i18n[language]['email.signupSubject'], + headers: { + 'X-Freesewing-Confirmation-ID': '' + id + }, text, html - }; - transporter.sendMail(options, (error, info) => { - if (error) return console.log(error); - console.log('Message sent', info); - }); + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) } email.emailchange = (newAddress, currentAddress, language, id) => { - let html = loadTemplate("emailchange", "html", language); - let text = loadTemplate("emailchange", "text", language); + let html = loadTemplate('emailchange', 'html', language) + let text = loadTemplate('emailchange', 'text', language) let from = [ '__emailchangeActionLink__', + '__emailchangeActionText__', + '__emailchangeTitle__', + '__emailchangeCopy1__', '__headerOpeningLine__', '__hiddenIntro__', '__footerWhy__', - ]; + '__questionsJustReply__', + '__signature__' + ] let to = [ - `${config.website}/confirm/email/${id}`, - i18n[language].emailchangeHeaderOpeningLine, - i18n[language].emailchangeHiddenIntro, - i18n[language].emailchangeWhy, - ]; - html = replace(html, from, to); - text = replace(text, from, to); + createUrl(language, `/confirm/email/${id}`), + i18n[language]['email.emailchangeActionText'], + i18n[language]['email.emailchangeTitle'], + i18n[language]['email.emailchangeCopy1'], + i18n[language]['email.emailchangeHeaderOpeningLine'], + i18n[language]['email.emailchangeHiddenIntro'], + i18n[language]['email.emailchangeWhy'], + i18n[language]['email.questionsJustReply'], + i18n[language]['email.signature'], + ] + html = replace(html, from, to) + text = replace(text, from, to) - let options = { - from: `"${i18n[language].joostFromFreesewing}" `, + let options = { + from: `"${i18n[language]['email.joostFromFreesewing']}" `, to: newAddress, cc: currentAddress, - subject: i18n[language].emailchangeSubject, + subject: i18n[language]['email.emailchangeSubject'], + headers: { + 'X-Freesewing-Confirmation-ID': '' + id + }, text, html - }; - transporter.sendMail(options, (error, info) => { - if (error) return console.log(error); - console.log('Message sent', info); - }); + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) } email.passwordreset = (recipient, language, id) => { - let html = loadTemplate("passwordreset", "html", language); - let text = loadTemplate("passwordreset", "text", language); + let html = loadTemplate('passwordreset', 'html', language) + let text = loadTemplate('passwordreset', 'text', language) let from = [ '__passwordresetActionLink__', '__headerOpeningLine__', '__hiddenIntro__', - '__footerWhy__', - ]; + '__footerWhy__' + ] let to = [ - `${config.website}/confirm/reset/${id}`, - i18n[language].passwordresetHeaderOpeningLine, - i18n[language].passwordresetHiddenIntro, - i18n[language].passwordresetWhy, - ]; - html = replace(html, from, to); - text = replace(text, from, to); + createUrl(language, `/confirm/reset/${id}`), + i18n[language]['email.passwordresetHeaderOpeningLine'], + i18n[language]['email.passwordresetHiddenIntro'], + i18n[language]['email.passwordresetWhy'] + ] + html = replace(html, from, to) + text = replace(text, from, to) - let options = { - from: `"${i18n[language].joostFromFreesewing}" `, + let options = { + from: `"${i18n[language]['email.joostFromFreesewing']}" `, to: recipient, - subject: i18n[language].passwordresetSubject, + subject: i18n[language]['email.passwordresetSubject'], + headers: { + 'X-Freesewing-Confirmation-ID': '' + id + }, text, html - }; - transporter.sendMail(options, (error, info) => { - if (error) return console.log(error); - console.log('Message sent', info); - }); + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) } email.goodbye = async (recipient, language) => { - let html = loadTemplate("goodbye", "html", language); - let text = loadTemplate("goodbye", "text", language); - let from = [ - '__headerOpeningLine__', - '__hiddenIntro__', - '__footerWhy__', - ]; + let html = loadTemplate('goodbye', 'html', language) + let text = loadTemplate('goodbye', 'text', language) + let from = ['__headerOpeningLine__', '__hiddenIntro__', '__footerWhy__'] let to = [ - i18n[language].goodbyeHeaderOpeningLine, - i18n[language].goodbyeHiddenIntro, - i18n[language].goodbyeWhy, - ]; - html = replace(html, from, to); - text = replace(text, from, to); + i18n[language]['email.goodbyeHeaderOpeningLine'], + i18n[language]['email.goodbyeHiddenIntro'], + i18n[language]['email.goodbyeWhy'] + ] + html = replace(html, from, to) + text = replace(text, from, to) - let options = { - from: `"${i18n[language].joostFromFreesewing}" `, + let options = { + from: `"${i18n[language]['email.joostFromFreesewing']}" `, to: recipient, - subject: i18n[language].goodbyeSubject, + subject: i18n[language]['email.goodbyeSubject'], text, html - }; - await transporter.sendMail(options, (error, info) => { - if (error) return console.log(error); - console.log('Message sent', info); - return true; - }); + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) } -export default email; +email.subscribe = async (recipient, token) => { + let html = loadTemplate('newsletterSubscribe', 'html', 'en') + let text = loadTemplate('newsletterSubscribe', 'text', 'en') + let from = ['__hiddenIntro__', '__headerOpeningLine__', '__newsletterConfirmationLink__', '__footerWhy__'] + let to = [ + 'Confirm your subscription to the FreeSewing newsletter', + 'Please confirm it was you who requested this', + `https://backend.freesewing.org/newsletter/confirm/${token}`, + `You received this email because somebody tried to subscribe ${recipient} to the FreeSewing newsletter` + ] + html = replace(html, from, to) + text = replace(text, from, to) + + let options = { + from: `"FreeSewing" `, + to: recipient, + subject: 'Confirm your subscription to the FreeSewing newsletter', + text, + html + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) +} + +email.newsletterWelcome = async (recipient, ehash) => { + let html = loadTemplate('newsletterWelcome', 'html', 'en') + let text = loadTemplate('newsletterWelcome', 'text', 'en') + let from = ['__hiddenIntro__', '__headerOpeningLine__', '__newsletterUnsubscribeLink__', '__footerWhy__'] + let to = [ + 'No action required; This is just an FYI', + "You're in. Now what?", + `https://backend.freesewing.org/newsletter/unsubscribe/${ehash}`, + `You received this email because you subscribed to the FreeSewing newsletter` + ] + html = replace(html, from, to) + text = replace(text, from, to) + + let options = { + from: `"FreeSewing" `, + to: recipient, + subject: 'Welcome to the FreeSewing newsletter', + text, + html + } + deliver(options, (error, info) => { + if (error) return console.log(error) + console.log('Message sent', info) + }) +} + +export default email diff --git a/packages/backend/src/utils/email/relays.js b/packages/backend/src/utils/email/relays.js new file mode 100644 index 00000000000..c9dc4a322a2 --- /dev/null +++ b/packages/backend/src/utils/email/relays.js @@ -0,0 +1,12 @@ +import smtp from './smtp' +//import sendgrid from './sendgrid' + +const sendEmailWith = type => { + const types = { + smtp + //sendgrid, + } + return types[type] +} + +export default sendEmailWith diff --git a/packages/backend/src/utils/email/sendgrid.js b/packages/backend/src/utils/email/sendgrid.js new file mode 100644 index 00000000000..84b38eef507 --- /dev/null +++ b/packages/backend/src/utils/email/sendgrid.js @@ -0,0 +1,19 @@ +import sendgrid from '@sendgrid/mail' +import config from '../../config' + +sendgrid.setApiKey(config.sendgrid) + +const deliver = (data, callback) => { + sendgrid.send(data).then(result => { + // FIXME: This is obviously nonsense + if (result[0].statusCode === 202) + callback(false, { + from: data.from, + to: data.to, + subject: data.subject + }) + else callback(true, 'Sending via SendGridfailed') + }) +} + +export default deliver diff --git a/packages/backend/src/utils/email/smtp.js b/packages/backend/src/utils/email/smtp.js new file mode 100644 index 00000000000..5053fca7967 --- /dev/null +++ b/packages/backend/src/utils/email/smtp.js @@ -0,0 +1,18 @@ +import nodemailer from 'nodemailer' +import config from '../../config' + +const transporter = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: false, // Only needed or SSL, not for TLS + auth: { + user: config.smtp.user, + pass: config.smtp.pass + } +}) + +const deliver = (data, callback) => { + transporter.sendMail(data, callback) +} + +export default deliver diff --git a/packages/backend/src/utils/index.js b/packages/backend/src/utils/index.js index ef8542aaf73..4d131b0d5ae 100644 --- a/packages/backend/src/utils/index.js +++ b/packages/backend/src/utils/index.js @@ -1,93 +1,138 @@ -import { User, Model, Draft } from "../models"; -import crypto from "crypto"; -import jwt from "jsonwebtoken"; -import mailer from "./email"; -import logger from "./log"; -import config from "../config"; -import axios from "axios"; -import path from "path"; -import fs from "fs"; -import sharp from "sharp"; +import { User, Person, Pattern } from '../models' +import crypto from 'crypto' +import jwt from 'jsonwebtoken' +import mailer from './email' +import logger from './log' +import config from '../config' +import path from 'path' +import fs from 'fs' +import sharp from 'sharp' +import avatar from '../templates/avatar' -export const email = mailer; -export const log = logger; +export const email = mailer +export const log = logger -export const capitalize = string => string.charAt(0).toUpperCase() + string.slice(1); - -export const getHash = (email) => { - let hash = crypto.createHash("sha256"); - hash.update(clean(email)); - return hash.digest("hex"); +export const createUrl = (language, path) => { + // Handle development mode + if (config.api.indexOf('localhost') !== -1) return 'http://localhost:8000' + path + else return config.website.scheme + '://' + language + '.' + config.website.domain + path } -export const clean = (email) => email.toLowerCase().trim(); +export const getHash = email => { + let hash = crypto.createHash('sha256') + hash.update(clean(email)) + return hash.digest('hex') +} -export const getToken = (account) => { - return jwt.sign({ - _id: account._id, - handle: account.handle, - aud: config.jwt.audience, - iss: config.jwt.issuer, - }, config.jwt.secretOrKey); +export const clean = email => email.toLowerCase().trim() + +export const getToken = account => { + return jwt.sign( + { + _id: account._id, + handle: account.handle, + aud: config.jwt.audience, + iss: config.jwt.issuer + }, + config.jwt.secretOrKey + ) } export const getHandle = type => { - let go, handle, exists; - if (type === "model") go = Model; - else if (type === "draft") go = Draft; - else go = User; + let go, handle, exists + if (type === 'person') go = Person + else if (type === 'pattern') go = Pattern + else go = User do { - exists = false; - handle = createHandle(); + exists = false + handle = createHandle() go.findOne({ handle: handle }, (err, result) => { - if(result !== null) exists = true; - }); - } while (exists !== false); + if (result !== null) exists = true + }) + } while (exists !== false) - return handle; + return handle } export const createHandle = (length = 5) => { - let handle = ""; - let possible = "abcdefghijklmnopqrstuvwxyz"; + let handle = '' + let possible = 'abcdefghijklmnopqrstuvwxyz' for (let i = 0; i < length; i++) - handle += possible.charAt(Math.floor(Math.random() * possible.length)); + handle += possible.charAt(Math.floor(Math.random() * possible.length)) - return handle; + return handle } export const imageType = contentType => { - if (contentType === "image/png") return "png"; - if (contentType === "image/jpeg") return "jpg"; - if (contentType === "image/gif") return "gif"; - if (contentType === "image/bmp") return "bmp"; - if (contentType === "image/webp") return "webp"; + if (contentType === 'image/png') return 'png' + if (contentType === 'image/jpeg') return 'jpg' + if (contentType === 'image/gif') return 'gif' + if (contentType === 'image/bmp') return 'bmp' + if (contentType === 'image/webp') return 'webp' } export const saveAvatarFromBase64 = (data, handle, type) => { - fs.mkdir(userStoragePath(handle), {recursive: true}, (err) => { - if(err) log.error("mkdirFailed", err); - let imgBuffer = Buffer.from(data, 'base64'); - for(let size of Object.keys(config.avatar.sizes)) { + fs.mkdir(userStoragePath(handle), { recursive: true }, err => { + if (err) log.error('mkdirFailed', err) + let imgBuffer = Buffer.from(data, 'base64') + for (let size of Object.keys(config.avatar.sizes)) { sharp(imgBuffer) - .resize(config.avatar.sizes[size], config.avatar.sizes[size]) - .toFile(avatarPath(size, handle, type), (err, info) => { - if(err) log.error("avatarNotSaved", err); - }); + .resize(config.avatar.sizes[size], config.avatar.sizes[size]) + .toFile(avatarPath(size, handle, type), (err, info) => { + if (err) log.error('avatarNotSaved', err) + }) } - }); + }) } -export const userStoragePath = handle => { - return path.join( - config.storage, - handle.substring(0,1), - handle); +export const avatarPath = (size, handle, ext, type = 'user') => { + let dir = userStoragePath(handle) + if (size === 'l') return path.join(dir, handle + '.' + ext) + else return path.join(dir, size + '-' + handle + '.' + ext) } -export const avatarPath = (size, handle, ext, type="user") => { - let dir = userStoragePath(handle); - if(size === "l") return path.join(dir, handle+"."+ext); - else return path.join(dir, size+"-"+handle+"."+ext); +export const randomColor = () => (0x1000000 + Math.random() * 0xffffff).toString(16).substr(1, 6) + +export const randomAvatar = () => + avatar.replace('000000', randomColor()).replace('FFFFFF', randomColor()) + +export const ehash = email => { + let hash = crypto.createHash('sha256') + hash.update(clean(email)) + return hash.digest('hex') } +export 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 +} + +export const uniqueHandle = () => { + let handle, exists + do { + exists = false + handle = newHandle() + User.findOne({ handle: handle }, (err, user) => { + if (user !== null) exists = true + }) + } while (exists !== false) + + return handle +} + +export const userStoragePath = handle => + path.join(config.storage, 'users', handle.substring(0, 1), handle) + +export const createAvatar = handle => { + let dir = userStoragePath(handle) + fs.mkdir(dir, { recursive: true }, err => { + if (err) console.log('mkdirFailed', dir, err) + fs.writeFile(path.join(dir, handle) + '.svg', randomAvatar(), err => { + if (err) console.log('writeFileFailed', dir, err) + }) + }) +} diff --git a/packages/backend/src/utils/log/index.js b/packages/backend/src/utils/log/index.js index 009690c3577..73585da55b5 100644 --- a/packages/backend/src/utils/log/index.js +++ b/packages/backend/src/utils/log/index.js @@ -1,48 +1,45 @@ -import dateFormat from "dateformat"; +import dateFormat from 'dateformat' // FIXME: This needs work -const now = () => dateFormat(new Date(), "yyyy-mm-dd HH:MM:ss"); +const now = () => dateFormat(new Date(), 'yyyy-mm-dd HH:MM:ss') const logWorthy = (msg, data) => { - let d = {at: now()}; - switch(msg) { + let d = { at: now() } + switch (msg) { case 'ping': case 'login': case 'wrongPassword': case 'passwordSet': case 'dataExport': - d.user = data.user.handle; - d.from = data.req.ip; - d.with = data.req.headers['user-agent']; - break; + d.user = data.user.handle + d.from = data.req.ip + d.with = data.req.headers['user-agent'] + break case 'signupRequest': - d.email = data.email; - d.confirmation = data.confirmation; - break; + d.email = data.email + d.confirmation = data.confirmation + break case 'accountRemovalFailed': - d.err = data.err; - d.user = data.user.handle; - d.from = data.req.ip; - d.with = data.req.headers['user-agent']; - break; + d.err = data.err + d.user = data.user.handle + d.from = data.req.ip + d.with = data.req.headers['user-agent'] + break default: - d.data = data; - break; + d.data = data + break } - return d; + return d } - const log = (type, msg, data) => { - console.log(type, msg, logWorthy(msg, data)); + console.log(type, msg, logWorthy(msg, data)) } +log.info = (msg, data) => log('info', msg, data) +log.warning = (msg, data) => log('warning', msg, data) +log.error = (msg, data) => log('error', msg, data) -log.info = (msg, data) => log('info', msg, data); -log.warning = (msg, data) => log('warning', msg, data); -log.error = (msg, data) => log('error', msg, data); - - -export default log; +export default log diff --git a/packages/backend/tests/admin.test.js b/packages/backend/tests/admin.test.js new file mode 100644 index 00000000000..fd2cf416ae5 --- /dev/null +++ b/packages/backend/tests/admin.test.js @@ -0,0 +1,31 @@ +module.exports = function tests(store, config, chai) { + describe('Admin routes', () => { + it('should login ad admin', done => { + chai + .request(config.backend) + .post('/login') + .send({ + username: 'admin', + password: 'admin' + }) + .end((err, res) => { + console.log(res) + res.should.have.status(200) + done() + }) + }) + + it('should load a user account', done => { + chai + .request(config.backend) + .get('/admin/users/rracx') + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + console.log(data) + done() + }) + }) + }) +} diff --git a/packages/backend/tests/config.js b/packages/backend/tests/config.js new file mode 100644 index 00000000000..d3de6391563 --- /dev/null +++ b/packages/backend/tests/config.js @@ -0,0 +1,31 @@ +const strings = require('@freesewing/i18n').strings + +module.exports = { + backend: process.env.FS_BACKEND, + mailhog: 'http://localhost:8025', + sleep: 0, + languages: { + en: '🇬🇧', + de: '🇩🇪', + es: '🇪🇸', + nl: '🇳🇱', + fr: '🇫🇷' + }, + strings: strings, + user: { + email: 'test@freesewing.org', + changedEmail: 'changedtest@freesewing.org', + password: 'test', + username: 'test_user' + }, + oauth: { + github: { + url: `https://github.com/login/oauth/authorize?client_id=${process.env.FS_GITHUB_CLIENT_ID}&redirect_uri=${process.env.FS_BACKEND}/oauth/callback/from/github&scope=read:user&state=` + }, + google: { + url: `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${process.env.FS_GOOGLE_CLIENT_ID}&redirect_uri=${process.env.FS_BACKEND}/oauth/callback/from/google&scope=https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email&access_type=online&state=` + } + }, + avatar: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAQAAADa613fAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAADsQAAA7EAZUrDhsAAAAHdElNRQfjCAkODQHZS6QWAAAMsUlEQVR42u2ce3TV1ZXHv/uc87v35p1UkkB4GyL4RB5VWqXSKE7bUWwVixY6ZRy1qy+Xs6x9WGacaWm71nRNa2vttNbR1emyTrVYXWoLA/IaERAYK8hbQkigCRAS8r739zvn7PkjN/GGJPf3u/nd0Kwudv7ISu7J+Z3P2fvss/c+5xdCO/4qRPylB3AB5ALIKJcLIKNNLoCMNhk1IPKvAUShWt4UkkSN7BAlKqhSTKZSyiNCnM+iic9wC7ehiz0QcjGO5sibJeNhd9SCRHGTvNuZJ8ZRDlHfbw0SiHMXutgFIQ8lBLxqHnWP2HBPo5EKGitoZWSJyiW/dqf4e+4vva7QzxshkBJ6OvrJQNo2aOTN5llvgwkHM0IgDzo/jPoqI0W6sEE/5r1ueHSBFNCa2Icy9kIt/FPv373WYbKMgPsdS/8amTUMZ1pCKyJPRadkosgUybpG5svvR64LsSf8r/lCYu8wPFhWNaJwr/PfsTAYwHz5dPTSYYwqixoppBXOlyM5Wehpo/ls/HiGayVrGimlJ6JfzQoGsEB+J+OesgTyAfpJdJka5jodRJY6f+f8BUBiWBm5K6vBjoOHnGkZjS0rIJ937s1w/vylSjzgZOI1sgAyT349knUOAPeoL2WAIvFIuMfl4kfRuWGzokElQjNllSiksdSJTt/WoS37FvWxEUsF3jSbzEQyMAHahhxEPt3rREMOt9ZGqGJQhzdVvGraAu4nIdfIHDEv9Co7zsvjGwed9EqaHtijhxzGfFkQevO4QjTz0vi6QVCK6PLA4wsFInBlFrxeMc2Sf+YHE4cGhIqEy84PSAwVWdnMpxOw1/7I0wM+qRRBHWIokAj55+RBZKIQAFbpPQN0MpHyAz4hFAhj+KlpqnyAHACneYNp5YZ+XZZSUcA+QoF43J0VkmhyGHtMG17vt+iLaMz50IiLM1kBMUnNtiEHB217Sp+5VHY+QDTqswLSxT1qiCIHLqeGIxGMOx8gwOGQ9cEeOc0eAKCcJHlI7ZIwIeAIQ4IcsPEsgLyX1Ot00c0diPX7bOL50UgNNw0wLg09RGuLDm7m1uT894qLdy0A5NPVop5dzus39EkiEmgkIYPGBq61FfJ1c73MQQtvM7tsLTczoYqWOzP6TVIrv6A3mlru5AjGiY/K5SpCR+1lAmjiAxYALqFLxSpdSFG4kH3nJROoiE4HWIkhQTp5v71UvKI/LHbYr7lbTaLvk032t7FJKXP7uPdP7x8c2Jd1Jz/gvKYnOflUa08wACyQhbTRzJLAWn2dLCYA8FBG4wOBhI6V9thuKHTim+7GFAxgm1mdYmFxbDonKHzFxHGMaxk4ZDsYiOFm1ci7bBU18wu61/j2224ECxxDg+y3ERRRA783wH81pszjwBig3p7hDj7FQE9gMlnMEtvMKZ4g2riOE3297zLBCn6hQWo4gUoR55wB3mU8ARrbzA5zknMGhJcJnOU4SqgLOy0ATBcl9Loh5CNGHnpL2S38sp4rLgrguUKDnOTjtoosJpzzsDE0VwK19v7E4vhrGji3Op8HizxcTH8y71gAKCMPey3DopgKcCIJEsM6I2h+AJ2EBunkbVZRM1ee09NH5AwBrDV7bB1vMsBCObUf6mQhUCHy6TndM/sed/NZbuPjHEOl2Gd7WzXxK3qp8nfBWUiMfuM94zXxzH49TaR/dKLo4FUaAHbbMzxNpNa+BO5SZVROb5rnky6hgeNguFhvgI/Id0yPb7hczBC/8Arob329axZAdto/mmvlJ+R1MoIo8lBBi9SvY9dL4C273QLAUT5igS84KyJXiHE0iWaLb0c+p6aIEnogcSppRIdsE+cR8KKu54VSUm3S4JapRl6RmCVKfdZJVko5n5IzBPDb2AHLiKGcJoqeMOMl3cEA0MqbzDWyhL4d+YrTyg6KqYgIwN/Ix/v6qOMtZiwB++0vvJWRu9Ue22Ost6uf6532sOdXEspKyXS6AIDxdKO8SV4vq5IYp/n9vWOVbmGAUEZVYoooTh5Xj6F5fcvY4j+9HuX8h7faVMtS6mIAuFjcKoFW7vDZFH1AptJitVhN8lHr4GcZCQjk4vPOLTIXQ2UVqSeGf7KbDAFo5q8nDtu5sndj+qQKku6mPei5TX3JYSjkYIW7Po1up9LK6O0qds5vGQfsGZ4ju/iIvVgMzPQsVut/SDQOOgm3qWeiJcm/aONPxLf4FhvT1H6vFNXyx94T3nO6kB5w1pmzQ3ZyFq/qMpojX9YxlPQNmFBKk4SDXBovUosUHs5yLiXwL+4Kd6go6qAVtED2mEuUanmzL4gY+oMCetzbbePoxs+8DtyTtuDu4imvhRv4q66fLQNPeU96wC7zUy9dovyEt6YvVrtB5vp1OjQIY7vpfVAnXtO3qvRlgH12u1mi2vhd35xxl50ogBd1+qpuG//Aa0m2mC2u8HVKaUBStbndjqWr0nbmYrO5iO5zCO6QiRWg4WKJqpanea2vsbxh1iTblFC17zYRcB85ZuM8U6xP+/Bdthu3K4HndL0dSxG6hHbYa+QZnkxdUFA4wYQGHksLJbDGHPLVnMaL+g7VY9HV8jGkT6oDgQgUUhyz5QTdik4eagT7bYO9WABltJU3GI0qsc8etdvsh8VxzqF8NHAZTaQrFACs10Fy/R3mhJ0iAOAaUS3/kHYaA4BUiq85laKUFsqqGFDHNfY4dwEM9F80seSPH5UfFCe4mXOgqJ2v5smUQCFcNPJMMV5EARiU06cVQUPDYxcuXCTYgwcPCe5GnOOwABq5hqcAAE7yLSqPNpimIddVAJAb5R3qSe8iYpzg29W1MGjn/tfdeggYPRuXQjEVE5AAIHAtGC67aOWqvnjJ4C7nTljW0HDhopu70Mmd6OJ2Ps3H+JA9aDsBxFFve5L3LvzEG09FFArkN9rjalVOAu/hMe+IrbGNnEjJ+Hr1YvBZ9UhfvH2K3zJtsKixpVTPs0QHz5a9IGv0w64Gw7CFhoGGhmGL3q/3+25Ofi8nc04xdRggHfyM/i/9fGwMLY+nvxz2mvkyFyYHO4ZuUh63o4oKyGC6EH11EYPf6YMBC3vJ8AsFKKfDaVsG9FoGzTxVKEpffn/HvGl6j0YFYohRASoGZHc7zeogp5sAACc5LVHyS3cDR7+nuBgFPm068V2vzmdnr7GPuKcCV4x7DxUUCn1aBgap46IAlfE3zL3xA2nMZq/9+8T6wPpQfVEzwe9KQmCQGhtBZYDWa82S+P8MMdT1ZmncP/x7X/JpXPKJDM+nbWCQo9yB2YFa77ZL499x6znVfizq+HvuZ+LvZFS9L6NxBLTzcdZo9jHHwKlugz1i58kYguzITfzP7rP6GjFVFMIhRhsfttutf1ByrlwmSgk4zC2cL/wuogUG6cRWs9yZJvyj2x45aIO62KFlgXQAbDFXizr2O1LKIGdfb/Jp4YhcnxlcxtKNEmjmbWay2Gz8TCsDkB32kF2sCrN3Tc5HPi5nCGCdscjFKu3XOgOQJv69nhv69YigUkL3OAod/LR3ndxstg4/1R1Mntdn+ItO+NsnQWSZ+pAEXjJ7bSX90Ev4ts8IZJ991lsgl4zwOycAcKV40JE4zj9wK+gl419Dyfje7zTxxxhwa9rdO7wU0a+itymNhxOPeeXUzkHeY8iw0vie/TdvqlgZGUnzcvAtZ5ECXtC/9ICTgTCGcadxv51MdzuETWZklCLwkPONqMI288XE6Qz+LmMQjf+z18rFThdvt9m5UpMqCg85j0Zy6IC9L7Evo+6Hccu0FW/bG+RiR+Mtk0EEGEAK6NHIN6M5dNjen9iaocKHdV22kXfZ6+WnVTHtsN1Zw5gmfhy9z3Gw296feCPjGRrmvd8T/Ka5StyprpYH7Z+zYGEO7lA/i1ZLwjpzf2LXMJbfsC8wn+S1poQ+pRYpiUMBPctQcpX4buRbkQmik3+uH0wcHdbEhHp/JIZlzjecSvGWecJ7xbQMawAzxOfUMmcCAW/b77u/175B1UiAAMAl4ivOZ1Qx7TDP6j+Yoza4VeTRHHGnWqQmEXDM/ko/6Z0IYaRZeKOH8EFxj3ObGkv1vEGvMTtsvc/lpxKaIW4QN6u5ooCAw/Z3+td6/+h4M5RwqVgkF6mZIodO8l77tnnX1vBJbuU4NDMkOchDCU2gS8RMMVNWUT4Bp3mnedms1sey4C6y+tZbIV0pbpDzxeWynBwk0M6t3IE4MxRyqZAKkU8SQCvX8W67xWwxh7JycS3rID0SwTiqFDNElZhEZVRIUQAaCbRzMzdynT3CR2wdN3N2Q5wRe+kYAASiiJICwUDDZS/QCxSjEOR8yqj4DwMXQC6AjGK5ADLa5ALIaJP/B4cPSNrdNcsBAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE5LTA4LTA5VDE0OjEyOjUyKzAwOjAwR3qVBAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxOS0wOC0wOVQxNDoxMjo1MiswMDowMDYnLbgAAAAZdEVYdFNvZnR3YXJlAHd3dy5pbmtzY2FwZS5vcmeb7jwaAAAAAElFTkSuQmCC' +} diff --git a/packages/backend/tests/index.js b/packages/backend/tests/index.js new file mode 100644 index 00000000000..6580fb5a36f --- /dev/null +++ b/packages/backend/tests/index.js @@ -0,0 +1,31 @@ +require('dotenv').config() +const config = require('./config.js') +const chai = require('chai') + +chai.use(require('chai-http')) +const should = chai.should() +const backend = config.backend + +const EMAIL = process.env.SEND_TEST_EMAILS === '1' ? true : false +const store = {} + +const userTests = require('./user.test.js') +const userTestsEmail = require('./user.all.test.js') +const personTests = require('./person.test.js') +const patternTests = require('./pattern.test.js') +const oauthTests = require('./oauth.test.js') + +if (!EMAIL) { + // Set credentials to run the tests + store.en = config.user + store.de = config.user + store.es = config.user + store.nl = config.user + store.fr = config.user +} + +if (EMAIL) userTestsEmail(store, config, chai) +userTests(store, config, chai) +personTests(store, config, chai) +patternTests(store, config, chai) +oauthTests(store, config, chai) diff --git a/packages/backend/tests/oauth.test.js b/packages/backend/tests/oauth.test.js new file mode 100644 index 00000000000..67fda68ee7c --- /dev/null +++ b/packages/backend/tests/oauth.test.js @@ -0,0 +1,40 @@ +module.exports = function tests(store, config, chai) { + + describe('Oauth via Github', () => { + it('should trigger Oauth via Github', done => { + chai + .request(config.backend) + .post('/oauth/init') + .send({ + provider: 'github', + language: 'en' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.state.should.be.a('string') + config.oauth.github.state = data.state + done() + }) + }) + + it('should trigger Oauth via Google', done => { + chai + .request(config.backend) + .post('/oauth/init') + .send({ + provider: 'github', + language: 'en' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.state.should.be.a('string') + config.oauth.google.state = data.state + done() + }) + }) + + // FIXME: Test the rest of the Oauth flow (perhaps easier to do from the frontend) + }) +} diff --git a/packages/backend/tests/pattern.test.js b/packages/backend/tests/pattern.test.js new file mode 100644 index 00000000000..3676039dc2b --- /dev/null +++ b/packages/backend/tests/pattern.test.js @@ -0,0 +1,96 @@ +module.exports = function tests(store, config, chai) { + describe('Pattern endpoints', () => { + it('should create a pattern', done => { + chai + .request(config.backend) + .post('/patterns') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + name: 'Test pattern', + person: 'Someone', + notes: 'Some notes', + data: { + test: 'value' + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.name.should.equal('Test pattern') + data.notes.should.equal('Some notes') + data.data.test.should.equal('value') + config.user.pattern = data.handle + done() + }) + }) + + it('should update the pattern name', done => { + chai + .request(config.backend) + .put('/patterns/' + config.user.pattern) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + name: 'New name' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.name.should.equal('New name') + data.handle.should.equal(config.user.pattern) + done() + }) + }) + + it('should update the pattern notes', done => { + chai + .request(config.backend) + .put('/patterns/' + config.user.pattern) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + notes: 'These are the notes' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.notes.should.equal('These are the notes') + data.handle.should.equal(config.user.pattern) + done() + }) + }) + + it('should load the pattern data without authentication', done => { + chai + .request(config.backend) + .get('/patterns/' + config.user.pattern) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.notes.should.equal('These are the notes') + data.handle.should.equal(config.user.pattern) + done() + }) + }) + + it('should delete the pattern', done => { + chai + .request(config.backend) + .delete('/patterns/' + config.user.pattern) + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(204) + done() + }) + }) + + it('should no longer have this pattern', done => { + chai + .request(config.backend) + .get('/patterns/' + config.user.pattern) + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(404) + done() + }) + }) + }) +} diff --git a/packages/backend/tests/person.test.js b/packages/backend/tests/person.test.js new file mode 100644 index 00000000000..5b43947ebdd --- /dev/null +++ b/packages/backend/tests/person.test.js @@ -0,0 +1,195 @@ +module.exports = function tests(store, config, chai) { + const should = chai.should() + + describe('Person endpoints', () => { + it('should create a person', done => { + chai + .request(config.backend) + .post('/people') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + name: 'Test person', + units: 'imperial', + breasts: true + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.breasts.should.be.true + data.person.units.should.equal('imperial') + data.person.name.should.equal('Test person') + data.person.pictureUris.xs + .split('/') + .pop() + .should.equal(data.person.handle + '.svg') + config.user.person = data.person.handle + done() + }) + }) + + it('should update the person name', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + name: 'New person name' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.name.should.equal('New person name') + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should update the person chest', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + breasts: 'false' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.breasts.should.be.false + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should update the person units', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + units: 'metric' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.units.should.equal('metric') + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should update the person notes', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + notes: 'These are the notes' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.notes.should.equal('These are the notes') + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should update the person measurements', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + measurements: { + shoulderToShoulder: 456, + neck: 345 + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.measurements.shoulderToShoulder.should.equal(456) + data.person.measurements.neck.should.equal(345) + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should not set a non-existing measurement', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + measurements: { + hairLength: 12 + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + should.not.exist(data.person.measurements.hairLength) + data.person.measurements.shoulderToShoulder.should.equal(456) + data.person.measurements.neck.should.equal(345) + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should update the person avatar', done => { + chai + .request(config.backend) + .put('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + picture: config.avatar + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.measurements.shoulderToShoulder.should.equal(456) + data.person.measurements.neck.should.equal(345) + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should load the person data', done => { + chai + .request(config.backend) + .get('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.person.measurements.shoulderToShoulder.should.equal(456) + data.person.measurements.neck.should.equal(345) + data.person.handle.should.equal(config.user.person) + done() + }) + }) + + it('should delete the person', done => { + chai + .request(config.backend) + .delete('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(204) + done() + }) + }) + + it('should no longer have this person', done => { + chai + .request(config.backend) + .get('/people/' + config.user.person) + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(404) + done() + }) + }) + }) +} diff --git a/packages/backend/tests/user.all.test.js b/packages/backend/tests/user.all.test.js new file mode 100644 index 00000000000..286f14cabc4 --- /dev/null +++ b/packages/backend/tests/user.all.test.js @@ -0,0 +1,201 @@ +module.exports = function tests(store, config, chai) { + const email = Date.now() + config.user.email + + describe(`Language-specific User controller signup routes`, () => { + for (let lang in config.languages) { + store[lang] = { + email: lang + email, + password: 'test' + } + + it(config.languages[lang] + ' => should create a pending signup', done => { + chai + .request(config.backend) + .post('/signup') + .send({ + email: store[lang].email, + password: store[lang].password, + language: lang + }) + .end((err, res) => { + res.should.have.status(200) + res.text.should.equal('OK') + done() + }) + }) + + it(config.languages[lang] + ' => should detect a pre-existing signup', done => { + chai + .request(config.backend) + .post('/signup') + .send({ + email: store[lang].email, + password: store[lang].password, + language: lang + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('userExists') + done() + }) + }) + } + }) + + describe(`Send out emails for other tests that rely on email delivery`, () => { + it('should fetch the JWT token for test_user', done => { + chai + .request(config.backend) + .post('/login') + .send({ + username: config.user.username, + password: config.user.password + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + store.emailChange = { token: data.token } + done() + }) + }) + + it('should trigger the email change confirmation email', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + store.emailChange.token) + .send({ + email: config.user.changedEmail + }) + .end((err, res) => { + res.should.have.status(200) + done() + }) + }) + }) + + describe(`Take a ${config.sleep} second power nap while we wait for the emails to be delivered`, () => { + it(`should sleep for ${config.sleep} seconds to allow the emails to arrive`, done => { + console.log('\n 😴 \n') + setTimeout(() => { + done() + }, config.sleep * 1000) + }).timeout(config.sleep * 1010) + + for (let lang in config.languages) { + it( + config.languages[lang] + ' => should have sent out an email to the signup email address', + done => { + let url = `/api/v2/search?kind=to&query=${lang}${email}` + chai + .request(config.mailhog) + .get(url) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.total.should.equal(1) + let message = data.items[0] + message.From.Mailbox.should.equal('info') + message.From.Domain.should.equal('freesewing.org') + message.Content.Headers.Subject[0].should.equal( + config.strings[lang]['email.signupSubject'] + ) + store[lang].confirmation = message.Content.Headers[ + 'X-Freesewing-Confirmation-ID' + ].pop() + done() + }) + } + ) + + it( + config.languages[lang] + + ' => should not activate the pending confirmation without consent', + done => { + chai + .request(config.backend) + .post('/account') + .send({ + id: store[lang].confirmation + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('consentRequired') + done() + }) + } + ) + + it(config.languages[lang] + ' => should activate the pending confirmation', done => { + chai + .request(config.backend) + .post('/account') + .send({ + id: store[lang].confirmation, + consent: { + profile: true + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.settings.language.should.equal(lang) + data.account.settings.units.should.equal('metric') + data.account.handle.should.be.a('string') + data.account.role.should.equal('user') + data.account.patron.should.equal(0) + data.account.bio.should.equal('') + data.account.username.should.equal('user-' + data.account.handle) + data.account.__v.should.equal(0) + data.account.email.should.equal(lang + email) + data.account.pictureUris.xs + .split('/') + .pop() + .should.equal(data.account.handle + '.svg') + data.token.should.be.a('string') + store[lang].token = data.token + store[lang].username = data.account.username + store[lang].handle = data.account.handle + done() + }) + }) + } + + it('should have sent out an email to confirm the email change', done => { + chai + .request(config.mailhog) + .get(`/api/v2/search?kind=to&query=${config.user.changedEmail}`) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + let message = data.items[0] + message.From.Mailbox.should.equal('info') + message.From.Domain.should.equal('freesewing.org') + config.user.confirmation = message.Content.Headers['X-Freesewing-Confirmation-ID'].pop() + done() + }) + }) + }) + + /* + describe(`Other tests that depend on emails we sent out`, () => { + + it('should update the confirmed account email address', (done) => { + chai.request(config.backend) + .post('/confirm/changed/email') + .set('Authorization', 'Bearer '+store.emailChange.token) + .send({ + id: config.user.confirmation, + }) + .end((err, res) => { + let data = JSON.parse(res.text); + res.should.have.status(200); + res.data.account.email.should.equal(config.user.changedEmail); + done(); + }); + }) + + }); + + */ +} diff --git a/packages/backend/tests/user.test.js b/packages/backend/tests/user.test.js new file mode 100644 index 00000000000..ad23717d604 --- /dev/null +++ b/packages/backend/tests/user.test.js @@ -0,0 +1,349 @@ +module.exports = function tests(store, config, chai) { + describe('Non language-specific User controller signup routes', () => { + it('should not create signup without email address', done => { + chai + .request(config.backend) + .post('/signup') + .send({ + password: config.user.password, + language: 'en' + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('emailMissing') + done() + }) + }) + + it('should not create signup without password', done => { + chai + .request(config.backend) + .post('/signup') + .send({ + email: config.user.email, + language: 'en' + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('passwordMissing') + done() + }) + }) + + it('should not create signup without language', done => { + chai + .request(config.backend) + .post('/signup') + .send({ + email: config.user.email, + password: config.user.password + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('languageMissing') + done() + }) + }) + }) + + describe('Login/Logout and session handling', () => { + it('should login with the username', done => { + chai + .request(config.backend) + .post('/login') + .send({ + username: config.user.username, + password: config.user.password + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.username.should.equal(config.user.username) + data.token.should.be.a('string') + config.user.token = data.token + done() + }) + }) + + it('should login with the email address', done => { + chai + .request(config.backend) + .post('/login') + .send({ + username: config.user.email, + password: config.user.password + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.username.should.equal(config.user.username) + data.token.should.be.a('string') + done() + }) + }) + + it('should load account with JSON Web Token', done => { + chai + .request(config.backend) + .get('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + if (err) console.log(err) + let data = JSON.parse(res.text) + res.should.have.status(200) + data.account.username.should.equal(config.user.username) + // Enable this once cleanup is implemented + //Object.keys(data.recipes).length.should.equal(0) + //Object.keys(data.people).length.should.equal(0) + done() + }) + }) + }) + + describe('Account management', () => { + it('should update the account avatar', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + avatar: config.avatar + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.pictureUris.l.slice(-4).should.equal('.png') + done() + }) + }) + it('should update the account username', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + username: config.user.username + '_updated' + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.username.should.equal(config.user.username + '_updated') + done() + }) + }) + + it('should restore the account username', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + username: config.user.username + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.username.should.equal(config.user.username) + done() + }) + }) + + it('should not update the account username if that username is taken', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + username: 'admin' + }) + .end((err, res) => { + res.should.have.status(400) + res.text.should.equal('usernameTaken') + done() + }) + }) + + it('should update the account bio', done => { + let bio = 'This is the test bio ' + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + bio: bio + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.bio.should.equal(bio) + done() + }) + }) + + it('should update the account language', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + settings: { + language: 'nl' + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.settings.language.should.equal('nl') + done() + }) + }) + + it('should update the account units', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + settings: { + units: 'imperial' + } + }) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.account.settings.units.should.equal('imperial') + done() + }) + }) + + for (let network of ['github', 'twitter', 'instagram']) { + it(`should update the account's ${network} username`, done => { + let data = { social: {} } + data.social[network] = network + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send(data) + .end((err, res) => { + res.should.have.status(200) + JSON.parse(res.text).account.social[network].should.equal(network) + done() + }) + }) + } + + it('should update the account password', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + password: 'changeme' + }) + .end((err, res) => { + res.should.have.status(200) + done() + }) + }) + + it('should login with the new password', done => { + chai + .request(config.backend) + .post('/login') + .send({ + username: config.user.username, + password: 'changeme' + }) + .end((err, res) => { + res.should.have.status(200) + done() + }) + }) + + it('should restore the account password', done => { + chai + .request(config.backend) + .put('/account') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + password: config.user.password + }) + .end((err, res) => { + res.should.have.status(200) + done() + }) + }) + }) + + describe('Other user endpoints', () => { + it("should load a user's profile", done => { + chai + .request(config.backend) + .get('/users/admin') + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.username.should.equal('admin') + done() + }) + }) + + it('should confirm that a username is available', done => { + chai + .request(config.backend) + .post('/available/username') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + username: Date.now() + ' ' + Date.now() + }) + .end((err, res) => { + res.should.have.status(200) + done() + }) + }) + + it('should confirm that a username is not available', done => { + chai + .request(config.backend) + .post('/available/username') + .set('Authorization', 'Bearer ' + config.user.token) + .send({ + username: 'admin' + }) + .end((err, res) => { + res.should.have.status(400) + done() + }) + }) + + it('should load the patron list', done => { + chai + .request(config.backend) + .get('/patrons') + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data['2'].should.be.an('array') + data['4'].should.be.an('array') + data['8'].should.be.an('array') + done() + }) + }) + + it('should export the user data', done => { + chai + .request(config.backend) + .get('/account/export') + .set('Authorization', 'Bearer ' + config.user.token) + .end((err, res) => { + res.should.have.status(200) + let data = JSON.parse(res.text) + data.export.should.be.a('string') + store.exportLink = data.export + done() + }) + }) + }) +}