aboutsummaryrefslogtreecommitdiffstats
path: root/app/Controllers
diff options
context:
space:
mode:
authorLibravatar Ricardo <ricardo@cino.io>2023-10-13 14:12:03 +0200
committerLibravatar GitHub <noreply@github.com>2023-10-13 13:12:03 +0100
commite503468660a13760010a94ecda5f0625c6f47f87 (patch)
treefa532f54fc5f091de08d55405ec6339bd2440a02 /app/Controllers
parent1.3.16 [skip ci] (diff)
downloadferdium-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/Controllers')
-rw-r--r--app/Controllers/Http/Api/Static/AnnouncementsController.ts20
-rw-r--r--app/Controllers/Http/Api/Static/EmptyController.ts7
-rw-r--r--app/Controllers/Http/Api/Static/FeaturesController.ts14
-rw-r--r--app/Controllers/Http/Dashboard/AccountController.ts80
-rw-r--r--app/Controllers/Http/Dashboard/DataController.ts24
-rw-r--r--app/Controllers/Http/Dashboard/DeleteController.ts20
-rw-r--r--app/Controllers/Http/Dashboard/ExportController.ts56
-rw-r--r--app/Controllers/Http/Dashboard/ForgotPasswordController.ts41
-rw-r--r--app/Controllers/Http/Dashboard/LogOutController.ts12
-rw-r--r--app/Controllers/Http/Dashboard/LoginController.ts81
-rw-r--r--app/Controllers/Http/Dashboard/ResetPasswordController.ts85
-rw-r--r--app/Controllers/Http/Dashboard/TransferController.ts128
-rw-r--r--app/Controllers/Http/DashboardController.ts (renamed from app/Controllers/Http/DashboardController.js)6
-rw-r--r--app/Controllers/Http/HealthController.ts10
-rw-r--r--app/Controllers/Http/HomeController.ts9
-rw-r--r--app/Controllers/Http/RecipeController.js226
-rw-r--r--app/Controllers/Http/RecipeController.ts254
-rw-r--r--app/Controllers/Http/ServiceController.ts (renamed from app/Controllers/Http/ServiceController.js)290
-rw-r--r--app/Controllers/Http/StaticController.js44
-rw-r--r--app/Controllers/Http/StaticsController.ts3
-rw-r--r--app/Controllers/Http/UserController.ts (renamed from app/Controllers/Http/UserController.js)264
-rw-r--r--app/Controllers/Http/WorkspaceController.js172
-rw-r--r--app/Controllers/Http/WorkspaceController.ts180
23 files changed, 1328 insertions, 698 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import Application from '@ioc:Adonis/Core/Application';
3import path from 'node:path';
4import fs from 'fs-extra';
5
6export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, rules, validator } from '@ioc:Adonis/Core/Validator';
3import crypto from 'node:crypto';
4
5export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3// eslint-disable-next-line @typescript-eslint/no-explicit-any
4function 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
34export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, rules, validator } from '@ioc:Adonis/Core/Validator';
3import User from 'App/Models/User';
4
5export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2
3export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, rules, validator } from '@ioc:Adonis/Core/Validator';
3import User from 'App/Models/User';
4import crypto from 'node:crypto';
5import { handleVerifyAndReHash } from '../../../../helpers/PasswordHash';
6
7export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, rules, validator } from '@ioc:Adonis/Core/Validator';
3import Token from 'App/Models/Token';
4import moment from 'moment';
5import crypto from 'node:crypto';
6
7export 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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, validator } from '@ioc:Adonis/Core/Validator';
3import Service from 'App/Models/Service';
4import Workspace from 'App/Models/Workspace';
5import { v4 as uuidv4 } from 'uuid';
6
7const 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
15export 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
3export default class DashboardController {}
4
5/*
1const { validateAll } = use('Validator'); 6const { validateAll } = use('Validator');
2 7
3const Service = use('App/Models/Service'); 8const Service = use('App/Models/Service');
@@ -319,3 +324,4 @@ class DashboardController {
319} 324}
320 325
321module.exports = DashboardController; 326module.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 @@
1export 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
3export 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 @@
1const Recipe = use('App/Models/Recipe');
2const Helpers = use('Helpers');
3const Drive = use('Drive');
4const {
5 validateAll,
6} = use('Validator');
7const Env = use('Env');
8
9const targz = require('targz');
10const path = require('path');
11const fs = require('fs-extra');
12const semver = require('semver');
13
14const 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
27class 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
226module.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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import fs from 'fs-extra';
3import Application from '@ioc:Adonis/Core/Application';
4import path from 'node:path';
5import Recipe from 'App/Models/Recipe';
6import { isCreationEnabled } from 'Config/app';
7import { validator, schema, rules } from '@ioc:Adonis/Core/Validator';
8import targz from 'targz';
9import semver from 'semver';
10import Drive from '@ioc:Adonis/Core/Drive';
11
12// TODO: This file needs to be refactored and cleaned up to include types
13
14const 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
23const searchSchema = schema.create({
24 needle: schema.string(),
25});
26
27const downloadSchema = schema.create({
28 // TODO: Check if this is correct
29 // recipe: 'required|accepted',
30 recipe: schema.string(),
31});
32
33const 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
50export 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 @@
1const Service = use('App/Models/Service'); 1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2const { validateAll } = use('Validator'); 2import { schema } from '@ioc:Adonis/Core/Validator';
3const Env = use('Env'); 3import Service from 'App/Models/Service';
4const Helpers = use('Helpers'); 4import { url } from 'Config/app';
5 5import { v4 as uuid } from 'uuid';
6const { v4: uuid } = require('uuid'); 6import * as fs from 'fs-extra';
7const path = require('path'); 7import path from 'node:path';
8const fs = require('fs-extra'); 8import Application from '@ioc:Adonis/Core/Application';
9const sanitize = require('sanitize-filename'); 9import sanitize from 'sanitize-filename';
10 10
11class ServiceController { 11const createSchema = schema.create({
12 name: schema.string(),
13 recipeId: schema.string(),
14});
15
16export 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
347module.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 */
4const Helpers = use('Helpers');
5const fs = require('fs-extra');
6const path = require('path');
7
8class 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
44module.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
3export 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 @@
1const User = use('App/Models/User'); 1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2const Service = use('App/Models/Service'); 2import { schema, rules } from '@ioc:Adonis/Core/Validator';
3const Workspace = use('App/Models/Workspace'); 3import User from 'App/Models/User';
4const { validateAll } = use('Validator'); 4import { connectWithFranz, isRegistrationEnabled } from '../../../config/app';
5const Env = use('Env'); 5import crypto from 'node:crypto';
6 6import { v4 as uuid } from 'uuid';
7const atob = require('atob'); 7import Workspace from 'App/Models/Workspace';
8const btoa = require('btoa'); 8import Service from 'App/Models/Service';
9const fetch = require('node-fetch'); 9import fetch from 'node-fetch';
10const { v4: uuid } = require('uuid'); 10
11const crypto = require('crypto'); 11// TODO: This file needs to be refactored and cleaned up to include types
12 12import { 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
14const franzRequest = (route, method, auth) => 14const 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
24const 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
34const 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
35class UserController { 55export 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
386module.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 @@
1const Workspace = use('App/Models/Workspace');
2const { validateAll } = use('Validator');
3
4const { v4: uuid } = require('uuid');
5
6class 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
172module.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 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { validator, schema } from '@ioc:Adonis/Core/Validator';
3import Workspace from 'App/Models/Workspace';
4import { v4 as uuid } from 'uuid';
5
6const createSchema = schema.create({
7 name: schema.string(),
8});
9
10const editSchema = schema.create({
11 name: schema.string(),
12});
13
14const deleteSchema = schema.create({
15 id: schema.string(),
16});
17
18export 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}