diff options
Diffstat (limited to 'app/Controllers/Http/UserController.ts')
-rw-r--r-- | app/Controllers/Http/UserController.ts | 410 |
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 @@ | |||
1 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; | ||
2 | import { schema, rules } from '@ioc:Adonis/Core/Validator'; | ||
3 | import User from 'App/Models/User'; | ||
4 | import { connectWithFranz, isRegistrationEnabled } from '../../../config/app'; | ||
5 | import crypto from 'node:crypto'; | ||
6 | import { v4 as uuid } from 'uuid'; | ||
7 | import Workspace from 'App/Models/Workspace'; | ||
8 | import Service from 'App/Models/Service'; | ||
9 | import fetch from 'node-fetch'; | ||
10 | |||
11 | // TODO: This file needs to be refactored and cleaned up to include types | ||
12 | import { handleVerifyAndReHash } from '../../../helpers/PasswordHash'; | ||
13 | |||
14 | const newPostSchema = schema.create({ | ||
15 | firstname: schema.string(), | ||
16 | lastname: schema.string(), | ||
17 | email: schema.string([ | ||
18 | rules.email(), | ||
19 | rules.unique({ table: 'users', column: 'email' }), | ||
20 | ]), | ||
21 | password: schema.string([rules.minLength(8)]), | ||
22 | }); | ||
23 | |||
24 | const franzImportSchema = schema.create({ | ||
25 | email: schema.string([ | ||
26 | rules.email(), | ||
27 | rules.unique({ table: 'users', column: 'email' }), | ||
28 | ]), | ||
29 | password: schema.string([rules.minLength(8)]), | ||
30 | }); | ||
31 | |||
32 | // // TODO: This whole controller needs to be changed such that it can support importing from both Franz and Ferdi | ||
33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
34 | const franzRequest = (route: any, method: any, auth: any) => | ||
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 | |||
55 | export 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 | } | ||