summaryrefslogtreecommitdiffstats
path: root/src/api/server/ServerApi.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/server/ServerApi.ts')
-rw-r--r--src/api/server/ServerApi.ts616
1 files changed, 616 insertions, 0 deletions
diff --git a/src/api/server/ServerApi.ts b/src/api/server/ServerApi.ts
new file mode 100644
index 000000000..2fd1a8d0d
--- /dev/null
+++ b/src/api/server/ServerApi.ts
@@ -0,0 +1,616 @@
1/* eslint-disable import/no-import-module-exports */
2/* eslint-disable global-require */
3import { join } from 'path';
4import tar from 'tar';
5import {
6 readdirSync,
7 statSync,
8 writeFileSync,
9 copySync,
10 ensureDirSync,
11 pathExistsSync,
12 readJsonSync,
13 removeSync,
14 PathOrFileDescriptor,
15} from 'fs-extra';
16import fetch from 'electron-fetch';
17
18import ServiceModel from '../../models/Service';
19import RecipePreviewModel from '../../models/RecipePreview';
20import RecipeModel from '../../models/Recipe';
21import UserModel from '../../models/User';
22
23import { sleep } from '../../helpers/async-helpers';
24
25import { SERVER_NOT_LOADED } from '../../config';
26import { userDataRecipesPath, userDataPath } from '../../environment-remote';
27import { asarRecipesPath } from '../../helpers/asar-helpers';
28import apiBase from '../apiBase';
29import { prepareAuthRequest, sendAuthRequest } from '../utils/auth';
30
31import {
32 getRecipeDirectory,
33 getDevRecipeDirectory,
34 loadRecipeConfig,
35} from '../../helpers/recipe-helpers';
36
37import { removeServicePartitionDirectory } from '../../helpers/service-helpers';
38
39const debug = require('debug')('Ferdi:ServerApi');
40
41module.paths.unshift(getDevRecipeDirectory(), getRecipeDirectory());
42
43export default class ServerApi {
44 recipePreviews: any[] = [];
45
46 recipes: any[] = [];
47
48 // User
49 async login(email: string, passwordHash: string) {
50 const request = await sendAuthRequest(
51 `${apiBase()}/auth/login`,
52 {
53 method: 'POST',
54 headers: {
55 Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`,
56 },
57 },
58 false,
59 );
60 if (!request.ok) {
61 throw new Error(request.statusText);
62 }
63 const u = await request.json();
64
65 debug('ServerApi::login resolves', u);
66 return u.token;
67 }
68
69 async signup(data: any) {
70 const request = await sendAuthRequest(
71 `${apiBase()}/auth/signup`,
72 {
73 method: 'POST',
74 body: JSON.stringify(data),
75 },
76 false,
77 );
78 if (!request.ok) {
79 throw new Error(request.statusText);
80 }
81 const u = await request.json();
82
83 debug('ServerApi::signup resolves', u);
84 return u.token;
85 }
86
87 async inviteUser(data: any) {
88 const request = await sendAuthRequest(`${apiBase()}/invite`, {
89 method: 'POST',
90 body: JSON.stringify(data),
91 });
92 if (!request.ok) {
93 throw new Error(request.statusText);
94 }
95
96 debug('ServerApi::inviteUser');
97 return true;
98 }
99
100 async retrievePassword(email: string) {
101 const request = await sendAuthRequest(
102 `${apiBase()}/auth/password`,
103 {
104 method: 'POST',
105 body: JSON.stringify({
106 email,
107 }),
108 },
109 false,
110 );
111 if (!request.ok) {
112 throw new Error(request.statusText);
113 }
114 const r = await request.json();
115
116 debug('ServerApi::retrievePassword');
117 return r;
118 }
119
120 async userInfo() {
121 if (apiBase() === SERVER_NOT_LOADED) {
122 throw new Error('Server not loaded');
123 }
124
125 const request = await sendAuthRequest(`${apiBase()}/me`);
126 if (!request.ok) {
127 throw new Error(request.statusText);
128 }
129 const data = await request.json();
130
131 const user = new UserModel(data);
132 debug('ServerApi::userInfo resolves', user);
133
134 return user;
135 }
136
137 async updateUserInfo(data: any) {
138 const request = await sendAuthRequest(`${apiBase()}/me`, {
139 method: 'PUT',
140 body: JSON.stringify(data),
141 });
142 if (!request.ok) {
143 throw new Error(request.statusText);
144 }
145 const updatedData = await request.json();
146
147 const user = Object.assign(updatedData, {
148 data: new UserModel(updatedData.data),
149 });
150 debug('ServerApi::updateUserInfo resolves', user);
151 return user;
152 }
153
154 async deleteAccount() {
155 const request = await sendAuthRequest(`${apiBase()}/me`, {
156 method: 'DELETE',
157 });
158 if (!request.ok) {
159 throw new Error(request.statusText);
160 }
161 const data = await request.json();
162
163 debug('ServerApi::deleteAccount resolves', data);
164 return data;
165 }
166
167 // Services
168 async getServices() {
169 if (apiBase() === SERVER_NOT_LOADED) {
170 throw new Error('Server not loaded');
171 }
172
173 const request = await sendAuthRequest(`${apiBase()}/me/services`);
174 if (!request.ok) {
175 throw new Error(request.statusText);
176 }
177 const data = await request.json();
178
179 const services = await this._mapServiceModels(data);
180 const filteredServices = services.filter(service => !!service);
181 debug('ServerApi::getServices resolves', filteredServices);
182 return filteredServices;
183 }
184
185 async createService(recipeId: string, data: { iconFile: any }) {
186 const request = await sendAuthRequest(`${apiBase()}/service`, {
187 method: 'POST',
188 body: JSON.stringify({ recipeId, ...data }),
189 });
190 if (!request.ok) {
191 throw new Error(request.statusText);
192 }
193 const serviceData = await request.json();
194
195 if (data.iconFile) {
196 const iconData = await this.uploadServiceIcon(
197 serviceData.data.id,
198 data.iconFile,
199 );
200
201 serviceData.data = iconData;
202 }
203
204 const service = Object.assign(serviceData, {
205 data: await this._prepareServiceModel(serviceData.data),
206 });
207
208 debug('ServerApi::createService resolves', service);
209 return service;
210 }
211
212 async updateService(serviceId: string, rawData: any) {
213 const data = rawData;
214
215 if (data.iconFile) {
216 await this.uploadServiceIcon(serviceId, data.iconFile);
217 }
218
219 const request = await sendAuthRequest(`${apiBase()}/service/${serviceId}`, {
220 method: 'PUT',
221 body: JSON.stringify(data),
222 });
223
224 if (!request.ok) {
225 throw new Error(request.statusText);
226 }
227
228 const serviceData = await request.json();
229
230 const service = Object.assign(serviceData, {
231 data: await this._prepareServiceModel(serviceData.data),
232 });
233
234 debug('ServerApi::updateService resolves', service);
235 return service;
236 }
237
238 async uploadServiceIcon(serviceId: string, icon: string | Blob) {
239 const formData = new FormData();
240 formData.append('icon', icon);
241
242 const requestData = prepareAuthRequest({
243 method: 'PUT',
244 // @ts-expect-error Argument of type '{ method: string; body: FormData; }' is not assignable to parameter of type '{ method: string; }'.
245 body: formData,
246 });
247
248 delete requestData.headers['Content-Type'];
249
250 const request = await window.fetch(
251 `${apiBase()}/service/${serviceId}`,
252 // @ts-expect-error Argument of type '{ method: string; } & { mode: string; headers: any; }' is not assignable to parameter of type 'RequestInit | undefined'.
253 requestData,
254 );
255
256 if (!request.ok) {
257 throw new Error(request.statusText);
258 }
259
260 const serviceData = await request.json();
261
262 return serviceData.data;
263 }
264
265 async reorderService(data: any) {
266 const request = await sendAuthRequest(`${apiBase()}/service/reorder`, {
267 method: 'PUT',
268 body: JSON.stringify(data),
269 });
270 if (!request.ok) {
271 throw new Error(request.statusText);
272 }
273 const serviceData = await request.json();
274 debug('ServerApi::reorderService resolves', serviceData);
275 return serviceData;
276 }
277
278 async deleteService(id: string) {
279 const request = await sendAuthRequest(`${apiBase()}/service/${id}`, {
280 method: 'DELETE',
281 });
282 if (!request.ok) {
283 throw new Error(request.statusText);
284 }
285 const data = await request.json();
286
287 removeServicePartitionDirectory(id, true);
288
289 debug('ServerApi::deleteService resolves', data);
290 return data;
291 }
292
293 // Features
294 async getDefaultFeatures() {
295 const request = await sendAuthRequest(`${apiBase()}/features/default`);
296 if (!request.ok) {
297 throw new Error(request.statusText);
298 }
299 const data = await request.json();
300
301 const features = data;
302 debug('ServerApi::getDefaultFeatures resolves', features);
303 return features;
304 }
305
306 async getFeatures() {
307 if (apiBase() === SERVER_NOT_LOADED) {
308 throw new Error('Server not loaded');
309 }
310
311 const request = await sendAuthRequest(`${apiBase()}/features`);
312 if (!request.ok) {
313 throw new Error(request.statusText);
314 }
315 const data = await request.json();
316
317 const features = data;
318 debug('ServerApi::getFeatures resolves', features);
319 return features;
320 }
321
322 // Recipes
323 async getInstalledRecipes() {
324 const recipesDirectory = getRecipeDirectory();
325 const paths = readdirSync(recipesDirectory).filter(
326 file =>
327 statSync(join(recipesDirectory, file)).isDirectory() &&
328 file !== 'temp' &&
329 file !== 'dev',
330 );
331
332 this.recipes = paths
333 .map(id => {
334 // eslint-disable-next-line import/no-dynamic-require
335 const Recipe = require(id)(RecipeModel);
336 return new Recipe(loadRecipeConfig(id));
337 })
338 .filter(recipe => recipe.id);
339
340 // eslint-disable-next-line unicorn/prefer-spread
341 this.recipes = this.recipes.concat(this._getDevRecipes());
342
343 debug('StubServerApi::getInstalledRecipes resolves', this.recipes);
344 return this.recipes;
345 }
346
347 async getRecipeUpdates(recipeVersions: any) {
348 const request = await sendAuthRequest(`${apiBase()}/recipes/update`, {
349 method: 'POST',
350 body: JSON.stringify(recipeVersions),
351 });
352 if (!request.ok) {
353 throw new Error(request.statusText);
354 }
355 const recipes = await request.json();
356 debug('ServerApi::getRecipeUpdates resolves', recipes);
357 return recipes;
358 }
359
360 // Recipes Previews
361 async getRecipePreviews() {
362 const request = await sendAuthRequest(`${apiBase()}/recipes`);
363 if (!request.ok) throw new Error(request.statusText);
364 const data = await request.json();
365 const recipePreviews = this._mapRecipePreviewModel(data);
366 debug('ServerApi::getRecipes resolves', recipePreviews);
367 return recipePreviews;
368 }
369
370 async getFeaturedRecipePreviews() {
371 // TODO: If we are hitting the internal-server, we need to return an empty list, else we can hit the remote server and get the data
372 const request = await sendAuthRequest(`${apiBase()}/recipes/popular`);
373 if (!request.ok) throw new Error(request.statusText);
374
375 const data = await request.json();
376 const recipePreviews = this._mapRecipePreviewModel(data);
377 debug('ServerApi::getFeaturedRecipes resolves', recipePreviews);
378 return recipePreviews;
379 }
380
381 async searchRecipePreviews(needle: string) {
382 const url = `${apiBase()}/recipes/search?needle=${needle}`;
383 const request = await sendAuthRequest(url);
384 if (!request.ok) throw new Error(request.statusText);
385
386 const data = await request.json();
387 const recipePreviews = this._mapRecipePreviewModel(data);
388 debug('ServerApi::searchRecipePreviews resolves', recipePreviews);
389 return recipePreviews;
390 }
391
392 async getRecipePackage(recipeId: string) {
393 try {
394 const recipesDirectory = userDataRecipesPath();
395 const recipeTempDirectory = join(recipesDirectory, 'temp', recipeId);
396 const tempArchivePath = join(recipeTempDirectory, 'recipe.tar.gz');
397
398 const internalRecipeFile = asarRecipesPath(`${recipeId}.tar.gz`);
399
400 ensureDirSync(recipeTempDirectory);
401
402 let archivePath: PathOrFileDescriptor;
403
404 if (pathExistsSync(internalRecipeFile)) {
405 debug('[ServerApi::getRecipePackage] Using internal recipe file');
406 archivePath = internalRecipeFile;
407 } else {
408 debug('[ServerApi::getRecipePackage] Downloading recipe from server');
409 archivePath = tempArchivePath;
410
411 const packageUrl = `${apiBase()}/recipes/download/${recipeId}`;
412
413 const res = await fetch(packageUrl);
414 debug('Recipe downloaded', recipeId);
415 const buffer = await res.buffer();
416 writeFileSync(archivePath, buffer);
417 }
418 debug(archivePath);
419
420 await sleep(10);
421
422 // @ts-expect-error No overload matches this call.
423 await tar.x({
424 file: archivePath,
425 cwd: recipeTempDirectory,
426 preservePaths: true,
427 unlink: true,
428 preserveOwner: false,
429 onwarn: x => debug('warn', recipeId, x),
430 });
431
432 await sleep(10);
433
434 const { id } = readJsonSync(join(recipeTempDirectory, 'package.json'));
435 const recipeDirectory = join(recipesDirectory, id);
436 copySync(recipeTempDirectory, recipeDirectory);
437 removeSync(recipeTempDirectory);
438 removeSync(join(recipesDirectory, recipeId, 'recipe.tar.gz'));
439
440 return id;
441 } catch (error) {
442 console.error(error);
443
444 return false;
445 }
446 }
447
448 // Health Check
449 async healthCheck() {
450 if (apiBase() === SERVER_NOT_LOADED) {
451 throw new Error('Server not loaded');
452 }
453
454 const request = await sendAuthRequest(
455 `${apiBase(false)}/health`,
456 {
457 method: 'GET',
458 },
459 false,
460 );
461 if (!request.ok) {
462 throw new Error(request.statusText);
463 }
464 debug('ServerApi::healthCheck resolves');
465 }
466
467 async getLegacyServices() {
468 const file = userDataPath('settings', 'services.json');
469
470 try {
471 const config = readJsonSync(file);
472
473 if (Object.prototype.hasOwnProperty.call(config, 'services')) {
474 const services = await Promise.all(
475 config.services.map(async (s: { service: any }) => {
476 const service = s;
477 const request = await sendAuthRequest(
478 `${apiBase()}/recipes/${s.service}`,
479 );
480
481 if (request.status === 200) {
482 const data = await request.json();
483 // @ts-expect-error Property 'recipe' does not exist on type '{ service: any; }'.
484 service.recipe = new RecipePreviewModel(data);
485 }
486
487 return service;
488 }),
489 );
490
491 debug('ServerApi::getLegacyServices resolves', services);
492 return services;
493 }
494 } catch {
495 console.error('ServerApi::getLegacyServices no config found');
496 }
497
498 return [];
499 }
500
501 // Helper
502 async _mapServiceModels(services: any[]) {
503 const recipes = services.map((s: { recipeId: string }) => s.recipeId);
504 await this._bulkRecipeCheck(recipes);
505 /* eslint-disable no-return-await */
506 return Promise.all(
507 services.map(async (service: any) => this._prepareServiceModel(service)),
508 );
509 /* eslint-enable no-return-await */
510 }
511
512 async _prepareServiceModel(service: { recipeId: string }) {
513 let recipe: undefined;
514 try {
515 recipe = this.recipes.find(r => r.id === service.recipeId);
516
517 if (!recipe) {
518 console.warn(`Recipe ${service.recipeId} not loaded`);
519 return null;
520 }
521
522 return new ServiceModel(service, recipe);
523 } catch (error) {
524 debug(error);
525 return null;
526 }
527 }
528
529 async _bulkRecipeCheck(unfilteredRecipes: any[]) {
530 // Filter recipe duplicates as we don't need to download 3 Slack recipes
531 const recipes = unfilteredRecipes.filter(
532 (elem: any, pos: number, arr: string | any[]) =>
533 arr.indexOf(elem) === pos,
534 );
535
536 return Promise.all(
537 recipes.map(async (recipeId: string) => {
538 let recipe = this.recipes.find(r => r.id === recipeId);
539
540 if (!recipe) {
541 console.warn(
542 `Recipe '${recipeId}' not installed, trying to fetch from server`,
543 );
544
545 await this.getRecipePackage(recipeId);
546
547 debug('Rerun ServerAPI::getInstalledRecipes');
548 await this.getInstalledRecipes();
549
550 recipe = this.recipes.find(r => r.id === recipeId);
551
552 if (!recipe) {
553 console.warn(`Could not load recipe ${recipeId}`);
554 return null;
555 }
556 }
557
558 return recipe;
559 }),
560 ).catch(error => console.error("Can't load recipe", error));
561 }
562
563 _mapRecipePreviewModel(recipes: any[]) {
564 return recipes
565 .map(recipe => {
566 try {
567 return new RecipePreviewModel(recipe);
568 } catch (error) {
569 console.error(error);
570 return null;
571 }
572 })
573 .filter(recipe => recipe !== null);
574 }
575
576 _getDevRecipes() {
577 const recipesDirectory = getDevRecipeDirectory();
578 try {
579 const paths = readdirSync(recipesDirectory).filter(
580 file =>
581 statSync(join(recipesDirectory, file)).isDirectory() &&
582 file !== 'temp',
583 );
584
585 const recipes = paths
586 .map(id => {
587 let Recipe;
588 try {
589 // eslint-disable-next-line import/no-dynamic-require
590 Recipe = require(id)(RecipeModel);
591 return new Recipe(loadRecipeConfig(id));
592 } catch (error) {
593 console.error(error);
594 }
595
596 return false;
597 })
598 .filter(recipe => recipe.id)
599 .map(data => {
600 const recipe = data;
601
602 recipe.icons = {
603 svg: `${recipe.path}/icon.svg`,
604 };
605 recipe.local = true;
606
607 return data;
608 });
609
610 return recipes;
611 } catch {
612 debug('Could not load dev recipes');
613 return false;
614 }
615 }
616}