diff options
author | Ricardo <ricardo@cino.io> | 2023-10-13 14:12:03 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-13 13:12:03 +0100 |
commit | e503468660a13760010a94ecda5f0625c6f47f87 (patch) | |
tree | fa532f54fc5f091de08d55405ec6339bd2440a02 /app | |
parent | 1.3.16 [skip ci] (diff) | |
download | ferdium-server-e503468660a13760010a94ecda5f0625c6f47f87.tar.gz ferdium-server-e503468660a13760010a94ecda5f0625c6f47f87.tar.zst ferdium-server-e503468660a13760010a94ecda5f0625c6f47f87.zip |
Server re-build with latest AdonisJS framework & Typescript (#47)
* 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 <vraravam@users.noreply.github.com>
Co-authored-by: Balaji Vijayakumar <kuttibalaji.v6@gmail.com>
Co-authored-by: MCMXC <16797721+mcmxcdev@users.noreply.github.com>
Co-authored-by: André Oliveira <oliveira.andrerodrigues95@gmail.com>
Diffstat (limited to 'app')
42 files changed, 1806 insertions, 882 deletions
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import Application from '@ioc:Adonis/Core/Application'; | ||
3 | import path from 'node:path'; | ||
4 | import fs from 'fs-extra'; | ||
5 | |||
6 | export default class AnnouncementsController { | ||
7 | public async show({ response, params }: HttpContextContract) { | ||
8 | const announcement = path.join( | ||
9 | Application.resourcesPath(), | ||
10 | 'announcements', | ||
11 | `${params.version}.json`, | ||
12 | ); | ||
13 | |||
14 | if (await fs.pathExists(announcement)) { | ||
15 | return response.download(announcement); | ||
16 | } | ||
17 | |||
18 | return response.status(404).send('No announcement found.'); | ||
19 | } | ||
20 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | export default class EmptyController { | ||
4 | public async show({ response }: HttpContextContract) { | ||
5 | return response.send([]); | ||
6 | } | ||
7 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | export default class FeaturesController { | ||
4 | public async show({ response }: HttpContextContract) { | ||
5 | return response.send({ | ||
6 | isServiceProxyEnabled: true, | ||
7 | isWorkspaceEnabled: true, | ||
8 | isAnnouncementsEnabled: true, | ||
9 | isSettingsWSEnabled: false, | ||
10 | isMagicBarEnabled: true, | ||
11 | isTodosEnabled: true, | ||
12 | }); | ||
13 | } | ||
14 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; | ||
3 | import crypto from 'node:crypto'; | ||
4 | |||
5 | export default class AccountController { | ||
6 | /** | ||
7 | * Shows the user account page | ||
8 | */ | ||
9 | public async show({ auth, view }: HttpContextContract) { | ||
10 | return view.render('dashboard/account', { | ||
11 | username: auth.user?.username, | ||
12 | email: auth.user?.email, | ||
13 | lastname: auth.user?.lastname, | ||
14 | }); | ||
15 | } | ||
16 | |||
17 | /** | ||
18 | * Stores user account data | ||
19 | */ | ||
20 | public async store({ | ||
21 | auth, | ||
22 | request, | ||
23 | response, | ||
24 | session, | ||
25 | view, | ||
26 | }: HttpContextContract) { | ||
27 | try { | ||
28 | await validator.validate({ | ||
29 | schema: schema.create({ | ||
30 | username: schema.string([ | ||
31 | rules.required(), | ||
32 | rules.unique({ | ||
33 | table: 'users', | ||
34 | column: 'username', | ||
35 | caseInsensitive: true, | ||
36 | whereNot: { id: auth.user?.id }, | ||
37 | }), | ||
38 | ]), | ||
39 | email: schema.string([ | ||
40 | rules.required(), | ||
41 | rules.unique({ | ||
42 | table: 'users', | ||
43 | column: 'email', | ||
44 | caseInsensitive: true, | ||
45 | whereNot: { id: auth.user?.id }, | ||
46 | }), | ||
47 | ]), | ||
48 | lastname: schema.string([rules.required()]), | ||
49 | }), | ||
50 | data: request.only(['username', 'email', 'lastname']), | ||
51 | }); | ||
52 | } catch (error) { | ||
53 | session.flash(error.messages); | ||
54 | return response.redirect('/user/account'); | ||
55 | } | ||
56 | |||
57 | // Update user account | ||
58 | const { user } = auth; | ||
59 | if (user) { | ||
60 | user.username = request.input('username'); | ||
61 | user.lastname = request.input('lastname'); | ||
62 | user.email = request.input('email'); | ||
63 | if (request.input('password')) { | ||
64 | const hashedPassword = crypto | ||
65 | .createHash('sha256') | ||
66 | .update(request.input('password')) | ||
67 | .digest('base64'); | ||
68 | user.password = hashedPassword; | ||
69 | } | ||
70 | await user.save(); | ||
71 | } | ||
72 | |||
73 | return view.render('dashboard/account', { | ||
74 | username: user?.username, | ||
75 | lastname: user?.lastname, | ||
76 | email: user?.email, | ||
77 | success: user !== undefined, | ||
78 | }); | ||
79 | } | ||
80 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | export default class DataController { | ||
4 | /** | ||
5 | * Display the data page | ||
6 | */ | ||
7 | public async show({ view, auth }: HttpContextContract) { | ||
8 | const { user } = auth; | ||
9 | |||
10 | const services = await user?.related('services').query(); | ||
11 | const workspaces = await user?.related('workspaces').query(); | ||
12 | |||
13 | return view.render('dashboard/data', { | ||
14 | username: user?.username, | ||
15 | lastname: user?.lastname, | ||
16 | mail: user?.email, | ||
17 | created: user?.created_at.toFormat('yyyy-MM-dd HH:mm:ss'), | ||
18 | updated: user?.updated_at.toFormat('yyyy-MM-dd HH:mm:ss'), | ||
19 | stringify: JSON.stringify, | ||
20 | services, | ||
21 | workspaces, | ||
22 | }); | ||
23 | } | ||
24 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | export default class DeleteController { | ||
4 | /** | ||
5 | * Display the delete page | ||
6 | */ | ||
7 | public async show({ view }: HttpContextContract) { | ||
8 | return view.render('dashboard/delete'); | ||
9 | } | ||
10 | |||
11 | /** | ||
12 | * Delete user and session | ||
13 | */ | ||
14 | public async delete({ auth, response }: HttpContextContract) { | ||
15 | auth.user?.delete(); | ||
16 | auth.use('web').logout(); | ||
17 | |||
18 | return response.redirect('/user/login'); | ||
19 | } | ||
20 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
4 | function deepParseToJSON(obj: any): Record<string, unknown> { | ||
5 | if (typeof obj !== 'object' || obj === null) { | ||
6 | try { | ||
7 | // Try to parse the object as JSON | ||
8 | return JSON.parse(obj) as Record<string, unknown>; | ||
9 | } catch { | ||
10 | // If parsing fails, return the original value | ||
11 | return obj; | ||
12 | } | ||
13 | } | ||
14 | |||
15 | // If obj is an object, recursively parse its keys | ||
16 | if (Array.isArray(obj)) { | ||
17 | // If obj is an array, recursively parse each element | ||
18 | return obj.map(item => deepParseToJSON(item)) as unknown as Record< | ||
19 | string, | ||
20 | unknown | ||
21 | >; | ||
22 | } else { | ||
23 | // If obj is an object, recursively parse its keys | ||
24 | const parsedObj: Record<string, unknown> = {}; | ||
25 | for (const key in obj) { | ||
26 | if (obj.hasOwnProperty(key)) { | ||
27 | parsedObj[key] = deepParseToJSON(obj[key]); | ||
28 | } | ||
29 | } | ||
30 | return parsedObj; | ||
31 | } | ||
32 | } | ||
33 | |||
34 | export default class ExportController { | ||
35 | /** | ||
36 | * Display the export page | ||
37 | */ | ||
38 | public async show({ auth, response }: HttpContextContract) { | ||
39 | const user = auth.user!; | ||
40 | const services = await user.related('services').query(); | ||
41 | const workspaces = await user.related('workspaces').query(); | ||
42 | |||
43 | const exportData = { | ||
44 | username: user.username, | ||
45 | lastname: user.lastname, | ||
46 | mail: user.email, | ||
47 | services: deepParseToJSON(JSON.parse(JSON.stringify(services))), | ||
48 | workspaces: deepParseToJSON(JSON.parse(JSON.stringify(workspaces))), | ||
49 | }; | ||
50 | |||
51 | return response | ||
52 | .header('Content-Type', 'application/force-download') | ||
53 | .header('Content-disposition', 'attachment; filename=export.ferdium-data') | ||
54 | .send(exportData); | ||
55 | } | ||
56 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; | ||
3 | import User from 'App/Models/User'; | ||
4 | |||
5 | export default class ForgotPasswordController { | ||
6 | /** | ||
7 | * Display the forgot password form | ||
8 | */ | ||
9 | public async show({ view }: HttpContextContract) { | ||
10 | return view.render('dashboard/forgotPassword'); | ||
11 | } | ||
12 | |||
13 | /** | ||
14 | * Send forget password email to user | ||
15 | */ | ||
16 | public async forgotPassword({ view, request }: HttpContextContract) { | ||
17 | try { | ||
18 | await validator.validate({ | ||
19 | schema: schema.create({ | ||
20 | mail: schema.string([rules.email(), rules.required()]), | ||
21 | }), | ||
22 | data: request.only(['mail']), | ||
23 | }); | ||
24 | } catch { | ||
25 | return view.render('others/message', { | ||
26 | heading: 'Cannot reset your password', | ||
27 | text: 'Please enter a valid email address', | ||
28 | }); | ||
29 | } | ||
30 | |||
31 | try { | ||
32 | const user = await User.findByOrFail('email', request.input('mail')); | ||
33 | await user.forgotPassword(); | ||
34 | } catch {} | ||
35 | |||
36 | return view.render('others/message', { | ||
37 | heading: 'Reset password', | ||
38 | text: 'If your provided E-Mail address is linked to an account, we have just sent an E-Mail to that address.', | ||
39 | }); | ||
40 | } | ||
41 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | export default class LogOutController { | ||
4 | /** | ||
5 | * Login a user | ||
6 | */ | ||
7 | public async logout({ auth, response }: HttpContextContract) { | ||
8 | auth.logout(); | ||
9 | |||
10 | return response.redirect('/user/login'); | ||
11 | } | ||
12 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; | ||
3 | import User from 'App/Models/User'; | ||
4 | import crypto from 'node:crypto'; | ||
5 | import { handleVerifyAndReHash } from '../../../../helpers/PasswordHash'; | ||
6 | |||
7 | export default class LoginController { | ||
8 | /** | ||
9 | * Display the login form | ||
10 | */ | ||
11 | public async show({ view }: HttpContextContract) { | ||
12 | return view.render('dashboard/login'); | ||
13 | } | ||
14 | |||
15 | /** | ||
16 | * Login a user | ||
17 | */ | ||
18 | public async login({ | ||
19 | request, | ||
20 | response, | ||
21 | auth, | ||
22 | session, | ||
23 | }: HttpContextContract) { | ||
24 | try { | ||
25 | await validator.validate({ | ||
26 | schema: schema.create({ | ||
27 | mail: schema.string([rules.email(), rules.required()]), | ||
28 | password: schema.string([rules.required()]), | ||
29 | }), | ||
30 | data: request.only(['mail', 'password']), | ||
31 | }); | ||
32 | } catch { | ||
33 | session.flash({ | ||
34 | type: 'danger', | ||
35 | message: 'Invalid mail or password', | ||
36 | }); | ||
37 | session.flashExcept(['password']); | ||
38 | |||
39 | return response.redirect('/user/login'); | ||
40 | } | ||
41 | |||
42 | try { | ||
43 | const { mail, password } = request.all(); | ||
44 | |||
45 | // Check if user with email exists | ||
46 | const user = await User.query().where('email', mail).first(); | ||
47 | if (!user?.email) { | ||
48 | throw new Error('User credentials not valid (Invalid email)'); | ||
49 | } | ||
50 | |||
51 | const hashedPassword = crypto | ||
52 | .createHash('sha256') | ||
53 | .update(password) | ||
54 | .digest('base64'); | ||
55 | |||
56 | // Verify password | ||
57 | let isMatchedPassword = false; | ||
58 | try { | ||
59 | isMatchedPassword = await handleVerifyAndReHash(user, hashedPassword); | ||
60 | } catch (error) { | ||
61 | return response.internalServerError({ message: error.message }); | ||
62 | } | ||
63 | |||
64 | if (!isMatchedPassword) { | ||
65 | throw new Error('User credentials not valid (Invalid password)'); | ||
66 | } | ||
67 | |||
68 | await auth.use('web').login(user); | ||
69 | |||
70 | return response.redirect('/user/account'); | ||
71 | } catch { | ||
72 | session.flash({ | ||
73 | type: 'danger', | ||
74 | message: 'Invalid mail or password', | ||
75 | }); | ||
76 | session.flashExcept(['password']); | ||
77 | |||
78 | return response.redirect('/user/login'); | ||
79 | } | ||
80 | } | ||
81 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, rules, validator } from '@ioc:Adonis/Core/Validator'; | ||
3 | import Token from 'App/Models/Token'; | ||
4 | import moment from 'moment'; | ||
5 | import crypto from 'node:crypto'; | ||
6 | |||
7 | export default class ResetPasswordController { | ||
8 | /** | ||
9 | * Display the reset password form | ||
10 | */ | ||
11 | public async show({ view, request }: HttpContextContract) { | ||
12 | const { token } = request.qs(); | ||
13 | |||
14 | if (token) { | ||
15 | return view.render('dashboard/resetPassword', { token }); | ||
16 | } | ||
17 | |||
18 | return view.render('others/message', { | ||
19 | heading: 'Invalid token', | ||
20 | text: 'Please make sure you are using a valid and recent link to reset your password.', | ||
21 | }); | ||
22 | } | ||
23 | |||
24 | /** | ||
25 | * Resets user password | ||
26 | */ | ||
27 | public async resetPassword({ | ||
28 | response, | ||
29 | request, | ||
30 | session, | ||
31 | view, | ||
32 | }: HttpContextContract) { | ||
33 | try { | ||
34 | await validator.validate({ | ||
35 | schema: schema.create({ | ||
36 | password: schema.string([rules.required(), rules.confirmed()]), | ||
37 | token: schema.string([rules.required()]), | ||
38 | }), | ||
39 | data: request.only(['password', 'password_confirmation', 'token']), | ||
40 | }); | ||
41 | } catch { | ||
42 | session.flash({ | ||
43 | type: 'danger', | ||
44 | message: 'Passwords do not match', | ||
45 | }); | ||
46 | |||
47 | return response.redirect(`/user/reset?token=${request.input('token')}`); | ||
48 | } | ||
49 | |||
50 | const tokenRow = await Token.query() | ||
51 | .preload('user') | ||
52 | .where('token', request.input('token')) | ||
53 | .where('type', 'forgot_password') | ||
54 | .where('is_revoked', false) | ||
55 | .where( | ||
56 | 'updated_at', | ||
57 | '>=', | ||
58 | moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'), | ||
59 | ) | ||
60 | .first(); | ||
61 | |||
62 | if (!tokenRow) { | ||
63 | return view.render('others/message', { | ||
64 | heading: 'Cannot reset your password', | ||
65 | text: 'Please make sure you are using a valid and recent link to reset your password and that your passwords entered match.', | ||
66 | }); | ||
67 | } | ||
68 | |||
69 | // Update user password | ||
70 | const hashedPassword = crypto | ||
71 | .createHash('sha256') | ||
72 | .update(request.input('password')) | ||
73 | .digest('base64'); | ||
74 | tokenRow.user.password = hashedPassword; | ||
75 | await tokenRow.user.save(); | ||
76 | |||
77 | // Delete token to prevent it from being used again | ||
78 | await tokenRow.delete(); | ||
79 | |||
80 | return view.render('others/message', { | ||
81 | heading: 'Reset password', | ||
82 | text: 'Successfully reset your password. You can now login to your account using your new password.', | ||
83 | }); | ||
84 | } | ||
85 | } | ||
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, validator } from '@ioc:Adonis/Core/Validator'; | ||
3 | import Service from 'App/Models/Service'; | ||
4 | import Workspace from 'App/Models/Workspace'; | ||
5 | import { v4 as uuidv4 } from 'uuid'; | ||
6 | |||
7 | const importSchema = schema.create({ | ||
8 | username: schema.string(), | ||
9 | lastname: schema.string(), | ||
10 | mail: schema.string(), | ||
11 | services: schema.array().anyMembers(), | ||
12 | workspaces: schema.array().anyMembers(), | ||
13 | }); | ||
14 | |||
15 | export default class TransferController { | ||
16 | /** | ||
17 | * Display the transfer page | ||
18 | */ | ||
19 | public async show({ view }: HttpContextContract) { | ||
20 | return view.render('dashboard/transfer'); | ||
21 | } | ||
22 | |||
23 | public async import({ | ||
24 | auth, | ||
25 | request, | ||
26 | response, | ||
27 | session, | ||
28 | view, | ||
29 | }: HttpContextContract) { | ||
30 | let file; | ||
31 | try { | ||
32 | file = await validator.validate({ | ||
33 | schema: importSchema, | ||
34 | data: JSON.parse(request.body().file), | ||
35 | }); | ||
36 | } catch { | ||
37 | session.flash({ | ||
38 | message: 'Invalid Ferdium account file', | ||
39 | }); | ||
40 | |||
41 | return response.redirect('/user/transfer'); | ||
42 | } | ||
43 | |||
44 | if (!file?.services || !file.workspaces) { | ||
45 | session.flash({ | ||
46 | type: 'danger', | ||
47 | message: 'Invalid Ferdium account file (2)', | ||
48 | }); | ||
49 | return response.redirect('/user/transfer'); | ||
50 | } | ||
51 | |||
52 | const serviceIdTranslation = {}; | ||
53 | |||
54 | // Import services | ||
55 | try { | ||
56 | for (const service of file.services) { | ||
57 | // Get new, unused uuid | ||
58 | let serviceId; | ||
59 | do { | ||
60 | serviceId = uuidv4(); | ||
61 | } while ( | ||
62 | // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member | ||
63 | (await Service.query().where('serviceId', serviceId)).length > 0 | ||
64 | ); | ||
65 | |||
66 | // eslint-disable-next-line no-await-in-loop | ||
67 | await Service.create({ | ||
68 | userId: auth.user?.id, | ||
69 | serviceId, | ||
70 | name: service.name, | ||
71 | recipeId: service.recipe_id, | ||
72 | settings: JSON.stringify(service.settings), | ||
73 | }); | ||
74 | |||
75 | // @ts-expect-error Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}' | ||
76 | serviceIdTranslation[service.service_id] = serviceId; | ||
77 | } | ||
78 | } catch (error) { | ||
79 | // eslint-disable-next-line no-console | ||
80 | console.log(error); | ||
81 | const errorMessage = `Could not import your services into our system.\nError: ${error}`; | ||
82 | return view.render('others/message', { | ||
83 | heading: 'Error while importing', | ||
84 | text: errorMessage, | ||
85 | }); | ||
86 | } | ||
87 | |||
88 | // Import workspaces | ||
89 | try { | ||
90 | for (const workspace of file.workspaces) { | ||
91 | let workspaceId; | ||
92 | |||
93 | do { | ||
94 | workspaceId = uuidv4(); | ||
95 | } while ( | ||
96 | // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member | ||
97 | (await Workspace.query().where('workspaceId', workspaceId)).length > 0 | ||
98 | ); | ||
99 | |||
100 | const services = workspace.services.map( | ||
101 | // @ts-expect-error Parameter 'service' implicitly has an 'any' type. | ||
102 | service => serviceIdTranslation[service], | ||
103 | ); | ||
104 | |||
105 | // eslint-disable-next-line no-await-in-loop | ||
106 | await Workspace.create({ | ||
107 | userId: auth.user?.id, | ||
108 | workspaceId, | ||
109 | name: workspace.name, | ||
110 | order: workspace.order, | ||
111 | services: JSON.stringify(services), | ||
112 | data: JSON.stringify(workspace.data), | ||
113 | }); | ||
114 | } | ||
115 | } catch (error) { | ||
116 | const errorMessage = `Could not import your workspaces into our system.\nError: ${error}`; | ||
117 | return view.render('others/message', { | ||
118 | heading: 'Error while importing', | ||
119 | text: errorMessage, | ||
120 | }); | ||
121 | } | ||
122 | |||
123 | return view.render('others/message', { | ||
124 | heading: 'Successfully imported', | ||
125 | text: 'Your account has been imported, you can now login as usual!', | ||
126 | }); | ||
127 | } | ||
128 | } | ||
diff --git a/app/Controllers/Http/DashboardController.js b/app/Controllers/Http/DashboardController.ts index 9b3b08e..a6f5b44 100644 --- a/app/Controllers/Http/DashboardController.js +++ b/app/Controllers/Http/DashboardController.ts | |||
@@ -1,3 +1,8 @@ | |||
1 | // import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' | ||
2 | |||
3 | export default class DashboardController {} | ||
4 | |||
5 | /* | ||
1 | const { validateAll } = use('Validator'); | 6 | const { validateAll } = use('Validator'); |
2 | 7 | ||
3 | const Service = use('App/Models/Service'); | 8 | const Service = use('App/Models/Service'); |
@@ -319,3 +324,4 @@ class DashboardController { | |||
319 | } | 324 | } |
320 | 325 | ||
321 | module.exports = DashboardController; | 326 | module.exports = DashboardController; |
327 | */ | ||
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 @@ | |||
1 | export default class HealthController { | ||
2 | public async index() { | ||
3 | // TODO: Actually do something instead of alwayas returning success. | ||
4 | |||
5 | return { | ||
6 | api: 'success', | ||
7 | db: 'success', | ||
8 | }; | ||
9 | } | ||
10 | } | ||
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 @@ | |||
1 | // import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' | ||
2 | |||
3 | export default class HomeController { | ||
4 | public async index() { | ||
5 | // TODO: Actually do something instead of alwayas returning success. | ||
6 | |||
7 | return { hello: 'world' }; | ||
8 | } | ||
9 | } | ||
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 @@ | |||
1 | const Recipe = use('App/Models/Recipe'); | ||
2 | const Helpers = use('Helpers'); | ||
3 | const Drive = use('Drive'); | ||
4 | const { | ||
5 | validateAll, | ||
6 | } = use('Validator'); | ||
7 | const Env = use('Env'); | ||
8 | |||
9 | const targz = require('targz'); | ||
10 | const path = require('path'); | ||
11 | const fs = require('fs-extra'); | ||
12 | const semver = require('semver'); | ||
13 | |||
14 | const compress = (src, dest) => new Promise((resolve, reject) => { | ||
15 | targz.compress({ | ||
16 | src, | ||
17 | dest, | ||
18 | }, (err) => { | ||
19 | if (err) { | ||
20 | reject(err); | ||
21 | } else { | ||
22 | resolve(dest); | ||
23 | } | ||
24 | }); | ||
25 | }); | ||
26 | |||
27 | class RecipeController { | ||
28 | // List official and custom recipes | ||
29 | async list({ | ||
30 | response, | ||
31 | }) { | ||
32 | const officialRecipes = fs.readJsonSync(path.join(Helpers.appRoot(), 'recipes', 'all.json')); | ||
33 | const customRecipesArray = (await Recipe.all()).rows; | ||
34 | const customRecipes = customRecipesArray.map((recipe) => ({ | ||
35 | id: recipe.recipeId, | ||
36 | name: recipe.name, | ||
37 | ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, | ||
38 | })); | ||
39 | |||
40 | const recipes = [ | ||
41 | ...officialRecipes, | ||
42 | ...customRecipes, | ||
43 | ]; | ||
44 | |||
45 | return response.send(recipes); | ||
46 | } | ||
47 | |||
48 | // Create a new recipe using the new.html page | ||
49 | async create({ | ||
50 | request, | ||
51 | response, | ||
52 | }) { | ||
53 | // Check if recipe creation is enabled | ||
54 | if (Env.get('IS_CREATION_ENABLED') == 'false') { // eslint-disable-line eqeqeq | ||
55 | return response.send('This server doesn\'t allow the creation of new recipes.'); | ||
56 | } | ||
57 | |||
58 | // Validate user input | ||
59 | const validation = await validateAll(request.all(), { | ||
60 | name: 'required|string', | ||
61 | id: 'required|unique:recipes,recipeId', | ||
62 | author: 'required|accepted', | ||
63 | svg: 'required|url', | ||
64 | }); | ||
65 | if (validation.fails()) { | ||
66 | return response.status(401).send({ | ||
67 | message: 'Invalid POST arguments', | ||
68 | messages: validation.messages(), | ||
69 | status: 401, | ||
70 | }); | ||
71 | } | ||
72 | |||
73 | const data = request.all(); | ||
74 | |||
75 | if (!data.id) { | ||
76 | return response.send('Please provide an ID'); | ||
77 | } | ||
78 | |||
79 | // Check for invalid characters | ||
80 | if (/\.{1,}/.test(data.id) || /\/{1,}/.test(data.id)) { | ||
81 | return response.send('Invalid recipe name. Your recipe name may not contain "." or "/"'); | ||
82 | } | ||
83 | |||
84 | // Clear temporary recipe folder | ||
85 | await fs.emptyDir(Helpers.tmpPath('recipe')); | ||
86 | |||
87 | // Move uploaded files to temporary path | ||
88 | const files = request.file('files'); | ||
89 | await files.moveAll(Helpers.tmpPath('recipe')); | ||
90 | |||
91 | // Compress files to .tar.gz file | ||
92 | const source = Helpers.tmpPath('recipe'); | ||
93 | const destination = path.join(Helpers.appRoot(), `/recipes/archives/${data.id}.tar.gz`); | ||
94 | |||
95 | compress( | ||
96 | source, | ||
97 | destination, | ||
98 | ); | ||
99 | |||
100 | // Create recipe in db | ||
101 | await Recipe.create({ | ||
102 | name: data.name, | ||
103 | recipeId: data.id, | ||
104 | data: JSON.stringify({ | ||
105 | author: data.author, | ||
106 | featured: false, | ||
107 | version: '1.0.0', | ||
108 | icons: { | ||
109 | svg: data.svg, | ||
110 | }, | ||
111 | }), | ||
112 | }); | ||
113 | |||
114 | return response.send('Created new recipe'); | ||
115 | } | ||
116 | |||
117 | // Search official and custom recipes | ||
118 | async search({ | ||
119 | request, | ||
120 | response, | ||
121 | }) { | ||
122 | // Validate user input | ||
123 | const validation = await validateAll(request.all(), { | ||
124 | needle: 'required', | ||
125 | }); | ||
126 | if (validation.fails()) { | ||
127 | return response.status(401).send({ | ||
128 | message: 'Please provide a needle', | ||
129 | messages: validation.messages(), | ||
130 | status: 401, | ||
131 | }); | ||
132 | } | ||
133 | |||
134 | const needle = request.input('needle'); | ||
135 | |||
136 | // Get results | ||
137 | let results; | ||
138 | |||
139 | if (needle === 'ferdium:custom') { | ||
140 | const dbResults = (await Recipe.all()).toJSON(); | ||
141 | results = dbResults.map((recipe) => ({ | ||
142 | id: recipe.recipeId, | ||
143 | name: recipe.name, | ||
144 | ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, | ||
145 | })); | ||
146 | } else { | ||
147 | const localResultsArray = (await Recipe.query().where('name', 'LIKE', `%${needle}%`).fetch()).toJSON(); | ||
148 | results = localResultsArray.map((recipe) => ({ | ||
149 | id: recipe.recipeId, | ||
150 | name: recipe.name, | ||
151 | ...typeof recipe.data === 'string' ? JSON.parse(recipe.data) : recipe.data, | ||
152 | })); | ||
153 | } | ||
154 | |||
155 | return response.send(results); | ||
156 | } | ||
157 | |||
158 | popularRecipes({ | ||
159 | response, | ||
160 | }) { | ||
161 | return response.send( | ||
162 | fs | ||
163 | .readJsonSync(path.join( | ||
164 | Helpers.appRoot(), 'recipes', 'all.json', | ||
165 | )) | ||
166 | .filter((recipe) => recipe.featured), | ||
167 | ); | ||
168 | } | ||
169 | |||
170 | update({ request, response }) { | ||
171 | const updates = []; | ||
172 | const recipes = request.all(); | ||
173 | const allJson = fs.readJsonSync(path.join( | ||
174 | Helpers.appRoot(), 'recipes', 'all.json', | ||
175 | )); | ||
176 | |||
177 | for (const recipe of Object.keys(recipes)) { | ||
178 | const version = recipes[recipe]; | ||
179 | |||
180 | // Find recipe in local recipe repository | ||
181 | const localRecipe = allJson.find(r => r.id === recipe); | ||
182 | if (localRecipe && semver.lt(version, localRecipe.version)) { | ||
183 | updates.push(recipe); | ||
184 | } | ||
185 | } | ||
186 | |||
187 | return response.send(updates); | ||
188 | } | ||
189 | |||
190 | // Download a recipe | ||
191 | async download({ | ||
192 | response, | ||
193 | params, | ||
194 | }) { | ||
195 | // Validate user input | ||
196 | const validation = await validateAll(params, { | ||
197 | recipe: 'required|accepted', | ||
198 | }); | ||
199 | if (validation.fails()) { | ||
200 | return response.status(401).send({ | ||
201 | message: 'Please provide a recipe ID', | ||
202 | messages: validation.messages(), | ||
203 | status: 401, | ||
204 | }); | ||
205 | } | ||
206 | |||
207 | const service = params.recipe; | ||
208 | |||
209 | // Check for invalid characters | ||
210 | if (/\.{1,}/.test(service) || /\/{1,}/.test(service)) { | ||
211 | return response.send('Invalid recipe name'); | ||
212 | } | ||
213 | |||
214 | // Check if recipe exists in recipes folder | ||
215 | if (await Drive.exists(`${service}.tar.gz`)) { | ||
216 | return response.type('.tar.gz').send(await Drive.get(`${service}.tar.gz`)); | ||
217 | } | ||
218 | |||
219 | return response.status(400).send({ | ||
220 | message: 'Recipe not found', | ||
221 | code: 'recipe-not-found', | ||
222 | }); | ||
223 | } | ||
224 | } | ||
225 | |||
226 | 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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import fs from 'fs-extra'; | ||
3 | import Application from '@ioc:Adonis/Core/Application'; | ||
4 | import path from 'node:path'; | ||
5 | import Recipe from 'App/Models/Recipe'; | ||
6 | import { isCreationEnabled } from 'Config/app'; | ||
7 | import { validator, schema, rules } from '@ioc:Adonis/Core/Validator'; | ||
8 | import targz from 'targz'; | ||
9 | import semver from 'semver'; | ||
10 | import Drive from '@ioc:Adonis/Core/Drive'; | ||
11 | |||
12 | // TODO: This file needs to be refactored and cleaned up to include types | ||
13 | |||
14 | const createSchema = schema.create({ | ||
15 | name: schema.string(), | ||
16 | id: schema.string([rules.unique({ table: 'recipes', column: 'recipeId' })]), | ||
17 | // TODO: Check if this is correct | ||
18 | // author: 'required|accepted', | ||
19 | author: schema.string(), | ||
20 | svg: schema.string([rules.url()]), | ||
21 | }); | ||
22 | |||
23 | const searchSchema = schema.create({ | ||
24 | needle: schema.string(), | ||
25 | }); | ||
26 | |||
27 | const downloadSchema = schema.create({ | ||
28 | // TODO: Check if this is correct | ||
29 | // recipe: 'required|accepted', | ||
30 | recipe: schema.string(), | ||
31 | }); | ||
32 | |||
33 | const compress = (src: string, dest: string) => | ||
34 | new Promise((resolve, reject) => { | ||
35 | targz.compress( | ||
36 | { | ||
37 | src, | ||
38 | dest, | ||
39 | }, | ||
40 | err => { | ||
41 | if (err) { | ||
42 | reject(err); | ||
43 | } else { | ||
44 | resolve(dest); | ||
45 | } | ||
46 | }, | ||
47 | ); | ||
48 | }); | ||
49 | |||
50 | export default class RecipesController { | ||
51 | // List official and custom recipes | ||
52 | public async list({ response }: HttpContextContract) { | ||
53 | const officialRecipes = fs.readJsonSync( | ||
54 | path.join(Application.appRoot, 'recipes', 'all.json'), | ||
55 | ); | ||
56 | const customRecipesArray = await Recipe.all(); | ||
57 | const customRecipes = customRecipesArray.map(recipe => ({ | ||
58 | id: recipe.recipeId, | ||
59 | name: recipe.name, | ||
60 | ...(typeof recipe.data === 'string' | ||
61 | ? JSON.parse(recipe.data) | ||
62 | : recipe.data), | ||
63 | })); | ||
64 | |||
65 | const recipes = [...officialRecipes, ...customRecipes]; | ||
66 | |||
67 | return response.send(recipes); | ||
68 | } | ||
69 | |||
70 | // TODO: Test this endpoint | ||
71 | // Create a new recipe using the new.html page | ||
72 | public async create({ request, response }: HttpContextContract) { | ||
73 | // Check if recipe creation is enabled | ||
74 | if (isCreationEnabled === 'false') { | ||
75 | return response.send( | ||
76 | 'This server doesn\'t allow the creation of new recipes.', | ||
77 | ); | ||
78 | } | ||
79 | |||
80 | // Validate user input | ||
81 | let data; | ||
82 | try { | ||
83 | data = await request.validate({ schema: createSchema }); | ||
84 | } catch (error) { | ||
85 | return response.status(401).send({ | ||
86 | message: 'Invalid POST arguments', | ||
87 | messages: error.messages, | ||
88 | status: 401, | ||
89 | }); | ||
90 | } | ||
91 | |||
92 | if (!data.id) { | ||
93 | return response.send('Please provide an ID'); | ||
94 | } | ||
95 | |||
96 | // Check for invalid characters | ||
97 | if (/\.+/.test(data.id) || /\/+/.test(data.id)) { | ||
98 | return response.send( | ||
99 | 'Invalid recipe name. Your recipe name may not contain "." or "/"', | ||
100 | ); | ||
101 | } | ||
102 | |||
103 | // Clear temporary recipe folder | ||
104 | await fs.emptyDir(Application.tmpPath('recipe')); | ||
105 | |||
106 | // Move uploaded files to temporary path | ||
107 | const files = request.file('files'); | ||
108 | if (!files) { | ||
109 | return response.abort('Error processsing files.'); | ||
110 | } | ||
111 | await files.move(Application.tmpPath('recipe')); | ||
112 | |||
113 | // Compress files to .tar.gz file | ||
114 | const source = Application.tmpPath('recipe'); | ||
115 | const destination = path.join( | ||
116 | Application.appRoot, | ||
117 | `/recipes/archives/${data.id}.tar.gz`, | ||
118 | ); | ||
119 | |||
120 | compress(source, destination); | ||
121 | |||
122 | // Create recipe in db | ||
123 | await Recipe.create({ | ||
124 | name: data.name, | ||
125 | recipeId: data.id, | ||
126 | // @ts-expect-error | ||
127 | data: JSON.stringify({ | ||
128 | author: data.author, | ||
129 | featured: false, | ||
130 | version: '1.0.0', | ||
131 | icons: { | ||
132 | svg: data.svg, | ||
133 | }, | ||
134 | }), | ||
135 | }); | ||
136 | |||
137 | return response.send('Created new recipe'); | ||
138 | } | ||
139 | |||
140 | // Search official and custom recipes | ||
141 | public async search({ request, response }: HttpContextContract) { | ||
142 | // Validate user input | ||
143 | let data; | ||
144 | try { | ||
145 | data = await request.validate({ schema: searchSchema }); | ||
146 | } catch (error) { | ||
147 | return response.status(401).send({ | ||
148 | message: 'Please provide a needle', | ||
149 | messages: error.messages, | ||
150 | status: 401, | ||
151 | }); | ||
152 | } | ||
153 | |||
154 | const { needle } = data; | ||
155 | |||
156 | // Get results | ||
157 | let results; | ||
158 | |||
159 | if (needle === 'ferdium:custom') { | ||
160 | const dbResults = await Recipe.all(); | ||
161 | results = dbResults.map(recipe => ({ | ||
162 | id: recipe.recipeId, | ||
163 | name: recipe.name, | ||
164 | ...(typeof recipe.data === 'string' | ||
165 | ? JSON.parse(recipe.data) | ||
166 | : recipe.data), | ||
167 | })); | ||
168 | } else { | ||
169 | const localResultsArray = await Recipe.query().where( | ||
170 | 'name', | ||
171 | 'LIKE', | ||
172 | `%${needle}%`, | ||
173 | ); | ||
174 | results = localResultsArray.map(recipe => ({ | ||
175 | id: recipe.recipeId, | ||
176 | name: recipe.name, | ||
177 | ...(typeof recipe.data === 'string' | ||
178 | ? JSON.parse(recipe.data) | ||
179 | : recipe.data), | ||
180 | })); | ||
181 | } | ||
182 | |||
183 | return response.send(results); | ||
184 | } | ||
185 | |||
186 | public popularRecipes({ response }: HttpContextContract) { | ||
187 | return response.send( | ||
188 | fs | ||
189 | .readJsonSync(path.join(Application.appRoot, 'recipes', 'all.json')) | ||
190 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
191 | .filter((recipe: any) => recipe.featured), | ||
192 | ); | ||
193 | } | ||
194 | |||
195 | // TODO: test this endpoint | ||
196 | public update({ request, response }: HttpContextContract) { | ||
197 | const updates = []; | ||
198 | const recipes = request.all(); | ||
199 | const allJson = fs.readJsonSync( | ||
200 | path.join(Application.appRoot, 'recipes', 'all.json'), | ||
201 | ); | ||
202 | |||
203 | for (const recipe of Object.keys(recipes)) { | ||
204 | const version = recipes[recipe]; | ||
205 | |||
206 | // Find recipe in local recipe repository | ||
207 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
208 | const localRecipe = allJson.find((r: any) => r.id === recipe); | ||
209 | if (localRecipe && semver.lt(version, localRecipe.version)) { | ||
210 | updates.push(recipe); | ||
211 | } | ||
212 | } | ||
213 | |||
214 | return response.send(updates); | ||
215 | } | ||
216 | |||
217 | // TODO: test this endpoint | ||
218 | // Download a recipe | ||
219 | public async download({ response, params }: HttpContextContract) { | ||
220 | // Validate user input | ||
221 | let data; | ||
222 | try { | ||
223 | data = await validator.validate({ | ||
224 | data: params, | ||
225 | schema: downloadSchema, | ||
226 | }); | ||
227 | } catch (error) { | ||
228 | return response.status(401).send({ | ||
229 | message: 'Please provide a recipe ID', | ||
230 | messages: error.messages, | ||
231 | status: 401, | ||
232 | }); | ||
233 | } | ||
234 | |||
235 | const service = data.recipe; | ||
236 | |||
237 | // Check for invalid characters | ||
238 | if (/\.+/.test(service) || /\/+/.test(service)) { | ||
239 | return response.send('Invalid recipe name'); | ||
240 | } | ||
241 | |||
242 | // Check if recipe exists in recipes folder | ||
243 | if (await Drive.exists(`${service}.tar.gz`)) { | ||
244 | return response | ||
245 | .type('.tar.gz') | ||
246 | .send(await Drive.get(`${service}.tar.gz`)); | ||
247 | } | ||
248 | |||
249 | return response.status(400).send({ | ||
250 | message: 'Recipe not found', | ||
251 | code: 'recipe-not-found', | ||
252 | }); | ||
253 | } | ||
254 | } | ||
diff --git a/app/Controllers/Http/ServiceController.js b/app/Controllers/Http/ServiceController.ts index 6a27ef2..36c6ca4 100644 --- a/app/Controllers/Http/ServiceController.js +++ b/app/Controllers/Http/ServiceController.ts | |||
@@ -1,48 +1,51 @@ | |||
1 | const Service = use('App/Models/Service'); | 1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; |
2 | const { validateAll } = use('Validator'); | 2 | import { schema } from '@ioc:Adonis/Core/Validator'; |
3 | const Env = use('Env'); | 3 | import Service from 'App/Models/Service'; |
4 | const Helpers = use('Helpers'); | 4 | import { url } from 'Config/app'; |
5 | 5 | import { v4 as uuid } from 'uuid'; | |
6 | const { v4: uuid } = require('uuid'); | 6 | import * as fs from 'fs-extra'; |
7 | const path = require('path'); | 7 | import path from 'node:path'; |
8 | const fs = require('fs-extra'); | 8 | import Application from '@ioc:Adonis/Core/Application'; |
9 | const sanitize = require('sanitize-filename'); | 9 | import sanitize from 'sanitize-filename'; |
10 | 10 | ||
11 | class ServiceController { | 11 | const createSchema = schema.create({ |
12 | name: schema.string(), | ||
13 | recipeId: schema.string(), | ||
14 | }); | ||
15 | |||
16 | export default class ServicesController { | ||
12 | // Create a new service for user | 17 | // Create a new service for user |
13 | async create({ request, response, auth }) { | 18 | public async create({ request, response, auth }: HttpContextContract) { |
14 | try { | 19 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
15 | await auth.getUser(); | 20 | const user = auth.user ?? request.user; |
16 | } catch (error) { | 21 | |
17 | return response.send('Missing or invalid api token'); | 22 | if (!user) { |
23 | return response.unauthorized('Missing or invalid api token'); | ||
18 | } | 24 | } |
19 | 25 | ||
20 | // Validate user input | 26 | // Validate user input |
21 | const validation = await validateAll(request.all(), { | 27 | let data; |
22 | name: 'required|string', | 28 | try { |
23 | recipeId: 'required', | 29 | data = await request.validate({ schema: createSchema }); |
24 | }); | 30 | } catch (error) { |
25 | if (validation.fails()) { | ||
26 | return response.status(401).send({ | 31 | return response.status(401).send({ |
27 | message: 'Invalid POST arguments', | 32 | message: 'Invalid POST arguments', |
28 | messages: validation.messages(), | 33 | messages: error.messages, |
29 | status: 401, | 34 | status: 401, |
30 | }); | 35 | }); |
31 | } | 36 | } |
32 | 37 | ||
33 | const data = request.all(); | ||
34 | |||
35 | // Get new, unused uuid | 38 | // Get new, unused uuid |
36 | let serviceId; | 39 | let serviceId; |
37 | do { | 40 | do { |
38 | serviceId = uuid(); | 41 | serviceId = uuid(); |
39 | } while ( | 42 | } while ( |
40 | (await Service.query().where('serviceId', serviceId).fetch()).rows | 43 | // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member |
41 | .length > 0 | 44 | (await Service.query().where('serviceId', serviceId)).length > 0 |
42 | ); // eslint-disable-line no-await-in-loop | 45 | ); |
43 | 46 | ||
44 | await Service.create({ | 47 | await Service.create({ |
45 | userId: auth.user.id, | 48 | userId: user.id, |
46 | serviceId, | 49 | serviceId, |
47 | name: data.name, | 50 | name: data.name, |
48 | recipeId: data.recipeId, | 51 | recipeId: data.recipeId, |
@@ -51,7 +54,7 @@ class ServiceController { | |||
51 | 54 | ||
52 | return response.send({ | 55 | return response.send({ |
53 | data: { | 56 | data: { |
54 | userId: auth.user.id, | 57 | userId: user.id, |
55 | id: serviceId, | 58 | id: serviceId, |
56 | isEnabled: true, | 59 | isEnabled: true, |
57 | isNotificationEnabled: true, | 60 | isNotificationEnabled: true, |
@@ -63,6 +66,7 @@ class ServiceController { | |||
63 | customRecipe: false, | 66 | customRecipe: false, |
64 | hasCustomIcon: false, | 67 | hasCustomIcon: false, |
65 | workspaces: [], | 68 | workspaces: [], |
69 | // eslint-disable-next-line unicorn/no-null | ||
66 | iconUrl: null, | 70 | iconUrl: null, |
67 | ...data, | 71 | ...data, |
68 | }, | 72 | }, |
@@ -71,16 +75,20 @@ class ServiceController { | |||
71 | } | 75 | } |
72 | 76 | ||
73 | // List all services a user has created | 77 | // List all services a user has created |
74 | async list({ response, auth }) { | 78 | public async list({ request, response, auth }: HttpContextContract) { |
75 | try { | 79 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
76 | await auth.getUser(); | 80 | const user = auth.user ?? request.user; |
77 | } catch (error) { | 81 | |
78 | return response.send('Missing or invalid api token'); | 82 | if (!user) { |
83 | return response.unauthorized('Missing or invalid api token'); | ||
79 | } | 84 | } |
80 | 85 | ||
81 | const services = (await auth.user.services().fetch()).rows; | 86 | const { id } = user; |
87 | const services = await user.related('services').query(); | ||
88 | |||
82 | // Convert to array with all data Franz wants | 89 | // Convert to array with all data Franz wants |
83 | const servicesArray = services.map(service => { | 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
91 | const servicesArray = services.map((service: any) => { | ||
84 | const settings = | 92 | const settings = |
85 | typeof service.settings === 'string' | 93 | typeof service.settings === 'string' |
86 | ? JSON.parse(service.settings) | 94 | ? JSON.parse(service.settings) |
@@ -99,38 +107,65 @@ class ServiceController { | |||
99 | workspaces: [], | 107 | workspaces: [], |
100 | ...settings, | 108 | ...settings, |
101 | iconUrl: settings.iconId | 109 | iconUrl: settings.iconId |
102 | ? `${Env.get('APP_URL')}/v1/icon/${settings.iconId}` | 110 | ? `${url}/v1/icon/${settings.iconId}` |
103 | : null, | 111 | : // eslint-disable-next-line unicorn/no-null |
112 | null, | ||
104 | id: service.serviceId, | 113 | id: service.serviceId, |
105 | name: service.name, | 114 | name: service.name, |
106 | recipeId: service.recipeId, | 115 | recipeId: service.recipeId, |
107 | userId: auth.user.id, | 116 | userId: id, |
108 | }; | 117 | }; |
109 | }); | 118 | }); |
110 | 119 | ||
111 | return response.send(servicesArray); | 120 | return response.send(servicesArray); |
112 | } | 121 | } |
113 | 122 | ||
114 | async edit({ request, response, auth, params }) { | 123 | public async delete({ params, auth, response }: HttpContextContract) { |
115 | try { | 124 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
116 | await auth.getUser(); | 125 | const user = auth.user ?? request.user; |
117 | } catch (error) { | 126 | |
118 | return response.send('Missing or invalid api token'); | 127 | if (!user) { |
128 | return response.unauthorized('Missing or invalid api token'); | ||
119 | } | 129 | } |
120 | 130 | ||
131 | // Update data in database | ||
132 | await Service.query() | ||
133 | .where('serviceId', params.id) | ||
134 | .where('userId', user.id) | ||
135 | .delete(); | ||
136 | |||
137 | return response.send({ | ||
138 | message: 'Sucessfully deleted service', | ||
139 | status: 200, | ||
140 | }); | ||
141 | } | ||
142 | |||
143 | // TODO: Test if icon upload works | ||
144 | public async edit({ request, response, auth, params }: HttpContextContract) { | ||
145 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
146 | const user = auth.user ?? request.user; | ||
147 | |||
148 | if (!user) { | ||
149 | return response.unauthorized('Missing or invalid api token'); | ||
150 | } | ||
151 | |||
152 | const { id } = params; | ||
153 | const service = await Service.query() | ||
154 | .where('serviceId', id) | ||
155 | .where('userId', user.id) | ||
156 | .firstOrFail(); | ||
157 | |||
121 | if (request.file('icon')) { | 158 | if (request.file('icon')) { |
122 | // Upload custom service icon | 159 | // Upload custom service icon |
123 | const icon = request.file('icon', { | 160 | const icon = request.file('icon', { |
124 | types: ['image'], | 161 | extnames: ['png', 'jpg', 'jpeg', 'svg'], |
125 | size: '2mb', | 162 | size: '2mb', |
126 | }); | 163 | }); |
127 | const { id } = params; | 164 | |
128 | const service = ( | 165 | if (icon === null) { |
129 | await Service.query() | 166 | return response.badRequest('Icon not uploaded.'); |
130 | .where('serviceId', id) | 167 | } |
131 | .where('userId', auth.user.id) | 168 | |
132 | .fetch() | ||
133 | ).rows[0]; | ||
134 | const settings = | 169 | const settings = |
135 | typeof service.settings === 'string' | 170 | typeof service.settings === 'string' |
136 | ? JSON.parse(service.settings) | 171 | ? JSON.parse(service.settings) |
@@ -139,34 +174,34 @@ class ServiceController { | |||
139 | let iconId; | 174 | let iconId; |
140 | do { | 175 | do { |
141 | iconId = uuid() + uuid(); | 176 | iconId = uuid() + uuid(); |
177 | } while ( | ||
142 | // eslint-disable-next-line no-await-in-loop | 178 | // eslint-disable-next-line no-await-in-loop |
143 | } while (await fs.exists(path.join(Helpers.tmpPath('uploads'), iconId))); | 179 | await fs.exists(path.join(Application.tmpPath('uploads'), iconId)) |
180 | ); | ||
144 | iconId = `${iconId}.${icon.extname}`; | 181 | iconId = `${iconId}.${icon.extname}`; |
145 | 182 | ||
146 | await icon.move(Helpers.tmpPath('uploads'), { | 183 | await icon.move(Application.tmpPath('uploads'), { |
147 | name: iconId, | 184 | name: iconId, |
148 | overwrite: true, | 185 | overwrite: true, |
149 | }); | 186 | }); |
150 | 187 | ||
151 | if (!icon.moved()) { | 188 | if (icon.state !== 'moved') { |
152 | return response.status(500).send(icon.error()); | 189 | return response.status(500).send(icon.errors); |
153 | } | 190 | } |
154 | 191 | ||
155 | const newSettings = { | 192 | const newSettings = { |
156 | ...settings, | 193 | ...settings, |
157 | ...{ | 194 | |
158 | iconId, | 195 | iconId, |
159 | customIconVersion: | 196 | customIconVersion: settings?.customIconVersion |
160 | settings && settings.customIconVersion | 197 | ? settings.customIconVersion + 1 |
161 | ? settings.customIconVersion + 1 | 198 | : 1, |
162 | : 1, | ||
163 | }, | ||
164 | }; | 199 | }; |
165 | 200 | ||
166 | // Update data in database | 201 | // Update data in database |
167 | await Service.query() | 202 | await Service.query() |
168 | .where('serviceId', id) | 203 | .where('serviceId', id) |
169 | .where('userId', auth.user.id) | 204 | .where('userId', user.id) |
170 | .update({ | 205 | .update({ |
171 | name: service.name, | 206 | name: service.name, |
172 | settings: JSON.stringify(newSettings), | 207 | settings: JSON.stringify(newSettings), |
@@ -177,34 +212,25 @@ class ServiceController { | |||
177 | id, | 212 | id, |
178 | name: service.name, | 213 | name: service.name, |
179 | ...newSettings, | 214 | ...newSettings, |
180 | iconUrl: `${Env.get('APP_URL')}/v1/icon/${newSettings.iconId}`, | 215 | iconUrl: `${url}/v1/icon/${newSettings.iconId}`, |
181 | userId: auth.user.id, | 216 | userId: user.id, |
182 | }, | 217 | }, |
183 | status: ['updated'], | 218 | status: ['updated'], |
184 | }); | 219 | }); |
185 | } | 220 | } |
186 | // Update service info | 221 | // Update service info |
187 | const data = request.all(); | 222 | const data = request.all(); |
188 | const { id } = params; | ||
189 | |||
190 | // Get current settings from db | ||
191 | const serviceData = ( | ||
192 | await Service.query() | ||
193 | .where('serviceId', id) | ||
194 | .where('userId', auth.user.id) | ||
195 | .fetch() | ||
196 | ).rows[0]; | ||
197 | 223 | ||
198 | const settings = { | 224 | const settings = { |
199 | ...(typeof serviceData.settings === 'string' | 225 | ...(typeof service.settings === 'string' |
200 | ? JSON.parse(serviceData.settings) | 226 | ? JSON.parse(service.settings) |
201 | : serviceData.settings), | 227 | : service.settings), |
202 | ...data, | 228 | ...data, |
203 | }; | 229 | }; |
204 | 230 | ||
205 | if (settings.customIcon === 'delete') { | 231 | if (settings.customIcon === 'delete') { |
206 | fs.remove( | 232 | fs.remove( |
207 | path.join(Helpers.tmpPath('uploads'), settings.iconId), | 233 | path.join(Application.tmpPath('uploads'), settings.iconId), |
208 | ).catch(error => { | 234 | ).catch(error => { |
209 | console.error(error); | 235 | console.error(error); |
210 | }); | 236 | }); |
@@ -217,68 +243,48 @@ class ServiceController { | |||
217 | // Update data in database | 243 | // Update data in database |
218 | await Service.query() | 244 | await Service.query() |
219 | .where('serviceId', id) | 245 | .where('serviceId', id) |
220 | .where('userId', auth.user.id) | 246 | .where('userId', user.id) |
221 | .update({ | 247 | .update({ |
222 | name: data.name, | 248 | name: data.name, |
223 | settings: JSON.stringify(settings), | 249 | settings: JSON.stringify(settings), |
224 | }); | 250 | }); |
225 | 251 | ||
226 | // Get updated row | 252 | // Get updated row |
227 | const service = ( | 253 | const serviceUpdated = await Service.query() |
228 | await Service.query() | 254 | .where('serviceId', id) |
229 | .where('serviceId', id) | 255 | .where('userId', user.id) |
230 | .where('userId', auth.user.id) | 256 | .firstOrFail(); |
231 | .fetch() | ||
232 | ).rows[0]; | ||
233 | 257 | ||
234 | return response.send({ | 258 | return response.send({ |
235 | data: { | 259 | data: { |
236 | id, | 260 | id, |
237 | name: service.name, | 261 | name: serviceUpdated.name, |
238 | ...settings, | 262 | ...settings, |
239 | iconUrl: `${Env.get('APP_URL')}/v1/icon/${settings.iconId}`, | 263 | iconUrl: `${url}/v1/icon/${settings.iconId}`, |
240 | userId: auth.user.id, | 264 | userId: user.id, |
241 | }, | 265 | }, |
242 | status: ['updated'], | 266 | status: ['updated'], |
243 | }); | 267 | }); |
244 | } | 268 | } |
245 | 269 | ||
246 | async icon({ params, response }) { | 270 | // TODO: Test if this works |
247 | let { id } = params; | 271 | public async reorder({ request, response, auth }: HttpContextContract) { |
272 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
273 | const user = auth.user ?? request.user; | ||
248 | 274 | ||
249 | id = sanitize(id); | 275 | if (!user) { |
250 | if (id === '') { | 276 | return response.unauthorized('Missing or invalid api token'); |
251 | return response.status(404).send({ | ||
252 | status: "Icon doesn't exist", | ||
253 | }); | ||
254 | } | 277 | } |
255 | 278 | ||
256 | const iconPath = path.join(Helpers.tmpPath('uploads'), id); | ||
257 | |||
258 | try { | ||
259 | await fs.access(iconPath); | ||
260 | } catch (ex) { | ||
261 | console.log(ex); | ||
262 | // File not available. | ||
263 | return response.status(404).send({ | ||
264 | status: "Icon doesn't exist", | ||
265 | }); | ||
266 | } | ||
267 | |||
268 | return response.download(iconPath); | ||
269 | } | ||
270 | |||
271 | async reorder({ request, response, auth }) { | ||
272 | const data = request.all(); | 279 | const data = request.all(); |
273 | 280 | ||
274 | for (const service of Object.keys(data)) { | 281 | for (const service of Object.keys(data)) { |
275 | // Get current settings from db | 282 | // Get current settings from db |
276 | const serviceData = ( | 283 | const serviceData = await Service.query() // eslint-disable-line no-await-in-loop |
277 | await Service.query() // eslint-disable-line no-await-in-loop | 284 | .where('serviceId', service) |
278 | .where('serviceId', service) | 285 | .where('userId', user.id) |
279 | .where('userId', auth.user.id) | 286 | |
280 | .fetch() | 287 | .firstOrFail(); |
281 | ).rows[0]; | ||
282 | 288 | ||
283 | const settings = { | 289 | const settings = { |
284 | ...(typeof serviceData.settings === 'string' | 290 | ...(typeof serviceData.settings === 'string' |
@@ -290,16 +296,17 @@ class ServiceController { | |||
290 | // Update data in database | 296 | // Update data in database |
291 | await Service.query() // eslint-disable-line no-await-in-loop | 297 | await Service.query() // eslint-disable-line no-await-in-loop |
292 | .where('serviceId', service) | 298 | .where('serviceId', service) |
293 | .where('userId', auth.user.id) | 299 | .where('userId', user.id) |
294 | .update({ | 300 | .update({ |
295 | settings: JSON.stringify(settings), | 301 | settings: JSON.stringify(settings), |
296 | }); | 302 | }); |
297 | } | 303 | } |
298 | 304 | ||
299 | // Get new services | 305 | // Get new services |
300 | const services = (await auth.user.services().fetch()).rows; | 306 | const services = await user.related('services').query(); |
301 | // Convert to array with all data Franz wants | 307 | // Convert to array with all data Franz wants |
302 | const servicesArray = services.map(service => { | 308 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
309 | const servicesArray = services.map((service: any) => { | ||
303 | const settings = | 310 | const settings = |
304 | typeof service.settings === 'string' | 311 | typeof service.settings === 'string' |
305 | ? JSON.parse(service.settings) | 312 | ? JSON.parse(service.settings) |
@@ -318,30 +325,41 @@ class ServiceController { | |||
318 | workspaces: [], | 325 | workspaces: [], |
319 | ...settings, | 326 | ...settings, |
320 | iconUrl: settings.iconId | 327 | iconUrl: settings.iconId |
321 | ? `${Env.get('APP_URL')}/v1/icon/${settings.iconId}` | 328 | ? `${url}/v1/icon/${settings.iconId}` |
322 | : null, | 329 | : // eslint-disable-next-line unicorn/no-null |
330 | null, | ||
323 | id: service.serviceId, | 331 | id: service.serviceId, |
324 | name: service.name, | 332 | name: service.name, |
325 | recipeId: service.recipeId, | 333 | recipeId: service.recipeId, |
326 | userId: auth.user.id, | 334 | userId: user.id, |
327 | }; | 335 | }; |
328 | }); | 336 | }); |
329 | 337 | ||
330 | return response.send(servicesArray); | 338 | return response.send(servicesArray); |
331 | } | 339 | } |
332 | 340 | ||
333 | async delete({ params, auth, response }) { | 341 | // TODO: Test if this works |
334 | // Update data in database | 342 | public async icon({ params, response }: HttpContextContract) { |
335 | await Service.query() | 343 | let { id } = params; |
336 | .where('serviceId', params.id) | ||
337 | .where('userId', auth.user.id) | ||
338 | .delete(); | ||
339 | 344 | ||
340 | return response.send({ | 345 | id = sanitize(id); |
341 | message: 'Sucessfully deleted service', | 346 | if (id === '') { |
342 | status: 200, | 347 | return response.status(404).send({ |
343 | }); | 348 | status: 'Icon doesn\'t exist', |
349 | }); | ||
350 | } | ||
351 | |||
352 | const iconPath = path.join(Application.tmpPath('uploads'), id); | ||
353 | |||
354 | try { | ||
355 | await fs.access(iconPath); | ||
356 | } catch { | ||
357 | // File not available. | ||
358 | return response.status(404).send({ | ||
359 | status: 'Icon doesn\'t exist', | ||
360 | }); | ||
361 | } | ||
362 | |||
363 | return response.download(iconPath); | ||
344 | } | 364 | } |
345 | } | 365 | } |
346 | |||
347 | module.exports = ServiceController; | ||
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 @@ | |||
1 | /** | ||
2 | * Controller for routes with static responses | ||
3 | */ | ||
4 | const Helpers = use('Helpers'); | ||
5 | const fs = require('fs-extra'); | ||
6 | const path = require('path'); | ||
7 | |||
8 | class StaticController { | ||
9 | // Enable all features | ||
10 | features({ | ||
11 | response, | ||
12 | }) { | ||
13 | return response.send({ | ||
14 | isServiceProxyEnabled: true, | ||
15 | isWorkspaceEnabled: true, | ||
16 | isAnnouncementsEnabled: true, | ||
17 | isSettingsWSEnabled: false, | ||
18 | isMagicBarEnabled: true, | ||
19 | isTodosEnabled: true, | ||
20 | }); | ||
21 | } | ||
22 | |||
23 | // Return an empty array | ||
24 | emptyArray({ | ||
25 | response, | ||
26 | }) { | ||
27 | return response.send([]); | ||
28 | } | ||
29 | |||
30 | // Show announcements | ||
31 | async announcement({ | ||
32 | response, | ||
33 | params, | ||
34 | }) { | ||
35 | const announcement = path.join(Helpers.resourcesPath(), 'announcements', `${params.version}.json`); | ||
36 | |||
37 | if (await fs.pathExists(announcement)) { | ||
38 | return response.download(announcement); | ||
39 | } | ||
40 | return response.status(404).send('No announcement found.'); | ||
41 | } | ||
42 | } | ||
43 | |||
44 | 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 @@ | |||
1 | // import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' | ||
2 | |||
3 | export default class StaticsController {} | ||
diff --git a/app/Controllers/Http/UserController.js b/app/Controllers/Http/UserController.ts index aef7f01..ef7cfdd 100644 --- a/app/Controllers/Http/UserController.js +++ b/app/Controllers/Http/UserController.ts | |||
@@ -1,17 +1,37 @@ | |||
1 | const User = use('App/Models/User'); | 1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; |
2 | const Service = use('App/Models/Service'); | 2 | import { schema, rules } from '@ioc:Adonis/Core/Validator'; |
3 | const Workspace = use('App/Models/Workspace'); | 3 | import User from 'App/Models/User'; |
4 | const { validateAll } = use('Validator'); | 4 | import { connectWithFranz, isRegistrationEnabled } from '../../../config/app'; |
5 | const Env = use('Env'); | 5 | import crypto from 'node:crypto'; |
6 | 6 | import { v4 as uuid } from 'uuid'; | |
7 | const atob = require('atob'); | 7 | import Workspace from 'App/Models/Workspace'; |
8 | const btoa = require('btoa'); | 8 | import Service from 'App/Models/Service'; |
9 | const fetch = require('node-fetch'); | 9 | import fetch from 'node-fetch'; |
10 | const { v4: uuid } = require('uuid'); | 10 | |
11 | const crypto = require('crypto'); | 11 | // TODO: This file needs to be refactored and cleaned up to include types |
12 | 12 | import { handleVerifyAndReHash } from '../../../helpers/PasswordHash'; | |
13 | // TODO: This whole controller needs to be changed such that it can support importing from both Franz and Ferdi | 13 | |
14 | const franzRequest = (route, method, auth) => | 14 | const newPostSchema = schema.create({ |
15 | firstname: schema.string(), | ||
16 | lastname: schema.string(), | ||
17 | email: schema.string([ | ||
18 | rules.email(), | ||
19 | rules.unique({ table: 'users', column: 'email' }), | ||
20 | ]), | ||
21 | password: schema.string([rules.minLength(8)]), | ||
22 | }); | ||
23 | |||
24 | const franzImportSchema = schema.create({ | ||
25 | email: schema.string([ | ||
26 | rules.email(), | ||
27 | rules.unique({ table: 'users', column: 'email' }), | ||
28 | ]), | ||
29 | password: schema.string([rules.minLength(8)]), | ||
30 | }); | ||
31 | |||
32 | // // TODO: This whole controller needs to be changed such that it can support importing from both Franz and Ferdi | ||
33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
34 | const franzRequest = (route: any, method: any, auth: any) => | ||
15 | new Promise((resolve, reject) => { | 35 | new Promise((resolve, reject) => { |
16 | const base = 'https://api.franzinfra.com/v1/'; | 36 | const base = 'https://api.franzinfra.com/v1/'; |
17 | const user = | 37 | const user = |
@@ -27,16 +47,15 @@ const franzRequest = (route, method, auth) => | |||
27 | }) | 47 | }) |
28 | .then(data => data.json()) | 48 | .then(data => data.json()) |
29 | .then(json => resolve(json)); | 49 | .then(json => resolve(json)); |
30 | } catch (e) { | 50 | } catch { |
31 | reject(); | 51 | reject(); |
32 | } | 52 | } |
33 | }); | 53 | }); |
34 | 54 | ||
35 | class UserController { | 55 | export default class UsersController { |
36 | // Register a new user | 56 | // Register a new user |
37 | async signup({ request, response, auth }) { | 57 | public async signup({ request, response, auth }: HttpContextContract) { |
38 | if (Env.get('IS_REGISTRATION_ENABLED') == 'false') { | 58 | if (isRegistrationEnabled === 'false') { |
39 | // eslint-disable-line eqeqeq | ||
40 | return response.status(401).send({ | 59 | return response.status(401).send({ |
41 | message: 'Registration is disabled on this server', | 60 | message: 'Registration is disabled on this server', |
42 | status: 401, | 61 | status: 401, |
@@ -44,23 +63,17 @@ class UserController { | |||
44 | } | 63 | } |
45 | 64 | ||
46 | // Validate user input | 65 | // Validate user input |
47 | const validation = await validateAll(request.all(), { | 66 | let data; |
48 | firstname: 'required', | 67 | try { |
49 | lastname: 'required', | 68 | data = await request.validate({ schema: newPostSchema }); |
50 | email: 'required|email|unique:users,email', | 69 | } catch (error) { |
51 | password: 'required', | ||
52 | }); | ||
53 | |||
54 | if (validation.fails()) { | ||
55 | return response.status(401).send({ | 70 | return response.status(401).send({ |
56 | message: 'Invalid POST arguments', | 71 | message: 'Invalid POST arguments', |
57 | messages: validation.messages(), | 72 | messages: error.messages, |
58 | status: 401, | 73 | status: 401, |
59 | }); | 74 | }); |
60 | } | 75 | } |
61 | 76 | ||
62 | const data = request.only(['firstname', 'lastname', 'email', 'password']); | ||
63 | |||
64 | // Create user in DB | 77 | // Create user in DB |
65 | let user; | 78 | let user; |
66 | try { | 79 | try { |
@@ -70,24 +83,24 @@ class UserController { | |||
70 | username: data.firstname, | 83 | username: data.firstname, |
71 | lastname: data.lastname, | 84 | lastname: data.lastname, |
72 | }); | 85 | }); |
73 | } catch (e) { | 86 | } catch { |
74 | return response.status(401).send({ | 87 | return response.status(401).send({ |
75 | message: 'E-Mail Address already in use', | 88 | message: 'E-Mail address already in use', |
76 | status: 401, | 89 | status: 401, |
77 | }); | 90 | }); |
78 | } | 91 | } |
79 | 92 | ||
80 | // Generate new auth token | 93 | // Generate new auth token |
81 | const token = await auth.generate(user); | 94 | const token = await auth.use('jwt').login(user, { payload: {} }); |
82 | 95 | ||
83 | return response.send({ | 96 | return response.send({ |
84 | message: 'Successfully created account', | 97 | message: 'Successfully created account', |
85 | token: token.token, | 98 | token: token.accessToken, |
86 | }); | 99 | }); |
87 | } | 100 | } |
88 | 101 | ||
89 | // Login using an existing user | 102 | // Login using an existing user |
90 | async login({ request, response, auth }) { | 103 | public async login({ request, response, auth }: HttpContextContract) { |
91 | if (!request.header('Authorization')) { | 104 | if (!request.header('Authorization')) { |
92 | return response.status(401).send({ | 105 | return response.status(401).send({ |
93 | message: 'Please provide authorization', | 106 | message: 'Please provide authorization', |
@@ -97,24 +110,28 @@ class UserController { | |||
97 | 110 | ||
98 | // Get auth data from auth token | 111 | // Get auth data from auth token |
99 | const authHeader = atob( | 112 | const authHeader = atob( |
100 | request.header('Authorization').replace('Basic ', ''), | 113 | request.header('Authorization')!.replace('Basic ', ''), |
101 | ).split(':'); | 114 | ).split(':'); |
102 | 115 | ||
103 | // Check if user with email exists | 116 | // Check if user with email exists |
104 | const user = await User.query().where('email', authHeader[0]).first(); | 117 | const user = await User.query().where('email', authHeader[0]).first(); |
105 | if (!user || !user.email) { | 118 | if (!user?.email) { |
106 | return response.status(401).send({ | 119 | return response.status(401).send({ |
107 | message: 'User credentials not valid (Invalid mail)', | 120 | message: 'User credentials not valid', |
108 | code: 'invalid-credentials', | 121 | code: 'invalid-credentials', |
109 | status: 401, | 122 | status: 401, |
110 | }); | 123 | }); |
111 | } | 124 | } |
112 | 125 | ||
113 | // Try to login | 126 | // Verify password |
114 | let token; | 127 | let isMatchedPassword = false; |
115 | try { | 128 | try { |
116 | token = await auth.attempt(user.email, authHeader[1]); | 129 | isMatchedPassword = await handleVerifyAndReHash(user, authHeader[1]); |
117 | } catch (e) { | 130 | } catch (error) { |
131 | return response.internalServerError({ message: error.message }); | ||
132 | } | ||
133 | |||
134 | if (!isMatchedPassword) { | ||
118 | return response.status(401).send({ | 135 | return response.status(401).send({ |
119 | message: 'User credentials not valid', | 136 | message: 'User credentials not valid', |
120 | code: 'invalid-credentials', | 137 | code: 'invalid-credentials', |
@@ -122,44 +139,55 @@ class UserController { | |||
122 | }); | 139 | }); |
123 | } | 140 | } |
124 | 141 | ||
142 | // Generate token | ||
143 | const token = await auth.use('jwt').login(user, { payload: {} }); | ||
144 | |||
125 | return response.send({ | 145 | return response.send({ |
126 | message: 'Successfully logged in', | 146 | message: 'Successfully logged in', |
127 | token: token.token, | 147 | token: token.accessToken, |
128 | }); | 148 | }); |
129 | } | 149 | } |
130 | 150 | ||
131 | // Return information about the current user | 151 | // Return information about the current user |
132 | async me({ response, auth }) { | 152 | public async me({ request, response, auth }: HttpContextContract) { |
133 | try { | 153 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
134 | await auth.getUser(); | 154 | const user = auth.user ?? request.user; |
135 | } catch (error) { | 155 | |
136 | response.send('Missing or invalid api token'); | 156 | if (!user) { |
157 | return response.send('Missing or invalid api token'); | ||
137 | } | 158 | } |
138 | 159 | ||
139 | const settings = | 160 | const settings = |
140 | typeof auth.user.settings === 'string' | 161 | typeof user.settings === 'string' |
141 | ? JSON.parse(auth.user.settings) | 162 | ? JSON.parse(user.settings) |
142 | : auth.user.settings; | 163 | : user.settings; |
143 | 164 | ||
144 | return response.send({ | 165 | return response.send({ |
145 | accountType: 'individual', | 166 | accountType: 'individual', |
146 | beta: false, | 167 | beta: false, |
147 | donor: {}, | 168 | donor: {}, |
148 | email: auth.user.email, | 169 | email: user.email, |
149 | emailValidated: true, | 170 | emailValidated: true, |
150 | features: {}, | 171 | features: {}, |
151 | firstname: auth.user.username, | 172 | firstname: user.username, |
152 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', | 173 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', |
153 | isPremium: true, | 174 | isPremium: true, |
154 | isSubscriptionOwner: true, | 175 | isSubscriptionOwner: true, |
155 | lastname: auth.user.lastname, | 176 | lastname: user.lastname, |
156 | locale: 'en-US', | 177 | locale: 'en-US', |
157 | ...(settings || {}), | 178 | ...settings, |
158 | }); | 179 | }); |
159 | } | 180 | } |
160 | 181 | ||
161 | async updateMe({ request, response, auth }) { | 182 | public async updateMe({ request, response, auth }: HttpContextContract) { |
162 | let settings = auth.user.settings || {}; | 183 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
184 | const user = auth.user ?? request.user; | ||
185 | |||
186 | if (!user) { | ||
187 | return response.send('Missing or invalid api token'); | ||
188 | } | ||
189 | |||
190 | let settings = user.settings || {}; | ||
163 | if (typeof settings === 'string') { | 191 | if (typeof settings === 'string') { |
164 | settings = JSON.parse(settings); | 192 | settings = JSON.parse(settings); |
165 | } | 193 | } |
@@ -169,83 +197,76 @@ class UserController { | |||
169 | ...request.all(), | 197 | ...request.all(), |
170 | }; | 198 | }; |
171 | 199 | ||
172 | // eslint-disable-next-line no-param-reassign | 200 | user.settings = JSON.stringify(newSettings); |
173 | auth.user.settings = JSON.stringify(newSettings); | 201 | await user.save(); |
174 | await auth.user.save(); | ||
175 | 202 | ||
176 | return response.send({ | 203 | return response.send({ |
177 | data: { | 204 | data: { |
178 | accountType: 'individual', | 205 | accountType: 'individual', |
179 | beta: false, | 206 | beta: false, |
180 | donor: {}, | 207 | donor: {}, |
181 | email: auth.user.email, | 208 | email: user.email, |
182 | emailValidated: true, | 209 | emailValidated: true, |
183 | features: {}, | 210 | features: {}, |
184 | firstname: auth.user.username, | 211 | firstname: user.username, |
185 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', | 212 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', |
186 | isPremium: true, | 213 | isPremium: true, |
187 | isSubscriptionOwner: true, | 214 | isSubscriptionOwner: true, |
188 | lastname: auth.user.lastname, | 215 | lastname: user.lastname, |
189 | locale: 'en-US', | 216 | locale: 'en-US', |
190 | ...(newSettings || {}), | 217 | ...newSettings, |
191 | }, | 218 | }, |
192 | status: ['data-updated'], | 219 | status: ['data-updated'], |
193 | }); | 220 | }); |
194 | } | 221 | } |
195 | 222 | ||
196 | async import({ request, response, view }) { | 223 | public async newToken({ request, response, auth }: HttpContextContract) { |
197 | if (Env.get('IS_REGISTRATION_ENABLED') == 'false') { | 224 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. |
198 | // eslint-disable-line eqeqeq | 225 | const user = auth.user ?? request.user; |
226 | |||
227 | if (!user) { | ||
228 | return response.send('Missing or invalid api token'); | ||
229 | } | ||
230 | |||
231 | const token = await auth.use('jwt').generate(user, { payload: {} }); | ||
232 | |||
233 | return response.send({ | ||
234 | token: token.accessToken, | ||
235 | }); | ||
236 | } | ||
237 | |||
238 | public async import({ request, response, view }: HttpContextContract) { | ||
239 | if (isRegistrationEnabled === 'false') { | ||
199 | return response.status(401).send({ | 240 | return response.status(401).send({ |
200 | message: 'Registration is disabled on this server', | 241 | message: 'Registration is disabled on this server', |
201 | status: 401, | 242 | status: 401, |
202 | }); | 243 | }); |
203 | } | 244 | } |
204 | 245 | ||
246 | if (connectWithFranz === 'false') { | ||
247 | return response.send( | ||
248 | '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.', | ||
249 | ); | ||
250 | } | ||
251 | |||
205 | // Validate user input | 252 | // Validate user input |
206 | const validation = await validateAll(request.all(), { | 253 | let data; |
207 | email: 'required|email|unique:users,email', | 254 | try { |
208 | password: 'required', | 255 | data = await request.validate({ schema: franzImportSchema }); |
209 | }); | 256 | } catch (error) { |
210 | if (validation.fails()) { | ||
211 | let errorMessage = | ||
212 | 'There was an error while trying to import your account:\n'; | ||
213 | for (const message of validation.messages()) { | ||
214 | if (message.validation === 'required') { | ||
215 | errorMessage += `- Please make sure to supply your ${message.field}\n`; | ||
216 | } else if (message.validation === 'unique') { | ||
217 | errorMessage += '- There is already a user with this email.\n'; | ||
218 | } else { | ||
219 | errorMessage += `${message.message}\n`; | ||
220 | } | ||
221 | } | ||
222 | return view.render('others.message', { | 257 | return view.render('others.message', { |
223 | heading: 'Error while importing', | 258 | heading: 'Error while importing', |
224 | text: errorMessage, | 259 | text: error.messages, |
225 | }); | 260 | }); |
226 | } | 261 | } |
227 | 262 | ||
228 | const { email, password } = request.all(); | 263 | const { email, password } = data; |
229 | 264 | ||
230 | const hashedPassword = crypto | 265 | const hashedPassword = crypto |
231 | .createHash('sha256') | 266 | .createHash('sha256') |
232 | .update(password) | 267 | .update(password) |
233 | .digest('base64'); | 268 | .digest('base64'); |
234 | 269 | ||
235 | if (Env.get('CONNECT_WITH_FRANZ') == 'false') { | ||
236 | // eslint-disable-line eqeqeq | ||
237 | await User.create({ | ||
238 | email, | ||
239 | password: hashedPassword, | ||
240 | username: 'Franz', | ||
241 | lastname: 'Franz', | ||
242 | }); | ||
243 | |||
244 | return response.send( | ||
245 | "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.", | ||
246 | ); | ||
247 | } | ||
248 | |||
249 | const base = 'https://api.franzinfra.com/v1/'; | 270 | const base = 'https://api.franzinfra.com/v1/'; |
250 | const userAgent = | 271 | const userAgent = |
251 | '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'; | 272 | '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'; |
@@ -278,19 +299,20 @@ class UserController { | |||
278 | } | 299 | } |
279 | 300 | ||
280 | token = content.token; | 301 | token = content.token; |
281 | } catch (e) { | 302 | } catch (error) { |
282 | return response.status(401).send({ | 303 | return response.status(401).send({ |
283 | message: 'Cannot login to Franz', | 304 | message: 'Cannot login to Franz', |
284 | error: e, | 305 | error: error, |
285 | }); | 306 | }); |
286 | } | 307 | } |
287 | 308 | ||
288 | // Get user information | 309 | // Get user information |
289 | let userInf = false; | 310 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
311 | let userInf: any = false; | ||
290 | try { | 312 | try { |
291 | userInf = await franzRequest('me', 'GET', token); | 313 | userInf = await franzRequest('me', 'GET', token); |
292 | } catch (e) { | 314 | } catch (error) { |
293 | const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${e}`; | 315 | const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${error}`; |
294 | return response.status(401).send(errorMessage); | 316 | return response.status(401).send(errorMessage); |
295 | } | 317 | } |
296 | if (!userInf) { | 318 | if (!userInf) { |
@@ -308,8 +330,8 @@ class UserController { | |||
308 | username: userInf.firstname, | 330 | username: userInf.firstname, |
309 | lastname: userInf.lastname, | 331 | lastname: userInf.lastname, |
310 | }); | 332 | }); |
311 | } catch (e) { | 333 | } catch (error) { |
312 | const errorMessage = `Could not create your user in our system.\nError: ${e}`; | 334 | const errorMessage = `Could not create your user in our system.\nError: ${error}`; |
313 | return response.status(401).send(errorMessage); | 335 | return response.status(401).send(errorMessage); |
314 | } | 336 | } |
315 | 337 | ||
@@ -319,18 +341,19 @@ class UserController { | |||
319 | try { | 341 | try { |
320 | const services = await franzRequest('me/services', 'GET', token); | 342 | const services = await franzRequest('me/services', 'GET', token); |
321 | 343 | ||
344 | // @ts-expect-error | ||
322 | for (const service of services) { | 345 | for (const service of services) { |
323 | // Get new, unused uuid | 346 | // Get new, unused uuid |
324 | let serviceId; | 347 | let serviceId; |
325 | do { | 348 | do { |
326 | serviceId = uuid(); | 349 | serviceId = uuid(); |
327 | } while ( | 350 | } while ( |
328 | (await Service.query().where('serviceId', serviceId).fetch()).rows | 351 | // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member |
329 | .length > 0 | 352 | (await Service.query().where('serviceId', serviceId)).length > 0 |
330 | ); // eslint-disable-line no-await-in-loop | 353 | ); |
331 | 354 | ||
355 | // eslint-disable-next-line no-await-in-loop | ||
332 | await Service.create({ | 356 | await Service.create({ |
333 | // eslint-disable-line no-await-in-loop | ||
334 | userId: user.id, | 357 | userId: user.id, |
335 | serviceId, | 358 | serviceId, |
336 | name: service.name, | 359 | name: service.name, |
@@ -338,10 +361,11 @@ class UserController { | |||
338 | settings: JSON.stringify(service), | 361 | settings: JSON.stringify(service), |
339 | }); | 362 | }); |
340 | 363 | ||
364 | // @ts-expect-error | ||
341 | serviceIdTranslation[service.id] = serviceId; | 365 | serviceIdTranslation[service.id] = serviceId; |
342 | } | 366 | } |
343 | } catch (e) { | 367 | } catch (error) { |
344 | const errorMessage = `Could not import your services into our system.\nError: ${e}`; | 368 | const errorMessage = `Could not import your services into our system.\nError: ${error}`; |
345 | return response.status(401).send(errorMessage); | 369 | return response.status(401).send(errorMessage); |
346 | } | 370 | } |
347 | 371 | ||
@@ -349,21 +373,23 @@ class UserController { | |||
349 | try { | 373 | try { |
350 | const workspaces = await franzRequest('workspace', 'GET', token); | 374 | const workspaces = await franzRequest('workspace', 'GET', token); |
351 | 375 | ||
376 | // @ts-expect-error | ||
352 | for (const workspace of workspaces) { | 377 | for (const workspace of workspaces) { |
353 | let workspaceId; | 378 | let workspaceId; |
354 | do { | 379 | do { |
355 | workspaceId = uuid(); | 380 | workspaceId = uuid(); |
356 | } while ( | 381 | } while ( |
357 | (await Workspace.query().where('workspaceId', workspaceId).fetch()) | 382 | // eslint-disable-next-line unicorn/no-await-expression-member, no-await-in-loop |
358 | .rows.length > 0 | 383 | (await Workspace.query().where('workspaceId', workspaceId)).length > 0 |
359 | ); // eslint-disable-line no-await-in-loop | 384 | ); |
360 | 385 | ||
361 | const services = workspace.services.map( | 386 | const services = workspace.services.map( |
387 | // @ts-expect-error | ||
362 | service => serviceIdTranslation[service], | 388 | service => serviceIdTranslation[service], |
363 | ); | 389 | ); |
364 | 390 | ||
391 | // eslint-disable-next-line no-await-in-loop | ||
365 | await Workspace.create({ | 392 | await Workspace.create({ |
366 | // eslint-disable-line no-await-in-loop | ||
367 | userId: user.id, | 393 | userId: user.id, |
368 | workspaceId, | 394 | workspaceId, |
369 | name: workspace.name, | 395 | name: workspace.name, |
@@ -372,8 +398,8 @@ class UserController { | |||
372 | data: JSON.stringify({}), | 398 | data: JSON.stringify({}), |
373 | }); | 399 | }); |
374 | } | 400 | } |
375 | } catch (e) { | 401 | } catch (error) { |
376 | const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; | 402 | const errorMessage = `Could not import your workspaces into our system.\nError: ${error}`; |
377 | return response.status(401).send(errorMessage); | 403 | return response.status(401).send(errorMessage); |
378 | } | 404 | } |
379 | 405 | ||
@@ -382,5 +408,3 @@ class UserController { | |||
382 | ); | 408 | ); |
383 | } | 409 | } |
384 | } | 410 | } |
385 | |||
386 | module.exports = UserController; | ||
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 @@ | |||
1 | const Workspace = use('App/Models/Workspace'); | ||
2 | const { validateAll } = use('Validator'); | ||
3 | |||
4 | const { v4: uuid } = require('uuid'); | ||
5 | |||
6 | class WorkspaceController { | ||
7 | // Create a new workspace for user | ||
8 | async create({ request, response, auth }) { | ||
9 | try { | ||
10 | await auth.getUser(); | ||
11 | } catch (error) { | ||
12 | return response.send('Missing or invalid api token'); | ||
13 | } | ||
14 | |||
15 | // Validate user input | ||
16 | const validation = await validateAll(request.all(), { | ||
17 | name: 'required', | ||
18 | }); | ||
19 | if (validation.fails()) { | ||
20 | return response.status(401).send({ | ||
21 | message: 'Invalid POST arguments', | ||
22 | messages: validation.messages(), | ||
23 | status: 401, | ||
24 | }); | ||
25 | } | ||
26 | |||
27 | const data = request.all(); | ||
28 | |||
29 | // Get new, unused uuid | ||
30 | let workspaceId; | ||
31 | do { | ||
32 | workspaceId = uuid(); | ||
33 | } while ( | ||
34 | (await Workspace.query().where('workspaceId', workspaceId).fetch()).rows | ||
35 | .length > 0 | ||
36 | ); // eslint-disable-line no-await-in-loop | ||
37 | |||
38 | const order = (await auth.user.workspaces().fetch()).rows.length; | ||
39 | |||
40 | await Workspace.create({ | ||
41 | userId: auth.user.id, | ||
42 | workspaceId, | ||
43 | name: data.name, | ||
44 | order, | ||
45 | services: JSON.stringify([]), | ||
46 | data: JSON.stringify(data), | ||
47 | }); | ||
48 | |||
49 | return response.send({ | ||
50 | userId: auth.user.id, | ||
51 | name: data.name, | ||
52 | id: workspaceId, | ||
53 | order, | ||
54 | workspaces: [], | ||
55 | }); | ||
56 | } | ||
57 | |||
58 | async edit({ request, response, auth, params }) { | ||
59 | try { | ||
60 | await auth.getUser(); | ||
61 | } catch (error) { | ||
62 | return response.send('Missing or invalid api token'); | ||
63 | } | ||
64 | |||
65 | // Validate user input | ||
66 | const validation = await validateAll(request.all(), { | ||
67 | name: 'required', | ||
68 | }); | ||
69 | if (validation.fails()) { | ||
70 | return response.status(401).send({ | ||
71 | message: 'Invalid POST arguments', | ||
72 | messages: validation.messages(), | ||
73 | status: 401, | ||
74 | }); | ||
75 | } | ||
76 | |||
77 | const data = request.all(); | ||
78 | const { id } = params; | ||
79 | |||
80 | // Update data in database | ||
81 | await Workspace.query() | ||
82 | .where('workspaceId', id) | ||
83 | .where('userId', auth.user.id) | ||
84 | .update({ | ||
85 | name: data.name, | ||
86 | services: JSON.stringify(data.services), | ||
87 | }); | ||
88 | |||
89 | // Get updated row | ||
90 | const workspace = ( | ||
91 | await Workspace.query() | ||
92 | .where('workspaceId', id) | ||
93 | .where('userId', auth.user.id) | ||
94 | .fetch() | ||
95 | ).rows[0]; | ||
96 | |||
97 | return response.send({ | ||
98 | id: workspace.workspaceId, | ||
99 | name: data.name, | ||
100 | order: workspace.order, | ||
101 | services: data.services, | ||
102 | userId: auth.user.id, | ||
103 | }); | ||
104 | } | ||
105 | |||
106 | async delete({ | ||
107 | // eslint-disable-next-line no-unused-vars | ||
108 | _request, | ||
109 | response, | ||
110 | auth, | ||
111 | params, | ||
112 | }) { | ||
113 | try { | ||
114 | await auth.getUser(); | ||
115 | } catch (error) { | ||
116 | return response.send('Missing or invalid api token'); | ||
117 | } | ||
118 | |||
119 | // Validate user input | ||
120 | const validation = await validateAll(params, { | ||
121 | id: 'required', | ||
122 | }); | ||
123 | if (validation.fails()) { | ||
124 | return response.status(401).send({ | ||
125 | message: 'Invalid arguments', | ||
126 | messages: validation.messages(), | ||
127 | status: 401, | ||
128 | }); | ||
129 | } | ||
130 | |||
131 | const { id } = params; | ||
132 | |||
133 | // Update data in database | ||
134 | await Workspace.query() | ||
135 | .where('workspaceId', id) | ||
136 | .where('userId', auth.user.id) | ||
137 | .delete(); | ||
138 | |||
139 | return response.send({ | ||
140 | message: 'Successfully deleted workspace', | ||
141 | }); | ||
142 | } | ||
143 | |||
144 | // List all workspaces a user has created | ||
145 | async list({ response, auth }) { | ||
146 | try { | ||
147 | await auth.getUser(); | ||
148 | } catch (error) { | ||
149 | return response.send('Missing or invalid api token'); | ||
150 | } | ||
151 | |||
152 | const workspaces = (await auth.user.workspaces().fetch()).rows; | ||
153 | // Convert to array with all data Franz wants | ||
154 | let workspacesArray = []; | ||
155 | if (workspaces) { | ||
156 | workspacesArray = workspaces.map(workspace => ({ | ||
157 | id: workspace.workspaceId, | ||
158 | name: workspace.name, | ||
159 | order: workspace.order, | ||
160 | services: | ||
161 | typeof workspace.services === 'string' | ||
162 | ? JSON.parse(workspace.services) | ||
163 | : workspace.services, | ||
164 | userId: auth.user.id, | ||
165 | })); | ||
166 | } | ||
167 | |||
168 | return response.send(workspacesArray); | ||
169 | } | ||
170 | } | ||
171 | |||
172 | 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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { validator, schema } from '@ioc:Adonis/Core/Validator'; | ||
3 | import Workspace from 'App/Models/Workspace'; | ||
4 | import { v4 as uuid } from 'uuid'; | ||
5 | |||
6 | const createSchema = schema.create({ | ||
7 | name: schema.string(), | ||
8 | }); | ||
9 | |||
10 | const editSchema = schema.create({ | ||
11 | name: schema.string(), | ||
12 | }); | ||
13 | |||
14 | const deleteSchema = schema.create({ | ||
15 | id: schema.string(), | ||
16 | }); | ||
17 | |||
18 | export default class WorkspacesController { | ||
19 | // Create a new workspace for user | ||
20 | public async create({ request, response, auth }: HttpContextContract) { | ||
21 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
22 | const user = auth.user ?? request.user; | ||
23 | |||
24 | if (!user) { | ||
25 | return response.unauthorized('Missing or invalid api token'); | ||
26 | } | ||
27 | |||
28 | // Validate user input | ||
29 | let data; | ||
30 | try { | ||
31 | data = await request.validate({ schema: createSchema }); | ||
32 | } catch (error) { | ||
33 | return response.status(401).send({ | ||
34 | message: 'Invalid POST arguments', | ||
35 | messages: error.messages, | ||
36 | status: 401, | ||
37 | }); | ||
38 | } | ||
39 | |||
40 | // Get new, unused uuid | ||
41 | let workspaceId; | ||
42 | do { | ||
43 | workspaceId = uuid(); | ||
44 | } while ( | ||
45 | // eslint-disable-next-line unicorn/no-await-expression-member, no-await-in-loop | ||
46 | (await Workspace.query().where('workspaceId', workspaceId)).length > 0 | ||
47 | ); | ||
48 | |||
49 | // eslint-disable-next-line unicorn/no-await-expression-member | ||
50 | const order = (await user.related('workspaces').query()).length; | ||
51 | |||
52 | await Workspace.create({ | ||
53 | userId: user.id, | ||
54 | workspaceId, | ||
55 | name: data.name, | ||
56 | order, | ||
57 | services: JSON.stringify([]), | ||
58 | data: JSON.stringify(data), | ||
59 | }); | ||
60 | |||
61 | return response.send({ | ||
62 | userId: user.id, | ||
63 | name: data.name, | ||
64 | id: workspaceId, | ||
65 | order, | ||
66 | workspaces: [], | ||
67 | }); | ||
68 | } | ||
69 | |||
70 | public async edit({ request, response, auth, params }: HttpContextContract) { | ||
71 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
72 | const user = auth.user ?? request.user; | ||
73 | |||
74 | if (!user) { | ||
75 | return response.unauthorized('Missing or invalid api token'); | ||
76 | } | ||
77 | |||
78 | // Validate user input | ||
79 | try { | ||
80 | await request.validate({ schema: editSchema }); | ||
81 | } catch (error) { | ||
82 | return response.status(401).send({ | ||
83 | message: 'Invalid POST arguments', | ||
84 | messages: error.messages, | ||
85 | status: 401, | ||
86 | }); | ||
87 | } | ||
88 | |||
89 | const data = request.all(); | ||
90 | const { id } = params; | ||
91 | |||
92 | // Update data in database | ||
93 | await Workspace.query() | ||
94 | .where('workspaceId', id) | ||
95 | .where('userId', user.id) | ||
96 | .update({ | ||
97 | name: data.name, | ||
98 | services: JSON.stringify(data.services), | ||
99 | }); | ||
100 | |||
101 | // Get updated row | ||
102 | const workspace = await Workspace.query() | ||
103 | .where('workspaceId', id) | ||
104 | .where('userId', user.id) | ||
105 | .firstOrFail(); | ||
106 | |||
107 | return response.send({ | ||
108 | id: workspace.workspaceId, | ||
109 | name: data.name, | ||
110 | order: workspace.order, | ||
111 | services: data.services, | ||
112 | userId: user.id, | ||
113 | }); | ||
114 | } | ||
115 | |||
116 | public async delete({ response, auth, params }: HttpContextContract) { | ||
117 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
118 | const user = auth.user ?? request.user; | ||
119 | |||
120 | if (!user) { | ||
121 | return response.unauthorized('Missing or invalid api token'); | ||
122 | } | ||
123 | |||
124 | // Validate user input | ||
125 | let data; | ||
126 | try { | ||
127 | data = await validator.validate({ | ||
128 | data: params, | ||
129 | schema: deleteSchema, | ||
130 | }); | ||
131 | } catch (error) { | ||
132 | return response.status(401).send({ | ||
133 | message: 'Invalid arguments', | ||
134 | messages: error.messages, | ||
135 | status: 401, | ||
136 | }); | ||
137 | } | ||
138 | |||
139 | const { id } = data; | ||
140 | |||
141 | // Update data in database | ||
142 | await Workspace.query() | ||
143 | .where('workspaceId', id) | ||
144 | .where('userId', user.id) | ||
145 | .delete(); | ||
146 | |||
147 | return response.send({ | ||
148 | message: 'Successfully deleted workspace', | ||
149 | }); | ||
150 | } | ||
151 | |||
152 | // List all workspaces a user has created | ||
153 | public async list({ request, response, auth }: HttpContextContract) { | ||
154 | // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'. | ||
155 | const user = auth.user ?? request.user; | ||
156 | |||
157 | if (!user) { | ||
158 | return response.unauthorized('Missing or invalid api token'); | ||
159 | } | ||
160 | |||
161 | const workspaces = await user.related('workspaces').query(); | ||
162 | // Convert to array with all data Franz wants | ||
163 | let workspacesArray: object[] = []; | ||
164 | if (workspaces) { | ||
165 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
166 | workspacesArray = workspaces.map((workspace: any) => ({ | ||
167 | id: workspace.workspaceId, | ||
168 | name: workspace.name, | ||
169 | order: workspace.order, | ||
170 | services: | ||
171 | typeof workspace.services === 'string' | ||
172 | ? JSON.parse(workspace.services) | ||
173 | : workspace.services, | ||
174 | userId: user.id, | ||
175 | })); | ||
176 | } | ||
177 | |||
178 | return response.send(workspacesArray); | ||
179 | } | ||
180 | } | ||
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 @@ | |||
1 | const BaseExceptionHandler = use('BaseExceptionHandler'); | ||
2 | const Sentry = require('@sentry/node'); | ||
3 | |||
4 | /** | ||
5 | * This class handles all exceptions thrown during | ||
6 | * the HTTP request lifecycle. | ||
7 | * | ||
8 | * @class ExceptionHandler | ||
9 | */ | ||
10 | class ExceptionHandler extends BaseExceptionHandler { | ||
11 | /** | ||
12 | * Handle exception thrown during the HTTP lifecycle | ||
13 | * | ||
14 | * @method handle | ||
15 | * | ||
16 | * @param {Object} error | ||
17 | * @param {Object} options.request | ||
18 | * @param {Object} options.response | ||
19 | * | ||
20 | * @return {void} | ||
21 | */ | ||
22 | async handle(error, { response }) { | ||
23 | if (error.name === 'ValidationException') { | ||
24 | return response.status(400).send('Invalid arguments'); | ||
25 | } if (error.name === 'InvalidSessionException') { | ||
26 | return response.status(401).redirect('/user/login'); | ||
27 | } | ||
28 | |||
29 | return response.status(error.status).send(error.message); | ||
30 | } | ||
31 | |||
32 | /** | ||
33 | * Report exception for logging or debugging. | ||
34 | * | ||
35 | * @method report | ||
36 | * | ||
37 | * @param {Object} error | ||
38 | * @param {Object} options.request | ||
39 | * | ||
40 | * @return {void} | ||
41 | */ | ||
42 | async report(error) { | ||
43 | Sentry.captureException(error); | ||
44 | return true; | ||
45 | } | ||
46 | } | ||
47 | |||
48 | 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 @@ | |||
1 | /* | ||
2 | |-------------------------------------------------------------------------- | ||
3 | | Http Exception Handler | ||
4 | |-------------------------------------------------------------------------- | ||
5 | | | ||
6 | | AdonisJs will forward all exceptions occurred during an HTTP request to | ||
7 | | the following class. You can learn more about exception handling by | ||
8 | | reading docs. | ||
9 | | | ||
10 | | The exception handler extends a base `HttpExceptionHandler` which is not | ||
11 | | mandatory, however it can do lot of heavy lifting to handle the errors | ||
12 | | properly. | ||
13 | | | ||
14 | */ | ||
15 | |||
16 | import Logger from '@ioc:Adonis/Core/Logger'; | ||
17 | import HttpExceptionHandler from '@ioc:Adonis/Core/HttpExceptionHandler'; | ||
18 | |||
19 | export default class ExceptionHandler extends HttpExceptionHandler { | ||
20 | constructor() { | ||
21 | super(Logger); | ||
22 | } | ||
23 | } | ||
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 @@ | |||
1 | import { GuardsList } from '@ioc:Adonis/Addons/Auth'; | ||
2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
3 | import { AuthenticationException } from '@adonisjs/auth/build/standalone'; | ||
4 | |||
5 | /** | ||
6 | * This is actually a reverted a reverted auth middleware available in ./Auth.ts | ||
7 | * provided by the AdonisJS project iself. | ||
8 | */ | ||
9 | export default class GuestMiddleware { | ||
10 | /** | ||
11 | * The URL to redirect to when request is authorized | ||
12 | */ | ||
13 | protected redirectTo = '/dashboard'; | ||
14 | |||
15 | protected async authenticate( | ||
16 | auth: HttpContextContract['auth'], | ||
17 | guards: (keyof GuardsList)[], | ||
18 | ) { | ||
19 | let guardLastAttempted: string | undefined; | ||
20 | |||
21 | for (const guard of guards) { | ||
22 | guardLastAttempted = guard; | ||
23 | |||
24 | // eslint-disable-next-line no-await-in-loop | ||
25 | if (await auth.use(guard).check()) { | ||
26 | auth.defaultGuard = guard; | ||
27 | |||
28 | throw new AuthenticationException( | ||
29 | 'Unauthorized access', | ||
30 | 'E_UNAUTHORIZED_ACCESS', | ||
31 | guardLastAttempted, | ||
32 | this.redirectTo, | ||
33 | ); | ||
34 | } | ||
35 | } | ||
36 | } | ||
37 | |||
38 | /** | ||
39 | * Handle request | ||
40 | */ | ||
41 | public async handle( | ||
42 | { auth }: HttpContextContract, | ||
43 | next: () => Promise<void>, | ||
44 | customGuards: (keyof GuardsList)[], | ||
45 | ) { | ||
46 | /** | ||
47 | * Uses the user defined guards or the default guard mentioned in | ||
48 | * the config file | ||
49 | */ | ||
50 | const guards = customGuards.length > 0 ? customGuards : [auth.name]; | ||
51 | |||
52 | await this.authenticate(auth, guards); | ||
53 | |||
54 | await next(); | ||
55 | } | ||
56 | } | ||
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 @@ | |||
1 | import { GuardsList } from '@ioc:Adonis/Addons/Auth'; | ||
2 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
3 | import { AuthenticationException } from '@adonisjs/auth/build/standalone'; | ||
4 | import * as jose from 'jose'; | ||
5 | import { appKey } from 'Config/app'; | ||
6 | import User from 'App/Models/User'; | ||
7 | |||
8 | /** | ||
9 | * Auth middleware is meant to restrict un-authenticated access to a given route | ||
10 | * or a group of routes. | ||
11 | * | ||
12 | * You must register this middleware inside `start/kernel.ts` file under the list | ||
13 | * of named middleware. | ||
14 | */ | ||
15 | export default class AuthMiddleware { | ||
16 | /** | ||
17 | * The URL to redirect to when request is Unauthorized | ||
18 | */ | ||
19 | protected redirectTo = '/user/login'; | ||
20 | |||
21 | /** | ||
22 | * Authenticates the current HTTP request against a custom set of defined | ||
23 | * guards. | ||
24 | * | ||
25 | * The authentication loop stops as soon as the user is authenticated using any | ||
26 | * of the mentioned guards and that guard will be used by the rest of the code | ||
27 | * during the current request. | ||
28 | */ | ||
29 | protected async authenticate( | ||
30 | auth: HttpContextContract['auth'], | ||
31 | guards: (keyof GuardsList)[], | ||
32 | request: HttpContextContract['request'], | ||
33 | ) { | ||
34 | /** | ||
35 | * Hold reference to the guard last attempted within the for loop. We pass | ||
36 | * the reference of the guard to the "AuthenticationException", so that | ||
37 | * it can decide the correct response behavior based upon the guard | ||
38 | * driver | ||
39 | */ | ||
40 | let guardLastAttempted: string | undefined; | ||
41 | |||
42 | for (const guard of guards) { | ||
43 | guardLastAttempted = guard; | ||
44 | |||
45 | let isLoggedIn = false; | ||
46 | try { | ||
47 | // eslint-disable-next-line no-await-in-loop | ||
48 | isLoggedIn = await auth.use(guard).check(); | ||
49 | } catch { | ||
50 | // Silent fail to allow the rest of the code to handle the error | ||
51 | } | ||
52 | |||
53 | if (isLoggedIn) { | ||
54 | /** | ||
55 | * Instruct auth to use the given guard as the default guard for | ||
56 | * the rest of the request, since the user authenticated | ||
57 | * succeeded here | ||
58 | */ | ||
59 | auth.defaultGuard = guard; | ||
60 | return; | ||
61 | } | ||
62 | } | ||
63 | |||
64 | // Manually try authenticating using the JWT (verfiy signature required) | ||
65 | // Legacy support for JWTs so that the client still works (older than 2.0.0) | ||
66 | const authToken = request.headers().authorization?.split(' ')[1]; | ||
67 | if (authToken) { | ||
68 | try { | ||
69 | const jwt = await jose.jwtVerify( | ||
70 | authToken, | ||
71 | new TextEncoder().encode(appKey), | ||
72 | ); | ||
73 | const { uid } = jwt.payload; | ||
74 | |||
75 | // @ts-expect-error | ||
76 | request.user = await User.findOrFail(uid); | ||
77 | return; | ||
78 | } catch { | ||
79 | // Silent fail to allow the rest of the code to handle the error | ||
80 | } | ||
81 | } | ||
82 | |||
83 | /** | ||
84 | * Unable to authenticate using any guard | ||
85 | */ | ||
86 | throw new AuthenticationException( | ||
87 | 'Unauthorized access', | ||
88 | 'E_UNAUTHORIZED_ACCESS', | ||
89 | guardLastAttempted, | ||
90 | this.redirectTo, | ||
91 | ); | ||
92 | } | ||
93 | |||
94 | /** | ||
95 | * Handle request | ||
96 | */ | ||
97 | public async handle( | ||
98 | { request, auth, response }: HttpContextContract, | ||
99 | next: () => Promise<void>, | ||
100 | customGuards: (keyof GuardsList)[], | ||
101 | ) { | ||
102 | /** | ||
103 | * Uses the user defined guards or the default guard mentioned in | ||
104 | * the config file | ||
105 | */ | ||
106 | const guards = customGuards.length > 0 ? customGuards : [auth.name]; | ||
107 | try { | ||
108 | await this.authenticate(auth, guards, request); | ||
109 | } catch (error) { | ||
110 | // If the user is not authenticated and it is a web endpoint, redirect to the login page | ||
111 | if (guards.includes('web')) { | ||
112 | return response.redirect(error.redirectTo); | ||
113 | } | ||
114 | throw error; | ||
115 | } | ||
116 | await next(); | ||
117 | } | ||
118 | } | ||
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 @@ | |||
1 | class ConvertEmptyStringsToNull { | ||
2 | async handle({ request }, next) { | ||
3 | if (Object.keys(request.body).length) { | ||
4 | request.body = Object.assign( | ||
5 | ...Object.keys(request.body).map((key) => ({ | ||
6 | [key]: request.body[key] !== '' ? request.body[key] : null, | ||
7 | })), | ||
8 | ); | ||
9 | } | ||
10 | |||
11 | await next(); | ||
12 | } | ||
13 | } | ||
14 | |||
15 | 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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import Config from '@ioc:Adonis/Core/Config'; | ||
3 | |||
4 | export default class Dashboard { | ||
5 | public async handle( | ||
6 | { response }: HttpContextContract, | ||
7 | next: () => Promise<void>, | ||
8 | ) { | ||
9 | if (Config.get('dashboard.enabled') === false) { | ||
10 | response.send( | ||
11 | '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.', | ||
12 | ); | ||
13 | } else { | ||
14 | await next(); | ||
15 | } | ||
16 | } | ||
17 | } | ||
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 @@ | |||
1 | /** @typedef {import('@adonisjs/framework/src/Request')} Request */ | ||
2 | /** @typedef {import('@adonisjs/framework/src/Response')} Response */ | ||
3 | /** @typedef {import('@adonisjs/framework/src/View')} View */ | ||
4 | |||
5 | class HandleDoubleSlash { | ||
6 | /** | ||
7 | * @param {object} ctx | ||
8 | * @param {Request} ctx.request | ||
9 | * @param {Function} next | ||
10 | */ | ||
11 | // eslint-disable-next-line consistent-return | ||
12 | async handle({ request, response }, next) { | ||
13 | // Redirect requests that contain duplicate slashes to the right path | ||
14 | if (request.url().includes('//')) { | ||
15 | return response.redirect( | ||
16 | request.url().replace(/\/{2,}/g, '/'), | ||
17 | ); | ||
18 | } | ||
19 | |||
20 | await next(); | ||
21 | } | ||
22 | } | ||
23 | |||
24 | 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 @@ | |||
1 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | |||
3 | /** | ||
4 | * Silent auth middleware can be used as a global middleware to silent check | ||
5 | * if the user is logged-in or not. | ||
6 | * | ||
7 | * The request continues as usual, even when the user is not logged-in. | ||
8 | */ | ||
9 | export default class SilentAuthMiddleware { | ||
10 | /** | ||
11 | * Handle request | ||
12 | */ | ||
13 | public async handle( | ||
14 | { auth }: HttpContextContract, | ||
15 | next: () => Promise<void>, | ||
16 | ) { | ||
17 | /** | ||
18 | * Check if user is logged-in or not. If yes, then `ctx.auth.user` will be | ||
19 | * set to the instance of the currently logged in user. | ||
20 | */ | ||
21 | await auth.check(); | ||
22 | await next(); | ||
23 | } | ||
24 | } | ||
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 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Recipe extends Model { | ||
5 | } | ||
6 | |||
7 | 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 @@ | |||
1 | import { DateTime } from 'luxon'; | ||
2 | import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'; | ||
3 | |||
4 | export default class Recipe extends BaseModel { | ||
5 | @column({ isPrimary: true }) | ||
6 | public id: number; | ||
7 | |||
8 | @column() | ||
9 | public name: string; | ||
10 | |||
11 | @column() | ||
12 | public recipeId: string; | ||
13 | |||
14 | // TODO: Type the data object. | ||
15 | @column() | ||
16 | public data: object; | ||
17 | |||
18 | @column.dateTime({ autoCreate: true }) | ||
19 | public createdAt: DateTime; | ||
20 | |||
21 | @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
22 | public updatedAt: DateTime; | ||
23 | } | ||
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 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Service extends Model { | ||
5 | user() { | ||
6 | return this.belongsTo('App/Models/User', 'userId', 'id'); | ||
7 | } | ||
8 | } | ||
9 | |||
10 | 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 @@ | |||
1 | import { DateTime } from 'luxon'; | ||
2 | import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; | ||
3 | import User from './User'; | ||
4 | |||
5 | export default class Service extends BaseModel { | ||
6 | @column({ isPrimary: true }) | ||
7 | public id: number; | ||
8 | |||
9 | @hasOne(() => User, { | ||
10 | foreignKey: 'userId', | ||
11 | }) | ||
12 | public user: HasOne<typeof User>; | ||
13 | |||
14 | @column({ | ||
15 | columnName: 'userId', | ||
16 | }) | ||
17 | public userId: number; | ||
18 | |||
19 | @column({ | ||
20 | columnName: 'serviceId', | ||
21 | }) | ||
22 | public serviceId: string; | ||
23 | |||
24 | @column() | ||
25 | public name: string; | ||
26 | |||
27 | @column({ | ||
28 | columnName: 'recipeId', | ||
29 | }) | ||
30 | public recipeId: string; | ||
31 | |||
32 | @column() | ||
33 | public settings: string; | ||
34 | |||
35 | @column.dateTime({ autoCreate: true }) | ||
36 | public createdAt: DateTime; | ||
37 | |||
38 | @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
39 | public updatedAt: DateTime; | ||
40 | } | ||
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 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Token extends Model { | ||
5 | user() { | ||
6 | return this.belongsTo('App/Models/User'); | ||
7 | } | ||
8 | } | ||
9 | |||
10 | 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 @@ | |||
1 | import { DateTime } from 'luxon'; | ||
2 | import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; | ||
3 | import User from './User'; | ||
4 | |||
5 | export default class Token extends BaseModel { | ||
6 | @column({ isPrimary: true }) | ||
7 | public id: number; | ||
8 | |||
9 | @hasOne(() => User, { | ||
10 | localKey: 'user_id', | ||
11 | foreignKey: 'id', | ||
12 | }) | ||
13 | public user: HasOne<typeof User>; | ||
14 | |||
15 | @column() | ||
16 | public user_id: number; | ||
17 | |||
18 | @column() | ||
19 | public token: string; | ||
20 | |||
21 | @column() | ||
22 | public type: string; | ||
23 | |||
24 | @column() | ||
25 | public is_revoked: boolean; | ||
26 | |||
27 | @column() | ||
28 | public name: string; | ||
29 | |||
30 | @column.dateTime() | ||
31 | public expires_at: DateTime; | ||
32 | |||
33 | @column.dateTime({ autoCreate: true }) | ||
34 | public created_at: DateTime; | ||
35 | |||
36 | @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
37 | public updated_at: DateTime; | ||
38 | } | ||
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 @@ | |||
1 | class NoTimestamp { | ||
2 | register(Model) { | ||
3 | Object.defineProperties(Model, { | ||
4 | createdAtColumn: { | ||
5 | get: () => null, | ||
6 | }, | ||
7 | updatedAtColumn: { | ||
8 | get: () => null, | ||
9 | }, | ||
10 | }); | ||
11 | } | ||
12 | } | ||
13 | |||
14 | 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 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | /** @type {import('@adonisjs/framework/src/Hash')} */ | ||
5 | const Hash = use('Hash'); | ||
6 | |||
7 | class User extends Model { | ||
8 | static boot() { | ||
9 | super.boot(); | ||
10 | |||
11 | /** | ||
12 | * A hook to hash the user password before saving | ||
13 | * it to the database. | ||
14 | */ | ||
15 | this.addHook('beforeSave', async (userInstance) => { | ||
16 | if (userInstance.dirty.password) { | ||
17 | // eslint-disable-next-line no-param-reassign | ||
18 | userInstance.password = await Hash.make(userInstance.password); | ||
19 | } | ||
20 | }); | ||
21 | } | ||
22 | |||
23 | /** | ||
24 | * A relationship on tokens is required for auth to | ||
25 | * work. Since features like `refreshTokens` or | ||
26 | * `rememberToken` will be saved inside the | ||
27 | * tokens table. | ||
28 | * | ||
29 | * @method tokens | ||
30 | * | ||
31 | * @return {Object} | ||
32 | */ | ||
33 | tokens() { | ||
34 | return this.hasMany('App/Models/Token'); | ||
35 | } | ||
36 | |||
37 | services() { | ||
38 | return this.hasMany('App/Models/Service', 'id', 'userId'); | ||
39 | } | ||
40 | |||
41 | workspaces() { | ||
42 | return this.hasMany('App/Models/Workspace', 'id', 'userId'); | ||
43 | } | ||
44 | } | ||
45 | |||
46 | 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 @@ | |||
1 | import { DateTime } from 'luxon'; | ||
2 | import { | ||
3 | BaseModel, | ||
4 | beforeSave, | ||
5 | column, | ||
6 | HasMany, | ||
7 | hasMany, | ||
8 | } from '@ioc:Adonis/Lucid/Orm'; | ||
9 | import Hash from '@ioc:Adonis/Core/Hash'; | ||
10 | import Event from '@ioc:Adonis/Core/Event'; | ||
11 | import moment from 'moment'; | ||
12 | import Encryption from '@ioc:Adonis/Core/Encryption'; | ||
13 | import randtoken from 'rand-token'; | ||
14 | import Token from './Token'; | ||
15 | import Workspace from './Workspace'; | ||
16 | import Service from './Service'; | ||
17 | |||
18 | export default class User extends BaseModel { | ||
19 | @column({ isPrimary: true }) | ||
20 | public id: number; | ||
21 | |||
22 | @column() | ||
23 | public email: string; | ||
24 | |||
25 | @column() | ||
26 | public username: string; | ||
27 | |||
28 | @column() | ||
29 | public password: string; | ||
30 | |||
31 | @column() | ||
32 | public lastname: string; | ||
33 | |||
34 | // TODO: Type the settings object. | ||
35 | @column() | ||
36 | public settings: object; | ||
37 | |||
38 | @column.dateTime({ autoCreate: true }) | ||
39 | public created_at: DateTime; | ||
40 | |||
41 | @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
42 | public updated_at: DateTime; | ||
43 | |||
44 | @beforeSave() | ||
45 | public static async hashPassword(user: User) { | ||
46 | if (user.$dirty.password) { | ||
47 | user.password = await Hash.make(user.password); | ||
48 | } | ||
49 | } | ||
50 | |||
51 | @hasMany(() => Token, { | ||
52 | foreignKey: 'user_id', | ||
53 | }) | ||
54 | public tokens: HasMany<typeof Token>; | ||
55 | |||
56 | @hasMany(() => Service, { | ||
57 | foreignKey: 'userId', | ||
58 | }) | ||
59 | public services: HasMany<typeof Service>; | ||
60 | |||
61 | @hasMany(() => Workspace, { | ||
62 | foreignKey: 'userId', | ||
63 | }) | ||
64 | public workspaces: HasMany<typeof Workspace>; | ||
65 | |||
66 | public async forgotPassword(): Promise<void> { | ||
67 | const token = await this.generateToken(this, 'forgot_password'); | ||
68 | |||
69 | await Event.emit('forgot:password', { | ||
70 | user: this, | ||
71 | token, | ||
72 | }); | ||
73 | } | ||
74 | |||
75 | private async generateToken(user: User, type: string): Promise<string> { | ||
76 | const query = user | ||
77 | .related('tokens') | ||
78 | .query() | ||
79 | .where('type', type) | ||
80 | .where('is_revoked', false) | ||
81 | .where( | ||
82 | 'updated_at', | ||
83 | '>=', | ||
84 | moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'), | ||
85 | ); | ||
86 | |||
87 | const row = await query.first(); | ||
88 | if (row) { | ||
89 | return row.token; | ||
90 | } | ||
91 | |||
92 | const token = Encryption.encrypt(randtoken.generate(16)); | ||
93 | |||
94 | await user.related('tokens').create({ type, token }); | ||
95 | |||
96 | return token; | ||
97 | } | ||
98 | } | ||
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 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Workspace extends Model { | ||
5 | user() { | ||
6 | return this.belongsTo('App/Models/User', 'userId', 'id'); | ||
7 | } | ||
8 | } | ||
9 | |||
10 | 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 @@ | |||
1 | import { DateTime } from 'luxon'; | ||
2 | import { BaseModel, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm'; | ||
3 | import User from './User'; | ||
4 | |||
5 | export default class Workspace extends BaseModel { | ||
6 | @column({ isPrimary: true }) | ||
7 | public id: number; | ||
8 | |||
9 | @column({ | ||
10 | columnName: 'workspaceId', | ||
11 | }) | ||
12 | public workspaceId: string; | ||
13 | |||
14 | @hasOne(() => User, { | ||
15 | foreignKey: 'userId', | ||
16 | }) | ||
17 | public user: HasOne<typeof User>; | ||
18 | |||
19 | @column({ | ||
20 | columnName: 'userId', | ||
21 | }) | ||
22 | public userId: number; | ||
23 | |||
24 | @column() | ||
25 | public name: string; | ||
26 | |||
27 | @column() | ||
28 | public order: number; | ||
29 | |||
30 | @column() | ||
31 | public services: string; | ||
32 | |||
33 | @column() | ||
34 | public data: string; | ||
35 | |||
36 | @column.dateTime({ autoCreate: true }) | ||
37 | public createdAt: DateTime; | ||
38 | |||
39 | @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
40 | public updatedAt: DateTime; | ||
41 | } | ||