diff --git a/sites/backend/.editorconfig b/sites/backend/.editorconfig deleted file mode 100644 index 9142239769f..00000000000 --- a/sites/backend/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -# editorconfig.org -root = true - -[*] -indent_size = 2 -indent_style = space -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/sites/backend/Dockerfile b/sites/backend/Dockerfile deleted file mode 100644 index 602a76ae9b5..00000000000 --- a/sites/backend/Dockerfile +++ /dev/null @@ -1,56 +0,0 @@ -FROM keymetrics/pm2:latest-alpine - -# For documentation, see https://freesewing.dev/containers/backend - -# Extra build argument for when you're using a private NPM registry -ARG npm_registry - -# Environment variables -ENV http_proxy=$http_proxy \ - https_proxy=$https_proxy \ - no_proxy=$no_proxy \ - NPM_CONFIG_REGISTRY=$npm_registry \ - FS_BACKEND=$FS_BACKEND \ - FS_SITE=$FS_SITE \ - FS_MONGO_URI=$FS_MONGO_URI \ - FS_ENC_KEY=$FS_ENC_KEY \ - FS_JWT_ISSUER=$FS_JWT_ISSUER \ - FS_SMTP_HOST=$FS_SMTP_HOST \ - FS_SMTP_USER=$FS_SMTP_USER \ - FS_SMTP_PASS=$FS_SMTP_PASS \ - FS_GITHUB_CLIENT_ID=$FS_GITHUB_CLIENT_ID \ - FS_GITHUB_CLIENT_SECRET=$FS_GITHUB_CLIENT_SECRET \ - FS_GOOGLE_CLIENT_ID=$FS_GOOGLE_CLIENT_ID \ - FS_GOOGLE_CLIENT_SECRET=$FS_GOOGLE_CLIENT_SECRET \ - FS_STATIC=/storage/static \ - FS_STORAGE=/storage/api \ - NODE_ENV=production - -# Install OS dependencies (needed to compile sharp) -RUN apk add git python make g++ - -# Create storage structure -RUN mkdir -p /storage/static && mkdir /storage/api && mkdir -p /backend/src - -# Creat and set workdir -WORKDIR /backend - -# Add user to run the app -RUN addgroup -S freesewing \ - && adduser -S freesewing -G freesewing \ - && chown -R freesewing . - -# Copy source -COPY ./package.json . -COPY ./package-lock.json* . -COPY ./src ./src/ - -# Install Node.js dependencies (will also compile sharp) -RUN npm install && npm install -g backpack-core - -# Build code -RUN backpack build - -# Drop privleges and run app -USER freesewing -CMD pm2-runtime /backend/build/main.js diff --git a/sites/backend/LICENSE b/sites/backend/LICENSE deleted file mode 100644 index 7c49b11ae2d..00000000000 --- a/sites/backend/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2019 Joost De Cock - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/sites/backend/README.md b/sites/backend/README.md index f6cde96df92..3edf11f2e2d 100644 --- a/sites/backend/README.md +++ b/sites/backend/README.md @@ -1,110 +1,6 @@ -![FreeSewing: A JavaScript library for made-to-measure sewing patterns](https://en.freesewing.org/banner.jpg) +## FreeSewing backend -# FreeSewing / backend +This is a work in process to port the v2 backend to a new v3 backend. -This is the backend for [FreeSewing.org](https://freesewing.org/), our maker site. - -Our backend is a REST API built with [Express](https://expressjs.com/), -using [MongoDB](https://www.mongodb.com/) as our database. - -This API is required if you want to use your own instance -of [freesewing.org](https://github.com/freesewing/backend), -in which case you have two ways to do so: - -## Run with docker - -### Using docker-compose - -You can use [docker-compose](https://docs.docker.com/compose/) to spin up both the backend -API and a mongo instance. Clone this repository, create a `.env` file (See [Configuration](#configuration)), and then run: - -``` -docker-compose up -``` - -Your backend will now be available at http://localhost:3000 - -### Using our docker image and your own database - -If you just want the backend and provide your own mongo instance, -you can run [our docker image](https://hub.docker.com/r/freesewing/backend) directly -from the internet: - -``` -docker run --env-file .env --name fs_backend -d -p 3000:3000 freesewing/backend -``` - -Your backend will now be available at http://localhost:3000 - -## Run from source - -To run the backend from source, you'll need to clone this repository -and intall dependencies. - -``` -git clone git@github.com:freesewing/backend -cd backend -npm install -npm install --global backpack-core -``` - -> Note that we're installing [backpack-core](https://www.npmjs.com/package/backpack-core) globally for ease-of-use - -While developing, you can run: - -``` -npm run develop -``` - -And backpack will compile the backend, and spin it up. -It will also watch for changes and re-compile every time. Handy! - -If you want to run this in production, you should build the code: - -``` -npm run build -``` - -Then use something like [PM2](http://pm2.keymetrics.io/) to run it and keep it running. - -## Configuration - -This backend can be configured with environment variables. They are detailed below. - -> **Note:** -> -> If you're using docker (or docker-compose) you can use an environment file (See [example.env](example.env)). -> -> If you're running from source, you need to set these manually, or via a script. - - -| Variable | Example | Description | -|---------------|-------------|-----------------| -| `FS_BACKEND` | `http://localhost:3000` | URL on which the backend is hosted | -| `FS_SITE` | `http://localhost:8000` | URL on which the frontend is hosted | -| `FS_MONGO_URI` | `mongodb://mongo/freesewing` | URL for the Mongo database | -| `FS_ENC_KEY` | `someLongAndComplexString` | Secret used for encryption of data at rest | -| `FS_JWT_ISSUER` | `freesewing.org` | The JSON Web Token issuer | -| `FS_SMTP_HOST` | `smtp.google.com` | SMTP relay through which to send outgoing emails | -| `FS_SMTP_USER` | `your.username@gmail.com` | SMTP relay username| -| `FS_SMTP_PASS` | `yourPasswordHere` | SMTP relay password| -| `FS_GITHUB_CLIENT_ID` | `clientIdForOathViaGithub` | Github client ID for signup/login via GitHub | -| `FS_GITHUB_CLIENT_SECRET` | `clientSecretForOathViaGithub` | Github client ID for signup/login via GitHub | -| `FS_GOOGLE_CLIENT_ID` | `clientIdForOathViaGoogle` | Google client ID for signup/login via Google | -| `FS_GOOGLE_CLIENT_SECRET` | `clientSecretForOathViaGoogle` | Google client ID for signup/login via Google | - - -## Links - - - - 💻 Maker site: [freesewing.org](https://freesewing.org) - - 👩‍💻 Developer site: [freesewing.dev](https://freesewing.dev) - - 💬 Chat/Support: [Gitter](https://gitter.im/freesewing/freesewing) - - 🐦 Twitter: [@freesewing_org](https://twitter.com/freesewing_org) - - 📷 Instagram: [@freesewing_org](https://instagram.com/freesewing_org) - -## License - -Copyright (c) 2019 Joost De Cock - Available under the MIT license. - -See the LICENSE file for more details. +It will be based on Express using Prisma with a SQLite database. +Watch this space. diff --git a/sites/backend/dev.sqlite b/sites/backend/dev.sqlite new file mode 100644 index 00000000000..5c194ad230b Binary files /dev/null and b/sites/backend/dev.sqlite differ diff --git a/sites/backend/docker-compose.yml b/sites/backend/docker-compose.yml deleted file mode 100644 index 225ca87addf..00000000000 --- a/sites/backend/docker-compose.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: '3' - -services: - fs_backend: - image: freesewing/backend:beta - env_file: - - .env - ports: - - "3000:3000" - depends_on: - - mongo - networks: - - public - - private - volumes: - - fs-backend-static:/storage/static - - fs-backend-api:/storage/api - - fs-backend-mongo:/data/db - dns_search: . - -### External Services - - mongo: - image: mongo:3.4 - env_file: - - .env - networks: - - private - volumes: - - fs-backend-mongo:/data/db - dns_search: . - -volumes: - fs-backend-static: - fs-backend-api: - fs-backend-mongo: - -networks: - public: - driver: bridge - private: - driver: bridge diff --git a/sites/backend/example.env b/sites/backend/example.env deleted file mode 100644 index 2ace3f400e7..00000000000 --- a/sites/backend/example.env +++ /dev/null @@ -1,35 +0,0 @@ -# Where to find things -FS_BACKEND=http://localhost:3000 -FS_STATIC=https://static.freesewing.org -FS_STORAGE=/tmp/backendstorage - -# Database -FS_MONGO_URI=mongodb://mongo:27017/freesewing - -# Secret to encrypt data in mongo -FS_ENC_KEY=longRandomStringHere - -# Strapi -FS_STRAPI_HOST=posts.freesewing.org -FS_STRAPI_PROTOCOL=https -FS_STRAPI_PORT=443 -FS_STRAPI_USERNAME=REPLACEME -FS_STRAPI_PASSWORD=REPLACEME -FS_STRAPI_TMP=/fs/ramdisk - -# SMTP (email) -FS_SMTP_USER=smtpRelayUsername -FS_SMTP_PASS=smtpRelayPassword -FS_SMTP_HOST=smtp.relay.somedomain.com - -# Github -FS_GITHUB_TOKEN=githubTokenHere - -# Oauth -FS_GITHUB_CLIENT_ID=githubClientIdHere -FS_GITHUB_CLIENT_SECRET=githubClientSecretHere -FS_GOOGLE_CLIENT_ID=googleClientIdHere -FS_GOOGLE_CLIENT_SECRET=googleClientSecretHere - -# The JSON Web Token issuer -FS_JWT_ISSUER=freesewing.org diff --git a/sites/backend/nodemon.json b/sites/backend/nodemon.json new file mode 100644 index 00000000000..04f0edbc23c --- /dev/null +++ b/sites/backend/nodemon.json @@ -0,0 +1,4 @@ +{ + "verbose": true, + "ignore": ["tests/**.test.mjs"] +} diff --git a/sites/backend/package.json b/sites/backend/package.json index a5bef444454..73c2171fd14 100644 --- a/sites/backend/package.json +++ b/sites/backend/package.json @@ -1,94 +1,24 @@ { - "name": "@freesewing/backend", - "version": "3.0.0-alpha.2", - "description": "The freesewing.org backend", - "private": true, - "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": "SEND_TEST_EMAILS=0 ./node_modules/.bin/mocha tests/index.js", - "testall": "SEND_TEST_EMAILS=1 ./node_modules/.bin/mocha tests/index.js", - "testci_IGNORE": "babel-node --presets '@babel/preset-env' scripts/testci.js", - "clean": "rimraf dist", - "prettier": "npx prettier --write 'src/**' 'tests/**'", - "develop": "backpack", - "build": "backpack build", - "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/freesewing.git" - }, - "author": "Joost De Cock", + "name": "backend.freesewing.org", + "version": "0.0.1", "license": "MIT", - "bugs": { - "url": "https://github.com/freesewing/freesewing/issues" - }, - "homepage": "https://github.com/freesewing/freesewing#readme", - "prettier": "@freesewing/prettier-config", - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, - "lint-staged": { - "*.{js,json}": [ - "prettier --write", - "git add" - ] + "scripts": { + "dev": "nodemon src/index.mjs", + "test": "npx mocha tests/*.test.mjs", + "initdb": "npx prisma db push", + "newdb": "node ./scripts/newdb.mjs", + "rmdb": "node ./scripts/rmdb.mjs" }, "dependencies": { - "@freesewing/i18n": "^3.0.0-alpha.2", - "axios": "1.1.3", - "body-parser": "1.20.1", - "chai": "^4.3.4", - "chai-http": "^4.3.0", - "chalk": "2.4.1", - "command-line-args": "^5.1.1", - "cors": "2.8.5", - "data-uri-to-buffer": "^3.0.1", - "data-uri-to-file": "^0.1.8", - "dateformat": "4.6.3", - "express": "4.18.2", - "form-data": "^4.0.0", - "formidable": "2.0.1", - "jsonwebtoken": "8.5.1", - "jszip": "3.10.1", - "mdast-util-to-string": "2", - "mocha": "^10.1.0", - "mongodb-memory-server": "^8.3.0", - "mongoose": "^6.1.8", - "mongoose-bcrypt": "^1.8.1", - "mongoose-encryption": "^2.1.0", - "nodemailer": "6.8.0", - "passport": "0.6.0", - "passport-jwt": "4.0.0", - "query-string": "7.1.1", - "remark": "14", - "remark-frontmatter": "3", - "remark-parse": "^9.0.0", - "remark-plain-text": "^0.2.0", - "rimraf": "2.6.2", - "sharp": "^0.31.0", - "tlds": "^1.221.1", - "yaml": "^2.1.1" + "@prisma/client": "4.5.0", + "crypto": "^1.0.1", + "express": "4.18.2" }, "devDependencies": { - "backpack-core": "0.8.4", - "prettier": "^2.3.1" + "mocha": "^10.1.0", + "prisma": "4.5.0" + }, + "prisma": { + "seed": "node prisma/seed.mjs" } } diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma new file mode 100644 index 00000000000..e49b1035118 --- /dev/null +++ b/sites/backend/prisma/schema.prisma @@ -0,0 +1,81 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("API_DB_URL") +} + +model Confirmation { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + data String + type Int + updatedAt DateTime @updatedAt +} + +model NewsletterSubscriber { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + data String + ehash String @unique + email String + updatedAt DateTime @updatedAt +} + +model User { + id Int @id @default(autoincrement()) + bio String @default("") + consent Int @default(0) + data String @default("{}") + ehash String @unique + email String + ihash String + initial String + lastlogin DateTime + newsletter Boolean @default(false) + password String + patron Int @default(0) + people Person[] + patterns Pattern[] + picture String + role String @default("user") + status Int @default(0) + units Boolean @default(false) + username String @unique + + @@index([ihash]) +} + +model Pattern { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + data String? + design String? + name String + notes String? + person Person? @relation(fields: [personId], references: [id]) + personId Int? + user User @relation(fields: [userId], references: [id]) + userId Int + updatedAt DateTime @updatedAt + + @@index([userId, personId]) +} + +model Person { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + data String? + notes String? + user User @relation(fields: [userId], references: [id]) + userId Int + name String + picture String + units Boolean @default(false) + measies String @default("{}") + Pattern Pattern[] + + @@index([userId]) +} diff --git a/sites/backend/prisma/schema.sqlite b/sites/backend/prisma/schema.sqlite new file mode 100644 index 00000000000..c710b8099cd Binary files /dev/null and b/sites/backend/prisma/schema.sqlite differ diff --git a/sites/backend/prisma/seed.mjs b/sites/backend/prisma/seed.mjs new file mode 100644 index 00000000000..8814b41db0f --- /dev/null +++ b/sites/backend/prisma/seed.mjs @@ -0,0 +1,27 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +const userData = [ +] + +async function main() { + console.log(`Start seeding ...`) + for (const u of userData) { + const user = await prisma.user.create({ + data: u, + }) + console.log(`Created user with id: ${user.id}`) + } + console.log(`Seeding finished.`) +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/sites/backend/public/avatar.svg b/sites/backend/public/avatar.svg new file mode 100644 index 00000000000..edbe95b3167 --- /dev/null +++ b/sites/backend/public/avatar.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/sites/backend/public/css/style.css b/sites/backend/public/css/style.css new file mode 100644 index 00000000000..1b43ad808ea --- /dev/null +++ b/sites/backend/public/css/style.css @@ -0,0 +1,44 @@ +body { + margin: 0; + padding: 0; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif; + color: #fafafa; + line-height: 1.25; + font-size: 24px; + background: #171717; +} +div.wrapper { + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + height: calc(100vh - 8rem); + padding: 4rem 2rem; + text-align: center; +} +div.msg { + max-width: 60ch; +} +h1 { + font-size: 3rem; + font-weight: 700; + margin: 0 0 2rem; +} +h2 { + font-size: 2rem; + font-weight: 700; + margin: 0 0 1rem; + line-height: 1.25; +} +p { + line-height: 1.5; +} +img { + max-width: 166px; +} +a, +a:visited, +a:active { + color: #d0bfff !important; + text-decoration: none; +} diff --git a/sites/backend/run-mongo.sh b/sites/backend/run-mongo.sh deleted file mode 100755 index 23c093393bd..00000000000 --- a/sites/backend/run-mongo.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker run -d --name mongo -p 27017:27017 mongo diff --git a/sites/backend/scripts/newdb.mjs b/sites/backend/scripts/newdb.mjs new file mode 100644 index 00000000000..2e453d8e90d --- /dev/null +++ b/sites/backend/scripts/newdb.mjs @@ -0,0 +1,34 @@ +import path from 'path' +import fs from 'fs' +import chalk from 'chalk' +import { banner } from '../../../scripts/banner.mjs' +import dotenv from 'dotenv' +dotenv.config() + +const newDb = () => { + // Say hi + console.log(banner + '\n') + const db = process.env.API_DB_URL.slice(6) + console.log(db) + const schema = path.resolve('./prisma/schema.sqlite') + try { + if (fs.existsSync(db)) { + console.log(` ⛔ Database detected - Not proceeding`) + console.log(` If you want to create a new database, remove this file: ${chalk.cyan(db)}`) + } + else { + console.log(` 🚨 Going to create a database at ${chalk.cyan(db)}`) + fs.copyFile(schema, db, err => { + if (err) console.log(` ⚠️ ${chalk.red(err)}: Unable to create database file`, err) + else { + console.log(` ${chalk.green('Database created')}`) + } + }) + } + } catch(err) { + console.log(` ERROR: Unable to detect database file at ${db}`, err) + } +} + +newDb() + diff --git a/sites/backend/scripts/prebuild.mjs b/sites/backend/scripts/prebuild.mjs new file mode 100644 index 00000000000..7d875def645 --- /dev/null +++ b/sites/backend/scripts/prebuild.mjs @@ -0,0 +1,29 @@ +import yaml from 'js-yaml' +import i18nConfig from '../next-i18next.config.js' +import fs from 'fs' +import path from 'path' + +// This will load YAML translation files and store them as JSON +const generateTranslationFiles = async () => { + const promises = [] + for (const locale of i18nConfig.i18n.locales) { + for (const namespace of i18nConfig.namespaces) { + const content = yaml.load( + fs.readFileSync(path.resolve(path.join('locales', locale, namespace + '.yaml')), 'utf-8') + ) + console.log(`Generating ${locale}/${namespace}.json translation file`) + fs.writeFileSync( + path.resolve(path.join('public', 'locales', locale, namespace + '.json')), + JSON.stringify(content) + ) + } + } +} + +// Wrapper method +const prebuild = async () => { + await generateTranslationFiles() +} + +// Get to work +prebuild() diff --git a/sites/backend/scripts/resetrootpassword.mjs b/sites/backend/scripts/resetrootpassword.mjs new file mode 100644 index 00000000000..87236ea1def --- /dev/null +++ b/sites/backend/scripts/resetrootpassword.mjs @@ -0,0 +1,43 @@ +import config from '../vahi.config.mjs' +import sqlite3 from 'sqlite3' +import path from 'path' +import { generatePassword } from '../api/utils.mjs' +import { vascii } from '../api/utils.mjs' +import kleur from 'kleur' + +const reset = () => { + // Connect to database + const db = new sqlite3.Database(path.resolve(config.db.path)) + console.log(` + ${kleur.green(vascii)} + 🔓 Resetting root admin password... + `) + // Check if database exists + let exists = false + db.exec('SELECT * FROM Admin;', err => { + if (err) console.log(` + VaHI: WARNING - Database does not exist. + You can create it by running: npm run initdb + ( or: yarn initdb ) + `) + else { + // Update admin user + const [pwd, hash, salt] = generatePassword() + db.exec(`UPDATE Admin SET password = "${hash}:${salt}" WHERE email = "${config.root.email}";`, err => { + if (err) console.log(`WARNING: Failed to update root admin user password. The error was:`, err) + else console.log(` + You can now login with the root admin account: + + username: ${kleur.yellow(config.root.email)} + password: ${kleur.yellow(pwd)} + + Please write this password down. + You can restore it by running ${kleur.cyan('npm run resetrootpassword')} + `) + }) + } + }) +} + +reset() + diff --git a/sites/backend/scripts/rmdb.mjs b/sites/backend/scripts/rmdb.mjs new file mode 100644 index 00000000000..e799e20709c --- /dev/null +++ b/sites/backend/scripts/rmdb.mjs @@ -0,0 +1,47 @@ +import path from 'path' +import fs from 'fs' +import prompts from 'prompts' +import chalk from 'chalk' +import { banner } from '../../../scripts/banner.mjs' +import dotenv from 'dotenv' +dotenv.config() + + +const rmdb = async () => { + // Say hi + console.log(banner + '\n') + + console.log(` + 🚨 This will ${chalk.yellow('remove your database')} + ⚠️ There is ${chalk.bold('no way back')} from this - proceed with caution + `) + + const answer = await prompts([{ + type: 'confirm', + name: 'confirms', + message: 'Are you sure you want to completely remove your FreeSewing database?', + initial: false + }]) + + if (answer.confirms) { + console.log() + // Nuke it from orbit + const db = process.env.API_DB_URL.slice(6) + fs.access(db, fs.constants.W_OK, err => { + if (err) console.log(` ⛔ Cannot remove ${chalk.green(db)} 🤔`) + else { + fs.unlinkSync(db) + console.log(` 🔥 Removed ${chalk.red(db)} 😬`) + } + console.log() + }) + } else { + console.log() + console.log(chalk.green(' 😅 Not removing database - Phew')) + console.log() + } +} + +rmdb() + + diff --git a/sites/backend/scripts/secret.mjs b/sites/backend/scripts/secret.mjs new file mode 100644 index 00000000000..9002812f7a5 --- /dev/null +++ b/sites/backend/scripts/secret.mjs @@ -0,0 +1,25 @@ +import { randomString } from '../api/utils.mjs' +import kleur from 'kleur' +import { vascii } from '../api/utils.mjs' + +const genKey = async () => { + const secret = randomString(32) + console.log(` + ${kleur.green(vascii)} + 🔐 Here's a random secret to encrypt the JSON Web Tokens: + + ${kleur.cyan(secret)} + + + To use this secret, you can set ${kleur.yellow('config.jwt.secret')} explicitly in ${kleur.green('vahi.config.mjs')} + Or set the ${kleur.yellow('VAHI_SECRET')} environment variable in a ${kleur.green('.env')} file like this: + + ${kleur.yellow('VAHI_SECRET="'+secret+'"')} + + 🗒️ Check the documentation for more details + `) + + return +} + +genKey() diff --git a/sites/backend/scripts/testci.js b/sites/backend/scripts/testci.js deleted file mode 100644 index 582e8ef06dc..00000000000 --- a/sites/backend/scripts/testci.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Starts an in-memory database and a server before running tests. - */ - -import '../tests/env.js'; -import mongoose from 'mongoose'; -import { MongoMemoryServer } from 'mongodb-memory-server'; -import { loadSampleData, runTasks } from '../src/cli/lib'; -import { startApp } from '../src/app'; -import { spawn } from 'child_process'; - -(async () => { - return MongoMemoryServer.create({ instance: { port: 27017 } }); -})() - .then((mongoServer) => { - mongoose.connect(mongoServer.getUri() + "freesewing", { useNewUrlParser: true }); - }) - .then(() => { runTasks({ reboot: true }) }) - .then(loadSampleData) - .then(startApp) - .then(() => { - // Forward command-line args to test process. - const args = ['run', 'test', '--', '--reporter', '../../tests/reporters/terse.js'].concat(process.argv.slice(2)); - spawn('npm', args, { stdio: 'inherit' }) - .on('exit', function(code) { - // Propagate exit code so that test failures are recognized. - process.exit(code); - }); - }); - diff --git a/sites/backend/src/app.js b/sites/backend/src/app.js deleted file mode 100644 index c852c7a691c..00000000000 --- a/sites/backend/src/app.js +++ /dev/null @@ -1,59 +0,0 @@ -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' -import fs from 'fs' - -export const connectToDb = () => { - // 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')) - }) - .catch((err) => { - console.log(chalk.red('Could not connect to the database. Exiting now...'), err) - process.exit() - }) -} - -export const startApp = () => { - // Verify configuration - verifyConfig(config, chalk) - - // Start Express - const app = express() - - // Load Express middleware - for (let type of Object.keys(expressMiddleware)) expressMiddleware[type](app) - - // Load Passport middleware - for (let type of Object.keys(passportMiddleware)) passportMiddleware[type](passport) - - // Load routes - for (let type of Object.keys(routes)) routes[type](app, passport) - - // Catch-all route (Load index.html once instead of at every request) - const index = fs.readFileSync(path.resolve(__dirname, 'landing', 'index.html')) - app.get('/', async (req, res) => res.set('Content-Type', 'text/html').status(200).send(index)) - - const port = process.env.PORT || 3000 - - app.listen(port, (err) => { - if (err) console.error(chalk.red('Error occured'), err) - if (process.env.NODE_ENV === 'development') console.log(chalk.yellow('> in development')) - console.log(chalk.green(`> listening on port ${port}`)) - }) - - return app -} diff --git a/sites/backend/src/cli/data.js b/sites/backend/src/cli/data.js deleted file mode 100644 index b0aa71e102b..00000000000 --- a/sites/backend/src/cli/data.js +++ /dev/null @@ -1,120 +0,0 @@ -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/sites/backend/src/cli/index.js b/sites/backend/src/cli/index.js deleted file mode 100644 index b5eceedbc50..00000000000 --- a/sites/backend/src/cli/index.js +++ /dev/null @@ -1,43 +0,0 @@ -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/sites/backend/src/cli/lib.js b/sites/backend/src/cli/lib.js deleted file mode 100644 index 67d21da982d..00000000000 --- a/sites/backend/src/cli/lib.js +++ /dev/null @@ -1,101 +0,0 @@ -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/sites/backend/src/cli/options.js b/sites/backend/src/cli/options.js deleted file mode 100644 index 8e5bbcde0b7..00000000000 --- a/sites/backend/src/cli/options.js +++ /dev/null @@ -1,34 +0,0 @@ -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/sites/backend/src/config.mjs b/sites/backend/src/config.mjs new file mode 100644 index 00000000000..464a30d18aa --- /dev/null +++ b/sites/backend/src/config.mjs @@ -0,0 +1,181 @@ +import chalk from 'chalk' +// Load environment variables +import dotenv from 'dotenv' +dotenv.config() + +const port = process.env.API_PORT || 3000 +// Construct config object +const config = { + port, + api: process.env.API_URL || `http://localhost:${port}`, + website: { + domain: process.env.API_WEBSITE_DOMAIN || 'freesewing.org', + scheme: process.env.API_WEBSITE_SCHEME || 'https', + }, + db: { + url: process.env.API_DB_URL, + }, + static: process.env.API_STATIC, + storage: process.env.API_STORAGE, + hashing: { + saltRounds: 10, + }, + encryption: { + key: process.env.API_ENC_KEY, + }, + jwt: { + secretOrKey: process.env.API_ENC_KEY, + issuer: process.env.API_JWT_ISSUER, + audience: process.env.API_JWT_ISSUER, + expiresIn: process.env.API_JWT_EXPIRY || '36 days', + }, + languages: ['en', 'de', 'es', 'fr', 'nl'], + ses: { + }, + oauth: { + github: { + clientId: process.env.API_GITHUB_CLIENT_ID, + clientSecret: process.env.API_GITHUB_CLIENT_SECRET, + 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.API_GOOGLE_CLIENT_ID, + clientSecret: process.env.API_GOOGLE_CLIENT_SECRET, + tokenUri: 'https://oauth2.googleapis.com/token', + dataUri: + 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos', + }, + }, + github: { + token: process.env.API_GITHUB_TOKEN, + api: 'https://api.github.com', + bot: { + user: process.env.API_GITHUB_USER || 'freesewing-robot', + name: process.env.API_GITHUB_USER_NAME || 'Freesewing bot', + email: process.env.API_GITHUB_USER_EMAIL || 'bot@freesewing.org', + }, + notify: { + specific: { + albert: ['woutervdub'], + bee: ['bobgeorgethe3rd'], + benjamin: ['woutervdub'], + cornelius: ['woutervdub'], + diana: ['alfalyr'], + holmes: ['alfalyr'], + hortensia: ['woutervdub'], + lunetius: ['starfetch'], + penelope: ['woutervdub'], + tiberius: ['starfetch'], + sandy: ['alfalyr'], + ursula: ['nataliasayang'], + yuri: ['biou', 'hellgy'], + walburga: ['starfetch'], + waralee: ['woutervdub'], + }, + dflt: [process.env.API_GITHUB_NOTIFY_DEFAULT_USER || 'joostdecock'], + }, + }, +} + +/* + * This method is how you load the config. + * + * It will verify whether whether everyting is setup correctly + * which is not a given since there's a number of environment + * variables that need to be set for this backend to function. + */ +export function verifyConfig() { + const nonEmptyString = (input) => { + if (typeof input === 'string' && input.length > 0) return true + return false + } + const warnings = [] + const errors = [] + + /* + * Required (error when missing) + */ + + // Database + if (!nonEmptyString(config.db.url)) errors.push({ e: 'API_DB_URL', i: './dev.sqlite' }) + + // Encryption + //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' }) + + // 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.user)) warnings.push({ e: 'FS_SMTP_USER', 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' }) + + //if (!nonEmptyString(config.oauth.github.clientSecret)) + // warnings.push({ e: 'FS_GITHUB_CLIENT_SECRET', i: 'oauth' }) + + //if (!nonEmptyString(config.oauth.google.clientId)) + // warnings.push({ e: 'FS_GOOGLE_CLIENT_ID', i: 'oauth' }) + + //if (!nonEmptyString(config.oauth.google.clientSecret)) + // warnings.push({ e: 'FS_GOOGLE_CLIENT_SECRET', i: 'oauth' }) + + for (let { e, i } of warnings) { + console.log( + 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' + ) + } + + for (let { e, i } of errors) { + console.log( + 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' + ) + } + + if (errors.length > 0) { + console.log(chalk.redBright('Invalid configuration. Stopping here...')) + return process.exit(1) + } + + if (process.env.API_DUMP_CONFIG_AT_STARTUP) { + console.log("Dumping configuration:", JSON.stringify({ + ...config, + encryption: { + ...config.encryption, + key: config.encryption.key.slice(0, 4) + '[ REDACTED ]' + config.encryption.key.slice(-4), + }, + jwt: { + secretOrKey: config.jwt.secretOrKey.slice(0, 4) + '[ REDACTED ]' + config.jwt.secretOrKey.slice(-4), + } + }, null, 2)) + } + + return config +} diff --git a/sites/backend/src/config/index.js b/sites/backend/src/config/index.js deleted file mode 100644 index 065e47038cc..00000000000 --- a/sites/backend/src/config/index.js +++ /dev/null @@ -1,108 +0,0 @@ -// Load environment variables -import dotenv from 'dotenv' -dotenv.config() - -// Construct config object -const config = { - api: process.env.FS_BACKEND, - website: { - domain: 'freesewing.org', - scheme: 'https', - }, - static: process.env.FS_STATIC, - storage: process.env.FS_STORAGE, - avatar: { - sizes: { - l: 1000, - m: 500, - s: 250, - xs: 100, - }, - }, - db: { - 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 - }, - jwt: { - secretOrKey: process.env.FS_ENC_KEY, - issuer: process.env.FS_JWT_ISSUER, - audience: process.env.FS_JWT_ISSUER, - expiresIn: '36 days', - }, - languages: ['en', 'de', 'es', 'fr', 'nl'], - smtp: { - host: process.env.FS_SMTP_HOST, - user: process.env.FS_SMTP_USER, - 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', - 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', - }, - }, - 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: { - albert: ['woutervdub'], - bee: ['bobgeorgethe3rd'], - benjamin: ['woutervdub'], - cornelius: ['woutervdub'], - diana: ['alfalyr'], - holmes: ['alfalyr'], - hortensia: ['woutervdub'], - lunetius: ['starfetch'], - penelope: ['woutervdub'], - tiberius: ['starfetch'], - sandy: ['alfalyr'], - ursula: ['nataliasayang'], - yuri: ['biou', 'hellgy'], - walburga: ['starfetch'], - waralee: ['woutervdub'], - }, - dflt: ['joostdecock'], - }, - }, - 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, - }, - og: { - template: ['..', '..', 'artwork', 'og', 'template.svg'], - chars: { - title_1: 18, - title_2: 19, - title_3: 20, - intro: 34, - sub: 42, - }, - }, -} - -export default config diff --git a/sites/backend/src/config/verify.js b/sites/backend/src/config/verify.js deleted file mode 100644 index 2195a988483..00000000000 --- a/sites/backend/src/config/verify.js +++ /dev/null @@ -1,79 +0,0 @@ -const verifyConfig = (config, chalk) => { - const nonEmptyString = (input) => { - if (typeof input === 'string' && input.length > 0) return true - return false - } - const warnings = [] - const errors = [] - - // Required (error when missing) - // - // Database - 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' }) - - // Wanted (warning when missing) - // - // API - if (!nonEmptyString(config.api)) warnings.push({ e: 'FS_BACKEND', i: 'api' }) - - // 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.user)) warnings.push({ e: 'FS_SMTP_USER', 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' }) - - if (!nonEmptyString(config.oauth.github.clientSecret)) - warnings.push({ e: 'FS_GITHUB_CLIENT_SECRET', i: 'oauth' }) - - if (!nonEmptyString(config.oauth.google.clientId)) - warnings.push({ e: 'FS_GOOGLE_CLIENT_ID', i: 'oauth' }) - - if (!nonEmptyString(config.oauth.google.clientSecret)) - warnings.push({ e: 'FS_GOOGLE_CLIENT_SECRET', i: 'oauth' }) - - for (let { e, i } of warnings) { - console.log( - 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' - ) - } - - for (let { e, i } of errors) { - console.log( - 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' - ) - } - - if (errors.length > 0) { - console.log(chalk.redBright('Invalid configuration. Stopping here...')) - return process.exit(1) - } - - return true -} - -export default verifyConfig diff --git a/sites/backend/src/controllers/user.js b/sites/backend/src/controllers/user.mjs similarity index 90% rename from sites/backend/src/controllers/user.js rename to sites/backend/src/controllers/user.mjs index be4de55816d..becc7765ffa 100644 --- a/sites/backend/src/controllers/user.js +++ b/sites/backend/src/controllers/user.mjs @@ -1,15 +1,126 @@ -import { User, Confirmation, Person, Pattern } from '../models' -import { log, email, ehash, newHandle, uniqueHandle } from '../utils' +//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 path from 'path' +//import fs from 'fs' import Zip from 'jszip' -import rimraf from 'rimraf' +//import rimraf from 'rimraf' +import { ehash } from '../utils/crypto.mjs' -function UserController() {} +export function UserController() {} -UserController.prototype.login = function (req, res) { +// Signup +UserController.prototype.signup = async (req, res, tools) => { + 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') + + // Requests looks ok - does the user exist? + const ehash = ehash(req.body.email) + + // Destructure what we need from tools + const { prisma, config, encrypt } = tools + if ((await prisma.user.findUnique({ where: { ehash } }))) return res.status(400).send('emailExists') + + // It does not. Creating user entry + /* + const user = await.prisma.user.create({ + ehash, + email + + const handle = uniqueHandle() + let handle = uniqueHandle() + let username = 'temporary-username-' + time() + req.body.email, + 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(), + }, + }) + ehash String @unique + email String + handle String @unique + ihash String + initial String + lastlogin DateTime + newsletter Boolean @default(false) + password String + patron Int @default(0) + people Person[] + patterns Pattern[] + picture String + role String @default("user") + status Int @default(0) + units Boolean @default(false) + username String @unique +*/ + return res.status(200).send(exists) + /* + (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) + }) + }) + } + } + ) + */ +} + +/* + + + + + +UserController.prototype.login = function (req, res, prisma, config) { if (!req.body || !req.body.username) return res.sendStatus(400) User.findOne( { @@ -256,65 +367,6 @@ UserController.prototype.isUsernameAvailable = (req, res) => { }) } -// // Signup flow -UserController.prototype.signup = (req, res) => { - 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) @@ -476,7 +528,7 @@ const loadAvatar = async (user) => { else return false } -/** restrict processing of data, aka freeze account */ +// restrict processing of data, aka freeze account UserController.prototype.restrict = (req, res) => { if (!req.user._id) return res.sendStatus(400) User.findById(req.user._id, (err, user) => { @@ -492,7 +544,7 @@ UserController.prototype.restrict = (req, res) => { }) } -/** Remove account */ +// Remove account UserController.prototype.remove = (req, res) => { if (!req.user._id) return res.sendStatus(400) User.findById(req.user._id, (err, user) => { @@ -540,4 +592,4 @@ const createTempDir = () => { const uri = (path) => config.static + path.substring(config.storage.length) -export default UserController +*/ diff --git a/sites/backend/src/env.js b/sites/backend/src/env.js deleted file mode 100644 index ca5ac26a577..00000000000 --- a/sites/backend/src/env.js +++ /dev/null @@ -1,2 +0,0 @@ -import * as dotenv from 'dotenv' -dotenv.config() diff --git a/sites/backend/src/index.js b/sites/backend/src/index.js deleted file mode 100644 index 3721ac0eb86..00000000000 --- a/sites/backend/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { connectToDb, startApp } from './app' - -connectToDb() - -const app = startApp() - -export default app diff --git a/sites/backend/src/index.mjs b/sites/backend/src/index.mjs new file mode 100644 index 00000000000..a4a36f73922 --- /dev/null +++ b/sites/backend/src/index.mjs @@ -0,0 +1,46 @@ +// Dependencies +import express from 'express' +import chalk from 'chalk' +import path from 'path' +import fs from 'fs' +import { PrismaClient } from '@prisma/client' +import passport from 'passport' +// Routes +import { routes } from './routes/index.mjs' +// Config +import { verifyConfig } from './config.mjs' +// Middleware +import { loadExpressMiddleware, loadPassportMiddleware } from './middleware.mjs' +// Encryption +import { ehash, encryption } from './utils/crypto.mjs' + +// Bootstrap +const config = verifyConfig() +const prisma = new PrismaClient() +const app = express() +app.use(express.json()) +app.use(express.static('public')) + +// Load middleware +loadExpressMiddleware(app) +loadPassportMiddleware(passport, config) + +const params = { + app, + passport, + ...encryption(config.encryption.key), + config +} +// Load routes +for (const type in routes) routes[type](params) + +// Catch-all route (Load index.html once instead of at every request) +const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html')) +app.get('/', async (req, res) => res.set('Content-Type', 'text/html').status(200).send(index)) + +// Start listening for requests +app.listen(config.port, (err) => { + if (err) console.error(chalk.red('Error occured'), err) + if (process.env.NODE_ENV === 'development') console.log(chalk.yellow('> in development')) + console.log(chalk.green(`🚀 REST API ready, listening on ${config.api}`)) +}) diff --git a/sites/backend/src/landing/index.html b/sites/backend/src/landing/index.html index a313597a81c..b529eb88e16 100644 --- a/sites/backend/src/landing/index.html +++ b/sites/backend/src/landing/index.html @@ -2,59 +2,27 @@ - You found our backend - - + FreeSewing backend + -
-

