From e503468660a13760010a94ecda5f0625c6f47f87 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 13 Oct 2023 14:12:03 +0200 Subject: Server re-build with latest AdonisJS framework & Typescript (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: setup first basis structure * chore: ensure styling is loaded correctly * chore: comply to new routing syntax by replace . with / in routes/resource locations * chore: add login controller * chore: correctly use views with slash instead of dot * chore: working login + tests * chore: clean up tests * chore: add password-forgot endpoint and matching test * chore: add delete page test * chore: add logout test * chore: add reset-password route and tests * chore: remove obsolete comment * chore: add account-page and tests * chore: add data page & first step of the test * chore: add transfer/import data feature and tests * chore: add export and basic test * chore: add all static api routes with tests * Regenerate 'pnpm-lock.json' and fix bad merge conflict WIP: - Tests have been commented out since they dont work - Server doesn't start * easier dev and test runs * - remove --require-pragma from reformat-files so formatting works properly - run pnpm reformat-files over codebase - remove .json files from .eslintignore - add invalid.json file to .eslintignore - configure prettier properly in eslint config - add type jsdoc to prettier config - run adonis generate:manifest command to regenerate ace-manifest.json - specify volta in package.json - introduce typecheck npm script - remove unused .mjs extension from npm scripts - install missing type definition dependencies - add pnpm.allowedDeprecatedVersions to package.json - fix invalid extends in tsconfig.json causing TS issues throughout codebase - remove @ts-ignore throughout codebase which is not relevant anymore - enable some of the tsconfig options - remove outdated eslint-disable from codebase - change deprecated faker.company.companyName() to faker.company.name() - fix TS issues inside transfer.spec.ts * - update to latest node and pnpm versions - upgrade all non-major dependencies to latest - install missing @types/luxon dependency - add cuid to pnpm.allowedDeprecatedVersions - add esModuleInterop config option to tsconfig - migrate more deprecated faker methods to new ones - add more temporary ts-ignore to code * - update eslint config - remove trailingComma: all since default in prettier v3 - add typecheck command to prepare-code npm script - upgrade various dependencies to latest major version - update tsconfig to include only useful config options - disable some lint issues and fix others * - add test command to prepare-code - disable strictPropertyInitialization flag in tsconfig which creates issues with adonis models - update precommit hook to excute pnpm prepare-code - remove ts-ignore statements from all models * fix node and pnpm dependency update * add cross env (so that we can develop on windows) * add signup endpoint (TODO: JWT auth) * Add login endpoint * Add me and updateMe endpoints * Add service endpoint * refactor: change endpoints to use jwt * add recipes endpoint * add workspaces endpoint * fix web controllors for login and post import * Update node deps * Change auth middleware (for web) and exempt api from CSRF * Add import endpoint (franz import) * Fix export/import logic * Fix service and workspace data in user/data * Fix partial lint * chore: workaround lint issues * fix: migration naming had two . * Sync back node with recipes repo * Temporarily ignore typescript * Fix adonisrc to handle public folder static assets * Fix issue with production database * add Legacy Password Provider * Fix lint errors * Fix issue on login errors frontend * add Legacy Password Provider * Fix issue with customIcons * Fix issue with auth tokens * Update 'node' to '18.18.0' * make docker work * improve docker entrypoint (test api performance) * Add migration database script * NODE_ENV on recipes * prefer @ts-expect-error over @ts-ignore * small fixes * Update 'pnpm' to '8.7.6' * fix error catch * Automatically generate JWT Public and Private keys * Use custom Adonis5-jwt * Update code to use secret (old way, no breaking changes) * Normalize appKey * Trick to make JWT tokens on client work with new version * Fix error with new JWT logic * Change migration and how we store JWT * Fix 500 response code (needs to be 401) * Improve logic and fix bugs * Fix build and entrypoint logic * Catch error if appKey changes * Add newToken logic * Fix lint (ignore any errors) * Add build for PRs * pnpm reformat-files result * Fix some tests * Fix reset password not working (test failing) * Restore csrfTokens (disabled by accident) * Fix pnpm start command with .env * Disable failing tests on the transfer endpoint (TODO) * Add tests to PR build * Fix build * Remove unnecessary assertStatus * Add typecheck * hash password on UserFactory (fix build) * Add JWT_USE_PEM true by default (increase security) * fix name of github action --------- Co-authored-by: Vijay A Co-authored-by: Balaji Vijayakumar Co-authored-by: MCMXC <16797721+mcmxcdev@users.noreply.github.com> Co-authored-by: André Oliveira --- .../Http/Dashboard/AccountController.ts | 80 +++++++++++++ app/Controllers/Http/Dashboard/DataController.ts | 24 ++++ app/Controllers/Http/Dashboard/DeleteController.ts | 20 ++++ app/Controllers/Http/Dashboard/ExportController.ts | 56 +++++++++ .../Http/Dashboard/ForgotPasswordController.ts | 41 +++++++ app/Controllers/Http/Dashboard/LogOutController.ts | 12 ++ app/Controllers/Http/Dashboard/LoginController.ts | 81 +++++++++++++ .../Http/Dashboard/ResetPasswordController.ts | 85 ++++++++++++++ .../Http/Dashboard/TransferController.ts | 128 +++++++++++++++++++++ 9 files changed, 527 insertions(+) create mode 100644 app/Controllers/Http/Dashboard/AccountController.ts create mode 100644 app/Controllers/Http/Dashboard/DataController.ts create mode 100644 app/Controllers/Http/Dashboard/DeleteController.ts create mode 100644 app/Controllers/Http/Dashboard/ExportController.ts create mode 100644 app/Controllers/Http/Dashboard/ForgotPasswordController.ts create mode 100644 app/Controllers/Http/Dashboard/LogOutController.ts create mode 100644 app/Controllers/Http/Dashboard/LoginController.ts create mode 100644 app/Controllers/Http/Dashboard/ResetPasswordController.ts create mode 100644 app/Controllers/Http/Dashboard/TransferController.ts (limited to 'app/Controllers/Http/Dashboard') diff --git a/app/Controllers/Http/Dashboard/AccountController.ts b/app/Controllers/Http/Dashboard/AccountController.ts new file mode 100644 index 0000000..3c4e919 --- /dev/null +++ b/app/Controllers/Http/Dashboard/AccountController.ts @@ -0,0 +1,80 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; +import crypto from 'node:crypto'; + +export default class AccountController { + /** + * Shows the user account page + */ + public async show({ auth, view }: HttpContextContract) { + return view.render('dashboard/account', { + username: auth.user?.username, + email: auth.user?.email, + lastname: auth.user?.lastname, + }); + } + + /** + * Stores user account data + */ + public async store({ + auth, + request, + response, + session, + view, + }: HttpContextContract) { + try { + await validator.validate({ + schema: schema.create({ + username: schema.string([ + rules.required(), + rules.unique({ + table: 'users', + column: 'username', + caseInsensitive: true, + whereNot: { id: auth.user?.id }, + }), + ]), + email: schema.string([ + rules.required(), + rules.unique({ + table: 'users', + column: 'email', + caseInsensitive: true, + whereNot: { id: auth.user?.id }, + }), + ]), + lastname: schema.string([rules.required()]), + }), + data: request.only(['username', 'email', 'lastname']), + }); + } catch (error) { + session.flash(error.messages); + return response.redirect('/user/account'); + } + + // Update user account + const { user } = auth; + if (user) { + user.username = request.input('username'); + user.lastname = request.input('lastname'); + user.email = request.input('email'); + if (request.input('password')) { + const hashedPassword = crypto + .createHash('sha256') + .update(request.input('password')) + .digest('base64'); + user.password = hashedPassword; + } + await user.save(); + } + + return view.render('dashboard/account', { + username: user?.username, + lastname: user?.lastname, + email: user?.email, + success: user !== undefined, + }); + } +} diff --git a/app/Controllers/Http/Dashboard/DataController.ts b/app/Controllers/Http/Dashboard/DataController.ts new file mode 100644 index 0000000..f77702f --- /dev/null +++ b/app/Controllers/Http/Dashboard/DataController.ts @@ -0,0 +1,24 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class DataController { + /** + * Display the data page + */ + public async show({ view, auth }: HttpContextContract) { + const { user } = auth; + + const services = await user?.related('services').query(); + const workspaces = await user?.related('workspaces').query(); + + return view.render('dashboard/data', { + username: user?.username, + lastname: user?.lastname, + mail: user?.email, + created: user?.created_at.toFormat('yyyy-MM-dd HH:mm:ss'), + updated: user?.updated_at.toFormat('yyyy-MM-dd HH:mm:ss'), + stringify: JSON.stringify, + services, + workspaces, + }); + } +} diff --git a/app/Controllers/Http/Dashboard/DeleteController.ts b/app/Controllers/Http/Dashboard/DeleteController.ts new file mode 100644 index 0000000..ef8188c --- /dev/null +++ b/app/Controllers/Http/Dashboard/DeleteController.ts @@ -0,0 +1,20 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class DeleteController { + /** + * Display the delete page + */ + public async show({ view }: HttpContextContract) { + return view.render('dashboard/delete'); + } + + /** + * Delete user and session + */ + public async delete({ auth, response }: HttpContextContract) { + auth.user?.delete(); + auth.use('web').logout(); + + return response.redirect('/user/login'); + } +} diff --git a/app/Controllers/Http/Dashboard/ExportController.ts b/app/Controllers/Http/Dashboard/ExportController.ts new file mode 100644 index 0000000..7155eab --- /dev/null +++ b/app/Controllers/Http/Dashboard/ExportController.ts @@ -0,0 +1,56 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deepParseToJSON(obj: any): Record { + if (typeof obj !== 'object' || obj === null) { + try { + // Try to parse the object as JSON + return JSON.parse(obj) as Record; + } catch { + // If parsing fails, return the original value + return obj; + } + } + + // If obj is an object, recursively parse its keys + if (Array.isArray(obj)) { + // If obj is an array, recursively parse each element + return obj.map(item => deepParseToJSON(item)) as unknown as Record< + string, + unknown + >; + } else { + // If obj is an object, recursively parse its keys + const parsedObj: Record = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + parsedObj[key] = deepParseToJSON(obj[key]); + } + } + return parsedObj; + } +} + +export default class ExportController { + /** + * Display the export page + */ + public async show({ auth, response }: HttpContextContract) { + const user = auth.user!; + const services = await user.related('services').query(); + const workspaces = await user.related('workspaces').query(); + + const exportData = { + username: user.username, + lastname: user.lastname, + mail: user.email, + services: deepParseToJSON(JSON.parse(JSON.stringify(services))), + workspaces: deepParseToJSON(JSON.parse(JSON.stringify(workspaces))), + }; + + return response + .header('Content-Type', 'application/force-download') + .header('Content-disposition', 'attachment; filename=export.ferdium-data') + .send(exportData); + } +} diff --git a/app/Controllers/Http/Dashboard/ForgotPasswordController.ts b/app/Controllers/Http/Dashboard/ForgotPasswordController.ts new file mode 100644 index 0000000..da05bbd --- /dev/null +++ b/app/Controllers/Http/Dashboard/ForgotPasswordController.ts @@ -0,0 +1,41 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; +import User from 'App/Models/User'; + +export default class ForgotPasswordController { + /** + * Display the forgot password form + */ + public async show({ view }: HttpContextContract) { + return view.render('dashboard/forgotPassword'); + } + + /** + * Send forget password email to user + */ + public async forgotPassword({ view, request }: HttpContextContract) { + try { + await validator.validate({ + schema: schema.create({ + mail: schema.string([rules.email(), rules.required()]), + }), + data: request.only(['mail']), + }); + } catch { + return view.render('others/message', { + heading: 'Cannot reset your password', + text: 'Please enter a valid email address', + }); + } + + try { + const user = await User.findByOrFail('email', request.input('mail')); + await user.forgotPassword(); + } catch {} + + return view.render('others/message', { + heading: 'Reset password', + text: 'If your provided E-Mail address is linked to an account, we have just sent an E-Mail to that address.', + }); + } +} diff --git a/app/Controllers/Http/Dashboard/LogOutController.ts b/app/Controllers/Http/Dashboard/LogOutController.ts new file mode 100644 index 0000000..41cbd31 --- /dev/null +++ b/app/Controllers/Http/Dashboard/LogOutController.ts @@ -0,0 +1,12 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class LogOutController { + /** + * Login a user + */ + public async logout({ auth, response }: HttpContextContract) { + auth.logout(); + + return response.redirect('/user/login'); + } +} diff --git a/app/Controllers/Http/Dashboard/LoginController.ts b/app/Controllers/Http/Dashboard/LoginController.ts new file mode 100644 index 0000000..ffb9eeb --- /dev/null +++ b/app/Controllers/Http/Dashboard/LoginController.ts @@ -0,0 +1,81 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; +import User from 'App/Models/User'; +import crypto from 'node:crypto'; +import { handleVerifyAndReHash } from '../../../../helpers/PasswordHash'; + +export default class LoginController { + /** + * Display the login form + */ + public async show({ view }: HttpContextContract) { + return view.render('dashboard/login'); + } + + /** + * Login a user + */ + public async login({ + request, + response, + auth, + session, + }: HttpContextContract) { + try { + await validator.validate({ + schema: schema.create({ + mail: schema.string([rules.email(), rules.required()]), + password: schema.string([rules.required()]), + }), + data: request.only(['mail', 'password']), + }); + } catch { + session.flash({ + type: 'danger', + message: 'Invalid mail or password', + }); + session.flashExcept(['password']); + + return response.redirect('/user/login'); + } + + try { + const { mail, password } = request.all(); + + // Check if user with email exists + const user = await User.query().where('email', mail).first(); + if (!user?.email) { + throw new Error('User credentials not valid (Invalid email)'); + } + + const hashedPassword = crypto + .createHash('sha256') + .update(password) + .digest('base64'); + + // Verify password + let isMatchedPassword = false; + try { + isMatchedPassword = await handleVerifyAndReHash(user, hashedPassword); + } catch (error) { + return response.internalServerError({ message: error.message }); + } + + if (!isMatchedPassword) { + throw new Error('User credentials not valid (Invalid password)'); + } + + await auth.use('web').login(user); + + return response.redirect('/user/account'); + } catch { + session.flash({ + type: 'danger', + message: 'Invalid mail or password', + }); + session.flashExcept(['password']); + + return response.redirect('/user/login'); + } + } +} diff --git a/app/Controllers/Http/Dashboard/ResetPasswordController.ts b/app/Controllers/Http/Dashboard/ResetPasswordController.ts new file mode 100644 index 0000000..0b9053f --- /dev/null +++ b/app/Controllers/Http/Dashboard/ResetPasswordController.ts @@ -0,0 +1,85 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; +import Token from 'App/Models/Token'; +import moment from 'moment'; +import crypto from 'node:crypto'; + +export default class ResetPasswordController { + /** + * Display the reset password form + */ + public async show({ view, request }: HttpContextContract) { + const { token } = request.qs(); + + if (token) { + return view.render('dashboard/resetPassword', { token }); + } + + return view.render('others/message', { + heading: 'Invalid token', + text: 'Please make sure you are using a valid and recent link to reset your password.', + }); + } + + /** + * Resets user password + */ + public async resetPassword({ + response, + request, + session, + view, + }: HttpContextContract) { + try { + await validator.validate({ + schema: schema.create({ + password: schema.string([rules.required(), rules.confirmed()]), + token: schema.string([rules.required()]), + }), + data: request.only(['password', 'password_confirmation', 'token']), + }); + } catch { + session.flash({ + type: 'danger', + message: 'Passwords do not match', + }); + + return response.redirect(`/user/reset?token=${request.input('token')}`); + } + + const tokenRow = await Token.query() + .preload('user') + .where('token', request.input('token')) + .where('type', 'forgot_password') + .where('is_revoked', false) + .where( + 'updated_at', + '>=', + moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'), + ) + .first(); + + if (!tokenRow) { + return view.render('others/message', { + heading: 'Cannot reset your password', + text: 'Please make sure you are using a valid and recent link to reset your password and that your passwords entered match.', + }); + } + + // Update user password + const hashedPassword = crypto + .createHash('sha256') + .update(request.input('password')) + .digest('base64'); + tokenRow.user.password = hashedPassword; + await tokenRow.user.save(); + + // Delete token to prevent it from being used again + await tokenRow.delete(); + + return view.render('others/message', { + heading: 'Reset password', + text: 'Successfully reset your password. You can now login to your account using your new password.', + }); + } +} diff --git a/app/Controllers/Http/Dashboard/TransferController.ts b/app/Controllers/Http/Dashboard/TransferController.ts new file mode 100644 index 0000000..a005c1b --- /dev/null +++ b/app/Controllers/Http/Dashboard/TransferController.ts @@ -0,0 +1,128 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, validator } from '@ioc:Adonis/Core/Validator'; +import Service from 'App/Models/Service'; +import Workspace from 'App/Models/Workspace'; +import { v4 as uuidv4 } from 'uuid'; + +const importSchema = schema.create({ + username: schema.string(), + lastname: schema.string(), + mail: schema.string(), + services: schema.array().anyMembers(), + workspaces: schema.array().anyMembers(), +}); + +export default class TransferController { + /** + * Display the transfer page + */ + public async show({ view }: HttpContextContract) { + return view.render('dashboard/transfer'); + } + + public async import({ + auth, + request, + response, + session, + view, + }: HttpContextContract) { + let file; + try { + file = await validator.validate({ + schema: importSchema, + data: JSON.parse(request.body().file), + }); + } catch { + session.flash({ + message: 'Invalid Ferdium account file', + }); + + return response.redirect('/user/transfer'); + } + + if (!file?.services || !file.workspaces) { + session.flash({ + type: 'danger', + message: 'Invalid Ferdium account file (2)', + }); + return response.redirect('/user/transfer'); + } + + const serviceIdTranslation = {}; + + // Import services + try { + for (const service of file.services) { + // Get new, unused uuid + let serviceId; + do { + serviceId = uuidv4(); + } while ( + // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member + (await Service.query().where('serviceId', serviceId)).length > 0 + ); + + // eslint-disable-next-line no-await-in-loop + await Service.create({ + userId: auth.user?.id, + serviceId, + name: service.name, + recipeId: service.recipe_id, + settings: JSON.stringify(service.settings), + }); + + // @ts-expect-error Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}' + serviceIdTranslation[service.service_id] = serviceId; + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + const errorMessage = `Could not import your services into our system.\nError: ${error}`; + return view.render('others/message', { + heading: 'Error while importing', + text: errorMessage, + }); + } + + // Import workspaces + try { + for (const workspace of file.workspaces) { + let workspaceId; + + do { + workspaceId = uuidv4(); + } while ( + // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member + (await Workspace.query().where('workspaceId', workspaceId)).length > 0 + ); + + const services = workspace.services.map( + // @ts-expect-error Parameter 'service' implicitly has an 'any' type. + service => serviceIdTranslation[service], + ); + + // eslint-disable-next-line no-await-in-loop + await Workspace.create({ + userId: auth.user?.id, + workspaceId, + name: workspace.name, + order: workspace.order, + services: JSON.stringify(services), + data: JSON.stringify(workspace.data), + }); + } + } catch (error) { + const errorMessage = `Could not import your workspaces into our system.\nError: ${error}`; + return view.render('others/message', { + heading: 'Error while importing', + text: errorMessage, + }); + } + + return view.render('others/message', { + heading: 'Successfully imported', + text: 'Your account has been imported, you can now login as usual!', + }); + } +} -- cgit v1.2.3-54-g00ecf