aboutsummaryrefslogtreecommitdiffstats
path: root/app/Controllers/Http/UserController.ts
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/Http/UserController.ts
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/Http/UserController.ts')
-rw-r--r--app/Controllers/Http/UserController.ts410
1 files changed, 410 insertions, 0 deletions
diff --git a/app/Controllers/Http/UserController.ts b/app/Controllers/Http/UserController.ts
new file mode 100644
index 0000000..ef7cfdd
--- /dev/null
+++ b/app/Controllers/Http/UserController.ts
@@ -0,0 +1,410 @@
1import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
2import { schema, rules } from '@ioc:Adonis/Core/Validator';
3import User from 'App/Models/User';
4import { connectWithFranz, isRegistrationEnabled } from '../../../config/app';
5import crypto from 'node:crypto';
6import { v4 as uuid } from 'uuid';
7import Workspace from 'App/Models/Workspace';
8import Service from 'App/Models/Service';
9import fetch from 'node-fetch';
10
11// TODO: This file needs to be refactored and cleaned up to include types
12import { handleVerifyAndReHash } from '../../../helpers/PasswordHash';
13
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) =>
35 new Promise((resolve, reject) => {
36 const base = 'https://api.franzinfra.com/v1/';
37 const user =
38 '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';
39
40 try {
41 fetch(base + route, {
42 method,
43 headers: {
44 Authorization: `Bearer ${auth}`,
45 'User-Agent': user,
46 },
47 })
48 .then(data => data.json())
49 .then(json => resolve(json));
50 } catch {
51 reject();
52 }
53 });
54
55export default class UsersController {
56 // Register a new user
57 public async signup({ request, response, auth }: HttpContextContract) {
58 if (isRegistrationEnabled === 'false') {
59 return response.status(401).send({
60 message: 'Registration is disabled on this server',
61 status: 401,
62 });
63 }
64
65 // Validate user input
66 let data;
67 try {
68 data = await request.validate({ schema: newPostSchema });
69 } catch (error) {
70 return response.status(401).send({
71 message: 'Invalid POST arguments',
72 messages: error.messages,
73 status: 401,
74 });
75 }
76
77 // Create user in DB
78 let user;
79 try {
80 user = await User.create({
81 email: data.email,
82 password: data.password,
83 username: data.firstname,
84 lastname: data.lastname,
85 });
86 } catch {
87 return response.status(401).send({
88 message: 'E-Mail address already in use',
89 status: 401,
90 });
91 }
92
93 // Generate new auth token
94 const token = await auth.use('jwt').login(user, { payload: {} });
95
96 return response.send({
97 message: 'Successfully created account',
98 token: token.accessToken,
99 });
100 }
101
102 // Login using an existing user
103 public async login({ request, response, auth }: HttpContextContract) {
104 if (!request.header('Authorization')) {
105 return response.status(401).send({
106 message: 'Please provide authorization',
107 status: 401,
108 });
109 }
110
111 // Get auth data from auth token
112 const authHeader = atob(
113 request.header('Authorization')!.replace('Basic ', ''),
114 ).split(':');
115
116 // Check if user with email exists
117 const user = await User.query().where('email', authHeader[0]).first();
118 if (!user?.email) {
119 return response.status(401).send({
120 message: 'User credentials not valid',
121 code: 'invalid-credentials',
122 status: 401,
123 });
124 }
125
126 // Verify password
127 let isMatchedPassword = false;
128 try {
129 isMatchedPassword = await handleVerifyAndReHash(user, authHeader[1]);
130 } catch (error) {
131 return response.internalServerError({ message: error.message });
132 }
133
134 if (!isMatchedPassword) {
135 return response.status(401).send({
136 message: 'User credentials not valid',
137 code: 'invalid-credentials',
138 status: 401,
139 });
140 }
141
142 // Generate token
143 const token = await auth.use('jwt').login(user, { payload: {} });
144
145 return response.send({
146 message: 'Successfully logged in',
147 token: token.accessToken,
148 });
149 }
150
151 // Return information about the current user
152 public async me({ request, response, auth }: HttpContextContract) {
153 // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'.
154 const user = auth.user ?? request.user;
155
156 if (!user) {
157 return response.send('Missing or invalid api token');
158 }
159
160 const settings =
161 typeof user.settings === 'string'
162 ? JSON.parse(user.settings)
163 : user.settings;
164
165 return response.send({
166 accountType: 'individual',
167 beta: false,
168 donor: {},
169 email: user.email,
170 emailValidated: true,
171 features: {},
172 firstname: user.username,
173 id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8',
174 isPremium: true,
175 isSubscriptionOwner: true,
176 lastname: user.lastname,
177 locale: 'en-US',
178 ...settings,
179 });
180 }
181
182 public async updateMe({ request, response, auth }: HttpContextContract) {
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 || {};
191 if (typeof settings === 'string') {
192 settings = JSON.parse(settings);
193 }
194
195 const newSettings = {
196 ...settings,
197 ...request.all(),
198 };
199
200 user.settings = JSON.stringify(newSettings);
201 await user.save();
202
203 return response.send({
204 data: {
205 accountType: 'individual',
206 beta: false,
207 donor: {},
208 email: user.email,
209 emailValidated: true,
210 features: {},
211 firstname: user.username,
212 id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8',
213 isPremium: true,
214 isSubscriptionOwner: true,
215 lastname: user.lastname,
216 locale: 'en-US',
217 ...newSettings,
218 },
219 status: ['data-updated'],
220 });
221 }
222
223 public async newToken({ request, response, auth }: HttpContextContract) {
224 // @ts-expect-error Property 'user' does not exist on type 'HttpContextContract'.
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') {
240 return response.status(401).send({
241 message: 'Registration is disabled on this server',
242 status: 401,
243 });
244 }
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
252 // Validate user input
253 let data;
254 try {
255 data = await request.validate({ schema: franzImportSchema });
256 } catch (error) {
257 return view.render('others.message', {
258 heading: 'Error while importing',
259 text: error.messages,
260 });
261 }
262
263 const { email, password } = data;
264
265 const hashedPassword = crypto
266 .createHash('sha256')
267 .update(password)
268 .digest('base64');
269
270 const base = 'https://api.franzinfra.com/v1/';
271 const userAgent =
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';
273
274 // Try to get an authentication token
275 let token;
276 try {
277 const basicToken = btoa(`${email}:${hashedPassword}`);
278 const loginBody = {
279 isZendeskLogin: false,
280 };
281
282 const rawResponse = await fetch(`${base}auth/login`, {
283 method: 'POST',
284 body: JSON.stringify(loginBody),
285 headers: {
286 Authorization: `Basic ${basicToken}`,
287 'User-Agent': userAgent,
288 'Content-Type': 'application/json',
289 accept: '*/*',
290 'x-franz-source': 'Web',
291 },
292 });
293 const content = await rawResponse.json();
294
295 if (!content.message || content.message !== 'Successfully logged in') {
296 const errorMessage =
297 'Could not login into Franz with your supplied credentials. Please check and try again';
298 return response.status(401).send(errorMessage);
299 }
300
301 token = content.token;
302 } catch (error) {
303 return response.status(401).send({
304 message: 'Cannot login to Franz',
305 error: error,
306 });
307 }
308
309 // Get user information
310 // eslint-disable-next-line @typescript-eslint/no-explicit-any
311 let userInf: any = false;
312 try {
313 userInf = await franzRequest('me', 'GET', token);
314 } catch (error) {
315 const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${error}`;
316 return response.status(401).send(errorMessage);
317 }
318 if (!userInf) {
319 const errorMessage =
320 'Could not get your user info from Franz. Please check your credentials or try again later';
321 return response.status(401).send(errorMessage);
322 }
323
324 // Create user in DB
325 let user;
326 try {
327 user = await User.create({
328 email: userInf.email,
329 password: hashedPassword,
330 username: userInf.firstname,
331 lastname: userInf.lastname,
332 });
333 } catch (error) {
334 const errorMessage = `Could not create your user in our system.\nError: ${error}`;
335 return response.status(401).send(errorMessage);
336 }
337
338 const serviceIdTranslation = {};
339
340 // Import services
341 try {
342 const services = await franzRequest('me/services', 'GET', token);
343
344 // @ts-expect-error
345 for (const service of services) {
346 // Get new, unused uuid
347 let serviceId;
348 do {
349 serviceId = uuid();
350 } while (
351 // eslint-disable-next-line no-await-in-loop, unicorn/no-await-expression-member
352 (await Service.query().where('serviceId', serviceId)).length > 0
353 );
354
355 // eslint-disable-next-line no-await-in-loop
356 await Service.create({
357 userId: user.id,
358 serviceId,
359 name: service.name,
360 recipeId: service.recipeId,
361 settings: JSON.stringify(service),
362 });
363
364 // @ts-expect-error
365 serviceIdTranslation[service.id] = serviceId;
366 }
367 } catch (error) {
368 const errorMessage = `Could not import your services into our system.\nError: ${error}`;
369 return response.status(401).send(errorMessage);
370 }
371
372 // Import workspaces
373 try {
374 const workspaces = await franzRequest('workspace', 'GET', token);
375
376 // @ts-expect-error
377 for (const workspace of workspaces) {
378 let workspaceId;
379 do {
380 workspaceId = uuid();
381 } while (
382 // eslint-disable-next-line unicorn/no-await-expression-member, no-await-in-loop
383 (await Workspace.query().where('workspaceId', workspaceId)).length > 0
384 );
385
386 const services = workspace.services.map(
387 // @ts-expect-error
388 service => serviceIdTranslation[service],
389 );
390
391 // eslint-disable-next-line no-await-in-loop
392 await Workspace.create({
393 userId: user.id,
394 workspaceId,
395 name: workspace.name,
396 order: workspace.order,
397 services: JSON.stringify(services),
398 data: JSON.stringify({}),
399 });
400 }
401 } catch (error) {
402 const errorMessage = `Could not import your workspaces into our system.\nError: ${error}`;
403 return response.status(401).send(errorMessage);
404 }
405
406 return response.send(
407 'Your account has been imported. You can now use your Franz/Ferdi account in Ferdium.',
408 );
409 }
410}