Hi

-

- This is the FreeSewing backend. -
- Try freesewing.org instead. -

-

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

+
+ +
+

👋

+

This is the FreeSewing backend

+

+ For info about FreeSewing, try freesewing.org instead. +

+

+ For info about this backend, refer to the FreeSewing backend refefence documentation. +

+

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

+
+
- diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs new file mode 100644 index 00000000000..b392006be21 --- /dev/null +++ b/sites/backend/src/middleware.mjs @@ -0,0 +1,28 @@ +//import bodyParser from 'body-parser' +import cors from 'cors' +import jwt from 'passport-jwt' + +function loadExpressMiddleware(app) { + // FIXME: Is this still needed in FreeSewing v3? + //app.use(bodyParser.urlencoded({ extended: true })) + app.use(cors()) +} + +function loadPassportMiddleware(passport, config) { + passport.use( + new jwt.Strategy( + { + jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), + ...config.jwt, + }, + (jwt_payload, done) => { + return done(null, jwt_payload) + } + ) + ) +} + +export { + loadExpressMiddleware, + loadPassportMiddleware, +} diff --git a/sites/backend/src/middleware/express/bodyParser.js b/sites/backend/src/middleware/express/bodyParser.js deleted file mode 100644 index 8e242ebf56d..00000000000 --- a/sites/backend/src/middleware/express/bodyParser.js +++ /dev/null @@ -1,6 +0,0 @@ -import bodyParser from 'body-parser' - -export default (app) => { - app.use(bodyParser.json({ limit: '20mb' })) - app.use(bodyParser.urlencoded({ extended: true })) -} diff --git a/sites/backend/src/middleware/express/cors.js b/sites/backend/src/middleware/express/cors.js deleted file mode 100644 index 206b0970a24..00000000000 --- a/sites/backend/src/middleware/express/cors.js +++ /dev/null @@ -1,5 +0,0 @@ -import cors from 'cors' - -export default (app) => { - app.use(cors()) -} diff --git a/sites/backend/src/middleware/express/index.js b/sites/backend/src/middleware/express/index.js deleted file mode 100644 index 4051efc9e48..00000000000 --- a/sites/backend/src/middleware/express/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import bodyParser from './bodyParser' -import cors from './cors' - -export default { bodyParser, cors } diff --git a/sites/backend/src/middleware/passport/index.js b/sites/backend/src/middleware/passport/index.js deleted file mode 100644 index cfa6f74b433..00000000000 --- a/sites/backend/src/middleware/passport/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import jwt from './jwt' - -export default { jwt } diff --git a/sites/backend/src/middleware/passport/jwt.js b/sites/backend/src/middleware/passport/jwt.js deleted file mode 100644 index 85af45370f6..00000000000 --- a/sites/backend/src/middleware/passport/jwt.js +++ /dev/null @@ -1,15 +0,0 @@ -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) - }) - ) -} diff --git a/sites/backend/src/models/confirmation.js b/sites/backend/src/models/confirmation.js deleted file mode 100644 index 7be3d196519..00000000000 --- a/sites/backend/src/models/confirmation.js +++ /dev/null @@ -1,27 +0,0 @@ -import mongoose, { Schema } from 'mongoose' -import bcrypt from 'mongoose-bcrypt' -import encrypt from 'mongoose-encryption' -import config from '../config' - -const ConfirmationSchema = new Schema({ - created: { - type: Date, - default: Date.now, - }, - type: { - type: String, - enum: ['signup', 'emailchange', 'passwordreset', 'oauth', 'newsletter'], - required: true, - }, - data: {}, -}) - -ConfirmationSchema.plugin(bcrypt) - -ConfirmationSchema.plugin(encrypt, { - secret: config.encryption.key, - encryptedFields: ['data'], - decryptPostSave: false, -}) - -export default mongoose.model('Confirmation', ConfirmationSchema) diff --git a/sites/backend/src/models/index.js b/sites/backend/src/models/index.js deleted file mode 100644 index e05c9e04e05..00000000000 --- a/sites/backend/src/models/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import ConfirmationModel from './confirmation' -import PersonModel from './person' -import UserModel from './user' -import PatternModel from './pattern' -import NewsletterModel from './newsletter' - -export const Confirmation = ConfirmationModel -export const Person = PersonModel -export const User = UserModel -export const Pattern = PatternModel -export const Newsletter = NewsletterModel diff --git a/sites/backend/src/models/newsletter.js b/sites/backend/src/models/newsletter.js deleted file mode 100644 index b8c08a1937e..00000000000 --- a/sites/backend/src/models/newsletter.js +++ /dev/null @@ -1,34 +0,0 @@ -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/sites/backend/src/models/pattern.js b/sites/backend/src/models/pattern.js deleted file mode 100644 index 22c25377b79..00000000000 --- a/sites/backend/src/models/pattern.js +++ /dev/null @@ -1,62 +0,0 @@ -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/sites/backend/src/models/person.js b/sites/backend/src/models/person.js deleted file mode 100644 index b7732a72b48..00000000000 --- a/sites/backend/src/models/person.js +++ /dev/null @@ -1,171 +0,0 @@ -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/sites/backend/src/models/user.js b/sites/backend/src/models/user.js deleted file mode 100644 index d1c3e4eb551..00000000000 --- a/sites/backend/src/models/user.js +++ /dev/null @@ -1,275 +0,0 @@ -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, - 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, - }, - }, - { timestamps: true } -) - -UserSchema.pre('remove', function (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(encrypt, { - secret: config.encryption.key, - 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 - - account.pictureUris = { - l: this.avatarUri(), - m: this.avatarUri('m'), - s: this.avatarUri('s'), - xs: this.avatarUri('xs'), - } - - 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.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'), - } - - 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 - - return exported -} - -UserSchema.methods.updateLoginTime = function (callback) { - this.set({ time: { login: new Date() } }) - this.save(function (err, user) { - 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, 'users', this.handle.substring(0, 1), this.handle) -} - -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) - ) -} - -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/sites/backend/src/routes/auth.js b/sites/backend/src/routes/auth.js deleted file mode 100644 index 97a8f3ec69e..00000000000 --- a/sites/backend/src/routes/auth.js +++ /dev/null @@ -1,10 +0,0 @@ -import Controller from '../controllers/auth' - -const Auth = new Controller() - -export default (app, passport) => { - // Oauth - app.post('/oauth/init', Auth.initOauth) - app.get('/oauth/callback/from/:provider', Auth.providerCallback) - app.post('/oauth/login', Auth.loginOauth) -} diff --git a/sites/backend/src/routes/index.js b/sites/backend/src/routes/index.js deleted file mode 100644 index 80f8143b278..00000000000 --- a/sites/backend/src/routes/index.js +++ /dev/null @@ -1,11 +0,0 @@ -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' -import og from './og' - -export default { user, pattern, person, auth, github, admin, newsletter, strapi, og } diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs new file mode 100644 index 00000000000..fed859ac6d5 --- /dev/null +++ b/sites/backend/src/routes/index.mjs @@ -0,0 +1,17 @@ +//import pattern from './pattern' +//import person from './person' +import { userRoutes } from './user.mjs' +//import oauth from './auth.mjs' +//import github from './github' +//import admin from './admin' +//import newsletter from './newsletter' + +export const routes = { + userRoutes, +// pattern, +// person, +// oauth, +// github, +// admin, +// newsletter, +} diff --git a/sites/backend/src/routes/oauth.mjs b/sites/backend/src/routes/oauth.mjs new file mode 100644 index 00000000000..6f51b99b7f2 --- /dev/null +++ b/sites/backend/src/routes/oauth.mjs @@ -0,0 +1,10 @@ +import Controller from '../controllers/oauth.mjs' + +const OAuth = new Controller() + +export default (app, passport) => { + // Oauth + app.post('/oauth/init', OAuth.initOAuth) + app.get('/oauth/callback/from/:provider', OAuth.providerCallback) + app.post('/oauth/login', OAuth.loginOAuth) +} diff --git a/sites/backend/src/routes/user.js b/sites/backend/src/routes/user.js deleted file mode 100644 index fa3e310bd97..00000000000 --- a/sites/backend/src/routes/user.js +++ /dev/null @@ -1,34 +0,0 @@ -import Controller from '../controllers/user' - -const User = new Controller() - -export default (app, passport) => { - // 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 - ) - - // 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/sites/backend/src/routes/user.mjs b/sites/backend/src/routes/user.mjs new file mode 100644 index 00000000000..a67f3fd7bd1 --- /dev/null +++ b/sites/backend/src/routes/user.mjs @@ -0,0 +1,106 @@ +import { UserController } from '../controllers/user.mjs' + +const User = new UserController() +const jwt = ['jwt', { session: false }] + + +export function userRoutes(params) { + const { app, passport } = params + // Create account + app.post( + '/account', + (req, res) => User.create(req, res, params) + ) + + // Read account (own data) + app.get( + '/account', + passport.authenticate(...jwt), + (req, res) => User.readAccount(req, res, params) + ) + + // Update account + app.put( + '/account', + passport.authenticate(...jwt), + (req, res) => User.update(req, res, params) + ) + + // Remove account + app.delete( + '/account', + passport.authenticate(...jwt), + (req, res) => User.remove(req, res, params) + ) + + // Export account data + app.get( + '/account/export', + passport.authenticate(...jwt), + (req, res) => User.export(req, res, params) + ) + + // Restrict processing of account data + app.get( + '/account/restrict', + passport.authenticate(...jwt), + (req, res) => User.restrict(req, res, params) + ) + + // Recover account / Reset password + app.post( + '/account/recover', + (req, res) => User.resetPassword(req, res, params) + ) + + // Update email address + app.post( + '/account/change/email', + passport.authenticate(...jwt), + (req, res) => User.confirmChangedEmail(req, res, params) + ) + + // Sign up + app.post( + '/signup', + (req, res) => User.signup(req, res, params) + ) + + // Re-send account confirmation + app.post( + '/resend', + (req, res) => User.resend(req, res, params) + ) + + // Login + app.post( + '/login', + (req, res) => User.login(req, res, params) + ) + + // Account confirmation + app.post( + '/confirm/login', + (req, res) => User.confirmationLogin(req, res, params) + ) + + // Get list of patrons + app.get( + '/patrons', + (req, res) => User.patronList(req, res, params) + ) + + // Read profile (other user's data) + app.get( + '/users/:username', + (req, res) => User.readProfile(req, res, params) + ) + + // Check whether a username is available + app.post( + '/available/username', + passport.authenticate('jwt', { session: false }), + (req, res) => User.isUsernameAvailable(req, res, params) + ) +} + diff --git a/sites/backend/src/templates/avatar.js b/sites/backend/src/templates/avatar.js deleted file mode 100644 index dbbc34ccb88..00000000000 --- a/sites/backend/src/templates/avatar.js +++ /dev/null @@ -1,68 +0,0 @@ -export default ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -` diff --git a/sites/backend/src/templates/emailchange.js b/sites/backend/src/templates/emailchange.js deleted file mode 100644 index afd2cb88d3a..00000000000 --- a/sites/backend/src/templates/emailchange.js +++ /dev/null @@ -1,77 +0,0 @@ -const emailchange = { - i18n: [ - 'email.emailchangeTitle', - 'email.emailchangeCopy1', - 'email.emailchangeActionText', - 'email.questionsJustReply', - 'email.signature', - ], - html: ` - - - -
- - - - -
- - - - -
- - - - - - - - - - - - - - - - - -
__emailchangeTitle__
- __emailchangeCopy1__ -
- - - - -
- - - - -
__emailchangeActionText__
-
-
-
- __questionsJustReply__ -

