diff --git a/sites/backend/package.json b/sites/backend/package.json index 96a799069eb..d12c6ba8ab2 100644 --- a/sites/backend/package.json +++ b/sites/backend/package.json @@ -7,7 +7,6 @@ "author": "Joost De Cock (https://github.com/joostdecock)", "homepage": "https://freesewing.org/", "repository": "github:freesewing/freesewing", - "license": "MIT", "bugs": { "url": "https://github.com/freesewing/freesewing/issues" }, @@ -30,10 +29,13 @@ "crypto": "^1.0.1", "express": "4.18.2", "mustache": "^4.2.0", + "passport": "^0.6.0", "passport-http": "^0.3.0", + "passport-jwt": "^4.0.0", "pino": "^8.7.0" }, "devDependencies": { + "chai-http": "^4.3.0", "mocha": "^10.1.0", "mocha-steps": "^1.3.0", "prisma": "4.5.0" diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 3dddd3e74c8..0f579ad0120 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -73,9 +73,12 @@ model Pattern { data String design String img String? + name String @default("") + notes String person Person? @relation(fields: [personId], references: [id]) personId Int? - notes String + public Boolean @default(false) + settings String user User @relation(fields: [userId], references: [id]) userId Int updatedAt DateTime @updatedAt diff --git a/sites/backend/prisma/schema.sqlite b/sites/backend/prisma/schema.sqlite index 939ff2b1eda..dc9ba949a70 100644 Binary files a/sites/backend/prisma/schema.sqlite and b/sites/backend/prisma/schema.sqlite differ diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikeys.mjs similarity index 85% rename from sites/backend/src/controllers/apikey.mjs rename to sites/backend/src/controllers/apikeys.mjs index 56f4915f8dd..f45ea186bc4 100644 --- a/sites/backend/src/controllers/apikey.mjs +++ b/sites/backend/src/controllers/apikeys.mjs @@ -4,7 +4,7 @@ import { log } from '../utils/log.mjs' import { ApikeyModel } from '../models/apikey.mjs' import { UserModel } from '../models/user.mjs' -export function ApikeyController() {} +export function ApikeysController() {} /* * Create API key @@ -12,7 +12,7 @@ export function ApikeyController() {} * This is the endpoint that handles creation of API keys/tokens * See: https://freesewing.dev/reference/backend/api/apikey */ -ApikeyController.prototype.create = async (req, res, tools) => { +ApikeysController.prototype.create = async (req, res, tools) => { const Apikey = new ApikeyModel(tools) await Apikey.create(req) @@ -25,7 +25,7 @@ ApikeyController.prototype.create = async (req, res, tools) => { * This is the endpoint that handles creation of API keys/tokens * See: https://freesewing.dev/reference/backend/api/apikey */ -ApikeyController.prototype.read = async (req, res, tools) => { +ApikeysController.prototype.read = async (req, res, tools) => { const Apikey = new ApikeyModel(tools) await Apikey.guardedRead(req) @@ -39,7 +39,7 @@ ApikeyController.prototype.read = async (req, res, tools) => { * request * See: https://freesewing.dev/reference/backend/api/apikey */ -ApikeyController.prototype.whoami = async (req, res, tools) => { +ApikeysController.prototype.whoami = async (req, res, tools) => { const User = new UserModel(tools) const Apikey = new ApikeyModel(tools) @@ -69,7 +69,7 @@ ApikeyController.prototype.whoami = async (req, res, tools) => { * This is the endpoint that handles removal of API keys/tokens * See: https://freesewing.dev/reference/backend/api/apikey */ -ApikeyController.prototype.delete = async (req, res, tools) => { +ApikeysController.prototype.delete = async (req, res, tools) => { const Apikey = new ApikeyModel(tools) await Apikey.guardedDelete(req) diff --git a/sites/backend/src/controllers/patterns.mjs b/sites/backend/src/controllers/patterns.mjs new file mode 100644 index 00000000000..525181fe03a --- /dev/null +++ b/sites/backend/src/controllers/patterns.mjs @@ -0,0 +1,58 @@ +import { PatternModel } from '../models/pattern.mjs' + +export function PatternsController() {} + +/* + * Create a pattern + * See: https://freesewing.dev/reference/backend/api + */ +PatternsController.prototype.create = async (req, res, tools) => { + const Pattern = new PatternModel(tools) + await Pattern.guardedCreate(req) + + return Pattern.sendResponse(res) +} + +/* + * Read a pattern + * See: https://freesewing.dev/reference/backend/api + */ +PatternsController.prototype.read = async (req, res, tools) => { + const Pattern = new PatternModel(tools) + await Pattern.guardedRead(req) + + return Pattern.sendResponse(res) +} + +/* + * Update a pattern + * See: https://freesewing.dev/reference/backend/api + */ +PatternsController.prototype.update = async (req, res, tools) => { + const Pattern = new PatternModel(tools) + await Pattern.guardedUpdate(req) + + return Pattern.sendResponse(res) +} + +/* + * Remove a pattern + * See: https://freesewing.dev/reference/backend/api + */ +PatternsController.prototype.delete = async (req, res, tools) => { + const Pattern = new PatternModel(tools) + await Pattern.guardedDelete(req) + + return Pattern.sendResponse(res) +} + +/* + * Clone a pattern + * See: https://freesewing.dev/reference/backend/api + */ +PatternsController.prototype.clone = async (req, res, tools) => { + const Pattern = new PatternModel(tools) + await Pattern.guardedClone(req) + + return Pattern.sendResponse(res) +} diff --git a/sites/backend/src/controllers/person.mjs b/sites/backend/src/controllers/people.mjs similarity index 74% rename from sites/backend/src/controllers/person.mjs rename to sites/backend/src/controllers/people.mjs index e6081b3a5c2..636524c6849 100644 --- a/sites/backend/src/controllers/person.mjs +++ b/sites/backend/src/controllers/people.mjs @@ -1,12 +1,12 @@ import { PersonModel } from '../models/person.mjs' -export function PersonController() {} +export function PeopleController() {} /* * Create a person for the authenticated user * See: https://freesewing.dev/reference/backend/api */ -PersonController.prototype.create = async (req, res, tools) => { +PeopleController.prototype.create = async (req, res, tools) => { const Person = new PersonModel(tools) await Person.guardedCreate(req) @@ -17,7 +17,7 @@ PersonController.prototype.create = async (req, res, tools) => { * Read a person * See: https://freesewing.dev/reference/backend/api */ -PersonController.prototype.read = async (req, res, tools) => { +PeopleController.prototype.read = async (req, res, tools) => { const Person = new PersonModel(tools) await Person.guardedRead(req) @@ -28,7 +28,7 @@ PersonController.prototype.read = async (req, res, tools) => { * Update a person * See: https://freesewing.dev/reference/backend/api */ -PersonController.prototype.update = async (req, res, tools) => { +PeopleController.prototype.update = async (req, res, tools) => { const Person = new PersonModel(tools) await Person.guardedUpdate(req) @@ -39,7 +39,7 @@ PersonController.prototype.update = async (req, res, tools) => { * Remove a person * See: https://freesewing.dev/reference/backend/api */ -PersonController.prototype.delete = async (req, res, tools) => { +PeopleController.prototype.delete = async (req, res, tools) => { const Person = new PersonModel(tools) await Person.guardedDelete(req) @@ -50,7 +50,7 @@ PersonController.prototype.delete = async (req, res, tools) => { * Clone a person * See: https://freesewing.dev/reference/backend/api */ -PersonController.prototype.clone = async (req, res, tools) => { +PeopleController.prototype.clone = async (req, res, tools) => { const Person = new PersonModel(tools) await Person.guardedClone(req) diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/users.mjs similarity index 78% rename from sites/backend/src/controllers/user.mjs rename to sites/backend/src/controllers/users.mjs index 648000db8eb..f649619bd5d 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/users.mjs @@ -1,6 +1,6 @@ import { UserModel } from '../models/user.mjs' -export function UserController() {} +export function UsersController() {} /* * Signup @@ -8,7 +8,7 @@ export function UserController() {} * This is the endpoint that handles account signups * See: https://freesewing.dev/reference/backend/api */ -UserController.prototype.signup = async (req, res, tools) => { +UsersController.prototype.signup = async (req, res, tools) => { const User = new UserModel(tools) await User.guardedCreate(req) @@ -21,7 +21,7 @@ UserController.prototype.signup = async (req, res, tools) => { * This is the endpoint that fully unlocks the account if the user gives their consent * See: https://freesewing.dev/reference/backend/api */ -UserController.prototype.confirm = async (req, res, tools) => { +UsersController.prototype.confirm = async (req, res, tools) => { const User = new UserModel(tools) await User.confirm(req) @@ -34,7 +34,7 @@ UserController.prototype.confirm = async (req, res, tools) => { * This is the endpoint that provides traditional username/password login * See: https://freesewing.dev/reference/backend/api */ -UserController.prototype.login = async function (req, res, tools) { +UsersController.prototype.login = async function (req, res, tools) { const User = new UserModel(tools) await User.passwordLogin(req) @@ -46,7 +46,7 @@ UserController.prototype.login = async function (req, res, tools) { * * See: https://freesewing.dev/reference/backend/api */ -UserController.prototype.whoami = async (req, res, tools) => { +UsersController.prototype.whoami = async (req, res, tools) => { const User = new UserModel(tools) await User.guardedRead({ id: req.user.uid }, req) @@ -58,7 +58,7 @@ UserController.prototype.whoami = async (req, res, tools) => { * * See: https://freesewing.dev/reference/backend/api */ -UserController.prototype.update = async (req, res, tools) => { +UsersController.prototype.update = async (req, res, tools) => { const User = new UserModel(tools) await User.guardedRead({ id: req.user.uid }, req) await User.guardedUpdate(req) diff --git a/sites/backend/src/models/pattern.mjs b/sites/backend/src/models/pattern.mjs new file mode 100644 index 00000000000..66ea00a1abc --- /dev/null +++ b/sites/backend/src/models/pattern.mjs @@ -0,0 +1,317 @@ +import { log } from '../utils/log.mjs' +import { setPatternAvatar } from '../utils/sanity.mjs' + +export function PatternModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + this.decrypt = tools.decrypt + this.encrypt = tools.encrypt + this.encryptedFields = ['data', 'img', 'name', 'notes', 'settings'] + this.clear = {} // For holding decrypted data + + return this +} +/* + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + data String + design String + img String? + person Person? @relation(fields: [personId], references: [id]) + personId Int? + name String @default("") + notes String + public + settings String + user User @relation(fields: [userId], references: [id]) + userId Int + updatedAt DateTime @updatedAt + */ + +PatternModel.prototype.guardedCreate = async function ({ body, user }) { + if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (Object.keys(body) < 2) return this.setResponse(400, 'postBodyMissing') + if (!body.person) return this.setResponse(400, 'personMissing') + if (typeof body.person !== 'number') return this.setResponse(400, 'personNotNumeric') + if (typeof body.settings !== 'object') return this.setResponse(400, 'settingsNotAnObject') + if (body.data && typeof body.data !== 'object') return this.setResponse(400, 'dataNotAnObject') + if (!body.design && !body.data?.design) return this.setResponse(400, 'designMissing') + if (typeof body.design !== 'string') return this.setResponse(400, 'designNotStringy') + + // Prepare data + const data = { + design: body.design, + personId: body.person, + settings: body.settings, + } + // Data (will be encrypted, so always set _some_ value) + if (typeof body.data === 'object') data.data = body.data + else data.data = {} + // Name (will be encrypted, so always set _some_ value) + if (typeof body.name === 'string' && body.name.length > 0) data.name = body.name + else data.name = '--' + // Notes (will be encrypted, so always set _some_ value) + if (typeof body.notes === 'string' && body.notes.length > 0) data.notes = body.notes + else data.notes = '--' + // Public + if (body.public === true) data.public = true + data.userId = user.uid + // Set this one initially as we need the ID to create a custom img via Sanity + data.img = this.config.avatars.pattern + + // Create record + await this.unguardedCreate(data) + + // Update img? (now that we have the ID) + const img = + this.config.use.sanity && + typeof body.img === 'string' && + (!body.unittest || (body.unittest && this.config.use.tests?.sanity)) + ? await setPatternAvatar(this.record.id, body.img) + : false + + if (img) await this.unguardedUpdate(this.cloak({ img: img.url })) + else await this.read({ id: this.record.id }) + + return this.setResponse(201, 'created', { pattern: this.asPattern() }) +} + +PatternModel.prototype.unguardedCreate = async function (data) { + try { + this.record = await this.prisma.pattern.create({ data: this.cloak(data) }) + } catch (err) { + log.warn(err, 'Could not create pattern') + return this.setResponse(500, 'createPatternFailed') + } + + return this +} + +/* + * Loads a pattern from the database based on the where clause you pass it + * + * Stores result in this.record + */ +PatternModel.prototype.read = async function (where) { + try { + this.record = await this.prisma.pattern.findUnique({ where }) + } catch (err) { + log.warn({ err, where }, 'Could not read pattern') + } + + this.reveal() + + return this.setExists() +} + +/* + * Loads a pattern from the database based on the where clause you pass it + * In addition prepares it for returning the pattern data + * + * Stores result in this.record + */ +PatternModel.prototype.guardedRead = async function ({ params, user }) { + if (user.level < 1) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + await this.read({ id: parseInt(params.id) }) + if (this.record.userId !== user.uid && user.level < 5) { + return this.setResponse(403, 'insufficientAccessLevel') + } + + return this.setResponse(200, false, { + result: 'success', + pattern: this.asPattern(), + }) +} + +/* + * Clones a pattern + * In addition prepares it for returning the pattern data + * + * Stores result in this.record + */ +PatternModel.prototype.guardedClone = async function ({ params, user }) { + if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + await this.read({ id: parseInt(params.id) }) + if (this.record.userId !== user.uid && !this.record.public && user.level < 5) { + return this.setResponse(403, 'insufficientAccessLevel') + } + + // Clone pattern + const data = this.asPattern() + delete data.id + data.name += ` (cloned from #${this.record.id})` + data.notes += ` (Note: This pattern was cloned from pattern #${this.record.id})` + await this.unguardedCreate(data) + + // Update unencrypted data + this.reveal() + + return this.setResponse(200, false, { + result: 'success', + pattern: this.asPattern(), + }) +} + +/* + * Helper method to decrypt at-rest data + */ +PatternModel.prototype.reveal = async function () { + this.clear = {} + if (this.record) { + for (const field of this.encryptedFields) { + this.clear[field] = this.decrypt(this.record[field]) + } + } + + return this +} + +/* + * Helper method to encrypt at-rest data + */ +PatternModel.prototype.cloak = function (data) { + for (const field of this.encryptedFields) { + if (typeof data[field] !== 'undefined') { + data[field] = this.encrypt(data[field]) + } + } + + return data +} + +/* + * Checks this.record and sets a boolean to indicate whether + * the pattern exists or not + * + * Stores result in this.exists + */ +PatternModel.prototype.setExists = function () { + this.exists = this.record ? true : false + + return this +} + +/* + * Updates the pattern data - Used when we create the data ourselves + * so we know it's safe + */ +PatternModel.prototype.unguardedUpdate = async function (data) { + try { + this.record = await this.prisma.pattern.update({ + where: { id: this.record.id }, + data, + }) + } catch (err) { + log.warn(err, 'Could not update pattern record') + process.exit() + return this.setResponse(500, 'updatePatternFailed') + } + await this.reveal() + + return this.setResponse(200) +} + +/* + * Updates the pattern data - Used when we pass through user-provided data + * so we can't be certain it's safe + */ +PatternModel.prototype.guardedUpdate = async function ({ params, body, user }) { + if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + await this.read({ id: parseInt(params.id) }) + if (this.record.userId !== user.uid && user.level < 8) { + return this.setResponse(403, 'insufficientAccessLevel') + } + const data = {} + // Name + if (typeof body.name === 'string') data.name = body.name + // Notes + if (typeof body.notes === 'string') data.notes = body.notes + // Public + if (body.public === true || body.public === false) data.public = body.public + // Data + if (typeof body.data === 'object') data.data = body.data + // Settings + if (typeof body.settings === 'object') data.settings = body.settings + // Image (img) + if (typeof body.img === 'string') { + const img = await setPatternAvatar(params.id, body.img) + data.img = img.url + } + + // Now update the record + await this.unguardedUpdate(this.cloak(data)) + + return this.setResponse(200, false, { pattern: this.asPattern() }) +} + +/* + * Removes the pattern - No questions asked + */ +PatternModel.prototype.unguardedDelete = async function () { + await this.prisma.pattern.delete({ here: { id: this.record.id } }) + this.record = null + this.clear = null + + return this.setExists() +} + +/* + * Removes the pattern - Checks permissions + */ +PatternModel.prototype.guardedDelete = async function ({ params, body, user }) { + if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + await this.read({ id: parseInt(params.id) }) + if (this.record.userId !== user.uid && user.level < 8) { + return this.setResponse(403, 'insufficientAccessLevel') + } + + await this.unguardedDelete() + + return this.setResponse(204, false) +} + +/* + * Returns record data + */ +PatternModel.prototype.asPattern = function () { + return { + ...this.record, + ...this.clear, + } +} + +/* + * Helper method to set the response code, result, and body + * + * Will be used by this.sendResponse() + */ +PatternModel.prototype.setResponse = function (status = 200, error = false, data = {}) { + this.response = { + status, + body: { + result: 'success', + ...data, + }, + } + if (status > 201) { + this.response.body.error = error + this.response.body.result = 'error' + this.error = true + } else this.error = false + + return this.setExists() +} + +/* + * Helper method to send response + */ +PatternModel.prototype.sendResponse = async function (res) { + return res.status(this.response.status).send(this.response.body) +} diff --git a/sites/backend/src/models/person.mjs b/sites/backend/src/models/person.mjs index c75d4de7047..4e1a3f2ea82 100644 --- a/sites/backend/src/models/person.mjs +++ b/sites/backend/src/models/person.mjs @@ -19,7 +19,12 @@ PersonModel.prototype.guardedCreate = async function ({ body, user }) { // Prepare data const data = { name: body.name } + // Name (will be encrypted, so always set _some_ value) + if (typeof body.name === 'string') data.name = body.name + else data.name = '--' + // Notes (will be encrypted, so always set _some_ value) if (body.notes || typeof body.notes === 'string') data.notes = body.notes + else data.notes = '--' if (body.public === true) data.public = true if (body.measies) data.measies = this.sanitizeMeasurements(body.measies) data.imperial = body.imperial === true ? true : false diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 9deb4127f22..da3ab4e2aba 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -223,9 +223,9 @@ UserModel.prototype.guardedCreate = async function ({ body }) { * Login based on username + password */ UserModel.prototype.passwordLogin = async function (req) { - if (Object.keys(req.body) < 1) return this.setReponse(400, 'postBodyMissing') - if (!req.body.username) return this.setReponse(400, 'usernameMissing') - if (!req.body.password) return this.setReponse(400, 'passwordMissing') + if (Object.keys(req.body) < 1) return this.setResponse(400, 'postBodyMissing') + if (!req.body.username) return this.setResponse(400, 'usernameMissing') + if (!req.body.password) return this.setResponse(400, 'passwordMissing') await this.find(req.body) if (!this.exists) { @@ -255,7 +255,7 @@ UserModel.prototype.passwordLogin = async function (req) { * Confirms a user account */ UserModel.prototype.confirm = async function ({ body, params }) { - if (!params.id) return this.setReponse(404, 'missingConfirmationId') + if (!params.id) return this.setResponse(404, 'missingConfirmationId') if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (!body.consent || typeof body.consent !== 'number' || body.consent < 1) return this.setResponse(400, 'consentRequired') diff --git a/sites/backend/src/routes/apikey.mjs b/sites/backend/src/routes/apikey.mjs deleted file mode 100644 index b22eeb02643..00000000000 --- a/sites/backend/src/routes/apikey.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { ApikeyController } from '../controllers/apikey.mjs' - -const Apikey = new ApikeyController() -const jwt = ['jwt', { session: false }] -const bsc = ['basic', { session: false }] - -export function apikeyRoutes(tools) { - const { app, passport } = tools - - // Create Apikey - app.post('/apikey/jwt', passport.authenticate(...jwt), (req, res) => - Apikey.create(req, res, tools) - ) - app.post('/apikey/key', passport.authenticate(...bsc), (req, res) => - Apikey.create(req, res, tools) - ) - - // Read Apikey - app.get('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) => - Apikey.read(req, res, tools) - ) - app.get('/apikey/:id/key', passport.authenticate(...bsc), (req, res) => - Apikey.read(req, res, tools) - ) - - // Read current Apikey - app.get('/whoami/key', passport.authenticate(...bsc), (req, res) => - Apikey.whoami(req, res, tools) - ) - - // Remove Apikey - app.delete('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) => - Apikey.delete(req, res, tools) - ) - app.delete('/apikey/:id/key', passport.authenticate(...bsc), (req, res) => - Apikey.delete(req, res, tools) - ) -} diff --git a/sites/backend/src/routes/apikeys.mjs b/sites/backend/src/routes/apikeys.mjs new file mode 100644 index 00000000000..5b70d1256bf --- /dev/null +++ b/sites/backend/src/routes/apikeys.mjs @@ -0,0 +1,38 @@ +import { ApikeysController } from '../controllers/apikeys.mjs' + +const Apikeys = new ApikeysController() +const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] + +export function apikeysRoutes(tools) { + const { app, passport } = tools + + // Create Apikey + app.post('/apikeys/jwt', passport.authenticate(...jwt), (req, res) => + Apikeys.create(req, res, tools) + ) + app.post('/apikeys/key', passport.authenticate(...bsc), (req, res) => + Apikeys.create(req, res, tools) + ) + + // Read Apikey + app.get('/apikeys/:id/jwt', passport.authenticate(...jwt), (req, res) => + Apikeys.read(req, res, tools) + ) + app.get('/apikeys/:id/key', passport.authenticate(...bsc), (req, res) => + Apikeys.read(req, res, tools) + ) + + // Read current Apikey + app.get('/whoami/key', passport.authenticate(...bsc), (req, res) => + Apikeys.whoami(req, res, tools) + ) + + // Remove Apikey + app.delete('/apikeys/:id/jwt', passport.authenticate(...jwt), (req, res) => + Apikeys.delete(req, res, tools) + ) + app.delete('/apikeys/:id/key', passport.authenticate(...bsc), (req, res) => + Apikeys.delete(req, res, tools) + ) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index 1c4a70abc9e..cf8b5035c85 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -1,9 +1,11 @@ -import { apikeyRoutes } from './apikey.mjs' -import { userRoutes } from './user.mjs' -import { personRoutes } from './person.mjs' +import { apikeysRoutes } from './apikeys.mjs' +import { usersRoutes } from './users.mjs' +import { peopleRoutes } from './people.mjs' +import { patternsRoutes } from './patterns.mjs' export const routes = { - apikeyRoutes, - userRoutes, - personRoutes, + apikeysRoutes, + usersRoutes, + peopleRoutes, + patternsRoutes, } diff --git a/sites/backend/src/routes/patterns.mjs b/sites/backend/src/routes/patterns.mjs new file mode 100644 index 00000000000..ffa2eed4d8f --- /dev/null +++ b/sites/backend/src/routes/patterns.mjs @@ -0,0 +1,49 @@ +import { PatternsController } from '../controllers/patterns.mjs' + +const Patterns = new PatternsController() +const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] + +export function patternsRoutes(tools) { + const { app, passport } = tools + + // Create pattern + app.post('/patterns/jwt', passport.authenticate(...jwt), (req, res) => + Patterns.create(req, res, tools) + ) + app.post('/patterns/key', passport.authenticate(...bsc), (req, res) => + Patterns.create(req, res, tools) + ) + + // Clone pattern + app.post('/patterns/:id/clone/jwt', passport.authenticate(...jwt), (req, res) => + Patterns.clone(req, res, tools) + ) + app.post('/patterns/:id/clone/key', passport.authenticate(...bsc), (req, res) => + Patterns.clone(req, res, tools) + ) + + // Read pattern + app.get('/patterns/:id/jwt', passport.authenticate(...jwt), (req, res) => + Patterns.read(req, res, tools) + ) + app.get('/patterns/:id/key', passport.authenticate(...bsc), (req, res) => + Patterns.read(req, res, tools) + ) + + // Update pattern + app.put('/patterns/:id/jwt', passport.authenticate(...jwt), (req, res) => + Patterns.update(req, res, tools) + ) + app.put('/patterns/:id/key', passport.authenticate(...bsc), (req, res) => + Patterns.update(req, res, tools) + ) + + // Delete pattern + app.delete('/patterns/:id/jwt', passport.authenticate(...jwt), (req, res) => + Patterns.delete(req, res, tools) + ) + app.delete('/patterns/:id/key', passport.authenticate(...bsc), (req, res) => + Patterns.delete(req, res, tools) + ) +} diff --git a/sites/backend/src/routes/person.mjs b/sites/backend/src/routes/people.mjs similarity index 67% rename from sites/backend/src/routes/person.mjs rename to sites/backend/src/routes/people.mjs index 1ee0609dc55..d43550ef205 100644 --- a/sites/backend/src/routes/person.mjs +++ b/sites/backend/src/routes/people.mjs @@ -1,49 +1,49 @@ -import { PersonController } from '../controllers/person.mjs' +import { PeopleController } from '../controllers/people.mjs' -const Person = new PersonController() +const People = new PeopleController() const jwt = ['jwt', { session: false }] const bsc = ['basic', { session: false }] -export function personRoutes(tools) { +export function peopleRoutes(tools) { const { app, passport } = tools // Create person app.post('/people/jwt', passport.authenticate(...jwt), (req, res) => - Person.create(req, res, tools) + People.create(req, res, tools) ) app.post('/people/key', passport.authenticate(...bsc), (req, res) => - Person.create(req, res, tools) + People.create(req, res, tools) ) // Clone person app.post('/people/:id/clone/jwt', passport.authenticate(...jwt), (req, res) => - Person.clone(req, res, tools) + People.clone(req, res, tools) ) app.post('/people/:id/clone/key', passport.authenticate(...bsc), (req, res) => - Person.clone(req, res, tools) + People.clone(req, res, tools) ) // Read person app.get('/people/:id/jwt', passport.authenticate(...jwt), (req, res) => - Person.read(req, res, tools) + People.read(req, res, tools) ) app.get('/people/:id/key', passport.authenticate(...bsc), (req, res) => - Person.read(req, res, tools) + People.read(req, res, tools) ) // Update person app.put('/people/:id/jwt', passport.authenticate(...jwt), (req, res) => - Person.update(req, res, tools) + People.update(req, res, tools) ) app.put('/people/:id/key', passport.authenticate(...bsc), (req, res) => - Person.update(req, res, tools) + People.update(req, res, tools) ) // Delete person app.delete('/people/:id/jwt', passport.authenticate(...jwt), (req, res) => - Person.delete(req, res, tools) + People.delete(req, res, tools) ) app.delete('/people/:id/key', passport.authenticate(...bsc), (req, res) => - Person.delete(req, res, tools) + People.delete(req, res, tools) ) } diff --git a/sites/backend/src/routes/user.mjs b/sites/backend/src/routes/users.mjs similarity index 76% rename from sites/backend/src/routes/user.mjs rename to sites/backend/src/routes/users.mjs index 69d6c35f0f8..2e2998bdc28 100644 --- a/sites/backend/src/routes/user.mjs +++ b/sites/backend/src/routes/users.mjs @@ -1,30 +1,38 @@ -import { UserController } from '../controllers/user.mjs' +import { UsersController } from '../controllers/users.mjs' -const User = new UserController() +const Users = new UsersController() const jwt = ['jwt', { session: false }] const bsc = ['basic', { session: false }] -export function userRoutes(tools) { +export function usersRoutes(tools) { const { app, passport } = tools // Sign up - app.post('/signup', (req, res) => User.signup(req, res, tools)) + app.post('/signup', (req, res) => Users.signup(req, res, tools)) // Confirm account - app.post('/confirm/signup/:id', (req, res) => User.confirm(req, res, tools)) + app.post('/confirm/signup/:id', (req, res) => Users.confirm(req, res, tools)) // Login - app.post('/login', (req, res) => User.login(req, res, tools)) + app.post('/login', (req, res) => Users.login(req, res, tools)) // Read current jwt - app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools)) - app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools)) - app.get('/account/key', passport.authenticate(...bsc), (req, res) => User.whoami(req, res, tools)) + app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools)) + app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => + Users.whoami(req, res, tools) + ) + app.get('/account/key', passport.authenticate(...bsc), (req, res) => + Users.whoami(req, res, tools) + ) // Update account - app.put('/account/jwt', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools)) - app.put('/account/key', passport.authenticate(...bsc), (req, res) => User.update(req, res, tools)) + app.put('/account/jwt', passport.authenticate(...jwt), (req, res) => + Users.update(req, res, tools) + ) + app.put('/account/key', passport.authenticate(...bsc), (req, res) => + Users.update(req, res, tools) + ) /* diff --git a/sites/backend/src/utils/sanity.mjs b/sites/backend/src/utils/sanity.mjs index 5158c87666f..6b5fec36945 100644 --- a/sites/backend/src/utils/sanity.mjs +++ b/sites/backend/src/utils/sanity.mjs @@ -33,6 +33,7 @@ async function getAvatar(type, id) { */ export const setUserAvatar = async (id, data) => setAvatar('user', id, data) export const setPersonAvatar = async (id, data) => setAvatar('person', id, data) +export const setPatternAvatar = async (id, data) => setAvatar('pattern', id, data) export async function setAvatar(type, id, data) { // Step 1: Upload the image as asset const [contentType, binary] = b64ToBinaryWithType(data) diff --git a/sites/backend/tests/apikey.mjs b/sites/backend/tests/apikey.mjs index 0c63a3ca0fd..be3a6807ee9 100644 --- a/sites/backend/tests/apikey.mjs +++ b/sites/backend/tests/apikey.mjs @@ -3,7 +3,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'jwt')} Create API Key (jwt)`, (done) => { chai .request(config.api) - .post('/apikey/jwt') + .post('/apikeys/jwt') .set('Authorization', 'Bearer ' + store.account.token) .send({ name: 'Test API key', @@ -27,7 +27,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'key')} Create API Key (key)`, (done) => { chai .request(config.api) - .post('/apikey/key') + .post('/apikeys/key') .auth(store.apikey1.key, store.apikey1.secret) .send({ name: 'Test API key with key', @@ -67,7 +67,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'key')} Read API key (key)`, (done) => { chai .request(config.api) - .get(`/apikey/${store.apikey1.key}/key`) + .get(`/apikeys/${store.apikey1.key}/key`) .auth(store.apikey2.key, store.apikey2.secret) .end((err, res) => { expect(res.status).to.equal(200) @@ -83,7 +83,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'jwt')} Read API key (jwt)`, (done) => { chai .request(config.api) - .get(`/apikey/${store.apikey2.key}/jwt`) + .get(`/apikeys/${store.apikey2.key}/jwt`) .set('Authorization', 'Bearer ' + store.account.token) .end((err, res) => { expect(res.status).to.equal(200) @@ -99,7 +99,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'key')} Remove API key (key)`, (done) => { chai .request(config.api) - .delete(`/apikey/${store.apikey2.key}/key`) + .delete(`/apikeys/${store.apikey2.key}/key`) .auth(store.apikey2.key, store.apikey2.secret) .end((err, res) => { expect(res.status).to.equal(204) @@ -110,7 +110,7 @@ export const apikeyTests = async (chai, config, expect, store) => { step(`${store.icon('key', 'jwt')} Remove API key (jwt)`, (done) => { chai .request(config.api) - .delete(`/apikey/${store.apikey1.key}/jwt`) + .delete(`/apikeys/${store.apikey1.key}/jwt`) .set('Authorization', 'Bearer ' + store.account.token) .end((err, res) => { expect(res.status).to.equal(204) diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index 44543d586bf..c57833285e8 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -2,6 +2,7 @@ import { userTests } from './user.mjs' import { accountTests } from './account.mjs' import { apikeyTests } from './apikey.mjs' import { personTests } from './person.mjs' +import { patternTests } from './pattern.mjs' import { setup } from './shared.mjs' const runTests = async (...params) => { @@ -9,6 +10,7 @@ const runTests = async (...params) => { await apikeyTests(...params) await accountTests(...params) await personTests(...params) + await patternTests(...params) } // Load initial data required for tests diff --git a/sites/backend/tests/pattern.mjs b/sites/backend/tests/pattern.mjs new file mode 100644 index 00000000000..835c3468a77 --- /dev/null +++ b/sites/backend/tests/pattern.mjs @@ -0,0 +1,326 @@ +import { cat } from './cat.mjs' + +export const patternTests = async (chai, config, expect, store) => { + store.account.patterns = {} + for (const auth of ['jwt', 'key']) { + describe(`${store.icon('pattern', auth)} Pattern tests (${auth})`, () => { + it(`${store.icon('pattern', auth)} Should create a new pattern (${auth})`, (done) => { + chai + .request(config.api) + .post(`/patterns/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ + design: 'aaron', + settings: {}, + person: store.account.people.her.id, + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(201) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.pattern?.id).to.equal('number') + expect(res.body.pattern.userId).to.equal(store.account.id) + expect(res.body.pattern.personId).to.equal(store.account.people.her.id) + expect(res.body.pattern.design).to.equal('aaron') + expect(res.body.pattern.public).to.equal(false) + store.account.patterns[auth] = res.body.pattern + done() + }) + }).timeout(5000) + + for (const field of ['name', 'notes']) { + it(`${store.icon('pattern', auth)} Should update the ${field} field (${auth})`, (done) => { + const data = {} + const val = store.account.patterns[auth][field] + '_updated' + data[field] = val + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer( + `${store.account.apikey.key}:${store.account.apikey.secret}` + ).toString('base64') + ) + .send(data) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern[field]).to.equal('--_updated') + done() + }) + }) + } + + it(`${store.icon('person', auth)} Should update the public field (${auth})`, (done) => { + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ public: true }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern.public).to.equal(true) + done() + }) + }) + + it(`${store.icon('person', auth)} Should not update the design field (${auth})`, (done) => { + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ design: 'updated' }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern.design).to.equal('aaron') + done() + }) + }) + + it(`${store.icon('person', auth)} Should not update the person field (${auth})`, (done) => { + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ person: 1 }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern.personId).to.equal(store.account.people.her.id) + done() + }) + }) + + for (const field of ['data', 'settings']) { + it(`${store.icon('person', auth)} Should update the ${field} field (${auth})`, (done) => { + const data = {} + data[field] = { test: { value: 'hello' } } + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer( + `${store.account.apikey.key}:${store.account.apikey.secret}` + ).toString('base64') + ) + .send(data) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern[field].test.value).to.equal('hello') + done() + }) + }) + } + + it(`${store.icon('pattern', auth)} Should read a pattern (${auth})`, (done) => { + chai + .request(config.api) + .get(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.pattern.data.test.value).to.equal('hello') + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow reading another user's pattern (${auth})`, (done) => { + chai + .request(config.api) + .get(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow updating another user's pattern (${auth})`, (done) => { + chai + .request(config.api) + .put(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .send({ + name: 'I have been taken over', + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow removing another user's pattern (${auth})`, (done) => { + chai + .request(config.api) + .delete(`/patterns/${store.account.patterns[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + /* + it(`${store.icon('person', auth)} Should clone a person (${auth})`, (done) => { + chai + .request(config.api) + .post(`/people/${store.person[auth].id}/clone/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.error).to.equal(`undefined`) + expect(typeof res.body.person.id).to.equal(`number`) + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should (not) clone a public person across accounts (${auth})`, (done) => { + chai + .request(config.api) + .post(`/people/${store.person[auth].id}/clone/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .end((err, res) => { + if (store.person[auth].public) { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.error).to.equal(`undefined`) + expect(typeof res.body.person.id).to.equal(`number`) + } else { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + } + done() + }) + }) + + // TODO: + // - Clone person + // - Clone person accross accounts of they are public + */ + }) + } +} diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index 463469d381e..ae9cd0f093c 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -4,12 +4,17 @@ import chai from 'chai' import http from 'chai-http' import { verifyConfig } from '../src/config.mjs' import { randomString } from '../src/utils/crypto.mjs' +import { + cisFemaleAdult34 as her, + cisMaleAdult42 as him, +} from '../../../packages/models/src/index.mjs' dotenv.config() const config = verifyConfig(true) const expect = chai.expect chai.use(http) +const people = { her, him } export const setup = async () => { // Initial store contents @@ -21,17 +26,20 @@ export const setup = async () => { email: `test_${randomString()}@${config.tests.domain}`, language: 'en', password: randomString(), + people: {}, }, altaccount: { email: `test_${randomString()}@${config.tests.domain}`, language: 'en', password: randomString(), + people: {}, }, icons: { user: '🧑 ', jwt: '🎫 ', key: '🎟️ ', person: '🧕 ', + pattern: '👕 ', }, randomString, } @@ -63,12 +71,12 @@ export const setup = async () => { } store[acc].token = result.data.token store[acc].username = result.data.account.username - store[acc].userid = result.data.account.id + store[acc].id = result.data.account.id // Create API key try { result = await axios.post( - `${store.config.api}/apikey/jwt`, + `${store.config.api}/apikeys/jwt`, { name: 'Test API key', level: 4, @@ -85,6 +93,29 @@ export const setup = async () => { process.exit() } store[acc].apikey = result.data.apikey + + // Create people key + for (const name in people) { + try { + result = await axios.post( + `${store.config.api}/people/jwt`, + { + name: `This is ${name} name`, + name: `These are ${name} notes`, + measies: people[name], + }, + { + headers: { + authorization: `Bearer ${store[acc].token}`, + }, + } + ) + } catch (err) { + console.log('Failed at API key creation request', err) + process.exit() + } + store[acc].people[name] = result.data.person + } } return { chai, config, expect, store } diff --git a/sites/backend/tests/user.mjs b/sites/backend/tests/user.mjs index 253d583d30d..e3a259a322e 100644 --- a/sites/backend/tests/user.mjs +++ b/sites/backend/tests/user.mjs @@ -184,12 +184,12 @@ export const userTests = async (chai, config, expect, store) => { }) }) - step(`${store.icon('user')} Should login with userid and password`, (done) => { + step(`${store.icon('user')} Should login with id and password`, (done) => { chai .request(config.api) .post('/login') .send({ - username: store.account.userid, + username: store.account.id, password: store.account.password, }) .end((err, res) => {