1
0
Fork 0

wip(backend): Started work on v3 backend

This commit is contained in:
joostdecock 2022-10-29 22:21:24 +02:00
parent 94f0ca0e0b
commit 88d9b2a1e9
67 changed files with 1375 additions and 2842 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

BIN
sites/backend/dev.sqlite Normal file

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
{
"verbose": true,
"ignore": ["tests/**.test.mjs"]
}

View file

@ -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"
}
}

View file

@ -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])
}

Binary file not shown.

View file

@ -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)
})

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -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;
}

View file

@ -1,3 +0,0 @@
#!/bin/bash
docker run -d --name mongo -p 27017:27017 mongo

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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);
});
});

View file

@ -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
}

View file

@ -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',
},
],
}

View file

@ -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()
})

View file

@ -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)
}

View file

@ -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,
},
]

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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
*/

View file

@ -1,2 +0,0 @@
import * as dotenv from 'dotenv'
dotenv.config()

View file

@ -1,7 +0,0 @@
import { connectToDb, startApp } from './app'
connectToDb()
const app = startApp()
export default app

View file

@ -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}`))
})

View file

@ -2,59 +2,27 @@
<html>
<head>
<meta charset="utf-8" />
<title>You found our backend</title>
<link
href="https://fonts.googleapis.com/css?family=Raleway:300,500&display=swap"
rel="stylesheet"
/>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: #f8f9fa;
line-height: 1.25;
font-size: 24px;
background: #212529;
}
h1 {
font-family: 'Raleway', sans-serif;
font-size: 3rem;
font-weight: 700;
margin: 0 0 2rem;
}
img {
width: 166px;
position: absolute;
bottom: 20px;
left: calc(50% - 83px);
}
div.msg {
text-align: left;
max-width: 36ch;
margin: 6rem auto;
}
a,
a:visited,
a:active {
color: #d0bfff !important;
text-decoration: none;
}
</style>
<title>FreeSewing backend</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="wrapper">
<span></span>
<div class="msg">
<h1>Hi</h1>
<h1><span role="img">👋</span></h1>
<h2>This is the FreeSewing backend</h2>
<p>
This is the FreeSewing backend.
<br />
Try <a href="https://freesewing.org/">freesewing.org</a> instead.
For info about FreeSewing, try <a href="https://freesewing.org/">freesewing.org</a> instead.
</p>
<p>
For info about this backend, refer to <a href="https://freesewing.dev/reference/backend">the FreeSewing backend refefence documentation</a>.
</p>
<p>
For questions, join us at
<a href="https://discord.freesewing.org/">discord.freesewing.org</a>
</p>
</div>
<img src="https://freesewing.org/avatar.svg" />
<img src="/avatar.svg" />
</div>
</body>
</html>

View file

@ -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,
}

View file

@ -1,6 +0,0 @@
import bodyParser from 'body-parser'
export default (app) => {
app.use(bodyParser.json({ limit: '20mb' }))
app.use(bodyParser.urlencoded({ extended: true }))
}

View file

@ -1,5 +0,0 @@
import cors from 'cors'
export default (app) => {
app.use(cors())
}

View file

@ -1,4 +0,0 @@
import bodyParser from './bodyParser'
import cors from './cors'
export default { bodyParser, cors }

View file

@ -1,3 +0,0 @@
import jwt from './jwt'
export default { jwt }

View file

@ -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)
})
)
}

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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 }

View file

@ -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,
}

View file

@ -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)
}

View file

@ -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
)
}

View file

@ -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)
)
}

File diff suppressed because one or more lines are too long

View file

@ -1,77 +0,0 @@
const emailchange = {
i18n: [
'email.emailchangeTitle',
'email.emailchangeCopy1',
'email.emailchangeActionText',
'email.questionsJustReply',
'email.signature',
],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">__emailchangeTitle__</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
__emailchangeCopy1__
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 30px 30px 30px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#212121"><a href="__emailchangeActionLink__" target="_blank" style="font-size: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #111111; display: inline-block;">__emailchangeActionText__</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="padding: 0 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
<br>
__questionsJustReply__
<br><br>
__signature__
<br>
joost
</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
text: `__emailchangeTitle__
__emailchangeCopy1__
__emailchangeActionLink__
__questionsJustReply__`,
}
export default emailchange

View file

@ -1,35 +0,0 @@
const footer = {
i18n: [],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- FOOTER -->
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 0px 15px 0px 15px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 500px;" >
<!-- PERMISSION REMINDER -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 0 0 30px 0; color: #292B2C; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; font-weight: 300; line-height: 18px;" >
<p style="margin: 0;">
__footerWhy__
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>`,
text: '',
}
export default footer

View file

@ -1,45 +0,0 @@
const goodbye = {
i18n: ['goodbyeTitle', 'goodbyeCopy1', 'signature'],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">__goodbyeTitle__</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
__goodbyeCopy1__
<br><br>
__signature__
<br>
joost
</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
text: `__goodbyeTitle__
__goodbyeCopy1__`,
}
export default goodbye

View file