- __signature__ -
- joost -
- -
-
-
-
`, - text: `__emailchangeTitle__ - -__emailchangeCopy1__ - -__emailchangeActionLink__ - -__questionsJustReply__`, -} - -export default emailchange diff --git a/sites/backend/src/templates/footer.js b/sites/backend/src/templates/footer.js deleted file mode 100644 index 8cb96af50e4..00000000000 --- a/sites/backend/src/templates/footer.js +++ /dev/null @@ -1,35 +0,0 @@ -const footer = { - i18n: [], - html: ` - - - - -
- - - - - - -
-

- __footerWhy__ -

-
- -
- -`, - text: '', -} - -export default footer diff --git a/sites/backend/src/templates/goodbye.js b/sites/backend/src/templates/goodbye.js deleted file mode 100644 index 60ed4f1db3b..00000000000 --- a/sites/backend/src/templates/goodbye.js +++ /dev/null @@ -1,45 +0,0 @@ -const goodbye = { - i18n: ['goodbyeTitle', 'goodbyeCopy1', 'signature'], - html: ` - - - -
- - - - -
- - - - -
- - - - - - - - - - -
__goodbyeTitle__
- __goodbyeCopy1__ -

- __signature__ -
- joost -
- -
-
-
-
`, - text: `__goodbyeTitle__ - -__goodbyeCopy1__`, -} - -export default goodbye diff --git a/sites/backend/src/templates/header.js b/sites/backend/src/templates/header.js deleted file mode 100644 index 2d27b0be6cc..00000000000 --- a/sites/backend/src/templates/header.js +++ /dev/null @@ -1,119 +0,0 @@ -const header = { - html: ` - - -freesewing - - - - - - -
- __hiddenIntro__ -
- - - - - - -
- - - - - - - - -
-

