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/Api/Static/AnnouncementsController.ts | 20 + app/Controllers/Http/Api/Static/EmptyController.ts | 7 + .../Http/Api/Static/FeaturesController.ts | 14 + .../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 +++++++ app/Controllers/Http/DashboardController.js | 321 ---------------- app/Controllers/Http/DashboardController.ts | 327 ++++++++++++++++ app/Controllers/Http/HealthController.ts | 10 + app/Controllers/Http/HomeController.ts | 9 + app/Controllers/Http/RecipeController.js | 226 ------------ app/Controllers/Http/RecipeController.ts | 254 +++++++++++++ app/Controllers/Http/ServiceController.js | 347 ----------------- app/Controllers/Http/ServiceController.ts | 365 ++++++++++++++++++ app/Controllers/Http/StaticController.js | 44 --- app/Controllers/Http/StaticsController.ts | 3 + app/Controllers/Http/UserController.js | 386 ------------------- app/Controllers/Http/UserController.ts | 410 +++++++++++++++++++++ app/Controllers/Http/WorkspaceController.js | 172 --------- app/Controllers/Http/WorkspaceController.ts | 180 +++++++++ app/Exceptions/Handler.js | 48 --- app/Exceptions/Handler.ts | 23 ++ app/Middleware/AllowGuestOnly.ts | 56 +++ app/Middleware/Auth.ts | 118 ++++++ app/Middleware/ConvertEmptyStringsToNull.js | 15 - app/Middleware/Dashboard.ts | 17 + app/Middleware/HandleDoubleSlash.js | 24 -- app/Middleware/SilentAuth.ts | 24 ++ app/Models/Recipe.js | 7 - app/Models/Recipe.ts | 23 ++ app/Models/Service.js | 10 - app/Models/Service.ts | 40 ++ app/Models/Token.js | 10 - app/Models/Token.ts | 38 ++ app/Models/Traits/NoTimestamp.js | 14 - app/Models/User.js | 46 --- app/Models/User.ts | 98 +++++ app/Models/Workspace.js | 10 - app/Models/Workspace.ts | 41 +++ 45 files changed, 2604 insertions(+), 1680 deletions(-) create mode 100644 app/Controllers/Http/Api/Static/AnnouncementsController.ts create mode 100644 app/Controllers/Http/Api/Static/EmptyController.ts create mode 100644 app/Controllers/Http/Api/Static/FeaturesController.ts 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 delete mode 100644 app/Controllers/Http/DashboardController.js create mode 100644 app/Controllers/Http/DashboardController.ts create mode 100644 app/Controllers/Http/HealthController.ts create mode 100644 app/Controllers/Http/HomeController.ts delete mode 100644 app/Controllers/Http/RecipeController.js create mode 100644 app/Controllers/Http/RecipeController.ts delete mode 100644 app/Controllers/Http/ServiceController.js create mode 100644 app/Controllers/Http/ServiceController.ts delete mode 100644 app/Controllers/Http/StaticController.js create mode 100644 app/Controllers/Http/StaticsController.ts delete mode 100644 app/Controllers/Http/UserController.js create mode 100644 app/Controllers/Http/UserController.ts delete mode 100644 app/Controllers/Http/WorkspaceController.js create mode 100644 app/Controllers/Http/WorkspaceController.ts delete mode 100644 app/Exceptions/Handler.js create mode 100644 app/Exceptions/Handler.ts create mode 100644 app/Middleware/AllowGuestOnly.ts create mode 100644 app/Middleware/Auth.ts delete mode 100644 app/Middleware/ConvertEmptyStringsToNull.js create mode 100644 app/Middleware/Dashboard.ts delete mode 100644 app/Middleware/HandleDoubleSlash.js create mode 100644 app/Middleware/SilentAuth.ts delete mode 100644 app/Models/Recipe.js create mode 100644 app/Models/Recipe.ts delete mode 100644 app/Models/Service.js create mode 100644 app/Models/Service.ts delete mode 100644 app/Models/Token.js create mode 100644 app/Models/Token.ts delete mode 100644 app/Models/Traits/NoTimestamp.js delete mode 100644 app/Models/User.js create mode 100644 app/Models/User.ts delete mode 100644 app/Models/Workspace.js create mode 100644 app/Models/Workspace.ts (limited to 'app') diff --git a/app/Controllers/Http/Api/Static/AnnouncementsController.ts b/app/Controllers/Http/Api/Static/AnnouncementsController.ts new file mode 100644 index 0000000..c20707b --- /dev/null +++ b/app/Controllers/Http/Api/Static/AnnouncementsController.ts @@ -0,0 +1,20 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import Application from '@ioc:Adonis/Core/Application'; +import path from 'node:path'; +import fs from 'fs-extra'; + +export default class AnnouncementsController { + public async show({ response, params }: HttpContextContract) { + const announcement = path.join( + Application.resourcesPath(), + 'announcements', + `${params.version}.json`, + ); + + if (await fs.pathExists(announcement)) { + return response.download(announcement); + } + + return response.status(404).send('No announcement found.'); + } +} diff --git a/app/Controllers/Http/Api/Static/EmptyController.ts b/app/Controllers/Http/Api/Static/EmptyController.ts new file mode 100644 index 0000000..a07790e --- /dev/null +++ b/app/Controllers/Http/Api/Static/EmptyController.ts @@ -0,0 +1,7 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class EmptyController { + public async show({ response }: HttpContextContract) { + return response.send([]); + } +} diff --git a/app/Controllers/Http/Api/Static/FeaturesController.ts b/app/Controllers/Http/Api/Static/FeaturesController.ts new file mode 100644 index 0000000..d471b11 --- /dev/null +++ b/app/Controllers/Http/Api/Static/FeaturesController.ts @@ -0,0 +1,14 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +export default class FeaturesController { + public async show({ response }: HttpContextContract) { + return response.send({ + isServiceProxyEnabled: true, + isWorkspaceEnabled: true, + isAnnouncementsEnabled: true, + isSettingsWSEnabled: false, + isMagicBarEnabled: true, + isTodosEnabled: true, + }); + } +} 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!', + }); + } +} diff --git a/app/Controllers/Http/DashboardController.js b/app/Controllers/Http/DashboardController.js deleted file mode 100644 index 9b3b08e..0000000 --- a/app/Controllers/Http/DashboardController.js +++ /dev/null @@ -1,321 +0,0 @@ -const { validateAll } = use('Validator'); - -const Service = use('App/Models/Service'); -const Workspace = use('App/Models/Workspace'); -const Persona = use('Persona'); - -const crypto = require('crypto'); -const { v4: uuid } = require('uuid'); - -class DashboardController { - async login({ request, response, auth, session }) { - const validation = await validateAll(request.all(), { - mail: 'required|email', - password: 'required', - }); - if (validation.fails()) { - session - .withErrors({ - type: 'danger', - message: 'Invalid mail or password', - }) - .flashExcept(['password']); - return response.redirect('back'); - } - - const { mail, password } = request.all(); - - const hashedPassword = crypto - .createHash('sha256') - .update(password) - .digest('base64'); - - try { - await auth.authenticator('session').attempt(mail, hashedPassword); - } catch (error) { - session.flash({ - type: 'danger', - message: 'Invalid mail or password', - }); - return response.redirect('back'); - } - return response.redirect('/user/account'); - } - - async forgotPassword({ request, view }) { - const validation = await validateAll(request.all(), { - mail: 'required|email', - }); - if (validation.fails()) { - return view.render('others.message', { - heading: 'Cannot reset your password', - text: 'If your provided E-Mail address is linked to an account, we have just sent an E-Mail to that address.', - }); - } - try { - await Persona.forgotPassword(request.input('mail')); - // eslint-disable-next-line no-empty - } catch (e) {} - - 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.', - }); - } - - async resetPassword({ request, view }) { - const validation = await validateAll(request.all(), { - password: 'required', - password_confirmation: 'required', - token: 'required', - }); - if (validation.fails()) { - session.withErrors({ - type: 'danger', - message: 'Passwords do not match', - }); - return response.redirect('back'); - } - - const payload = { - password: crypto - .createHash('sha256') - .update(request.input('password')) - .digest('base64'), - password_confirmation: crypto - .createHash('sha256') - .update(request.input('password_confirmation')) - .digest('base64'), - }; - - try { - await Persona.updatePasswordByToken(request.input('token'), payload); - } catch (e) { - 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.', - }); - } - - return view.render('others.message', { - heading: 'Reset password', - text: 'Successfully reset your password. You can now login to your account using your new password.', - }); - } - - async account({ auth, view, response }) { - try { - await auth.check(); - } catch (error) { - return response.redirect('/user/login'); - } - - return view.render('dashboard.account', { - username: auth.user.username, - email: auth.user.email, - lastname: auth.user.lastname, - }); - } - - async edit({ auth, request, session, view, response }) { - let validation = await validateAll(request.all(), { - username: 'required', - email: 'required', - lastname: 'required', - }); - if (validation.fails()) { - session.withErrors(validation.messages()).flashExcept(['password']); - return response.redirect('back'); - } - - // Check new username - if (request.input('username') !== auth.user.username) { - validation = await validateAll(request.all(), { - username: 'required|unique:users,username', - email: 'required', - }); - if (validation.fails()) { - session.withErrors(validation.messages()).flashExcept(['password']); - return response.redirect('back'); - } - } - - // Check new email - if (request.input('email') !== auth.user.email) { - validation = await validateAll(request.all(), { - username: 'required', - email: 'required|email|unique:users,email', - }); - if (validation.fails()) { - session.withErrors(validation.messages()).flashExcept(['password']); - return response.redirect('back'); - } - } - - // Update user account - const { user } = auth; - 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; - } - user.save(); - - return view.render('dashboard.account', { - username: user.username, - email: user.email, - success: true, - }); - } - - async data({ auth, view }) { - const general = auth.user; - const services = (await auth.user.services().fetch()).toJSON(); - const workspaces = (await auth.user.workspaces().fetch()).toJSON(); - - return view.render('dashboard.data', { - username: general.username, - lastname: general.lastname, - mail: general.email, - created: general.created_at, - updated: general.updated_at, - stringify: JSON.stringify, - services, - workspaces, - }); - } - - async export({ auth, response }) { - const general = auth.user; - const services = (await auth.user.services().fetch()).toJSON(); - const workspaces = (await auth.user.workspaces().fetch()).toJSON(); - - const exportData = { - username: general.username, - lastname: general.lastname, - mail: general.email, - services, - workspaces, - }; - - return response - .header('Content-Type', 'application/force-download') - .header('Content-disposition', 'attachment; filename=export.ferdium-data') - .send(exportData); - } - - async import({ auth, request, session, response, view }) { - const validation = await validateAll(request.all(), { - file: 'required', - }); - if (validation.fails()) { - session.withErrors(validation.messages()).flashExcept(['password']); - return response.redirect('back'); - } - - let file; - try { - file = JSON.parse(request.input('file')); - } catch (e) { - session.flash({ type: 'danger', message: 'Invalid Ferdium account file' }); - return response.redirect('back'); - } - - if (!file || !file.services || !file.workspaces) { - session.flash({ - type: 'danger', - message: 'Invalid Ferdium account file (2)', - }); - return response.redirect('back'); - } - - const serviceIdTranslation = {}; - - // Import services - try { - for (const service of file.services) { - // Get new, unused uuid - let serviceId; - do { - serviceId = uuid(); - } while ( - (await Service.query().where('serviceId', serviceId).fetch()).rows - .length > 0 - ); // eslint-disable-line no-await-in-loop - - await Service.create({ - // eslint-disable-line no-await-in-loop - userId: auth.user.id, - serviceId, - name: service.name, - recipeId: service.recipeId, - settings: JSON.stringify(service.settings), - }); - - serviceIdTranslation[service.serviceId] = serviceId; - } - } catch (e) { - const errorMessage = `Could not import your services into our system.\nError: ${e}`; - return view.render('others.message', { - heading: 'Error while importing', - text: errorMessage, - }); - } - - // Import workspaces - try { - for (const workspace of file.workspaces) { - let workspaceId; - do { - workspaceId = uuid(); - } while ( - (await Workspace.query().where('workspaceId', workspaceId).fetch()) - .rows.length > 0 - ); // eslint-disable-line no-await-in-loop - - const services = workspace.services.map( - service => serviceIdTranslation[service], - ); - - await Workspace.create({ - // eslint-disable-line no-await-in-loop - userId: auth.user.id, - workspaceId, - name: workspace.name, - order: workspace.order, - services: JSON.stringify(services), - data: JSON.stringify(workspace.data), - }); - } - } catch (e) { - const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; - 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!', - }); - } - - logout({ auth, response }) { - auth.authenticator('session').logout(); - return response.redirect('/user/login'); - } - - delete({ auth, response }) { - auth.user.delete(); - auth.authenticator('session').logout(); - return response.redirect('/user/login'); - } -} - -module.exports = DashboardController; diff --git a/app/Controllers/Http/DashboardController.ts b/app/Controllers/Http/DashboardController.ts new file mode 100644 index 0000000..a6f5b44 --- /dev/null +++ b/app/Controllers/Http/DashboardController.ts @@ -0,0 +1,327 @@ +// import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class DashboardController {} + +/* +const { validateAll } = use('Validator'); + +const Service = use('App/Models/Service'); +const Workspace = use('App/Models/Workspace'); +const Persona = use('Persona'); + +const crypto = require('crypto'); +const { v4: uuid } = require('uuid'); + +class DashboardController { + async login({ request, response, auth, session }) { + const validation = await validateAll(request.all(), { + mail: 'required|email', + password: 'required', + }); + if (validation.fails()) { + session + .withErrors({ + type: 'danger', + message: 'Invalid mail or password', + }) + .flashExcept(['password']); + return response.redirect('back'); + } + + const { mail, password } = request.all(); + + const hashedPassword = crypto + .createHash('sha256') + .update(password) + .digest('base64'); + + try { + await auth.authenticator('session').attempt(mail, hashedPassword); + } catch (error) { + session.flash({ + type: 'danger', + message: 'Invalid mail or password', + }); + return response.redirect('back'); + } + return response.redirect('/user/account'); + } + + async forgotPassword({ request, view }) { + const validation = await validateAll(request.all(), { + mail: 'required|email', + }); + if (validation.fails()) { + return view.render('others.message', { + heading: 'Cannot reset your password', + text: 'If your provided E-Mail address is linked to an account, we have just sent an E-Mail to that address.', + }); + } + try { + await Persona.forgotPassword(request.input('mail')); + // eslint-disable-next-line no-empty + } catch (e) {} + + 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.', + }); + } + + async resetPassword({ request, view }) { + const validation = await validateAll(request.all(), { + password: 'required', + password_confirmation: 'required', + token: 'required', + }); + if (validation.fails()) { + session.withErrors({ + type: 'danger', + message: 'Passwords do not match', + }); + return response.redirect('back'); + } + + const payload = { + password: crypto + .createHash('sha256') + .update(request.input('password')) + .digest('base64'), + password_confirmation: crypto + .createHash('sha256') + .update(request.input('password_confirmation')) + .digest('base64'), + }; + + try { + await Persona.updatePasswordByToken(request.input('token'), payload); + } catch (e) { + 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.', + }); + } + + return view.render('others.message', { + heading: 'Reset password', + text: 'Successfully reset your password. You can now login to your account using your new password.', + }); + } + + async account({ auth, view, response }) { + try { + await auth.check(); + } catch (error) { + return response.redirect('/user/login'); + } + + return view.render('dashboard.account', { + username: auth.user.username, + email: auth.user.email, + lastname: auth.user.lastname, + }); + } + + async edit({ auth, request, session, view, response }) { + let validation = await validateAll(request.all(), { + username: 'required', + email: 'required', + lastname: 'required', + }); + if (validation.fails()) { + session.withErrors(validation.messages()).flashExcept(['password']); + return response.redirect('back'); + } + + // Check new username + if (request.input('username') !== auth.user.username) { + validation = await validateAll(request.all(), { + username: 'required|unique:users,username', + email: 'required', + }); + if (validation.fails()) { + session.withErrors(validation.messages()).flashExcept(['password']); + return response.redirect('back'); + } + } + + // Check new email + if (request.input('email') !== auth.user.email) { + validation = await validateAll(request.all(), { + username: 'required', + email: 'required|email|unique:users,email', + }); + if (validation.fails()) { + session.withErrors(validation.messages()).flashExcept(['password']); + return response.redirect('back'); + } + } + + // Update user account + const { user } = auth; + 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; + } + user.save(); + + return view.render('dashboard.account', { + username: user.username, + email: user.email, + success: true, + }); + } + + async data({ auth, view }) { + const general = auth.user; + const services = (await auth.user.services().fetch()).toJSON(); + const workspaces = (await auth.user.workspaces().fetch()).toJSON(); + + return view.render('dashboard.data', { + username: general.username, + lastname: general.lastname, + mail: general.email, + created: general.created_at, + updated: general.updated_at, + stringify: JSON.stringify, + services, + workspaces, + }); + } + + async export({ auth, response }) { + const general = auth.user; + const services = (await auth.user.services().fetch()).toJSON(); + const workspaces = (await auth.user.workspaces().fetch()).toJSON(); + + const exportData = { + username: general.username, + lastname: general.lastname, + mail: general.email, + services, + workspaces, + }; + + return response + .header('Content-Type', 'application/force-download') + .header('Content-disposition', 'attachment; filename=export.ferdium-data') + .send(exportData); + } + + async import({ auth, request, session, response, view }) { + const validation = await validateAll(request.all(), { + file: 'required', + }); + if (validation.fails()) { + session.withErrors(validation.messages()).flashExcept(['password']); + return response.redirect('back'); + } + + let file; + try { + file = JSON.parse(request.input('file')); + } catch (e) { + session.flash({ type: 'danger', message: 'Invalid Ferdium account file' }); + return response.redirect('back'); + } + + if (!file || !file.services || !file.workspaces) { + session.flash({ + type: 'danger', + message: 'Invalid Ferdium account file (2)', + }); + return response.redirect('back'); + } + + const serviceIdTranslation = {}; + + // Import services + try { + for (const service of file.services) { + // Get new, unused uuid + let serviceId; + do { + serviceId = uuid(); + } while ( + (await Service.query().where('serviceId', serviceId).fetch()).rows + .length > 0 + ); // eslint-disable-line no-await-in-loop + + await Service.create({ + // eslint-disable-line no-await-in-loop + userId: auth.user.id, + serviceId, + name: service.name, + recipeId: service.recipeId, + settings: JSON.stringify(service.settings), + }); + + serviceIdTranslation[service.serviceId] = serviceId; + } + } catch (e) { + const errorMessage = `Could not import your services into our system.\nError: ${e}`; + return view.render('others.message', { + heading: 'Error while importing', + text: errorMessage, + }); + } + + // Import workspaces + try { + for (const workspace of file.workspaces) { + let workspaceId; + do { + workspaceId = uuid(); + } while ( + (await Workspace.query().where('workspaceId', workspaceId).fetch()) + .rows.length > 0 + ); // eslint-disable-line no-await-in-loop + + const services = workspace.services.map( + service => serviceIdTranslation[service], + ); + + await Workspace.create({ + // eslint-disable-line no-await-in-loop + userId: auth.user.id, + workspaceId, + name: workspace.name, + order: workspace.order, + services: JSON.stringify(services), + data: JSON.stringify(workspace.data), + }); + } + } catch (e) { + const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; + 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!', + }); + } + + logout({ auth, response }) { + auth.authenticator('session').logout(); + return response.redirect('/user/login'); + } + + delete({ auth, response }) { + auth.user.delete(); + auth.authenticator('session').logout(); + return response.redirect('/user/login'); + } +} + +module.exports = DashboardController; +*/ diff --git a/app/Controllers/Http/HealthController.ts b/app/Controllers/Http/HealthController.ts new file mode 100644 index 0000000..bf185d8 --- /dev/null +++ b/app/Controllers/Http/HealthController.ts @@ -0,0 +1,10 @@ +export default class HealthController { + public async index() { + // TODO: Actually do something instead of alwayas returning success. + + return { + api: 'success', + db: 'success', + }; + } +} diff --git a/app/Controllers/Http/HomeController.ts b/app/Controllers/Http/HomeController.ts new file mode 100644 index 0000000..dbe9fbd --- /dev/null +++ b/app/Controllers/Http/HomeController.ts @@ -0,0 +1,9 @@ +// import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class HomeController { + public async index() { + // TODO: Actually do something instead of alwayas returning success. + + return { hello: 'world' }; + } +} diff --git a/app/Controllers/Http/RecipeController.js b/app/Controllers/Http/RecipeController.js deleted file mode 100644 index cbdddaf..0000000 --- a/app/Controllers/Http/RecipeController.js +++ /dev/null @@ -1,226 +0,0 @@ -const Recipe = use('App/Models/Recipe'); -const Helpers = use('Helpers'); -const Drive = use('Drive'); -const { - validateAll, -} = use('Validator'); -const Env = use('Env'); - -const targz = require('targz'); -const path = require('path'); -const fs = require('fs-extra'); -const semver = require('semver'); - -const compress = (src, dest) => new Promise((resolve, reject) => { - targz.compress({ - src, - dest, - }, (err) => { - if (err) { - reject(err); - } else { - resolve(dest); - } - }); -}); - -class RecipeController { - // List official and custom recipes - async list({ - response, - }) { - const officialRecipes = fs.readJsonSync(path.join(Helpers.appRoot(), 'recipes', 'all.json')); - const customRecipesArray = (await Recipe.all()).rows; - const customRecipes = customRecipesArray.map((recipe) => ({ - id: recipe.recipeId, - name: recipe.name, - ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, - })); - - const recipes = [ - ...officialRecipes, - ...customRecipes, - ]; - - return response.send(recipes); - } - - // Create a new recipe using the new.html page - async create({ - request, - response, - }) { - // Check if recipe creation is enabled - if (Env.get('IS_CREATION_ENABLED') == 'false') { // eslint-disable-line eqeqeq - return response.send('This server doesn\'t allow the creation of new recipes.'); - } - - // Validate user input - const validation = await validateAll(request.all(), { - name: 'required|string', - id: 'required|unique:recipes,recipeId', - author: 'required|accepted', - svg: 'required|url', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid POST arguments', - messages: validation.messages(), - status: 401, - }); - } - - const data = request.all(); - - if (!data.id) { - return response.send('Please provide an ID'); - } - - // Check for invalid characters - if (/\.{1,}/.test(data.id) || /\/{1,}/.test(data.id)) { - return response.send('Invalid recipe name. Your recipe name may not contain "." or "/"'); - } - - // Clear temporary recipe folder - await fs.emptyDir(Helpers.tmpPath('recipe')); - - // Move uploaded files to temporary path - const files = request.file('files'); - await files.moveAll(Helpers.tmpPath('recipe')); - - // Compress files to .tar.gz file - const source = Helpers.tmpPath('recipe'); - const destination = path.join(Helpers.appRoot(), `/recipes/archives/${data.id}.tar.gz`); - - compress( - source, - destination, - ); - - // Create recipe in db - await Recipe.create({ - name: data.name, - recipeId: data.id, - data: JSON.stringify({ - author: data.author, - featured: false, - version: '1.0.0', - icons: { - svg: data.svg, - }, - }), - }); - - return response.send('Created new recipe'); - } - - // Search official and custom recipes - async search({ - request, - response, - }) { - // Validate user input - const validation = await validateAll(request.all(), { - needle: 'required', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Please provide a needle', - messages: validation.messages(), - status: 401, - }); - } - - const needle = request.input('needle'); - - // Get results - let results; - - if (needle === 'ferdium:custom') { - const dbResults = (await Recipe.all()).toJSON(); - results = dbResults.map((recipe) => ({ - id: recipe.recipeId, - name: recipe.name, - ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, - })); - } else { - const localResultsArray = (await Recipe.query().where('name', 'LIKE', `%${needle}%`).fetch()).toJSON(); - results = localResultsArray.map((recipe) => ({ - id: recipe.recipeId, - name: recipe.name, - ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, - })); - } - - return response.send(results); - } - - popularRecipes({ - response, - }) { - return response.send( - fs - .readJsonSync(path.join( - Helpers.appRoot(), 'recipes', 'all.json', - )) - .filter((recipe) => recipe.featured), - ); - } - - update({ request, response }) { - const updates = []; - const recipes = request.all(); - const allJson = fs.readJsonSync(path.join( - Helpers.appRoot(), 'recipes', 'all.json', - )); - - for (const recipe of Object.keys(recipes)) { - const version = recipes[recipe]; - - // Find recipe in local recipe repository - const localRecipe = allJson.find(r => r.id === recipe); - if (localRecipe && semver.lt(version, localRecipe.version)) { - updates.push(recipe); - } - } - - return response.send(updates); - } - - // Download a recipe - async download({ - response, - params, - }) { - // Validate user input - const validation = await validateAll(params, { - recipe: 'required|accepted', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Please provide a recipe ID', - messages: validation.messages(), - status: 401, - }); - } - - const service = params.recipe; - - // Check for invalid characters - if (/\.{1,}/.test(service) || /\/{1,}/.test(service)) { - return response.send('Invalid recipe name'); - } - - // Check if recipe exists in recipes folder - if (await Drive.exists(`${service}.tar.gz`)) { - return response.type('.tar.gz').send(await Drive.get(`${service}.tar.gz`)); - } - - return response.status(400).send({ - message: 'Recipe not found', - code: 'recipe-not-found', - }); - } -} - -module.exports = RecipeController; diff --git a/app/Controllers/Http/RecipeController.ts b/app/Controllers/Http/RecipeController.ts new file mode 100644 index 0000000..5186a11 --- /dev/null +++ b/app/Controllers/Http/RecipeController.ts @@ -0,0 +1,254 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import fs from 'fs-extra'; +import Application from '@ioc:Adonis/Core/Application'; +import path from 'node:path'; +import Recipe from 'App/Models/Recipe'; +import { isCreationEnabled } from 'Config/app'; +import { validator, schema, rules } from '@ioc:Adonis/Core/Validator'; +import targz from 'targz'; +import semver from 'semver'; +import Drive from '@ioc:Adonis/Core/Drive'; + +// TODO: This file needs to be refactored and cleaned up to include types + +const createSchema = schema.create({ + name: schema.string(), + id: schema.string([rules.unique({ table: 'recipes', column: 'recipeId' })]), + // TODO: Check if this is correct + // author: 'required|accepted', + author: schema.string(), + svg: schema.string([rules.url()]), +}); + +const searchSchema = schema.create({ + needle: schema.string(), +}); + +const downloadSchema = schema.create({ + // TODO: Check if this is correct + // recipe: 'required|accepted', + recipe: schema.string(), +}); + +const compress = (src: string, dest: string) => + new Promise((resolve, reject) => { + targz.compress( + { + src, + dest, + }, + err => { + if (err) { + reject(err); + } else { + resolve(dest); + } + }, + ); + }); + +export default class RecipesController { + // List official and custom recipes + public async list({ response }: HttpContextContract) { + const officialRecipes = fs.readJsonSync( + path.join(Application.appRoot, 'recipes', 'all.json'), + ); + const customRecipesArray = await Recipe.all(); + const customRecipes = customRecipesArray.map(recipe => ({ + id: recipe.recipeId, + name: recipe.name, + ...(typeof recipe.data === 'string' + ? JSON.parse(recipe.data) + : recipe.data), + })); + + const recipes = [...officialRecipes, ...customRecipes]; + + return response.send(recipes); + } + + // TODO: Test this endpoint + // Create a new recipe using the new.html page + public async create({ request, response }: HttpContextContract) { + // Check if recipe creation is enabled + if (isCreationEnabled === 'false') { + return response.send( + 'This server doesn\'t allow the creation of new recipes.', + ); + } + + // Validate user input + let data; + try { + data = await request.validate({ schema: createSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid POST arguments', + messages: error.messages, + status: 401, + }); + } + + if (!data.id) { + return response.send('Please provide an ID'); + } + + // Check for invalid characters + if (/\.+/.test(data.id) || /\/+/.test(data.id)) { + return response.send( + 'Invalid recipe name. Your recipe name may not contain "." or "/"', + ); + } + + // Clear temporary recipe folder + await fs.emptyDir(Application.tmpPath('recipe')); + + // Move uploaded files to temporary path + const files = request.file('files'); + if (!files) { + return response.abort('Error processsing files.'); + } + await files.move(Application.tmpPath('recipe')); + + // Compress files to .tar.gz file + const source = Application.tmpPath('recipe'); + const destination = path.join( + Application.appRoot, + `/recipes/archives/${data.id}.tar.gz`, + ); + + compress(source, destination); + + // Create recipe in db + await Recipe.create({ + name: data.name, + recipeId: data.id, + // @ts-expect-error + data: JSON.stringify({ + author: data.author, + featured: false, + version: '1.0.0', + icons: { + svg: data.svg, + }, + }), + }); + + return response.send('Created new recipe'); + } + + // Search official and custom recipes + public async search({ request, response }: HttpContextContract) { + // Validate user input + let data; + try { + data = await request.validate({ schema: searchSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Please provide a needle', + messages: error.messages, + status: 401, + }); + } + + const { needle } = data; + + // Get results + let results; + + if (needle === 'ferdium:custom') { + const dbResults = await Recipe.all(); + results = dbResults.map(recipe => ({ + id: recipe.recipeId, + name: recipe.name, + ...(typeof recipe.data === 'string' + ? JSON.parse(recipe.data) + : recipe.data), + })); + } else { + const localResultsArray = await Recipe.query().where( + 'name', + 'LIKE', + `%${needle}%`, + ); + results = localResultsArray.map(recipe => ({ + id: recipe.recipeId, + name: recipe.name, + ...(typeof recipe.data === 'string' + ? JSON.parse(recipe.data) + : recipe.data), + })); + } + + return response.send(results); + } + + public popularRecipes({ response }: HttpContextContract) { + return response.send( + fs + .readJsonSync(path.join(Application.appRoot, 'recipes', 'all.json')) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((recipe: any) => recipe.featured), + ); + } + + // TODO: test this endpoint + public update({ request, response }: HttpContextContract) { + const updates = []; + const recipes = request.all(); + const allJson = fs.readJsonSync( + path.join(Application.appRoot, 'recipes', 'all.json'), + ); + + for (const recipe of Object.keys(recipes)) { + const version = recipes[recipe]; + + // Find recipe in local recipe repository + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const localRecipe = allJson.find((r: any) => r.id === recipe); + if (localRecipe && semver.lt(version, localRecipe.version)) { + updates.push(recipe); + } + } + + return response.send(updates); + } + + // TODO: test this endpoint + // Download a recipe + public async download({ response, params }: HttpContextContract) { + // Validate user input + let data; + try { + data = await validator.validate({ + data: params, + schema: downloadSchema, + }); + } catch (error) { + return response.status(401).send({ + message: 'Please provide a recipe ID', + messages: error.messages, + status: 401, + }); + } + + const service = data.recipe; + + // Check for invalid characters + if (/\.+/.test(service) || /\/+/.test(service)) { + return response.send('Invalid recipe name'); + } + + // Check if recipe exists in recipes folder + if (await Drive.exists(`${service}.tar.gz`)) { + return response + .type('.tar.gz') + .send(await Drive.get(`${service}.tar.gz`)); + } + + return response.status(400).send({ + message: 'Recipe not found', + code: 'recipe-not-found', + }); + } +} diff --git a/app/Controllers/Http/ServiceController.js b/app/Controllers/Http/ServiceController.js deleted file mode 100644 index 6a27ef2..0000000 --- a/app/Controllers/Http/ServiceController.js +++ /dev/null @@ -1,347 +0,0 @@ -const Service = use('App/Models/Service'); -const { validateAll } = use('Validator'); -const Env = use('Env'); -const Helpers = use('Helpers'); - -const { v4: uuid } = require('uuid'); -const path = require('path'); -const fs = require('fs-extra'); -const sanitize = require('sanitize-filename'); - -class ServiceController { - // Create a new service for user - async create({ request, response, auth }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - // Validate user input - const validation = await validateAll(request.all(), { - name: 'required|string', - recipeId: 'required', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid POST arguments', - messages: validation.messages(), - status: 401, - }); - } - - const data = request.all(); - - // Get new, unused uuid - let serviceId; - do { - serviceId = uuid(); - } while ( - (await Service.query().where('serviceId', serviceId).fetch()).rows - .length > 0 - ); // eslint-disable-line no-await-in-loop - - await Service.create({ - userId: auth.user.id, - serviceId, - name: data.name, - recipeId: data.recipeId, - settings: JSON.stringify(data), - }); - - return response.send({ - data: { - userId: auth.user.id, - id: serviceId, - isEnabled: true, - isNotificationEnabled: true, - isBadgeEnabled: true, - isMuted: false, - isDarkModeEnabled: '', - spellcheckerLanguage: '', - order: 1, - customRecipe: false, - hasCustomIcon: false, - workspaces: [], - iconUrl: null, - ...data, - }, - status: ['created'], - }); - } - - // List all services a user has created - async list({ response, auth }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - const services = (await auth.user.services().fetch()).rows; - // Convert to array with all data Franz wants - const servicesArray = services.map(service => { - const settings = - typeof service.settings === 'string' - ? JSON.parse(service.settings) - : service.settings; - - return { - customRecipe: false, - hasCustomIcon: !!settings.iconId, - isBadgeEnabled: true, - isDarkModeEnabled: '', - isEnabled: true, - isMuted: false, - isNotificationEnabled: true, - order: 1, - spellcheckerLanguage: '', - workspaces: [], - ...settings, - iconUrl: settings.iconId - ? `${Env.get('APP_URL')}/v1/icon/${settings.iconId}` - : null, - id: service.serviceId, - name: service.name, - recipeId: service.recipeId, - userId: auth.user.id, - }; - }); - - return response.send(servicesArray); - } - - async edit({ request, response, auth, params }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - if (request.file('icon')) { - // Upload custom service icon - const icon = request.file('icon', { - types: ['image'], - size: '2mb', - }); - const { id } = params; - const service = ( - await Service.query() - .where('serviceId', id) - .where('userId', auth.user.id) - .fetch() - ).rows[0]; - const settings = - typeof service.settings === 'string' - ? JSON.parse(service.settings) - : service.settings; - - let iconId; - do { - iconId = uuid() + uuid(); - // eslint-disable-next-line no-await-in-loop - } while (await fs.exists(path.join(Helpers.tmpPath('uploads'), iconId))); - iconId = `${iconId}.${icon.extname}`; - - await icon.move(Helpers.tmpPath('uploads'), { - name: iconId, - overwrite: true, - }); - - if (!icon.moved()) { - return response.status(500).send(icon.error()); - } - - const newSettings = { - ...settings, - ...{ - iconId, - customIconVersion: - settings && settings.customIconVersion - ? settings.customIconVersion + 1 - : 1, - }, - }; - - // Update data in database - await Service.query() - .where('serviceId', id) - .where('userId', auth.user.id) - .update({ - name: service.name, - settings: JSON.stringify(newSettings), - }); - - return response.send({ - data: { - id, - name: service.name, - ...newSettings, - iconUrl: `${Env.get('APP_URL')}/v1/icon/${newSettings.iconId}`, - userId: auth.user.id, - }, - status: ['updated'], - }); - } - // Update service info - const data = request.all(); - const { id } = params; - - // Get current settings from db - const serviceData = ( - await Service.query() - .where('serviceId', id) - .where('userId', auth.user.id) - .fetch() - ).rows[0]; - - const settings = { - ...(typeof serviceData.settings === 'string' - ? JSON.parse(serviceData.settings) - : serviceData.settings), - ...data, - }; - - if (settings.customIcon === 'delete') { - fs.remove( - path.join(Helpers.tmpPath('uploads'), settings.iconId), - ).catch(error => { - console.error(error); - }); - - settings.iconId = undefined; - settings.customIconVersion = undefined; - settings.customIcon = ''; - } - - // Update data in database - await Service.query() - .where('serviceId', id) - .where('userId', auth.user.id) - .update({ - name: data.name, - settings: JSON.stringify(settings), - }); - - // Get updated row - const service = ( - await Service.query() - .where('serviceId', id) - .where('userId', auth.user.id) - .fetch() - ).rows[0]; - - return response.send({ - data: { - id, - name: service.name, - ...settings, - iconUrl: `${Env.get('APP_URL')}/v1/icon/${settings.iconId}`, - userId: auth.user.id, - }, - status: ['updated'], - }); - } - - async icon({ params, response }) { - let { id } = params; - - id = sanitize(id); - if (id === '') { - return response.status(404).send({ - status: "Icon doesn't exist", - }); - } - - const iconPath = path.join(Helpers.tmpPath('uploads'), id); - - try { - await fs.access(iconPath); - } catch (ex) { - console.log(ex); - // File not available. - return response.status(404).send({ - status: "Icon doesn't exist", - }); - } - - return response.download(iconPath); - } - - async reorder({ request, response, auth }) { - const data = request.all(); - - for (const service of Object.keys(data)) { - // Get current settings from db - const serviceData = ( - await Service.query() // eslint-disable-line no-await-in-loop - .where('serviceId', service) - .where('userId', auth.user.id) - .fetch() - ).rows[0]; - - const settings = { - ...(typeof serviceData.settings === 'string' - ? JSON.parse(serviceData.settings) - : serviceData.settings), - order: data[service], - }; - - // Update data in database - await Service.query() // eslint-disable-line no-await-in-loop - .where('serviceId', service) - .where('userId', auth.user.id) - .update({ - settings: JSON.stringify(settings), - }); - } - - // Get new services - const services = (await auth.user.services().fetch()).rows; - // Convert to array with all data Franz wants - const servicesArray = services.map(service => { - const settings = - typeof service.settings === 'string' - ? JSON.parse(service.settings) - : service.settings; - - return { - customRecipe: false, - hasCustomIcon: !!settings.iconId, - isBadgeEnabled: true, - isDarkModeEnabled: '', - isEnabled: true, - isMuted: false, - isNotificationEnabled: true, - order: 1, - spellcheckerLanguage: '', - workspaces: [], - ...settings, - iconUrl: settings.iconId - ? `${Env.get('APP_URL')}/v1/icon/${settings.iconId}` - : null, - id: service.serviceId, - name: service.name, - recipeId: service.recipeId, - userId: auth.user.id, - }; - }); - - return response.send(servicesArray); - } - - async delete({ params, auth, response }) { - // Update data in database - await Service.query() - .where('serviceId', params.id) - .where('userId', auth.user.id) - .delete(); - - return response.send({ - message: 'Sucessfully deleted service', - status: 200, - }); - } -} - -module.exports = ServiceController; diff --git a/app/Controllers/Http/ServiceController.ts b/app/Controllers/Http/ServiceController.ts new file mode 100644 index 0000000..36c6ca4 --- /dev/null +++ b/app/Controllers/Http/ServiceController.ts @@ -0,0 +1,365 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema } from '@ioc:Adonis/Core/Validator'; +import Service from 'App/Models/Service'; +import { url } from 'Config/app'; +import { v4 as uuid } from 'uuid'; +import * as fs from 'fs-extra'; +import path from 'node:path'; +import Application from '@ioc:Adonis/Core/Application'; +import sanitize from 'sanitize-filename'; + +const createSchema = schema.create({ + name: schema.string(), + recipeId: schema.string(), +}); + +export default class ServicesController { + // Create a new service for user + public async create({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + // Validate user input + let data; + try { + data = await request.validate({ schema: createSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid POST arguments', + messages: error.messages, + status: 401, + }); + } + + // Get new, unused uuid + let serviceId; + do { + serviceId = uuid(); + } while ( + // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member + (await Service.query().where('serviceId', serviceId)).length > 0 + ); + + await Service.create({ + userId: user.id, + serviceId, + name: data.name, + recipeId: data.recipeId, + settings: JSON.stringify(data), + }); + + return response.send({ + data: { + userId: user.id, + id: serviceId, + isEnabled: true, + isNotificationEnabled: true, + isBadgeEnabled: true, + isMuted: false, + isDarkModeEnabled: '', + spellcheckerLanguage: '', + order: 1, + customRecipe: false, + hasCustomIcon: false, + workspaces: [], + // eslint-disable-next-line unicorn/no-null + iconUrl: null, + ...data, + }, + status: ['created'], + }); + } + + // List all services a user has created + public async list({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + const { id } = user; + const services = await user.related('services').query(); + + // Convert to array with all data Franz wants + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const servicesArray = services.map((service: any) => { + const settings = + typeof service.settings === 'string' + ? JSON.parse(service.settings) + : service.settings; + + return { + customRecipe: false, + hasCustomIcon: !!settings.iconId, + isBadgeEnabled: true, + isDarkModeEnabled: '', + isEnabled: true, + isMuted: false, + isNotificationEnabled: true, + order: 1, + spellcheckerLanguage: '', + workspaces: [], + ...settings, + iconUrl: settings.iconId + ? `${url}/v1/icon/${settings.iconId}` + : // eslint-disable-next-line unicorn/no-null + null, + id: service.serviceId, + name: service.name, + recipeId: service.recipeId, + userId: id, + }; + }); + + return response.send(servicesArray); + } + + public async delete({ params, auth, response }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + // Update data in database + await Service.query() + .where('serviceId', params.id) + .where('userId', user.id) + .delete(); + + return response.send({ + message: 'Sucessfully deleted service', + status: 200, + }); + } + + // TODO: Test if icon upload works + public async edit({ request, response, auth, params }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + const { id } = params; + const service = await Service.query() + .where('serviceId', id) + .where('userId', user.id) + .firstOrFail(); + + if (request.file('icon')) { + // Upload custom service icon + const icon = request.file('icon', { + extnames: ['png', 'jpg', 'jpeg', 'svg'], + size: '2mb', + }); + + if (icon === null) { + return response.badRequest('Icon not uploaded.'); + } + + const settings = + typeof service.settings === 'string' + ? JSON.parse(service.settings) + : service.settings; + + let iconId; + do { + iconId = uuid() + uuid(); + } while ( + // eslint-disable-next-line no-await-in-loop + await fs.exists(path.join(Application.tmpPath('uploads'), iconId)) + ); + iconId = `${iconId}.${icon.extname}`; + + await icon.move(Application.tmpPath('uploads'), { + name: iconId, + overwrite: true, + }); + + if (icon.state !== 'moved') { + return response.status(500).send(icon.errors); + } + + const newSettings = { + ...settings, + + iconId, + customIconVersion: settings?.customIconVersion + ? settings.customIconVersion + 1 + : 1, + }; + + // Update data in database + await Service.query() + .where('serviceId', id) + .where('userId', user.id) + .update({ + name: service.name, + settings: JSON.stringify(newSettings), + }); + + return response.send({ + data: { + id, + name: service.name, + ...newSettings, + iconUrl: `${url}/v1/icon/${newSettings.iconId}`, + userId: user.id, + }, + status: ['updated'], + }); + } + // Update service info + const data = request.all(); + + const settings = { + ...(typeof service.settings === 'string' + ? JSON.parse(service.settings) + : service.settings), + ...data, + }; + + if (settings.customIcon === 'delete') { + fs.remove( + path.join(Application.tmpPath('uploads'), settings.iconId), + ).catch(error => { + console.error(error); + }); + + settings.iconId = undefined; + settings.customIconVersion = undefined; + settings.customIcon = ''; + } + + // Update data in database + await Service.query() + .where('serviceId', id) + .where('userId', user.id) + .update({ + name: data.name, + settings: JSON.stringify(settings), + }); + + // Get updated row + const serviceUpdated = await Service.query() + .where('serviceId', id) + .where('userId', user.id) + .firstOrFail(); + + return response.send({ + data: { + id, + name: serviceUpdated.name, + ...settings, + iconUrl: `${url}/v1/icon/${settings.iconId}`, + userId: user.id, + }, + status: ['updated'], + }); + } + + // TODO: Test if this works + public async reorder({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + const data = request.all(); + + for (const service of Object.keys(data)) { + // Get current settings from db + const serviceData = await Service.query() // eslint-disable-line no-await-in-loop + .where('serviceId', service) + .where('userId', user.id) + + .firstOrFail(); + + const settings = { + ...(typeof serviceData.settings === 'string' + ? JSON.parse(serviceData.settings) + : serviceData.settings), + order: data[service], + }; + + // Update data in database + await Service.query() // eslint-disable-line no-await-in-loop + .where('serviceId', service) + .where('userId', user.id) + .update({ + settings: JSON.stringify(settings), + }); + } + + // Get new services + const services = await user.related('services').query(); + // Convert to array with all data Franz wants + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const servicesArray = services.map((service: any) => { + const settings = + typeof service.settings === 'string' + ? JSON.parse(service.settings) + : service.settings; + + return { + customRecipe: false, + hasCustomIcon: !!settings.iconId, + isBadgeEnabled: true, + isDarkModeEnabled: '', + isEnabled: true, + isMuted: false, + isNotificationEnabled: true, + order: 1, + spellcheckerLanguage: '', + workspaces: [], + ...settings, + iconUrl: settings.iconId + ? `${url}/v1/icon/${settings.iconId}` + : // eslint-disable-next-line unicorn/no-null + null, + id: service.serviceId, + name: service.name, + recipeId: service.recipeId, + userId: user.id, + }; + }); + + return response.send(servicesArray); + } + + // TODO: Test if this works + public async icon({ params, response }: HttpContextContract) { + let { id } = params; + + id = sanitize(id); + if (id === '') { + return response.status(404).send({ + status: 'Icon doesn\'t exist', + }); + } + + const iconPath = path.join(Application.tmpPath('uploads'), id); + + try { + await fs.access(iconPath); + } catch { + // File not available. + return response.status(404).send({ + status: 'Icon doesn\'t exist', + }); + } + + return response.download(iconPath); + } +} diff --git a/app/Controllers/Http/StaticController.js b/app/Controllers/Http/StaticController.js deleted file mode 100644 index 82cf1db..0000000 --- a/app/Controllers/Http/StaticController.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Controller for routes with static responses - */ -const Helpers = use('Helpers'); -const fs = require('fs-extra'); -const path = require('path'); - -class StaticController { - // Enable all features - features({ - response, - }) { - return response.send({ - isServiceProxyEnabled: true, - isWorkspaceEnabled: true, - isAnnouncementsEnabled: true, - isSettingsWSEnabled: false, - isMagicBarEnabled: true, - isTodosEnabled: true, - }); - } - - // Return an empty array - emptyArray({ - response, - }) { - return response.send([]); - } - - // Show announcements - async announcement({ - response, - params, - }) { - const announcement = path.join(Helpers.resourcesPath(), 'announcements', `${params.version}.json`); - - if (await fs.pathExists(announcement)) { - return response.download(announcement); - } - return response.status(404).send('No announcement found.'); - } -} - -module.exports = StaticController; diff --git a/app/Controllers/Http/StaticsController.ts b/app/Controllers/Http/StaticsController.ts new file mode 100644 index 0000000..e221177 --- /dev/null +++ b/app/Controllers/Http/StaticsController.ts @@ -0,0 +1,3 @@ +// import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class StaticsController {} diff --git a/app/Controllers/Http/UserController.js b/app/Controllers/Http/UserController.js deleted file mode 100644 index aef7f01..0000000 --- a/app/Controllers/Http/UserController.js +++ /dev/null @@ -1,386 +0,0 @@ -const User = use('App/Models/User'); -const Service = use('App/Models/Service'); -const Workspace = use('App/Models/Workspace'); -const { validateAll } = use('Validator'); -const Env = use('Env'); - -const atob = require('atob'); -const btoa = require('btoa'); -const fetch = require('node-fetch'); -const { v4: uuid } = require('uuid'); -const crypto = require('crypto'); - -// TODO: This whole controller needs to be changed such that it can support importing from both Franz and Ferdi -const franzRequest = (route, method, auth) => - new Promise((resolve, reject) => { - const base = 'https://api.franzinfra.com/v1/'; - const user = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; - - try { - fetch(base + route, { - method, - headers: { - Authorization: `Bearer ${auth}`, - 'User-Agent': user, - }, - }) - .then(data => data.json()) - .then(json => resolve(json)); - } catch (e) { - reject(); - } - }); - -class UserController { - // Register a new user - async signup({ request, response, auth }) { - if (Env.get('IS_REGISTRATION_ENABLED') == 'false') { - // eslint-disable-line eqeqeq - return response.status(401).send({ - message: 'Registration is disabled on this server', - status: 401, - }); - } - - // Validate user input - const validation = await validateAll(request.all(), { - firstname: 'required', - lastname: 'required', - email: 'required|email|unique:users,email', - password: 'required', - }); - - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid POST arguments', - messages: validation.messages(), - status: 401, - }); - } - - const data = request.only(['firstname', 'lastname', 'email', 'password']); - - // Create user in DB - let user; - try { - user = await User.create({ - email: data.email, - password: data.password, - username: data.firstname, - lastname: data.lastname, - }); - } catch (e) { - return response.status(401).send({ - message: 'E-Mail Address already in use', - status: 401, - }); - } - - // Generate new auth token - const token = await auth.generate(user); - - return response.send({ - message: 'Successfully created account', - token: token.token, - }); - } - - // Login using an existing user - async login({ request, response, auth }) { - if (!request.header('Authorization')) { - return response.status(401).send({ - message: 'Please provide authorization', - status: 401, - }); - } - - // Get auth data from auth token - const authHeader = atob( - request.header('Authorization').replace('Basic ', ''), - ).split(':'); - - // Check if user with email exists - const user = await User.query().where('email', authHeader[0]).first(); - if (!user || !user.email) { - return response.status(401).send({ - message: 'User credentials not valid (Invalid mail)', - code: 'invalid-credentials', - status: 401, - }); - } - - // Try to login - let token; - try { - token = await auth.attempt(user.email, authHeader[1]); - } catch (e) { - return response.status(401).send({ - message: 'User credentials not valid', - code: 'invalid-credentials', - status: 401, - }); - } - - return response.send({ - message: 'Successfully logged in', - token: token.token, - }); - } - - // Return information about the current user - async me({ response, auth }) { - try { - await auth.getUser(); - } catch (error) { - response.send('Missing or invalid api token'); - } - - const settings = - typeof auth.user.settings === 'string' - ? JSON.parse(auth.user.settings) - : auth.user.settings; - - return response.send({ - accountType: 'individual', - beta: false, - donor: {}, - email: auth.user.email, - emailValidated: true, - features: {}, - firstname: auth.user.username, - id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', - isPremium: true, - isSubscriptionOwner: true, - lastname: auth.user.lastname, - locale: 'en-US', - ...(settings || {}), - }); - } - - async updateMe({ request, response, auth }) { - let settings = auth.user.settings || {}; - if (typeof settings === 'string') { - settings = JSON.parse(settings); - } - - const newSettings = { - ...settings, - ...request.all(), - }; - - // eslint-disable-next-line no-param-reassign - auth.user.settings = JSON.stringify(newSettings); - await auth.user.save(); - - return response.send({ - data: { - accountType: 'individual', - beta: false, - donor: {}, - email: auth.user.email, - emailValidated: true, - features: {}, - firstname: auth.user.username, - id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', - isPremium: true, - isSubscriptionOwner: true, - lastname: auth.user.lastname, - locale: 'en-US', - ...(newSettings || {}), - }, - status: ['data-updated'], - }); - } - - async import({ request, response, view }) { - if (Env.get('IS_REGISTRATION_ENABLED') == 'false') { - // eslint-disable-line eqeqeq - return response.status(401).send({ - message: 'Registration is disabled on this server', - status: 401, - }); - } - - // Validate user input - const validation = await validateAll(request.all(), { - email: 'required|email|unique:users,email', - password: 'required', - }); - if (validation.fails()) { - let errorMessage = - 'There was an error while trying to import your account:\n'; - for (const message of validation.messages()) { - if (message.validation === 'required') { - errorMessage += `- Please make sure to supply your ${message.field}\n`; - } else if (message.validation === 'unique') { - errorMessage += '- There is already a user with this email.\n'; - } else { - errorMessage += `${message.message}\n`; - } - } - return view.render('others.message', { - heading: 'Error while importing', - text: errorMessage, - }); - } - - const { email, password } = request.all(); - - const hashedPassword = crypto - .createHash('sha256') - .update(password) - .digest('base64'); - - if (Env.get('CONNECT_WITH_FRANZ') == 'false') { - // eslint-disable-line eqeqeq - await User.create({ - email, - password: hashedPassword, - username: 'Franz', - lastname: 'Franz', - }); - - return response.send( - "Your account has been created but due to this server's configuration, we could not import your Franz account data.\n\nIf you are the server owner, please set CONNECT_WITH_FRANZ to true to enable account imports.", - ); - } - - const base = 'https://api.franzinfra.com/v1/'; - const userAgent = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; - - // Try to get an authentication token - let token; - try { - const basicToken = btoa(`${email}:${hashedPassword}`); - const loginBody = { - isZendeskLogin: false, - }; - - const rawResponse = await fetch(`${base}auth/login`, { - method: 'POST', - body: JSON.stringify(loginBody), - headers: { - Authorization: `Basic ${basicToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - accept: '*/*', - 'x-franz-source': 'Web', - }, - }); - const content = await rawResponse.json(); - - if (!content.message || content.message !== 'Successfully logged in') { - const errorMessage = - 'Could not login into Franz with your supplied credentials. Please check and try again'; - return response.status(401).send(errorMessage); - } - - token = content.token; - } catch (e) { - return response.status(401).send({ - message: 'Cannot login to Franz', - error: e, - }); - } - - // Get user information - let userInf = false; - try { - userInf = await franzRequest('me', 'GET', token); - } catch (e) { - const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${e}`; - return response.status(401).send(errorMessage); - } - if (!userInf) { - const errorMessage = - 'Could not get your user info from Franz. Please check your credentials or try again later'; - return response.status(401).send(errorMessage); - } - - // Create user in DB - let user; - try { - user = await User.create({ - email: userInf.email, - password: hashedPassword, - username: userInf.firstname, - lastname: userInf.lastname, - }); - } catch (e) { - const errorMessage = `Could not create your user in our system.\nError: ${e}`; - return response.status(401).send(errorMessage); - } - - const serviceIdTranslation = {}; - - // Import services - try { - const services = await franzRequest('me/services', 'GET', token); - - for (const service of services) { - // Get new, unused uuid - let serviceId; - do { - serviceId = uuid(); - } while ( - (await Service.query().where('serviceId', serviceId).fetch()).rows - .length > 0 - ); // eslint-disable-line no-await-in-loop - - await Service.create({ - // eslint-disable-line no-await-in-loop - userId: user.id, - serviceId, - name: service.name, - recipeId: service.recipeId, - settings: JSON.stringify(service), - }); - - serviceIdTranslation[service.id] = serviceId; - } - } catch (e) { - const errorMessage = `Could not import your services into our system.\nError: ${e}`; - return response.status(401).send(errorMessage); - } - - // Import workspaces - try { - const workspaces = await franzRequest('workspace', 'GET', token); - - for (const workspace of workspaces) { - let workspaceId; - do { - workspaceId = uuid(); - } while ( - (await Workspace.query().where('workspaceId', workspaceId).fetch()) - .rows.length > 0 - ); // eslint-disable-line no-await-in-loop - - const services = workspace.services.map( - service => serviceIdTranslation[service], - ); - - await Workspace.create({ - // eslint-disable-line no-await-in-loop - userId: user.id, - workspaceId, - name: workspace.name, - order: workspace.order, - services: JSON.stringify(services), - data: JSON.stringify({}), - }); - } - } catch (e) { - const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; - return response.status(401).send(errorMessage); - } - - return response.send( - 'Your account has been imported. You can now use your Franz/Ferdi account in Ferdium.', - ); - } -} - -module.exports = UserController; diff --git a/app/Controllers/Http/UserController.ts b/app/Controllers/Http/UserController.ts new file mode 100644 index 0000000..ef7cfdd --- /dev/null +++ b/app/Controllers/Http/UserController.ts @@ -0,0 +1,410 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules } from '@ioc:Adonis/Core/Validator'; +import User from 'App/Models/User'; +import { connectWithFranz, isRegistrationEnabled } from '../../../config/app'; +import crypto from 'node:crypto'; +import { v4 as uuid } from 'uuid'; +import Workspace from 'App/Models/Workspace'; +import Service from 'App/Models/Service'; +import fetch from 'node-fetch'; + +// TODO: This file needs to be refactored and cleaned up to include types +import { handleVerifyAndReHash } from '../../../helpers/PasswordHash'; + +const newPostSchema = schema.create({ + firstname: schema.string(), + lastname: schema.string(), + email: schema.string([ + rules.email(), + rules.unique({ table: 'users', column: 'email' }), + ]), + password: schema.string([rules.minLength(8)]), +}); + +const franzImportSchema = schema.create({ + email: schema.string([ + rules.email(), + rules.unique({ table: 'users', column: 'email' }), + ]), + password: schema.string([rules.minLength(8)]), +}); + +// // TODO: This whole controller needs to be changed such that it can support importing from both Franz and Ferdi +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const franzRequest = (route: any, method: any, auth: any) => + new Promise((resolve, reject) => { + const base = 'https://api.franzinfra.com/v1/'; + const user = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; + + try { + fetch(base + route, { + method, + headers: { + Authorization: `Bearer ${auth}`, + 'User-Agent': user, + }, + }) + .then(data => data.json()) + .then(json => resolve(json)); + } catch { + reject(); + } + }); + +export default class UsersController { + // Register a new user + public async signup({ request, response, auth }: HttpContextContract) { + if (isRegistrationEnabled === 'false') { + return response.status(401).send({ + message: 'Registration is disabled on this server', + status: 401, + }); + } + + // Validate user input + let data; + try { + data = await request.validate({ schema: newPostSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid POST arguments', + messages: error.messages, + status: 401, + }); + } + + // Create user in DB + let user; + try { + user = await User.create({ + email: data.email, + password: data.password, + username: data.firstname, + lastname: data.lastname, + }); + } catch { + return response.status(401).send({ + message: 'E-Mail address already in use', + status: 401, + }); + } + + // Generate new auth token + const token = await auth.use('jwt').login(user, { payload: {} }); + + return response.send({ + message: 'Successfully created account', + token: token.accessToken, + }); + } + + // Login using an existing user + public async login({ request, response, auth }: HttpContextContract) { + if (!request.header('Authorization')) { + return response.status(401).send({ + message: 'Please provide authorization', + status: 401, + }); + } + + // Get auth data from auth token + const authHeader = atob( + request.header('Authorization')!.replace('Basic ', ''), + ).split(':'); + + // Check if user with email exists + const user = await User.query().where('email', authHeader[0]).first(); + if (!user?.email) { + return response.status(401).send({ + message: 'User credentials not valid', + code: 'invalid-credentials', + status: 401, + }); + } + + // Verify password + let isMatchedPassword = false; + try { + isMatchedPassword = await handleVerifyAndReHash(user, authHeader[1]); + } catch (error) { + return response.internalServerError({ message: error.message }); + } + + if (!isMatchedPassword) { + return response.status(401).send({ + message: 'User credentials not valid', + code: 'invalid-credentials', + status: 401, + }); + } + + // Generate token + const token = await auth.use('jwt').login(user, { payload: {} }); + + return response.send({ + message: 'Successfully logged in', + token: token.accessToken, + }); + } + + // Return information about the current user + public async me({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.send('Missing or invalid api token'); + } + + const settings = + typeof user.settings === 'string' + ? JSON.parse(user.settings) + : user.settings; + + return response.send({ + accountType: 'individual', + beta: false, + donor: {}, + email: user.email, + emailValidated: true, + features: {}, + firstname: user.username, + id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', + isPremium: true, + isSubscriptionOwner: true, + lastname: user.lastname, + locale: 'en-US', + ...settings, + }); + } + + public async updateMe({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.send('Missing or invalid api token'); + } + + let settings = user.settings || {}; + if (typeof settings === 'string') { + settings = JSON.parse(settings); + } + + const newSettings = { + ...settings, + ...request.all(), + }; + + user.settings = JSON.stringify(newSettings); + await user.save(); + + return response.send({ + data: { + accountType: 'individual', + beta: false, + donor: {}, + email: user.email, + emailValidated: true, + features: {}, + firstname: user.username, + id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', + isPremium: true, + isSubscriptionOwner: true, + lastname: user.lastname, + locale: 'en-US', + ...newSettings, + }, + status: ['data-updated'], + }); + } + + public async newToken({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.send('Missing or invalid api token'); + } + + const token = await auth.use('jwt').generate(user, { payload: {} }); + + return response.send({ + token: token.accessToken, + }); + } + + public async import({ request, response, view }: HttpContextContract) { + if (isRegistrationEnabled === 'false') { + return response.status(401).send({ + message: 'Registration is disabled on this server', + status: 401, + }); + } + + if (connectWithFranz === 'false') { + return response.send( + 'We could not import your Franz account data.\n\nIf you are the server owner, please set CONNECT_WITH_FRANZ to true to enable account imports.', + ); + } + + // Validate user input + let data; + try { + data = await request.validate({ schema: franzImportSchema }); + } catch (error) { + return view.render('others.message', { + heading: 'Error while importing', + text: error.messages, + }); + } + + const { email, password } = data; + + const hashedPassword = crypto + .createHash('sha256') + .update(password) + .digest('base64'); + + const base = 'https://api.franzinfra.com/v1/'; + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; + + // Try to get an authentication token + let token; + try { + const basicToken = btoa(`${email}:${hashedPassword}`); + const loginBody = { + isZendeskLogin: false, + }; + + const rawResponse = await fetch(`${base}auth/login`, { + method: 'POST', + body: JSON.stringify(loginBody), + headers: { + Authorization: `Basic ${basicToken}`, + 'User-Agent': userAgent, + 'Content-Type': 'application/json', + accept: '*/*', + 'x-franz-source': 'Web', + }, + }); + const content = await rawResponse.json(); + + if (!content.message || content.message !== 'Successfully logged in') { + const errorMessage = + 'Could not login into Franz with your supplied credentials. Please check and try again'; + return response.status(401).send(errorMessage); + } + + token = content.token; + } catch (error) { + return response.status(401).send({ + message: 'Cannot login to Franz', + error: error, + }); + } + + // Get user information + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let userInf: any = false; + try { + userInf = await franzRequest('me', 'GET', token); + } catch (error) { + const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${error}`; + return response.status(401).send(errorMessage); + } + if (!userInf) { + const errorMessage = + 'Could not get your user info from Franz. Please check your credentials or try again later'; + return response.status(401).send(errorMessage); + } + + // Create user in DB + let user; + try { + user = await User.create({ + email: userInf.email, + password: hashedPassword, + username: userInf.firstname, + lastname: userInf.lastname, + }); + } catch (error) { + const errorMessage = `Could not create your user in our system.\nError: ${error}`; + return response.status(401).send(errorMessage); + } + + const serviceIdTranslation = {}; + + // Import services + try { + const services = await franzRequest('me/services', 'GET', token); + + // @ts-expect-error + for (const service of services) { + // Get new, unused uuid + let serviceId; + do { + serviceId = uuid(); + } 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: user.id, + serviceId, + name: service.name, + recipeId: service.recipeId, + settings: JSON.stringify(service), + }); + + // @ts-expect-error + serviceIdTranslation[service.id] = serviceId; + } + } catch (error) { + const errorMessage = `Could not import your services into our system.\nError: ${error}`; + return response.status(401).send(errorMessage); + } + + // Import workspaces + try { + const workspaces = await franzRequest('workspace', 'GET', token); + + // @ts-expect-error + for (const workspace of workspaces) { + let workspaceId; + do { + workspaceId = uuid(); + } while ( + // eslint-disable-next-line unicorn/no-await-expression-member, no-await-in-loop + (await Workspace.query().where('workspaceId', workspaceId)).length > 0 + ); + + const services = workspace.services.map( + // @ts-expect-error + service => serviceIdTranslation[service], + ); + + // eslint-disable-next-line no-await-in-loop + await Workspace.create({ + userId: user.id, + workspaceId, + name: workspace.name, + order: workspace.order, + services: JSON.stringify(services), + data: JSON.stringify({}), + }); + } + } catch (error) { + const errorMessage = `Could not import your workspaces into our system.\nError: ${error}`; + return response.status(401).send(errorMessage); + } + + return response.send( + 'Your account has been imported. You can now use your Franz/Ferdi account in Ferdium.', + ); + } +} diff --git a/app/Controllers/Http/WorkspaceController.js b/app/Controllers/Http/WorkspaceController.js deleted file mode 100644 index a9c2872..0000000 --- a/app/Controllers/Http/WorkspaceController.js +++ /dev/null @@ -1,172 +0,0 @@ -const Workspace = use('App/Models/Workspace'); -const { validateAll } = use('Validator'); - -const { v4: uuid } = require('uuid'); - -class WorkspaceController { - // Create a new workspace for user - async create({ request, response, auth }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - // Validate user input - const validation = await validateAll(request.all(), { - name: 'required', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid POST arguments', - messages: validation.messages(), - status: 401, - }); - } - - const data = request.all(); - - // Get new, unused uuid - let workspaceId; - do { - workspaceId = uuid(); - } while ( - (await Workspace.query().where('workspaceId', workspaceId).fetch()).rows - .length > 0 - ); // eslint-disable-line no-await-in-loop - - const order = (await auth.user.workspaces().fetch()).rows.length; - - await Workspace.create({ - userId: auth.user.id, - workspaceId, - name: data.name, - order, - services: JSON.stringify([]), - data: JSON.stringify(data), - }); - - return response.send({ - userId: auth.user.id, - name: data.name, - id: workspaceId, - order, - workspaces: [], - }); - } - - async edit({ request, response, auth, params }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - // Validate user input - const validation = await validateAll(request.all(), { - name: 'required', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid POST arguments', - messages: validation.messages(), - status: 401, - }); - } - - const data = request.all(); - const { id } = params; - - // Update data in database - await Workspace.query() - .where('workspaceId', id) - .where('userId', auth.user.id) - .update({ - name: data.name, - services: JSON.stringify(data.services), - }); - - // Get updated row - const workspace = ( - await Workspace.query() - .where('workspaceId', id) - .where('userId', auth.user.id) - .fetch() - ).rows[0]; - - return response.send({ - id: workspace.workspaceId, - name: data.name, - order: workspace.order, - services: data.services, - userId: auth.user.id, - }); - } - - async delete({ - // eslint-disable-next-line no-unused-vars - _request, - response, - auth, - params, - }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - // Validate user input - const validation = await validateAll(params, { - id: 'required', - }); - if (validation.fails()) { - return response.status(401).send({ - message: 'Invalid arguments', - messages: validation.messages(), - status: 401, - }); - } - - const { id } = params; - - // Update data in database - await Workspace.query() - .where('workspaceId', id) - .where('userId', auth.user.id) - .delete(); - - return response.send({ - message: 'Successfully deleted workspace', - }); - } - - // List all workspaces a user has created - async list({ response, auth }) { - try { - await auth.getUser(); - } catch (error) { - return response.send('Missing or invalid api token'); - } - - const workspaces = (await auth.user.workspaces().fetch()).rows; - // Convert to array with all data Franz wants - let workspacesArray = []; - if (workspaces) { - workspacesArray = workspaces.map(workspace => ({ - id: workspace.workspaceId, - name: workspace.name, - order: workspace.order, - services: - typeof workspace.services === 'string' - ? JSON.parse(workspace.services) - : workspace.services, - userId: auth.user.id, - })); - } - - return response.send(workspacesArray); - } -} - -module.exports = WorkspaceController; diff --git a/app/Controllers/Http/WorkspaceController.ts b/app/Controllers/Http/WorkspaceController.ts new file mode 100644 index 0000000..4dff4df --- /dev/null +++ b/app/Controllers/Http/WorkspaceController.ts @@ -0,0 +1,180 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { validator, schema } from '@ioc:Adonis/Core/Validator'; +import Workspace from 'App/Models/Workspace'; +import { v4 as uuid } from 'uuid'; + +const createSchema = schema.create({ + name: schema.string(), +}); + +const editSchema = schema.create({ + name: schema.string(), +}); + +const deleteSchema = schema.create({ + id: schema.string(), +}); + +export default class WorkspacesController { + // Create a new workspace for user + public async create({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + // Validate user input + let data; + try { + data = await request.validate({ schema: createSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid POST arguments', + messages: error.messages, + status: 401, + }); + } + + // Get new, unused uuid + let workspaceId; + do { + workspaceId = uuid(); + } while ( + // eslint-disable-next-line unicorn/no-await-expression-member, no-await-in-loop + (await Workspace.query().where('workspaceId', workspaceId)).length > 0 + ); + + // eslint-disable-next-line unicorn/no-await-expression-member + const order = (await user.related('workspaces').query()).length; + + await Workspace.create({ + userId: user.id, + workspaceId, + name: data.name, + order, + services: JSON.stringify([]), + data: JSON.stringify(data), + }); + + return response.send({ + userId: user.id, + name: data.name, + id: workspaceId, + order, + workspaces: [], + }); + } + + public async edit({ request, response, auth, params }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + // Validate user input + try { + await request.validate({ schema: editSchema }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid POST arguments', + messages: error.messages, + status: 401, + }); + } + + const data = request.all(); + const { id } = params; + + // Update data in database + await Workspace.query() + .where('workspaceId', id) + .where('userId', user.id) + .update({ + name: data.name, + services: JSON.stringify(data.services), + }); + + // Get updated row + const workspace = await Workspace.query() + .where('workspaceId', id) + .where('userId', user.id) + .firstOrFail(); + + return response.send({ + id: workspace.workspaceId, + name: data.name, + order: workspace.order, + services: data.services, + userId: user.id, + }); + } + + public async delete({ response, auth, params }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + // Validate user input + let data; + try { + data = await validator.validate({ + data: params, + schema: deleteSchema, + }); + } catch (error) { + return response.status(401).send({ + message: 'Invalid arguments', + messages: error.messages, + status: 401, + }); + } + + const { id } = data; + + // Update data in database + await Workspace.query() + .where('workspaceId', id) + .where('userId', user.id) + .delete(); + + return response.send({ + message: 'Successfully deleted workspace', + }); + } + + // List all workspaces a user has created + public async list({ request, response, auth }: HttpContextContract) { + // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. + const user = auth.user ?? request.user; + + if (!user) { + return response.unauthorized('Missing or invalid api token'); + } + + const workspaces = await user.related('workspaces').query(); + // Convert to array with all data Franz wants + let workspacesArray: object[] = []; + if (workspaces) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspacesArray = workspaces.map((workspace: any) => ({ + id: workspace.workspaceId, + name: workspace.name, + order: workspace.order, + services: + typeof workspace.services === 'string' + ? JSON.parse(workspace.services) + : workspace.services, + userId: user.id, + })); + } + + return response.send(workspacesArray); + } +} diff --git a/app/Exceptions/Handler.js b/app/Exceptions/Handler.js deleted file mode 100644 index 43c3ef1..0000000 --- a/app/Exceptions/Handler.js +++ /dev/null @@ -1,48 +0,0 @@ -const BaseExceptionHandler = use('BaseExceptionHandler'); -const Sentry = require('@sentry/node'); - -/** - * This class handles all exceptions thrown during - * the HTTP request lifecycle. - * - * @class ExceptionHandler - */ -class ExceptionHandler extends BaseExceptionHandler { - /** - * Handle exception thrown during the HTTP lifecycle - * - * @method handle - * - * @param {Object} error - * @param {Object} options.request - * @param {Object} options.response - * - * @return {void} - */ - async handle(error, { response }) { - if (error.name === 'ValidationException') { - return response.status(400).send('Invalid arguments'); - } if (error.name === 'InvalidSessionException') { - return response.status(401).redirect('/user/login'); - } - - return response.status(error.status).send(error.message); - } - - /** - * Report exception for logging or debugging. - * - * @method report - * - * @param {Object} error - * @param {Object} options.request - * - * @return {void} - */ - async report(error) { - Sentry.captureException(error); - return true; - } -} - -module.exports = ExceptionHandler; diff --git a/app/Exceptions/Handler.ts b/app/Exceptions/Handler.ts new file mode 100644 index 0000000..35c77d0 --- /dev/null +++ b/app/Exceptions/Handler.ts @@ -0,0 +1,23 @@ +/* +|-------------------------------------------------------------------------- +| Http Exception Handler +|-------------------------------------------------------------------------- +| +| AdonisJs will forward all exceptions occurred during an HTTP request to +| the following class. You can learn more about exception handling by +| reading docs. +| +| The exception handler extends a base `HttpExceptionHandler` which is not +| mandatory, however it can do lot of heavy lifting to handle the errors +| properly. +| +*/ + +import Logger from '@ioc:Adonis/Core/Logger'; +import HttpExceptionHandler from '@ioc:Adonis/Core/HttpExceptionHandler'; + +export default class ExceptionHandler extends HttpExceptionHandler { + constructor() { + super(Logger); + } +} diff --git a/app/Middleware/AllowGuestOnly.ts b/app/Middleware/AllowGuestOnly.ts new file mode 100644 index 0000000..ee43571 --- /dev/null +++ b/app/Middleware/AllowGuestOnly.ts @@ -0,0 +1,56 @@ +import { GuardsList } from '@ioc:Adonis/Addons/Auth'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { AuthenticationException } from '@adonisjs/auth/build/standalone'; + +/** + * This is actually a reverted a reverted auth middleware available in ./Auth.ts + * provided by the AdonisJS project iself. + */ +export default class GuestMiddleware { + /** + * The URL to redirect to when request is authorized + */ + protected redirectTo = '/dashboard'; + + protected async authenticate( + auth: HttpContextContract['auth'], + guards: (keyof GuardsList)[], + ) { + let guardLastAttempted: string | undefined; + + for (const guard of guards) { + guardLastAttempted = guard; + + // eslint-disable-next-line no-await-in-loop + if (await auth.use(guard).check()) { + auth.defaultGuard = guard; + + throw new AuthenticationException( + 'Unauthorized access', + 'E_UNAUTHORIZED_ACCESS', + guardLastAttempted, + this.redirectTo, + ); + } + } + } + + /** + * Handle request + */ + public async handle( + { auth }: HttpContextContract, + next: () => Promise, + customGuards: (keyof GuardsList)[], + ) { + /** + * Uses the user defined guards or the default guard mentioned in + * the config file + */ + const guards = customGuards.length > 0 ? customGuards : [auth.name]; + + await this.authenticate(auth, guards); + + await next(); + } +} diff --git a/app/Middleware/Auth.ts b/app/Middleware/Auth.ts new file mode 100644 index 0000000..d0b212c --- /dev/null +++ b/app/Middleware/Auth.ts @@ -0,0 +1,118 @@ +import { GuardsList } from '@ioc:Adonis/Addons/Auth'; +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { AuthenticationException } from '@adonisjs/auth/build/standalone'; +import * as jose from 'jose'; +import { appKey } from 'Config/app'; +import User from 'App/Models/User'; + +/** + * Auth middleware is meant to restrict un-authenticated access to a given route + * or a group of routes. + * + * You must register this middleware inside `start/kernel.ts` file under the list + * of named middleware. + */ +export default class AuthMiddleware { + /** + * The URL to redirect to when request is Unauthorized + */ + protected redirectTo = '/user/login'; + + /** + * Authenticates the current HTTP request against a custom set of defined + * guards. + * + * The authentication loop stops as soon as the user is authenticated using any + * of the mentioned guards and that guard will be used by the rest of the code + * during the current request. + */ + protected async authenticate( + auth: HttpContextContract['auth'], + guards: (keyof GuardsList)[], + request: HttpContextContract['request'], + ) { + /** + * Hold reference to the guard last attempted within the for loop. We pass + * the reference of the guard to the "AuthenticationException", so that + * it can decide the correct response behavior based upon the guard + * driver + */ + let guardLastAttempted: string | undefined; + + for (const guard of guards) { + guardLastAttempted = guard; + + let isLoggedIn = false; + try { + // eslint-disable-next-line no-await-in-loop + isLoggedIn = await auth.use(guard).check(); + } catch { + // Silent fail to allow the rest of the code to handle the error + } + + if (isLoggedIn) { + /** + * Instruct auth to use the given guard as the default guard for + * the rest of the request, since the user authenticated + * succeeded here + */ + auth.defaultGuard = guard; + return; + } + } + + // Manually try authenticating using the JWT (verfiy signature required) + // Legacy support for JWTs so that the client still works (older than 2.0.0) + const authToken = request.headers().authorization?.split(' ')[1]; + if (authToken) { + try { + const jwt = await jose.jwtVerify( + authToken, + new TextEncoder().encode(appKey), + ); + const { uid } = jwt.payload; + + // @ts-expect-error + request.user = await User.findOrFail(uid); + return; + } catch { + // Silent fail to allow the rest of the code to handle the error + } + } + + /** + * Unable to authenticate using any guard + */ + throw new AuthenticationException( + 'Unauthorized access', + 'E_UNAUTHORIZED_ACCESS', + guardLastAttempted, + this.redirectTo, + ); + } + + /** + * Handle request + */ + public async handle( + { request, auth, response }: HttpContextContract, + next: () => Promise, + customGuards: (keyof GuardsList)[], + ) { + /** + * Uses the user defined guards or the default guard mentioned in + * the config file + */ + const guards = customGuards.length > 0 ? customGuards : [auth.name]; + try { + await this.authenticate(auth, guards, request); + } catch (error) { + // If the user is not authenticated and it is a web endpoint, redirect to the login page + if (guards.includes('web')) { + return response.redirect(error.redirectTo); + } + throw error; + } + await next(); + } +} diff --git a/app/Middleware/ConvertEmptyStringsToNull.js b/app/Middleware/ConvertEmptyStringsToNull.js deleted file mode 100644 index af6379a..0000000 --- a/app/Middleware/ConvertEmptyStringsToNull.js +++ /dev/null @@ -1,15 +0,0 @@ -class ConvertEmptyStringsToNull { - async handle({ request }, next) { - if (Object.keys(request.body).length) { - request.body = Object.assign( - ...Object.keys(request.body).map((key) => ({ - [key]: request.body[key] !== '' ? request.body[key] : null, - })), - ); - } - - await next(); - } -} - -module.exports = ConvertEmptyStringsToNull; diff --git a/app/Middleware/Dashboard.ts b/app/Middleware/Dashboard.ts new file mode 100644 index 0000000..62deea0 --- /dev/null +++ b/app/Middleware/Dashboard.ts @@ -0,0 +1,17 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import Config from '@ioc:Adonis/Core/Config'; + +export default class Dashboard { + public async handle( + { response }: HttpContextContract, + next: () => Promise, + ) { + if (Config.get('dashboard.enabled') === false) { + response.send( + 'The user dashboard is disabled on this server\n\nIf you are the server owner, please set IS_DASHBOARD_ENABLED to true to enable the dashboard.', + ); + } else { + await next(); + } + } +} diff --git a/app/Middleware/HandleDoubleSlash.js b/app/Middleware/HandleDoubleSlash.js deleted file mode 100644 index c4bc053..0000000 --- a/app/Middleware/HandleDoubleSlash.js +++ /dev/null @@ -1,24 +0,0 @@ -/** @typedef {import('@adonisjs/framework/src/Request')} Request */ -/** @typedef {import('@adonisjs/framework/src/Response')} Response */ -/** @typedef {import('@adonisjs/framework/src/View')} View */ - -class HandleDoubleSlash { - /** - * @param {object} ctx - * @param {Request} ctx.request - * @param {Function} next - */ - // eslint-disable-next-line consistent-return - async handle({ request, response }, next) { - // Redirect requests that contain duplicate slashes to the right path - if (request.url().includes('//')) { - return response.redirect( - request.url().replace(/\/{2,}/g, '/'), - ); - } - - await next(); - } -} - -module.exports = HandleDoubleSlash; diff --git a/app/Middleware/SilentAuth.ts b/app/Middleware/SilentAuth.ts new file mode 100644 index 0000000..ee73ec4 --- /dev/null +++ b/app/Middleware/SilentAuth.ts @@ -0,0 +1,24 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; + +/** + * Silent auth middleware can be used as a global middleware to silent check + * if the user is logged-in or not. + * + * The request continues as usual, even when the user is not logged-in. + */ +export default class SilentAuthMiddleware { + /** + * Handle request + */ + public async handle( + { auth }: HttpContextContract, + next: () => Promise, + ) { + /** + * Check if user is logged-in or not. If yes, then `ctx.auth.user` will be + * set to the instance of the currently logged in user. + */ + await auth.check(); + await next(); + } +} diff --git a/app/Models/Recipe.js b/app/Models/Recipe.js deleted file mode 100644 index bd97411..0000000 --- a/app/Models/Recipe.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ -const Model = use('Model'); - -class Recipe extends Model { -} - -module.exports = Recipe; diff --git a/app/Models/Recipe.ts b/app/Models/Recipe.ts new file mode 100644 index 0000000..fce5f3d --- /dev/null +++ b/app/Models/Recipe.ts @@ -0,0 +1,23 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'; + +export default class Recipe extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column() + public name: string; + + @column() + public recipeId: string; + + // TODO: Type the data object. + @column() + public data: object; + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime; +} diff --git a/app/Models/Service.js b/app/Models/Service.js deleted file mode 100644 index cf3e6f4..0000000 --- a/app/Models/Service.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ -const Model = use('Model'); - -class Service extends Model { - user() { - return this.belongsTo('App/Models/User', 'userId', 'id'); - } -} - -module.exports = Service; diff --git a/app/Models/Service.ts b/app/Models/Service.ts new file mode 100644 index 0000000..af1a8e1 --- /dev/null +++ b/app/Models/Service.ts @@ -0,0 +1,40 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; +import User from './User'; + +export default class Service extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @hasOne(() => User, { + foreignKey: 'userId', + }) + public user: HasOne; + + @column({ + columnName: 'userId', + }) + public userId: number; + + @column({ + columnName: 'serviceId', + }) + public serviceId: string; + + @column() + public name: string; + + @column({ + columnName: 'recipeId', + }) + public recipeId: string; + + @column() + public settings: string; + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime; +} diff --git a/app/Models/Token.js b/app/Models/Token.js deleted file mode 100644 index 7965a7a..0000000 --- a/app/Models/Token.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ -const Model = use('Model'); - -class Token extends Model { - user() { - return this.belongsTo('App/Models/User'); - } -} - -module.exports = Token; diff --git a/app/Models/Token.ts b/app/Models/Token.ts new file mode 100644 index 0000000..4f85ebc --- /dev/null +++ b/app/Models/Token.ts @@ -0,0 +1,38 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; +import User from './User'; + +export default class Token extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @hasOne(() => User, { + localKey: 'user_id', + foreignKey: 'id', + }) + public user: HasOne; + + @column() + public user_id: number; + + @column() + public token: string; + + @column() + public type: string; + + @column() + public is_revoked: boolean; + + @column() + public name: string; + + @column.dateTime() + public expires_at: DateTime; + + @column.dateTime({ autoCreate: true }) + public created_at: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updated_at: DateTime; +} diff --git a/app/Models/Traits/NoTimestamp.js b/app/Models/Traits/NoTimestamp.js deleted file mode 100644 index 914f542..0000000 --- a/app/Models/Traits/NoTimestamp.js +++ /dev/null @@ -1,14 +0,0 @@ -class NoTimestamp { - register(Model) { - Object.defineProperties(Model, { - createdAtColumn: { - get: () => null, - }, - updatedAtColumn: { - get: () => null, - }, - }); - } -} - -module.exports = NoTimestamp; diff --git a/app/Models/User.js b/app/Models/User.js deleted file mode 100644 index 4472017..0000000 --- a/app/Models/User.js +++ /dev/null @@ -1,46 +0,0 @@ -/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ -const Model = use('Model'); - -/** @type {import('@adonisjs/framework/src/Hash')} */ -const Hash = use('Hash'); - -class User extends Model { - static boot() { - super.boot(); - - /** - * A hook to hash the user password before saving - * it to the database. - */ - this.addHook('beforeSave', async (userInstance) => { - if (userInstance.dirty.password) { - // eslint-disable-next-line no-param-reassign - userInstance.password = await Hash.make(userInstance.password); - } - }); - } - - /** - * A relationship on tokens is required for auth to - * work. Since features like `refreshTokens` or - * `rememberToken` will be saved inside the - * tokens table. - * - * @method tokens - * - * @return {Object} - */ - tokens() { - return this.hasMany('App/Models/Token'); - } - - services() { - return this.hasMany('App/Models/Service', 'id', 'userId'); - } - - workspaces() { - return this.hasMany('App/Models/Workspace', 'id', 'userId'); - } -} - -module.exports = User; diff --git a/app/Models/User.ts b/app/Models/User.ts new file mode 100644 index 0000000..0ed4627 --- /dev/null +++ b/app/Models/User.ts @@ -0,0 +1,98 @@ +import { DateTime } from 'luxon'; +import { + BaseModel, + beforeSave, + column, + HasMany, + hasMany, +} from '@ioc:Adonis/Lucid/Orm'; +import Hash from '@ioc:Adonis/Core/Hash'; +import Event from '@ioc:Adonis/Core/Event'; +import moment from 'moment'; +import Encryption from '@ioc:Adonis/Core/Encryption'; +import randtoken from 'rand-token'; +import Token from './Token'; +import Workspace from './Workspace'; +import Service from './Service'; + +export default class User extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column() + public email: string; + + @column() + public username: string; + + @column() + public password: string; + + @column() + public lastname: string; + + // TODO: Type the settings object. + @column() + public settings: object; + + @column.dateTime({ autoCreate: true }) + public created_at: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updated_at: DateTime; + + @beforeSave() + public static async hashPassword(user: User) { + if (user.$dirty.password) { + user.password = await Hash.make(user.password); + } + } + + @hasMany(() => Token, { + foreignKey: 'user_id', + }) + public tokens: HasMany; + + @hasMany(() => Service, { + foreignKey: 'userId', + }) + public services: HasMany; + + @hasMany(() => Workspace, { + foreignKey: 'userId', + }) + public workspaces: HasMany; + + public async forgotPassword(): Promise { + const token = await this.generateToken(this, 'forgot_password'); + + await Event.emit('forgot:password', { + user: this, + token, + }); + } + + private async generateToken(user: User, type: string): Promise { + const query = user + .related('tokens') + .query() + .where('type', type) + .where('is_revoked', false) + .where( + 'updated_at', + '>=', + moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'), + ); + + const row = await query.first(); + if (row) { + return row.token; + } + + const token = Encryption.encrypt(randtoken.generate(16)); + + await user.related('tokens').create({ type, token }); + + return token; + } +} diff --git a/app/Models/Workspace.js b/app/Models/Workspace.js deleted file mode 100644 index e3a3285..0000000 --- a/app/Models/Workspace.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ -const Model = use('Model'); - -class Workspace extends Model { - user() { - return this.belongsTo('App/Models/User', 'userId', 'id'); - } -} - -module.exports = Workspace; diff --git a/app/Models/Workspace.ts b/app/Models/Workspace.ts new file mode 100644 index 0000000..8648e02 --- /dev/null +++ b/app/Models/Workspace.ts @@ -0,0 +1,41 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; +import User from './User'; + +export default class Workspace extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column({ + columnName: 'workspaceId', + }) + public workspaceId: string; + + @hasOne(() => User, { + foreignKey: 'userId', + }) + public user: HasOne; + + @column({ + columnName: 'userId', + }) + public userId: number; + + @column() + public name: string; + + @column() + public order: number; + + @column() + public services: string; + + @column() + public data: string; + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime; +} -- cgit v1.2.3-54-g00ecf