@ -1,119 +0,0 @@
const header = {
html: `<!DOCTYPE html>
<html lang="__locale__">
<head>
<title>freesewing</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style type="text/css">
#outlook a{padding:0;}
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;}
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
body, table, td, a{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;}
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;}
img{-ms-interpolation-mode:bicubic;}
body{margin:0; padding:0; color: #212121;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
table{border-collapse:collapse !important;}
body{height:100% !important; margin:0; padding:0; width:100% !important;}
a {color:#212121; text-decoration: underline;}
a:hover {color:#212121; text-decoration: underline;}
.appleBody a {color:#212121; text-decoration: underline;}
.appleFooter a {color:#212121; text-decoration: underline;}
@media screen and (max-width: 525px) {
table[class="wrapper"]{
width:100% !important;
}
table[class="responsive-table"]{
width:100%!important;
}
td[class="padding"]{
padding: 10px 5% 15px 5% !important;
}
td[class="padding-copy"]{
padding: 10px 5% 10px 5% !important;
text-align: center;
}
td[class="padding-meta"]{
padding: 30px 5% 0px 5% !important;
text-align: center;
}
td[class="no-pad"]{
padding: 0 0 20px 0 !important;
}
td[class="no-padding"]{
padding: 0 !important;
}
td[class="section-padding"]{
padding: 50px 15px 50px 15px !important;
}
td[class="mobile-wrapper"]{
padding: 10px 5% 15px 5% !important;
}
table[class="mobile-button-container"]{
margin:0 auto;
width:100% !important;
}
a[class="mobile-button"]{
width:80% !important;
padding: 15px !important;
border: 0 !important;
font-size: 16px !important;
}
}
</style>
</head>
<body style="margin: 0; padding: 0;">
<!-- HIDDEN PREHEADER TEXT -->
<div style="display: none; font-size: 1px; color: #ffffff; line-height: 1px; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
__hiddenIntro__
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- HEADER -->
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 0px 15px 0px 15px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 500px;" >
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 30px 0 0 0; color: #212121; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 16px; font-weight: 300; line-height: 18px;" >
<p style="margin: 0; font-weight: 700; color: #111111;">freesewing</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 0; color: #292B2C; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; font-weight: 300; line-height: 18px;" >
<p style="margin: 0;">__headerOpeningLine__<br><hr></p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>`,
text: '',
}
export default header

View file

@ -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,
}

View file

@ -1,62 +0,0 @@
const subscribe = {
i18n: [],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">Confirm your newsletter subscription</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
Somebody asked to subscribe this email address to the FreeSewing newsletter. If it was you, please click below to confirm your subscription:
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 30px 30px 30px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#212121"><a href="__newsletterConfirmationLink__" target="_blank" style="font-size: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #111111; display: inline-block;">Confirm your newsletter subscription</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
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

View file

@ -1,44 +0,0 @@
const welcome = {
i18n: [],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">You are now subscribed to the FreeSewing newsletter</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
If you'd like to catch up, we keep an online archive of previous editions at:
<a href="https://freesewing.org/newsletter/">https://freesewing.org/newsletter/</a>
<br><br>
You can <a href="__newsletterUnsubscribeLink__">unsubscribe</a> at any time by visiting this link: <a href="__newsletterUnsubscribeLink__">__newsletterUnsubscribeLink__</a>
<br><br>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
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

View file

@ -1,77 +0,0 @@
const passwordreset = {
i18n: [
'passwordresetTitle',
'passwordresetCopy1',
'passwordresetActionText',
'questionsJustReply',
'signature',
],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">__passwordresetTitle__</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
__passwordresetCopy1__
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 30px 30px 30px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#212121"><a href="__passwordresetActionLink__" target="_blank" style="font-size: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #111111; display: inline-block;">__passwordresetActionText__</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="padding: 0 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
<br>
__questionsJustReply__
<br><br>
__signature__
<br>
joost
</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
text: `__passwordresetTitle__
__passwordresetCopy1__
__passwordresetActionLink__
__questionsJustReply__`,
}
export default passwordreset

View file

@ -1,71 +0,0 @@
const signup = {
i18n: ['signupTitle', 'signupCopy1', 'signupActionText', 'questionsJustReply', 'signature'],
html: `<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 40px 15px;" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="500" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="font-size: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #212121; padding-top: 30px;" class="padding-copy">__signupTitle__</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
__signupCopy1__
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 30px 30px 30px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#212121"><a href="__signupActionLink__" target="_blank" style="font-size: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #111111; display: inline-block;">__signupActionText__</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="padding: 0 0 0 0; font-size: 16px; line-height: 25px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
<br>
__questionsJustReply__
<br><br>
__signature__
<br>
joost
</td>
</tr>
<tr>
<td align="left" style="padding: 20px 0 0 0; font-size: 16px; line-height: 20px; font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif; color: #292B2C;" class="padding-copy">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>`,
text: `__signupTitle__
__signupCopy1__
__signupActionLink__
__questionsJustReply__`,
}
export default signup

View file

@ -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')
)
}
}
}

View file

@ -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)
})
})
}

View file

@ -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()

View file

@ -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()
})
})
})
}

View file

@ -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()
})
})
*/
})