freesewing

-
-

__headerOpeningLine__


-
- -
`, - text: '', -} - -export default header diff --git a/sites/backend/src/templates/index.js b/sites/backend/src/templates/index.js deleted file mode 100644 index 72e80fb64e6..00000000000 --- a/sites/backend/src/templates/index.js +++ /dev/null @@ -1,19 +0,0 @@ -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, - footer, - signup, - emailchange, - passwordreset, - goodbye, - newsletterSubscribe, - newsletterWelcome, -} diff --git a/sites/backend/src/templates/newsletter-subscribe.js b/sites/backend/src/templates/newsletter-subscribe.js deleted file mode 100644 index f4518091d28..00000000000 --- a/sites/backend/src/templates/newsletter-subscribe.js +++ /dev/null @@ -1,62 +0,0 @@ -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/sites/backend/src/templates/newsletter-welcome.js b/sites/backend/src/templates/newsletter-welcome.js deleted file mode 100644 index ce6a3470da4..00000000000 --- a/sites/backend/src/templates/newsletter-welcome.js +++ /dev/null @@ -1,44 +0,0 @@ -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/sites/backend/src/templates/passwordreset.js b/sites/backend/src/templates/passwordreset.js deleted file mode 100644 index 347bff20fa9..00000000000 --- a/sites/backend/src/templates/passwordreset.js +++ /dev/null @@ -1,77 +0,0 @@ -const passwordreset = { - i18n: [ - 'passwordresetTitle', - 'passwordresetCopy1', - 'passwordresetActionText', - 'questionsJustReply', - 'signature', - ], - html: ` - - - -
- - - - -
- - - - -
- - - - - - - - - - - - - - - - - -
__passwordresetTitle__
- __passwordresetCopy1__ -
- - - - -
- - - - -
__passwordresetActionText__
-
-
-
- __questionsJustReply__ -

