diff options
Diffstat (limited to 'src/internal-server/app')
-rw-r--r-- | src/internal-server/app/Controllers/Http/RecipeController.js | 120 | ||||
-rw-r--r-- | src/internal-server/app/Controllers/Http/ServiceController.js | 292 | ||||
-rw-r--r-- | src/internal-server/app/Controllers/Http/StaticController.js | 37 | ||||
-rw-r--r-- | src/internal-server/app/Controllers/Http/UserController.js | 370 | ||||
-rw-r--r-- | src/internal-server/app/Controllers/Http/WorkspaceController.js | 148 | ||||
-rw-r--r-- | src/internal-server/app/Exceptions/Handler.js | 40 | ||||
-rw-r--r-- | src/internal-server/app/Middleware/ConvertEmptyStringsToNull.js | 15 | ||||
-rw-r--r-- | src/internal-server/app/Models/Recipe.js | 7 | ||||
-rw-r--r-- | src/internal-server/app/Models/Service.js | 7 | ||||
-rw-r--r-- | src/internal-server/app/Models/Token.js | 7 | ||||
-rw-r--r-- | src/internal-server/app/Models/Traits/NoTimestamp.js | 14 | ||||
-rw-r--r-- | src/internal-server/app/Models/User.js | 8 | ||||
-rw-r--r-- | src/internal-server/app/Models/Workspace.js | 7 |
13 files changed, 1072 insertions, 0 deletions
diff --git a/src/internal-server/app/Controllers/Http/RecipeController.js b/src/internal-server/app/Controllers/Http/RecipeController.js new file mode 100644 index 000000000..8a6b4f684 --- /dev/null +++ b/src/internal-server/app/Controllers/Http/RecipeController.js | |||
@@ -0,0 +1,120 @@ | |||
1 | const Recipe = use('App/Models/Recipe'); | ||
2 | const Drive = use('Drive'); | ||
3 | const { | ||
4 | validateAll, | ||
5 | } = use('Validator'); | ||
6 | const Env = use('Env'); | ||
7 | |||
8 | const fetch = require('node-fetch'); | ||
9 | |||
10 | const RECIPES_URL = 'https://api.getferdi.com/v1/recipes'; | ||
11 | |||
12 | class RecipeController { | ||
13 | // List official and custom recipes | ||
14 | async list({ | ||
15 | response, | ||
16 | }) { | ||
17 | const officialRecipes = JSON.parse(await (await fetch(RECIPES_URL)).text()); | ||
18 | const customRecipesArray = (await Recipe.all()).rows; | ||
19 | const customRecipes = customRecipesArray.map(recipe => ({ | ||
20 | id: recipe.recipeId, | ||
21 | name: recipe.name, | ||
22 | ...JSON.parse(recipe.data), | ||
23 | })); | ||
24 | |||
25 | const recipes = [ | ||
26 | ...officialRecipes, | ||
27 | ...customRecipes, | ||
28 | ]; | ||
29 | |||
30 | return response.send(recipes); | ||
31 | } | ||
32 | |||
33 | // Search official and custom recipes | ||
34 | async search({ | ||
35 | request, | ||
36 | response, | ||
37 | }) { | ||
38 | // Validate user input | ||
39 | const validation = await validateAll(request.all(), { | ||
40 | needle: 'required', | ||
41 | }); | ||
42 | if (validation.fails()) { | ||
43 | return response.status(401).send({ | ||
44 | message: 'Please provide a needle', | ||
45 | messages: validation.messages(), | ||
46 | status: 401, | ||
47 | }); | ||
48 | } | ||
49 | |||
50 | const needle = request.input('needle'); | ||
51 | |||
52 | // Get results | ||
53 | let results; | ||
54 | |||
55 | if (needle === 'ferdi:custom') { | ||
56 | const dbResults = (await Recipe.all()).toJSON(); | ||
57 | results = dbResults.map(recipe => ({ | ||
58 | id: recipe.recipeId, | ||
59 | name: recipe.name, | ||
60 | ...JSON.parse(recipe.data), | ||
61 | })); | ||
62 | } else { | ||
63 | let remoteResults = []; | ||
64 | if (Env.get('CONNECT_WITH_FRANZ') == 'true') { // eslint-disable-line eqeqeq | ||
65 | remoteResults = JSON.parse(await (await fetch(`${RECIPES_URL}/search?needle=${encodeURIComponent(needle)}`)).text()); | ||
66 | } | ||
67 | const localResultsArray = (await Recipe.query().where('name', 'LIKE', `%${needle}%`).fetch()).toJSON(); | ||
68 | const localResults = localResultsArray.map(recipe => ({ | ||
69 | id: recipe.recipeId, | ||
70 | name: recipe.name, | ||
71 | ...JSON.parse(recipe.data), | ||
72 | })); | ||
73 | |||
74 | results = [ | ||
75 | ...localResults, | ||
76 | ...remoteResults || [], | ||
77 | ]; | ||
78 | } | ||
79 | |||
80 | return response.send(results); | ||
81 | } | ||
82 | |||
83 | // Download a recipe | ||
84 | async download({ | ||
85 | response, | ||
86 | params, | ||
87 | }) { | ||
88 | // Validate user input | ||
89 | const validation = await validateAll(params, { | ||
90 | recipe: 'required|accepted', | ||
91 | }); | ||
92 | if (validation.fails()) { | ||
93 | return response.status(401).send({ | ||
94 | message: 'Please provide a recipe ID', | ||
95 | messages: validation.messages(), | ||
96 | status: 401, | ||
97 | }); | ||
98 | } | ||
99 | |||
100 | const service = params.recipe; | ||
101 | |||
102 | // Check for invalid characters | ||
103 | if (/\.{1,}/.test(service) || /\/{1,}/.test(service)) { | ||
104 | return response.send('Invalid recipe name'); | ||
105 | } | ||
106 | |||
107 | // Check if recipe exists in recipes folder | ||
108 | if (await Drive.exists(`${service}.tar.gz`)) { | ||
109 | return response.send(await Drive.get(`${service}.tar.gz`)); | ||
110 | } if (Env.get('CONNECT_WITH_FRANZ') == 'true') { // eslint-disable-line eqeqeq | ||
111 | return response.redirect(`${RECIPES_URL}/download/${service}`); | ||
112 | } | ||
113 | return response.status(400).send({ | ||
114 | message: 'Recipe not found', | ||
115 | code: 'recipe-not-found', | ||
116 | }); | ||
117 | } | ||
118 | } | ||
119 | |||
120 | module.exports = RecipeController; | ||
diff --git a/src/internal-server/app/Controllers/Http/ServiceController.js b/src/internal-server/app/Controllers/Http/ServiceController.js new file mode 100644 index 000000000..c76a287f7 --- /dev/null +++ b/src/internal-server/app/Controllers/Http/ServiceController.js | |||
@@ -0,0 +1,292 @@ | |||
1 | const Service = use('App/Models/Service'); | ||
2 | const { validateAll } = use('Validator'); | ||
3 | const Env = use('Env'); | ||
4 | |||
5 | const uuid = require('uuid/v4'); | ||
6 | const path = require('path'); | ||
7 | const fs = require('fs-extra'); | ||
8 | const { LOCAL_HOSTNAME } = require('../../../../config'); | ||
9 | |||
10 | const hostname = LOCAL_HOSTNAME; | ||
11 | const port = Env.get('PORT'); | ||
12 | |||
13 | class ServiceController { | ||
14 | // Create a new service for user | ||
15 | async create({ request, response }) { | ||
16 | // Validate user input | ||
17 | const validation = await validateAll(request.all(), { | ||
18 | name: 'required|string', | ||
19 | recipeId: 'required', | ||
20 | }); | ||
21 | if (validation.fails()) { | ||
22 | return response.status(401).send({ | ||
23 | message: 'Invalid POST arguments', | ||
24 | messages: validation.messages(), | ||
25 | status: 401, | ||
26 | }); | ||
27 | } | ||
28 | |||
29 | const data = request.all(); | ||
30 | |||
31 | // Get new, unused uuid | ||
32 | let serviceId; | ||
33 | do { | ||
34 | serviceId = uuid(); | ||
35 | } while ( | ||
36 | (await Service.query().where('serviceId', serviceId).fetch()).rows | ||
37 | .length > 0 | ||
38 | ); // eslint-disable-line no-await-in-loop | ||
39 | |||
40 | await Service.create({ | ||
41 | serviceId, | ||
42 | name: data.name, | ||
43 | recipeId: data.recipeId, | ||
44 | settings: JSON.stringify(data), | ||
45 | }); | ||
46 | |||
47 | return response.send({ | ||
48 | data: { | ||
49 | userId: 1, | ||
50 | id: serviceId, | ||
51 | isEnabled: true, | ||
52 | isNotificationEnabled: true, | ||
53 | isBadgeEnabled: true, | ||
54 | isMuted: false, | ||
55 | isDarkModeEnabled: '', // TODO: This should ideally be a boolean (false). But, changing it caused the sidebar toggle to not work. | ||
56 | spellcheckerLanguage: '', | ||
57 | order: 1, | ||
58 | customRecipe: false, | ||
59 | hasCustomIcon: false, | ||
60 | workspaces: [], | ||
61 | iconUrl: null, | ||
62 | ...data, | ||
63 | }, | ||
64 | status: ['created'], | ||
65 | }); | ||
66 | } | ||
67 | |||
68 | // List all services a user has created | ||
69 | async list({ response }) { | ||
70 | const services = (await Service.all()).rows; | ||
71 | // Convert to array with all data Franz wants | ||
72 | const servicesArray = services.map(service => { | ||
73 | const settings = | ||
74 | typeof service.settings === 'string' | ||
75 | ? JSON.parse(service.settings) | ||
76 | : service.settings; | ||
77 | |||
78 | return { | ||
79 | customRecipe: false, | ||
80 | hasCustomIcon: false, | ||
81 | isBadgeEnabled: true, | ||
82 | isDarkModeEnabled: '', // TODO: This should ideally be a boolean (false). But, changing it caused the sidebar toggle to not work. | ||
83 | isEnabled: true, | ||
84 | isMuted: false, | ||
85 | isNotificationEnabled: true, | ||
86 | order: 1, | ||
87 | spellcheckerLanguage: '', | ||
88 | workspaces: [], | ||
89 | ...JSON.parse(service.settings), | ||
90 | iconUrl: settings.iconId | ||
91 | ? `http://${hostname}:${port}/v1/icon/${settings.iconId}` | ||
92 | : null, | ||
93 | id: service.serviceId, | ||
94 | name: service.name, | ||
95 | recipeId: service.recipeId, | ||
96 | userId: 1, | ||
97 | }; | ||
98 | }); | ||
99 | |||
100 | return response.send(servicesArray); | ||
101 | } | ||
102 | |||
103 | async edit({ request, response, params }) { | ||
104 | if (request.file('icon')) { | ||
105 | // Upload custom service icon | ||
106 | await fs.ensureDir(path.join(Env.get('USER_PATH'), 'icons')); | ||
107 | |||
108 | const icon = request.file('icon', { | ||
109 | types: ['image'], | ||
110 | size: '2mb', | ||
111 | }); | ||
112 | const { id } = params; | ||
113 | const service = (await Service.query().where('serviceId', id).fetch()) | ||
114 | .rows[0]; | ||
115 | const settings = | ||
116 | typeof service.settings === 'string' | ||
117 | ? JSON.parse(service.settings) | ||
118 | : service.settings; | ||
119 | |||
120 | // Generate new icon ID | ||
121 | let iconId; | ||
122 | do { | ||
123 | iconId = uuid() + uuid(); | ||
124 | } while (fs.existsSync(path.join(Env.get('USER_PATH'), 'icons', iconId))); | ||
125 | |||
126 | await icon.move(path.join(Env.get('USER_PATH'), 'icons'), { | ||
127 | name: iconId, | ||
128 | overwrite: true, | ||
129 | }); | ||
130 | |||
131 | if (!icon.moved()) { | ||
132 | return response.status(500).send(icon.error()); | ||
133 | } | ||
134 | |||
135 | const newSettings = { | ||
136 | ...settings, | ||
137 | ...{ | ||
138 | iconId, | ||
139 | customIconVersion: | ||
140 | settings && settings.customIconVersion | ||
141 | ? settings.customIconVersion + 1 | ||
142 | : 1, | ||
143 | }, | ||
144 | }; | ||
145 | |||
146 | // Update data in database | ||
147 | await Service.query() | ||
148 | .where('serviceId', id) | ||
149 | .update({ | ||
150 | name: service.name, | ||
151 | settings: JSON.stringify(newSettings), | ||
152 | }); | ||
153 | |||
154 | return response.send({ | ||
155 | data: { | ||
156 | id, | ||
157 | name: service.name, | ||
158 | ...newSettings, | ||
159 | iconUrl: `http://${hostname}:${port}/v1/icon/${ | ||
160 | newSettings.iconId | ||
161 | }`, | ||
162 | userId: 1, | ||
163 | }, | ||
164 | status: ['updated'], | ||
165 | }); | ||
166 | } | ||
167 | // Update service info | ||
168 | const data = request.all(); | ||
169 | const { id } = params; | ||
170 | |||
171 | // Get current settings from db | ||
172 | const serviceData = (await Service.query().where('serviceId', id).fetch()) | ||
173 | .rows[0]; | ||
174 | |||
175 | const settings = { | ||
176 | ...(typeof serviceData.settings === 'string' | ||
177 | ? JSON.parse(serviceData.settings) | ||
178 | : serviceData.settings), | ||
179 | ...data, | ||
180 | }; | ||
181 | |||
182 | // Update data in database | ||
183 | await Service.query() | ||
184 | .where('serviceId', id) | ||
185 | .update({ | ||
186 | name: data.name, | ||
187 | settings: JSON.stringify(settings), | ||
188 | }); | ||
189 | |||
190 | // Get updated row | ||
191 | const service = (await Service.query().where('serviceId', id).fetch()) | ||
192 | .rows[0]; | ||
193 | |||
194 | return response.send({ | ||
195 | data: { | ||
196 | id, | ||
197 | name: service.name, | ||
198 | ...settings, | ||
199 | iconUrl: `${Env.get('APP_URL')}/v1/icon/${settings.iconId}`, | ||
200 | userId: 1, | ||
201 | }, | ||
202 | status: ['updated'], | ||
203 | }); | ||
204 | } | ||
205 | |||
206 | async icon({ params, response }) { | ||
207 | const { id } = params; | ||
208 | |||
209 | const iconPath = path.join(Env.get('USER_PATH'), 'icons', id); | ||
210 | if (!fs.existsSync(iconPath)) { | ||
211 | return response.status(404).send({ | ||
212 | status: "Icon doesn't exist", | ||
213 | }); | ||
214 | } | ||
215 | |||
216 | return response.download(iconPath); | ||
217 | } | ||
218 | |||
219 | async reorder({ request, response }) { | ||
220 | const data = request.all(); | ||
221 | |||
222 | for (const service of Object.keys(data)) { | ||
223 | // Get current settings from db | ||
224 | const serviceData = ( | ||
225 | await Service.query() // eslint-disable-line no-await-in-loop | ||
226 | .where('serviceId', service) | ||
227 | .fetch() | ||
228 | ).rows[0]; | ||
229 | |||
230 | const settings = { | ||
231 | ...JSON.parse(serviceData.settings), | ||
232 | order: data[service], | ||
233 | }; | ||
234 | |||
235 | // Update data in database | ||
236 | await Service.query() // eslint-disable-line no-await-in-loop | ||
237 | .where('serviceId', service) | ||
238 | .update({ | ||
239 | settings: JSON.stringify(settings), | ||
240 | }); | ||
241 | } | ||
242 | |||
243 | // Get new services | ||
244 | const services = (await Service.all()).rows; | ||
245 | // Convert to array with all data Franz wants | ||
246 | const servicesArray = services.map(service => { | ||
247 | const settings = | ||
248 | typeof service.settings === 'string' | ||
249 | ? JSON.parse(service.settings) | ||
250 | : service.settings; | ||
251 | |||
252 | return { | ||
253 | customRecipe: false, | ||
254 | hasCustomIcon: false, | ||
255 | isBadgeEnabled: true, | ||
256 | isDarkModeEnabled: '', // TODO: This should ideally be a boolean (false). But, changing it caused the sidebar toggle to not work. | ||
257 | isEnabled: true, | ||
258 | isMuted: false, | ||
259 | isNotificationEnabled: true, | ||
260 | order: 1, | ||
261 | spellcheckerLanguage: '', | ||
262 | workspaces: [], | ||
263 | ...JSON.parse(service.settings), | ||
264 | iconUrl: settings.iconId | ||
265 | ? `http://${hostname}:${port}/v1/icon/${settings.iconId}` | ||
266 | : null, | ||
267 | id: service.serviceId, | ||
268 | name: service.name, | ||
269 | recipeId: service.recipeId, | ||
270 | userId: 1, | ||
271 | }; | ||
272 | }); | ||
273 | |||
274 | return response.send(servicesArray); | ||
275 | } | ||
276 | |||
277 | update({ response }) { | ||
278 | return response.send([]); | ||
279 | } | ||
280 | |||
281 | async delete({ params, response }) { | ||
282 | // Update data in database | ||
283 | await Service.query().where('serviceId', params.id).delete(); | ||
284 | |||
285 | return response.send({ | ||
286 | message: 'Sucessfully deleted service', | ||
287 | status: 200, | ||
288 | }); | ||
289 | } | ||
290 | } | ||
291 | |||
292 | module.exports = ServiceController; | ||
diff --git a/src/internal-server/app/Controllers/Http/StaticController.js b/src/internal-server/app/Controllers/Http/StaticController.js new file mode 100644 index 000000000..b9a145061 --- /dev/null +++ b/src/internal-server/app/Controllers/Http/StaticController.js | |||
@@ -0,0 +1,37 @@ | |||
1 | /** | ||
2 | * Controller for routes with static responses | ||
3 | */ | ||
4 | |||
5 | class StaticController { | ||
6 | // Enable all features | ||
7 | features({ | ||
8 | response, | ||
9 | }) { | ||
10 | return response.send({ | ||
11 | isServiceProxyEnabled: true, | ||
12 | isWorkspaceEnabled: true, | ||
13 | isAnnouncementsEnabled: true, | ||
14 | isSettingsWSEnabled: false, | ||
15 | isMagicBarEnabled: true, | ||
16 | isTodosEnabled: true, | ||
17 | subscribeURL: 'https://getferdi.com', | ||
18 | hasInlineCheckout: true, | ||
19 | }); | ||
20 | } | ||
21 | |||
22 | // Return an empty array | ||
23 | emptyArray({ | ||
24 | response, | ||
25 | }) { | ||
26 | return response.send([]); | ||
27 | } | ||
28 | |||
29 | // Show announcements | ||
30 | announcement({ | ||
31 | response, | ||
32 | }) { | ||
33 | return response.send({}); | ||
34 | } | ||
35 | } | ||
36 | |||
37 | module.exports = StaticController; | ||
diff --git a/src/internal-server/app/Controllers/Http/UserController.js b/src/internal-server/app/Controllers/Http/UserController.js new file mode 100644 index 000000000..a3ad736fa --- /dev/null +++ b/src/internal-server/app/Controllers/Http/UserController.js | |||
@@ -0,0 +1,370 @@ | |||
1 | const User = use('App/Models/User'); | ||
2 | const Service = use('App/Models/Service'); | ||
3 | const Workspace = use('App/Models/Workspace'); | ||
4 | const { | ||
5 | validateAll, | ||
6 | } = use('Validator'); | ||
7 | |||
8 | const btoa = require('btoa'); | ||
9 | const fetch = require('node-fetch'); | ||
10 | const uuid = require('uuid/v4'); | ||
11 | const crypto = require('crypto'); | ||
12 | const { DEFAULT_APP_SETTINGS } = require('../../../../environment'); | ||
13 | |||
14 | const apiRequest = (url, route, method, auth) => new Promise((resolve, reject) => { | ||
15 | const base = `${url}/v1/`; | ||
16 | const user = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Ferdi/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; | ||
17 | |||
18 | try { | ||
19 | fetch(base + route, { | ||
20 | method, | ||
21 | headers: { | ||
22 | Authorization: `Bearer ${auth}`, | ||
23 | 'User-Agent': user, | ||
24 | }, | ||
25 | }) | ||
26 | .then(data => data.json()) | ||
27 | .then(json => resolve(json)); | ||
28 | } catch (e) { | ||
29 | reject(); | ||
30 | } | ||
31 | }); | ||
32 | |||
33 | class UserController { | ||
34 | // Register a new user | ||
35 | async signup({ | ||
36 | request, | ||
37 | response, | ||
38 | }) { | ||
39 | // Validate user input | ||
40 | const validation = await validateAll(request.all(), { | ||
41 | firstname: 'required', | ||
42 | email: 'required|email', | ||
43 | password: 'required', | ||
44 | }); | ||
45 | if (validation.fails()) { | ||
46 | return response.status(401).send({ | ||
47 | message: 'Invalid POST arguments', | ||
48 | messages: validation.messages(), | ||
49 | status: 401, | ||
50 | }); | ||
51 | } | ||
52 | |||
53 | return response.send({ | ||
54 | message: 'Successfully created account', | ||
55 | token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJGZXJkaSBJbnRlcm5hbCBTZXJ2ZXIiLCJpYXQiOjE1NzEwNDAyMTUsImV4cCI6MjUzMzk1NDE3ODQ0LCJhdWQiOiJnZXRmZXJkaS5jb20iLCJzdWIiOiJmZXJkaUBsb2NhbGhvc3QiLCJ1c2VySWQiOiIxIn0.9_TWFGp6HROv8Yg82Rt6i1-95jqWym40a-HmgrdMC6M', | ||
56 | }); | ||
57 | } | ||
58 | |||
59 | // Login using an existing user | ||
60 | async login({ | ||
61 | request, | ||
62 | response, | ||
63 | }) { | ||
64 | if (!request.header('Authorization')) { | ||
65 | return response.status(401).send({ | ||
66 | message: 'Please provide authorization', | ||
67 | status: 401, | ||
68 | }); | ||
69 | } | ||
70 | |||
71 | return response.send({ | ||
72 | message: 'Successfully logged in', | ||
73 | token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJGZXJkaSBJbnRlcm5hbCBTZXJ2ZXIiLCJpYXQiOjE1NzEwNDAyMTUsImV4cCI6MjUzMzk1NDE3ODQ0LCJhdWQiOiJnZXRmZXJkaS5jb20iLCJzdWIiOiJmZXJkaUBsb2NhbGhvc3QiLCJ1c2VySWQiOiIxIn0.9_TWFGp6HROv8Yg82Rt6i1-95jqWym40a-HmgrdMC6M', | ||
74 | }); | ||
75 | } | ||
76 | |||
77 | // Return information about the current user | ||
78 | async me({ | ||
79 | response, | ||
80 | }) { | ||
81 | const user = await User.find(1); | ||
82 | |||
83 | const settings = typeof user.settings === 'string' ? JSON.parse(user.settings) : user.settings; | ||
84 | |||
85 | return response.send({ | ||
86 | accountType: 'individual', | ||
87 | beta: false, | ||
88 | donor: {}, | ||
89 | email: '', | ||
90 | emailValidated: true, | ||
91 | features: {}, | ||
92 | firstname: 'Ferdi', | ||
93 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', | ||
94 | isSubscriptionOwner: true, | ||
95 | lastname: 'Application', | ||
96 | locale: DEFAULT_APP_SETTINGS.fallbackLocale, | ||
97 | ...settings || {}, | ||
98 | }); | ||
99 | } | ||
100 | |||
101 | async updateMe({ | ||
102 | request, | ||
103 | response, | ||
104 | }) { | ||
105 | const user = await User.find(1); | ||
106 | |||
107 | let settings = user.settings || {}; | ||
108 | if (typeof settings === 'string') { | ||
109 | settings = JSON.parse(settings); | ||
110 | } | ||
111 | |||
112 | const newSettings = { | ||
113 | ...settings, | ||
114 | ...request.all(), | ||
115 | }; | ||
116 | |||
117 | user.settings = JSON.stringify(newSettings); | ||
118 | await user.save(); | ||
119 | |||
120 | return response.send({ | ||
121 | data: { | ||
122 | accountType: 'individual', | ||
123 | beta: false, | ||
124 | donor: {}, | ||
125 | email: '', | ||
126 | emailValidated: true, | ||
127 | features: {}, | ||
128 | firstname: 'Ferdi', | ||
129 | id: '82c1cf9d-ab58-4da2-b55e-aaa41d2142d8', | ||
130 | isSubscriptionOwner: true, | ||
131 | lastname: 'Application', | ||
132 | locale: DEFAULT_APP_SETTINGS.fallbackLocale, | ||
133 | ...newSettings, | ||
134 | }, | ||
135 | status: [ | ||
136 | 'data-updated', | ||
137 | ], | ||
138 | }); | ||
139 | } | ||
140 | |||
141 | async import({ | ||
142 | request, | ||
143 | response, | ||
144 | }) { | ||
145 | // Validate user input | ||
146 | const validation = await validateAll(request.all(), { | ||
147 | email: 'required|email', | ||
148 | password: 'required', | ||
149 | server: 'required', | ||
150 | }); | ||
151 | if (validation.fails()) { | ||
152 | let errorMessage = 'There was an error while trying to import your account:\n'; | ||
153 | for (const message of validation.messages()) { | ||
154 | if (message.validation === 'required') { | ||
155 | errorMessage += `- Please make sure to supply your ${message.field}\n`; | ||
156 | } else if (message.validation === 'unique') { | ||
157 | errorMessage += '- There is already a user with this email.\n'; | ||
158 | } else { | ||
159 | errorMessage += `${message.message}\n`; | ||
160 | } | ||
161 | } | ||
162 | return response.status(401).send(errorMessage); | ||
163 | } | ||
164 | |||
165 | const { | ||
166 | email, | ||
167 | password, | ||
168 | server, | ||
169 | } = request.all(); | ||
170 | |||
171 | const hashedPassword = crypto.createHash('sha256').update(password).digest('base64'); | ||
172 | |||
173 | const base = `${server}/v1/`; | ||
174 | const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Ferdi/5.3.0-beta.1 Chrome/69.0.3497.128 Electron/4.2.4 Safari/537.36'; | ||
175 | |||
176 | // Try to get an authentication token | ||
177 | let token; | ||
178 | try { | ||
179 | const basicToken = btoa(`${email}:${hashedPassword}`); | ||
180 | |||
181 | const rawResponse = await fetch(`${base}auth/login`, { | ||
182 | method: 'POST', | ||
183 | headers: { | ||
184 | Authorization: `Basic ${basicToken}`, | ||
185 | 'User-Agent': userAgent, | ||
186 | }, | ||
187 | }); | ||
188 | const content = await rawResponse.json(); | ||
189 | |||
190 | if (!content.message || content.message !== 'Successfully logged in') { | ||
191 | const errorMessage = 'Could not login into Franz with your supplied credentials. Please check and try again'; | ||
192 | return response.status(401).send(errorMessage); | ||
193 | } | ||
194 | |||
195 | // eslint-disable-next-line prefer-destructuring | ||
196 | token = content.token; | ||
197 | } catch (e) { | ||
198 | return response.status(401).send({ | ||
199 | message: 'Cannot login to Franz', | ||
200 | error: e, | ||
201 | }); | ||
202 | } | ||
203 | |||
204 | // Get user information | ||
205 | let userInf = false; | ||
206 | try { | ||
207 | userInf = await apiRequest(server, 'me', 'GET', token); | ||
208 | } catch (e) { | ||
209 | const errorMessage = `Could not get your user info from Franz. Please check your credentials or try again later.\nError: ${e}`; | ||
210 | return response.status(401).send(errorMessage); | ||
211 | } | ||
212 | if (!userInf) { | ||
213 | const errorMessage = 'Could not get your user info from Franz. Please check your credentials or try again later'; | ||
214 | return response.status(401).send(errorMessage); | ||
215 | } | ||
216 | |||
217 | const serviceIdTranslation = {}; | ||
218 | |||
219 | // Import services | ||
220 | try { | ||
221 | const services = await apiRequest(server, 'me/services', 'GET', token); | ||
222 | |||
223 | for (const service of services) { | ||
224 | // Get new, unused uuid | ||
225 | let serviceId; | ||
226 | do { | ||
227 | serviceId = uuid(); | ||
228 | } while ((await Service.query().where('serviceId', serviceId).fetch()).rows.length > 0); // eslint-disable-line no-await-in-loop | ||
229 | |||
230 | await Service.create({ // eslint-disable-line no-await-in-loop | ||
231 | serviceId, | ||
232 | name: service.name, | ||
233 | recipeId: service.recipeId, | ||
234 | settings: JSON.stringify(service), | ||
235 | }); | ||
236 | |||
237 | serviceIdTranslation[service.id] = serviceId; | ||
238 | } | ||
239 | } catch (e) { | ||
240 | const errorMessage = `Could not import your services into our system.\nError: ${e}`; | ||
241 | return response.status(401).send(errorMessage); | ||
242 | } | ||
243 | |||
244 | // Import workspaces | ||
245 | try { | ||
246 | const workspaces = await apiRequest(server, 'workspace', 'GET', token); | ||
247 | |||
248 | for (const workspace of workspaces) { | ||
249 | let workspaceId; | ||
250 | do { | ||
251 | workspaceId = uuid(); | ||
252 | } while ((await Workspace.query().where('workspaceId', workspaceId).fetch()).rows.length > 0); // eslint-disable-line no-await-in-loop | ||
253 | |||
254 | const services = workspace.services.map(service => serviceIdTranslation[service]); | ||
255 | |||
256 | await Workspace.create({ // eslint-disable-line no-await-in-loop | ||
257 | workspaceId, | ||
258 | name: workspace.name, | ||
259 | order: workspace.order, | ||
260 | services: JSON.stringify(services), | ||
261 | data: JSON.stringify({}), | ||
262 | }); | ||
263 | } | ||
264 | } catch (e) { | ||
265 | const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; | ||
266 | return response.status(401).send(errorMessage); | ||
267 | } | ||
268 | |||
269 | return response.send('Your account has been imported. You can now use your Franz account in Ferdi.'); | ||
270 | } | ||
271 | |||
272 | // Account import/export | ||
273 | async export({ | ||
274 | // eslint-disable-next-line no-unused-vars | ||
275 | auth, | ||
276 | response, | ||
277 | }) { | ||
278 | const services = (await Service.all()).toJSON(); | ||
279 | const workspaces = (await Workspace.all()).toJSON(); | ||
280 | |||
281 | const exportData = { | ||
282 | username: 'Ferdi', | ||
283 | mail: 'internal@getferdi.com', | ||
284 | services, | ||
285 | workspaces, | ||
286 | }; | ||
287 | |||
288 | return response | ||
289 | .header('Content-Type', 'application/force-download') | ||
290 | .header('Content-disposition', 'attachment; filename=export.ferdi-data') | ||
291 | .send(exportData); | ||
292 | } | ||
293 | |||
294 | async importFerdi({ | ||
295 | request, | ||
296 | response, | ||
297 | }) { | ||
298 | const validation = await validateAll(request.all(), { | ||
299 | file: 'required', | ||
300 | }); | ||
301 | if (validation.fails()) { | ||
302 | return response.send(validation.messages()); | ||
303 | } | ||
304 | |||
305 | let file; | ||
306 | try { | ||
307 | file = JSON.parse(request.input('file')); | ||
308 | } catch (e) { | ||
309 | return response.send('Could not import: Invalid file, could not read file'); | ||
310 | } | ||
311 | |||
312 | if (!file || !file.services || !file.workspaces) { | ||
313 | return response.send('Could not import: Invalid file (2)'); | ||
314 | } | ||
315 | |||
316 | const serviceIdTranslation = {}; | ||
317 | |||
318 | // Import services | ||
319 | try { | ||
320 | for (const service of file.services) { | ||
321 | // Get new, unused uuid | ||
322 | let serviceId; | ||
323 | do { | ||
324 | serviceId = uuid(); | ||
325 | } while ((await Service.query().where('serviceId', serviceId).fetch()).rows.length > 0); // eslint-disable-line no-await-in-loop | ||
326 | |||
327 | await Service.create({ // eslint-disable-line no-await-in-loop | ||
328 | serviceId, | ||
329 | name: service.name, | ||
330 | recipeId: service.recipeId, | ||
331 | settings: JSON.stringify(service.settings), | ||
332 | }); | ||
333 | |||
334 | serviceIdTranslation[service.id] = serviceId; | ||
335 | } | ||
336 | } catch (e) { | ||
337 | const errorMessage = `Could not import your services into our system.\nError: ${e}`; | ||
338 | return response.send(errorMessage); | ||
339 | } | ||
340 | |||
341 | // Import workspaces | ||
342 | try { | ||
343 | for (const workspace of file.workspaces) { | ||
344 | let workspaceId; | ||
345 | do { | ||
346 | workspaceId = uuid(); | ||
347 | } while ((await Workspace.query().where('workspaceId', workspaceId).fetch()).rows.length > 0); // eslint-disable-line no-await-in-loop | ||
348 | |||
349 | const services = (workspace.services && typeof (workspace.services) === 'object') ? | ||
350 | workspace.services.map((service) => serviceIdTranslation[service]) : | ||
351 | []; | ||
352 | |||
353 | await Workspace.create({ // eslint-disable-line no-await-in-loop | ||
354 | workspaceId, | ||
355 | name: workspace.name, | ||
356 | order: workspace.order, | ||
357 | services: JSON.stringify(services), | ||
358 | data: JSON.stringify(workspace.data), | ||
359 | }); | ||
360 | } | ||
361 | } catch (e) { | ||
362 | const errorMessage = `Could not import your workspaces into our system.\nError: ${e}`; | ||
363 | return response.status(401).send(errorMessage); | ||
364 | } | ||
365 | |||
366 | return response.send('Your account has been imported.'); | ||
367 | } | ||
368 | } | ||
369 | |||
370 | module.exports = UserController; | ||
diff --git a/src/internal-server/app/Controllers/Http/WorkspaceController.js b/src/internal-server/app/Controllers/Http/WorkspaceController.js new file mode 100644 index 000000000..4189fbcdd --- /dev/null +++ b/src/internal-server/app/Controllers/Http/WorkspaceController.js | |||
@@ -0,0 +1,148 @@ | |||
1 | const Workspace = use('App/Models/Workspace'); | ||
2 | const { | ||
3 | validateAll, | ||
4 | } = use('Validator'); | ||
5 | |||
6 | const uuid = require('uuid/v4'); | ||
7 | |||
8 | class WorkspaceController { | ||
9 | // Create a new workspace for user | ||
10 | async create({ | ||
11 | request, | ||
12 | response, | ||
13 | }) { | ||
14 | // Validate user input | ||
15 | const validation = await validateAll(request.all(), { | ||
16 | name: 'required', | ||
17 | }); | ||
18 | if (validation.fails()) { | ||
19 | return response.status(401).send({ | ||
20 | message: 'Invalid POST arguments', | ||
21 | messages: validation.messages(), | ||
22 | status: 401, | ||
23 | }); | ||
24 | } | ||
25 | |||
26 | const data = request.all(); | ||
27 | |||
28 | // Get new, unused uuid | ||
29 | let workspaceId; | ||
30 | do { | ||
31 | workspaceId = uuid(); | ||
32 | } while ((await Workspace.query().where('workspaceId', workspaceId).fetch()).rows.length > 0); // eslint-disable-line no-await-in-loop | ||
33 | |||
34 | const order = (await Workspace.all()).rows.length; | ||
35 | |||
36 | await Workspace.create({ | ||
37 | workspaceId, | ||
38 | name: data.name, | ||
39 | order, | ||
40 | services: JSON.stringify([]), | ||
41 | data: JSON.stringify(data), | ||
42 | }); | ||
43 | |||
44 | return response.send({ | ||
45 | userId: 1, | ||
46 | name: data.name, | ||
47 | id: workspaceId, | ||
48 | order, | ||
49 | workspaces: [], | ||
50 | }); | ||
51 | } | ||
52 | |||
53 | async edit({ | ||
54 | request, | ||
55 | response, | ||
56 | params, | ||
57 | }) { | ||
58 | // Validate user input | ||
59 | const validation = await validateAll(request.all(), { | ||
60 | name: 'required', | ||
61 | services: 'required|array', | ||
62 | }); | ||
63 | if (validation.fails()) { | ||
64 | return response.status(401).send({ | ||
65 | message: 'Invalid POST arguments', | ||
66 | messages: validation.messages(), | ||
67 | status: 401, | ||
68 | }); | ||
69 | } | ||
70 | |||
71 | const data = request.all(); | ||
72 | const { | ||
73 | id, | ||
74 | } = params; | ||
75 | |||
76 | // Update data in database | ||
77 | await (Workspace.query() | ||
78 | .where('workspaceId', id)).update({ | ||
79 | name: data.name, | ||
80 | services: JSON.stringify(data.services), | ||
81 | }); | ||
82 | |||
83 | // Get updated row | ||
84 | const workspace = (await Workspace.query() | ||
85 | .where('workspaceId', id).fetch()).rows[0]; | ||
86 | |||
87 | return response.send({ | ||
88 | id: workspace.workspaceId, | ||
89 | name: data.name, | ||
90 | order: workspace.order, | ||
91 | services: data.services, | ||
92 | userId: 1, | ||
93 | }); | ||
94 | } | ||
95 | |||
96 | async delete({ | ||
97 | // eslint-disable-next-line no-unused-vars | ||
98 | request, | ||
99 | response, | ||
100 | params, | ||
101 | }) { | ||
102 | // Validate user input | ||
103 | const validation = await validateAll(params, { | ||
104 | id: 'required', | ||
105 | }); | ||
106 | if (validation.fails()) { | ||
107 | return response.status(401).send({ | ||
108 | message: 'Invalid arguments', | ||
109 | messages: validation.messages(), | ||
110 | status: 401, | ||
111 | }); | ||
112 | } | ||
113 | |||
114 | const { | ||
115 | id, | ||
116 | } = params; | ||
117 | |||
118 | // Update data in database | ||
119 | await (Workspace.query() | ||
120 | .where('workspaceId', id)).delete(); | ||
121 | |||
122 | return response.send({ | ||
123 | message: 'Successfully deleted workspace', | ||
124 | }); | ||
125 | } | ||
126 | |||
127 | // List all workspaces a user has created | ||
128 | async list({ | ||
129 | response, | ||
130 | }) { | ||
131 | const workspaces = (await Workspace.all()).rows; | ||
132 | // Convert to array with all data Franz wants | ||
133 | let workspacesArray = []; | ||
134 | if (workspaces) { | ||
135 | workspacesArray = workspaces.map(workspace => ({ | ||
136 | id: workspace.workspaceId, | ||
137 | name: workspace.name, | ||
138 | order: workspace.order, | ||
139 | services: typeof workspace.services === 'string' ? JSON.parse(workspace.services) : workspace.services, | ||
140 | userId: 1, | ||
141 | })); | ||
142 | } | ||
143 | |||
144 | return response.send(workspacesArray); | ||
145 | } | ||
146 | } | ||
147 | |||
148 | module.exports = WorkspaceController; | ||
diff --git a/src/internal-server/app/Exceptions/Handler.js b/src/internal-server/app/Exceptions/Handler.js new file mode 100644 index 000000000..ab323fd38 --- /dev/null +++ b/src/internal-server/app/Exceptions/Handler.js | |||
@@ -0,0 +1,40 @@ | |||
1 | const BaseExceptionHandler = use('BaseExceptionHandler'); | ||
2 | |||
3 | /** | ||
4 | * This class handles all exceptions thrown during | ||
5 | * the HTTP request lifecycle. | ||
6 | * | ||
7 | * @class ExceptionHandler | ||
8 | */ | ||
9 | class ExceptionHandler extends BaseExceptionHandler { | ||
10 | /** | ||
11 | * Handle exception thrown during the HTTP lifecycle | ||
12 | * | ||
13 | * @method handle | ||
14 | * | ||
15 | * @param {Object} error | ||
16 | * @param {object} options.response | ||
17 | * | ||
18 | * @return {Promise<void>} | ||
19 | */ | ||
20 | async handle(error, { response }) { | ||
21 | if (error.name === 'ValidationException') { | ||
22 | return response.status(400).send('Invalid arguments'); | ||
23 | } | ||
24 | |||
25 | return response.status(error.status).send(error.message); | ||
26 | } | ||
27 | |||
28 | /** | ||
29 | * Report exception for logging or debugging. | ||
30 | * | ||
31 | * @method report | ||
32 | * | ||
33 | * @return {Promise<boolean>} | ||
34 | */ | ||
35 | async report() { | ||
36 | return true; | ||
37 | } | ||
38 | } | ||
39 | |||
40 | module.exports = ExceptionHandler; | ||
diff --git a/src/internal-server/app/Middleware/ConvertEmptyStringsToNull.js b/src/internal-server/app/Middleware/ConvertEmptyStringsToNull.js new file mode 100644 index 000000000..87f1f6c25 --- /dev/null +++ b/src/internal-server/app/Middleware/ConvertEmptyStringsToNull.js | |||
@@ -0,0 +1,15 @@ | |||
1 | class ConvertEmptyStringsToNull { | ||
2 | async handle({ request }, next) { | ||
3 | if (Object.keys(request.body).length) { | ||
4 | request.body = Object.assign( | ||
5 | ...Object.keys(request.body).map(key => ({ | ||
6 | [key]: request.body[key] !== '' ? request.body[key] : null, | ||
7 | })), | ||
8 | ); | ||
9 | } | ||
10 | |||
11 | await next(); | ||
12 | } | ||
13 | } | ||
14 | |||
15 | module.exports = ConvertEmptyStringsToNull; | ||
diff --git a/src/internal-server/app/Models/Recipe.js b/src/internal-server/app/Models/Recipe.js new file mode 100644 index 000000000..bd9741114 --- /dev/null +++ b/src/internal-server/app/Models/Recipe.js | |||
@@ -0,0 +1,7 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Recipe extends Model { | ||
5 | } | ||
6 | |||
7 | module.exports = Recipe; | ||
diff --git a/src/internal-server/app/Models/Service.js b/src/internal-server/app/Models/Service.js new file mode 100644 index 000000000..a2e5c981e --- /dev/null +++ b/src/internal-server/app/Models/Service.js | |||
@@ -0,0 +1,7 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Service extends Model { | ||
5 | } | ||
6 | |||
7 | module.exports = Service; | ||
diff --git a/src/internal-server/app/Models/Token.js b/src/internal-server/app/Models/Token.js new file mode 100644 index 000000000..83e989117 --- /dev/null +++ b/src/internal-server/app/Models/Token.js | |||
@@ -0,0 +1,7 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Token extends Model { | ||
5 | } | ||
6 | |||
7 | module.exports = Token; | ||
diff --git a/src/internal-server/app/Models/Traits/NoTimestamp.js b/src/internal-server/app/Models/Traits/NoTimestamp.js new file mode 100644 index 000000000..914f542f0 --- /dev/null +++ b/src/internal-server/app/Models/Traits/NoTimestamp.js | |||
@@ -0,0 +1,14 @@ | |||
1 | class NoTimestamp { | ||
2 | register(Model) { | ||
3 | Object.defineProperties(Model, { | ||
4 | createdAtColumn: { | ||
5 | get: () => null, | ||
6 | }, | ||
7 | updatedAtColumn: { | ||
8 | get: () => null, | ||
9 | }, | ||
10 | }); | ||
11 | } | ||
12 | } | ||
13 | |||
14 | module.exports = NoTimestamp; | ||
diff --git a/src/internal-server/app/Models/User.js b/src/internal-server/app/Models/User.js new file mode 100644 index 000000000..907710d8d --- /dev/null +++ b/src/internal-server/app/Models/User.js | |||
@@ -0,0 +1,8 @@ | |||
1 | // File is required by AdonisJS but not used by the server | ||
2 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
3 | const Model = use('Model'); | ||
4 | |||
5 | class User extends Model { | ||
6 | } | ||
7 | |||
8 | module.exports = User; | ||
diff --git a/src/internal-server/app/Models/Workspace.js b/src/internal-server/app/Models/Workspace.js new file mode 100644 index 000000000..dcf39ac75 --- /dev/null +++ b/src/internal-server/app/Models/Workspace.js | |||
@@ -0,0 +1,7 @@ | |||
1 | /** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ | ||
2 | const Model = use('Model'); | ||
3 | |||
4 | class Workspace extends Model { | ||
5 | } | ||
6 | |||
7 | module.exports = Workspace; | ||