- __signature__ -
- joost -
- -
-
-
-
`, - text: `__passwordresetTitle__ - -__passwordresetCopy1__ - -__passwordresetActionLink__ - -__questionsJustReply__`, -} - -export default passwordreset diff --git a/sites/backend/src/templates/signup.js b/sites/backend/src/templates/signup.js deleted file mode 100644 index f9d0fc368e9..00000000000 --- a/sites/backend/src/templates/signup.js +++ /dev/null @@ -1,71 +0,0 @@ -const signup = { - i18n: ['signupTitle', 'signupCopy1', 'signupActionText', 'questionsJustReply', 'signature'], - html: ` - - - -
- - - - -
- - - - -
- - - - - - - - - - - - - - - - - -
__signupTitle__
- __signupCopy1__ -
- - - - -
- - - - -
__signupActionText__
-
-
-
- __questionsJustReply__ -

- __signature__ -
- joost -
- -
-
-
-
`, - text: `__signupTitle__ - -__signupCopy1__ - -__signupActionLink__ - -__questionsJustReply__`, -} - -export default signup diff --git a/sites/backend/src/utils/crypto.mjs b/sites/backend/src/utils/crypto.mjs new file mode 100644 index 00000000000..1bda6c03a42 --- /dev/null +++ b/sites/backend/src/utils/crypto.mjs @@ -0,0 +1,101 @@ +import { createHash, createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto' +import dotenv from 'dotenv' +dotenv.config() + +/* + * Cleans a string (typically email) for hashing + */ +export const clean = (email) => { + if (typeof email !== 'string') throw("clean() only takes a string as input") + + return email.toLowerCase().trim() +} + +// Hashes an email address +export const ehash = (email) => createHash('sha256').update(clean(email)).digest('hex') + +/* + * Returns an object holding encrypt() and decrypt() methods + * + * These utility methods keep the crypto code out of the regular codebase + * which makes things easier to read/understand for contributors, as well + * as allowing scrutiny of the implementation in a single file. + */ +export const encryption = (stringKey, salt='FreeSewing') => { + + // Shout-out to the OG crypto bros Joan and Vincent + const algorithm = 'aes-256-cbc' + + // Key and (optional) salt are passed in, prep them for aes-256 + const key = Buffer.from(scryptSync(stringKey, salt, 32)) + + return { + encrypt: (data) => { + + /* + * This will encrypt almost anything, but undefined we cannot encrypt. + * We could side-step this by assigning a default to data, but that would + * lead to confusing bugs when people thing they pass in data and instead + * get an encrypted default. So instead, let's bail out loudly + */ + if (typeof data === 'undefined') throw("Undefined cannot be uncrypted") + + /* + * With undefined out of the way, there's still thing we cannot encrypt. + * Essentially, anything that can't be serialized to JSON, such as functions. + * So let's catch the JSON.stringify() call and once again bail out if things + * go off the rails here. + */ + try { + data = JSON.stringify(data) + } + catch (err) { + throw('Could not parse input to encrypt() call', err) + } + + /* + * Even with the same salt, this initialization vector avoid that two + * identical input strings would generate the same cyphertext + */ + const iv = randomBytes(16) + + /* + * The thing that does the thing + */ + const cipher = createCipheriv(algorithm, key, iv) + + // Always return a string so we can store this in SQLite no problemo + return JSON.stringify({ + iv: iv.toString('hex'), + encrypted: Buffer.concat([cipher.update(data), cipher.final()]).toString('hex'), + }) + }, + decrypt: (data) => { + /* + * Don't blindly assume this data is properly formatted cyphertext + */ + try { + data = JSON.parse(data) + } + catch (err) { + throw('Could not parse encrypted data in decrypt() call', err) + } + if (!data.iv || typeof data.encrypted === 'undefined') { + throw('Encrypted data passed to decrypt() was malformed') + } + + /* + * The thing that does the thing + */ + const decipher = createDecipheriv(algorithm, key, Buffer.from(data.iv, 'hex')) + + // Parse this string as JSON so we return what was passed to encrypt() + return JSON.parse( + Buffer.concat([ + decipher.update(Buffer.from(data.encrypted, 'hex')), + decipher.final() + ]).toString('utf-8') + ) + } + } +} diff --git a/sites/backend/src/utils/index.js b/sites/backend/src/utils/index.js deleted file mode 100644 index 10dc8c35cb8..00000000000 --- a/sites/backend/src/utils/index.js +++ /dev/null @@ -1,140 +0,0 @@ -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 capitalize = (string) => string.charAt(0).toUpperCase() + string.slice(1) - -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 getHash = (email) => { - let hash = crypto.createHash('sha256') - hash.update(clean(email)) - return hash.digest('hex') -} - -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 === 'person') go = Person - else if (type === 'pattern') go = Pattern - else go = User - do { - exists = false - handle = createHandle() - go.findOne({ handle: handle }, (err, result) => { - if (result !== null) exists = true - }) - } while (exists !== false) - - return handle -} - -export const createHandle = (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 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' -} - -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)) { - sharp(imgBuffer) - .resize(config.avatar.sizes[size], config.avatar.sizes[size]) - .toFile(avatarPath(size, handle, type), (err, info) => { - if (err) log.error('avatarNotSaved', err) - }) - } - }) -} - -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/sites/backend/src/utils/index.mjs b/sites/backend/src/utils/index.mjs new file mode 100644 index 00000000000..c7505e1e9ff --- /dev/null +++ b/sites/backend/src/utils/index.mjs @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' +import dotenv from 'dotenv' +dotenv.config() + +// Cleans an email for hashing +export const clean = (email) => email.toLowerCase().trim() + +// Hashes an email address +export const ehash = (email) => createHash('sha256').update(clean(email)).digest('hex') + +// Get key from environment +const secret = createHash('sha256').update(process.env.API_ENC_KEY).digest() diff --git a/sites/backend/tests/user.test.js b/sites/backend/tests/user.test.js deleted file mode 100644 index 7950a91fbb0..00000000000 --- a/sites/backend/tests/user.test.js +++ /dev/null @@ -1,349 +0,0 @@ -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() - }) - }) - }) -} diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs new file mode 100644 index 00000000000..95d4866746d --- /dev/null +++ b/sites/backend/tests/user.test.mjs @@ -0,0 +1,371 @@ +import chai from 'chai' +import http from 'chai-http' +import { verifyConfig } from '../src/config.mjs' + +const config = verifyConfig() +const expect = chai.expect +chai.use(http) + +describe('Non language-specific User controller signup routes', () => { + + it('Should return 400 on signup without body', (done) => { + chai + .request(config.api) + .post('/signup') + .send() + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(400) + done() + }) + }) + + let data = { + email: 'test@freesewing.org', + language: 'en', + password: 'one two one two, this is just a test' + } + Object.keys(data).map(key => { + it(`Should not create signup without ${key}`, (done) => { + chai + .request(config.api) + .post('/signup') + .send(Object.fromEntries(Object.keys(data).filter(name => name !== key).map(name => [name, data[name]]))) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(400) + expect(res.text).to.equal(`${key}Missing`) + done() + }) + }) + }) + + it('should not create signup without password', (done) => { + chai + .request(config.api) + .post('/signup') + .send(data) + .end((err, res) => { + expect(res.status).to.equal(400) + 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() + }) + }) + */ +})