@actual-app/sync-server 25.4.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.dockerignore +12 -0
  2. package/README.md +19 -0
  3. package/app.js +11 -0
  4. package/babel.config.json +3 -0
  5. package/bin/@actual-app/sync-server +55 -0
  6. package/docker/alpine.Dockerfile +62 -0
  7. package/docker/ubuntu.Dockerfile +63 -0
  8. package/docker-compose.yml +29 -0
  9. package/jest.config.json +19 -0
  10. package/jest.global-setup.js +101 -0
  11. package/jest.global-teardown.js +6 -0
  12. package/migrations/1694360000000-create-folders.js +25 -0
  13. package/migrations/1694360479680-create-account-db.js +30 -0
  14. package/migrations/1694362247011-create-secret-table.js +16 -0
  15. package/migrations/1702667624000-rename-nordigen-secrets.js +19 -0
  16. package/migrations/1718889148000-openid.js +41 -0
  17. package/migrations/1719409568000-multiuser.js +116 -0
  18. package/package.json +64 -0
  19. package/src/account-db.js +239 -0
  20. package/src/accounts/openid.js +361 -0
  21. package/src/accounts/password.js +149 -0
  22. package/src/app-account.js +155 -0
  23. package/src/app-admin.js +410 -0
  24. package/src/app-admin.test.js +381 -0
  25. package/src/app-gocardless/README.md +198 -0
  26. package/src/app-gocardless/app-gocardless.js +274 -0
  27. package/src/app-gocardless/bank-factory.js +91 -0
  28. package/src/app-gocardless/banks/abanca_caglesmm.js +22 -0
  29. package/src/app-gocardless/banks/abnamro_abnanl2a.js +57 -0
  30. package/src/app-gocardless/banks/american_express_aesudef1.js +40 -0
  31. package/src/app-gocardless/banks/bancsabadell_bsabesbbb.js +31 -0
  32. package/src/app-gocardless/banks/bank.interface.ts +51 -0
  33. package/src/app-gocardless/banks/bank_of_ireland_b365_bofiie2d.js +39 -0
  34. package/src/app-gocardless/banks/bankinter_bkbkesmm.js +24 -0
  35. package/src/app-gocardless/banks/belfius_gkccbebb.js +17 -0
  36. package/src/app-gocardless/banks/berliner_sparkasse_beladebexxx.js +61 -0
  37. package/src/app-gocardless/banks/bnp_be_gebabebb.js +73 -0
  38. package/src/app-gocardless/banks/cbc_cregbebb.js +34 -0
  39. package/src/app-gocardless/banks/commerzbank_cobadeff.js +51 -0
  40. package/src/app-gocardless/banks/danskebank_dabno22.js +39 -0
  41. package/src/app-gocardless/banks/direkt_heladef1822.js +18 -0
  42. package/src/app-gocardless/banks/easybank_bawaatww.js +50 -0
  43. package/src/app-gocardless/banks/entercard_swednokk.js +40 -0
  44. package/src/app-gocardless/banks/fortuneo_ftnofrp1xxx.js +46 -0
  45. package/src/app-gocardless/banks/hype_hyeeit22.js +74 -0
  46. package/src/app-gocardless/banks/ing_ingbrobu.js +70 -0
  47. package/src/app-gocardless/banks/ing_ingddeff.js +47 -0
  48. package/src/app-gocardless/banks/ing_pl_ingbplpw.js +46 -0
  49. package/src/app-gocardless/banks/integration-bank.js +115 -0
  50. package/src/app-gocardless/banks/isybank_itbbitmm.js +18 -0
  51. package/src/app-gocardless/banks/kbc_kredbebb.js +33 -0
  52. package/src/app-gocardless/banks/lhv-lhvbee22.js +36 -0
  53. package/src/app-gocardless/banks/mbank_retail_brexplpw.js +56 -0
  54. package/src/app-gocardless/banks/nationwide_naiagb21.js +46 -0
  55. package/src/app-gocardless/banks/nbg_ethngraaxxx.js +51 -0
  56. package/src/app-gocardless/banks/norwegian_xx_norwnok1.js +74 -0
  57. package/src/app-gocardless/banks/revolut_revolt21.js +37 -0
  58. package/src/app-gocardless/banks/sandboxfinance_sfin0000.js +28 -0
  59. package/src/app-gocardless/banks/seb_kort_bank_ab.js +58 -0
  60. package/src/app-gocardless/banks/seb_privat.js +29 -0
  61. package/src/app-gocardless/banks/sparnord_spnodk22.js +24 -0
  62. package/src/app-gocardless/banks/spk_karlsruhe_karsde66.js +61 -0
  63. package/src/app-gocardless/banks/spk_marburg_biedenkopf_heladef1mar.js +30 -0
  64. package/src/app-gocardless/banks/spk_worms_alzey_ried_malade51wor.js +19 -0
  65. package/src/app-gocardless/banks/ssk_dusseldorf_dussdeddxxx.js +50 -0
  66. package/src/app-gocardless/banks/swedbank_habalv22.js +47 -0
  67. package/src/app-gocardless/banks/tests/abanca_caglesmm.spec.js +21 -0
  68. package/src/app-gocardless/banks/tests/abnamro_abnanl2a.spec.js +61 -0
  69. package/src/app-gocardless/banks/tests/bancsabadell_bsabesbbb.spec.js +53 -0
  70. package/src/app-gocardless/banks/tests/belfius_gkccbebb.spec.js +22 -0
  71. package/src/app-gocardless/banks/tests/cbc_cregbebb.spec.js +34 -0
  72. package/src/app-gocardless/banks/tests/commerzbank_cobadeff.spec.js +110 -0
  73. package/src/app-gocardless/banks/tests/easybank_bawaatww.spec.js +54 -0
  74. package/src/app-gocardless/banks/tests/fortuneo_ftnofrp1xxx.spec.js +206 -0
  75. package/src/app-gocardless/banks/tests/ing_ingddeff.spec.js +302 -0
  76. package/src/app-gocardless/banks/tests/ing_pl_ingbplpw.spec.js +202 -0
  77. package/src/app-gocardless/banks/tests/integration_bank.spec.js +158 -0
  78. package/src/app-gocardless/banks/tests/kbc_kredbebb.spec.js +38 -0
  79. package/src/app-gocardless/banks/tests/lhv-lhvbee22.spec.js +68 -0
  80. package/src/app-gocardless/banks/tests/mbank_retail_brexplpw.spec.js +171 -0
  81. package/src/app-gocardless/banks/tests/nationwide_naiagb21.spec.js +105 -0
  82. package/src/app-gocardless/banks/tests/nbg_ethngraaxxx.spec.js +48 -0
  83. package/src/app-gocardless/banks/tests/revolut_revolt21.spec.js +42 -0
  84. package/src/app-gocardless/banks/tests/sandboxfinance_sfin0000.spec.js +133 -0
  85. package/src/app-gocardless/banks/tests/spk_marburg_biedenkopf_heladef1mar.spec.js +256 -0
  86. package/src/app-gocardless/banks/tests/ssk_dusseldorf_dussdeddxxx.spec.js +102 -0
  87. package/src/app-gocardless/banks/tests/swedbank_habalv22.spec.js +57 -0
  88. package/src/app-gocardless/banks/tests/virgin_nrnbgb22.spec.js +54 -0
  89. package/src/app-gocardless/banks/util/extract-payeeName-from-remittanceInfo.js +36 -0
  90. package/src/app-gocardless/banks/virgin_nrnbgb22.js +39 -0
  91. package/src/app-gocardless/errors.js +84 -0
  92. package/src/app-gocardless/gocardless-node.types.ts +497 -0
  93. package/src/app-gocardless/gocardless.types.ts +93 -0
  94. package/src/app-gocardless/link.html +18 -0
  95. package/src/app-gocardless/services/gocardless-service.js +620 -0
  96. package/src/app-gocardless/services/tests/fixtures.js +181 -0
  97. package/src/app-gocardless/services/tests/gocardless-service.spec.js +537 -0
  98. package/src/app-gocardless/tests/bank-factory.spec.js +20 -0
  99. package/src/app-gocardless/tests/utils.spec.js +162 -0
  100. package/src/app-gocardless/util/handle-error.js +16 -0
  101. package/src/app-gocardless/utils.js +45 -0
  102. package/src/app-openid.js +108 -0
  103. package/src/app-pluggyai/app-pluggyai.js +215 -0
  104. package/src/app-pluggyai/pluggyai-service.js +120 -0
  105. package/src/app-secrets.js +61 -0
  106. package/src/app-simplefin/app-simplefin.js +418 -0
  107. package/src/app-sync/errors.js +13 -0
  108. package/src/app-sync/services/files-service.js +243 -0
  109. package/src/app-sync/tests/services/files-service.test.js +250 -0
  110. package/src/app-sync/validation.js +77 -0
  111. package/src/app-sync.js +391 -0
  112. package/src/app-sync.test.js +877 -0
  113. package/src/app.js +145 -0
  114. package/src/config-types.ts +44 -0
  115. package/src/db.js +58 -0
  116. package/src/load-config.js +307 -0
  117. package/src/migrations.js +36 -0
  118. package/src/run-migrations.js +8 -0
  119. package/src/scripts/disable-openid.js +44 -0
  120. package/src/scripts/enable-openid.js +53 -0
  121. package/src/scripts/health-check.js +23 -0
  122. package/src/scripts/reset-password.js +51 -0
  123. package/src/secrets.test.js +83 -0
  124. package/src/services/secrets-service.js +94 -0
  125. package/src/services/user-service.js +272 -0
  126. package/src/sql/messages.sql +9 -0
  127. package/src/sync-simple.js +95 -0
  128. package/src/util/hash.js +5 -0
  129. package/src/util/middlewares.js +62 -0
  130. package/src/util/paths.js +13 -0
  131. package/src/util/payee-name.js +45 -0
  132. package/src/util/prompt.js +88 -0
  133. package/src/util/title/index.js +59 -0
  134. package/src/util/title/lower-case.js +93 -0
  135. package/src/util/title/specials.js +21 -0
  136. package/src/util/validate-user.js +68 -0
  137. package/tsconfig.json +21 -0
@@ -0,0 +1,391 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import fs from 'node:fs/promises';
3
+
4
+ import { SyncProtoBuf } from '@actual-app/crdt';
5
+ import express from 'express';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+
8
+ import { getAccountDb } from './account-db.js';
9
+ import { FileNotFound } from './app-sync/errors.js';
10
+ import {
11
+ File,
12
+ FilesService,
13
+ FileUpdate,
14
+ } from './app-sync/services/files-service.js';
15
+ import {
16
+ validateSyncedFile,
17
+ validateUploadedFile,
18
+ } from './app-sync/validation.js';
19
+ import * as simpleSync from './sync-simple.js';
20
+ import {
21
+ errorMiddleware,
22
+ requestLoggerMiddleware,
23
+ validateSessionMiddleware,
24
+ } from './util/middlewares.js';
25
+ import { getPathForUserFile, getPathForGroupFile } from './util/paths.js';
26
+
27
+ const app = express();
28
+ app.use(validateSessionMiddleware);
29
+ app.use(errorMiddleware);
30
+ app.use(requestLoggerMiddleware);
31
+ app.use(express.raw({ type: 'application/actual-sync' }));
32
+ app.use(express.raw({ type: 'application/encrypted-file' }));
33
+ app.use(express.json());
34
+
35
+ export { app as handlers };
36
+
37
+ const OK_RESPONSE = { status: 'ok' };
38
+
39
+ function boolToInt(deleted) {
40
+ return deleted ? 1 : 0;
41
+ }
42
+
43
+ const verifyFileExists = (fileId, filesService, res, errorObject) => {
44
+ try {
45
+ return filesService.get(fileId);
46
+ } catch (e) {
47
+ if (e instanceof FileNotFound) {
48
+ //FIXME: error code should be 404. Need to make sure frontend is ok with it.
49
+ //TODO: put this into a middleware that checks if FileNotFound is thrown and returns 404 and same error message
50
+ // for every FileNotFound error
51
+ res.status(400).send(errorObject);
52
+ return;
53
+ }
54
+ throw e;
55
+ }
56
+ };
57
+
58
+ app.post('/sync', async (req, res) => {
59
+ let requestPb;
60
+ try {
61
+ requestPb = SyncProtoBuf.SyncRequest.deserializeBinary(req.body);
62
+ } catch (e) {
63
+ console.log('Error parsing sync request', e);
64
+ res.status(500);
65
+ res.send({ status: 'error', reason: 'internal-error' });
66
+ return;
67
+ }
68
+
69
+ const fileId = requestPb.getFileid() || null;
70
+ const groupId = requestPb.getGroupid() || null;
71
+ const keyId = requestPb.getKeyid() || null;
72
+ const since = requestPb.getSince() || null;
73
+ const messages = requestPb.getMessagesList();
74
+
75
+ if (!since) {
76
+ return res.status(422).send({
77
+ details: 'since-required',
78
+ reason: 'unprocessable-entity',
79
+ status: 'error',
80
+ });
81
+ }
82
+
83
+ const filesService = new FilesService(getAccountDb());
84
+
85
+ const currentFile = verifyFileExists(
86
+ fileId,
87
+ filesService,
88
+ res,
89
+ 'file-not-found',
90
+ );
91
+
92
+ if (!currentFile) {
93
+ return;
94
+ }
95
+
96
+ const errorMessage = validateSyncedFile(groupId, keyId, currentFile);
97
+ if (errorMessage) {
98
+ res.status(400);
99
+ res.send(errorMessage);
100
+ return;
101
+ }
102
+
103
+ const { trie, newMessages } = simpleSync.sync(messages, since, groupId);
104
+
105
+ // encode it back...
106
+ const responsePb = new SyncProtoBuf.SyncResponse();
107
+ responsePb.setMerkle(JSON.stringify(trie));
108
+ newMessages.forEach(msg => responsePb.addMessages(msg));
109
+
110
+ res.set('Content-Type', 'application/actual-sync');
111
+ res.set('X-ACTUAL-SYNC-METHOD', 'simple');
112
+ res.send(Buffer.from(responsePb.serializeBinary()));
113
+ });
114
+
115
+ app.post('/user-get-key', (req, res) => {
116
+ if (!res.locals) return;
117
+
118
+ const { fileId } = req.body;
119
+
120
+ const filesService = new FilesService(getAccountDb());
121
+ const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
122
+
123
+ if (!file) {
124
+ return;
125
+ }
126
+
127
+ res.send({
128
+ status: 'ok',
129
+ data: {
130
+ id: file.encryptKeyId,
131
+ salt: file.encryptSalt,
132
+ test: file.encryptTest,
133
+ },
134
+ });
135
+ });
136
+
137
+ app.post('/user-create-key', (req, res) => {
138
+ const { fileId, keyId, keySalt, testContent } = req.body;
139
+
140
+ const filesService = new FilesService(getAccountDb());
141
+
142
+ if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
143
+ return;
144
+ }
145
+
146
+ filesService.update(
147
+ fileId,
148
+ new FileUpdate({
149
+ encryptSalt: keySalt,
150
+ encryptKeyId: keyId,
151
+ encryptTest: testContent,
152
+ }),
153
+ );
154
+
155
+ res.send(OK_RESPONSE);
156
+ });
157
+
158
+ app.post('/reset-user-file', async (req, res) => {
159
+ const { fileId } = req.body;
160
+
161
+ const filesService = new FilesService(getAccountDb());
162
+ const file = verifyFileExists(
163
+ fileId,
164
+ filesService,
165
+ res,
166
+ 'User or file not found',
167
+ );
168
+
169
+ if (!file) {
170
+ return;
171
+ }
172
+
173
+ const groupId = file.groupId;
174
+
175
+ filesService.update(fileId, new FileUpdate({ groupId: null }));
176
+
177
+ if (groupId) {
178
+ try {
179
+ await fs.unlink(getPathForGroupFile(groupId));
180
+ } catch {
181
+ console.log(`Unable to delete sync data for group "${groupId}"`);
182
+ }
183
+ }
184
+
185
+ res.send(OK_RESPONSE);
186
+ });
187
+
188
+ app.post('/upload-user-file', async (req, res) => {
189
+ if (typeof req.headers['x-actual-name'] !== 'string') {
190
+ // FIXME: Not sure how this cannot be a string when the header is
191
+ // set.
192
+ res.status(400).send('single x-actual-name is required');
193
+ return;
194
+ }
195
+
196
+ const name = decodeURIComponent(req.headers['x-actual-name']);
197
+ const fileId = req.headers['x-actual-file-id'];
198
+
199
+ if (!fileId || typeof fileId !== 'string') {
200
+ res.status(400).send('fileId is required');
201
+ return;
202
+ }
203
+
204
+ let groupId = req.headers['x-actual-group-id'] || null;
205
+ const encryptMeta = req.headers['x-actual-encrypt-meta'] || null;
206
+ const syncFormatVersion = req.headers['x-actual-format'] || null;
207
+
208
+ const keyId =
209
+ encryptMeta && typeof encryptMeta === 'string'
210
+ ? JSON.parse(encryptMeta).keyId
211
+ : null;
212
+
213
+ const filesService = new FilesService(getAccountDb());
214
+ let currentFile;
215
+
216
+ try {
217
+ currentFile = filesService.get(fileId);
218
+ } catch (e) {
219
+ if (e instanceof FileNotFound) {
220
+ currentFile = null;
221
+ } else {
222
+ throw e;
223
+ }
224
+ }
225
+
226
+ const errorMessage = validateUploadedFile(groupId, keyId, currentFile);
227
+ if (errorMessage) {
228
+ res.status(400).send(errorMessage);
229
+ return;
230
+ }
231
+
232
+ try {
233
+ await fs.writeFile(getPathForUserFile(fileId), req.body);
234
+ } catch (err) {
235
+ console.log('Error writing file', err);
236
+ res.status(500).send({ status: 'error' });
237
+ return;
238
+ }
239
+
240
+ if (!currentFile) {
241
+ // it's new
242
+ groupId = uuidv4();
243
+
244
+ filesService.set(
245
+ new File({
246
+ id: fileId,
247
+ groupId,
248
+ syncVersion: syncFormatVersion,
249
+ name,
250
+ encryptMeta,
251
+ owner:
252
+ res.locals.user_id ||
253
+ (() => {
254
+ throw new Error('User ID is required for file creation');
255
+ })(),
256
+ }),
257
+ );
258
+
259
+ res.send({ status: 'ok', groupId });
260
+ return;
261
+ }
262
+
263
+ if (!groupId) {
264
+ // sync state was reset, create new group
265
+ groupId = uuidv4();
266
+ filesService.update(fileId, new FileUpdate({ groupId }));
267
+ }
268
+
269
+ // Regardless, update some properties
270
+ filesService.update(
271
+ fileId,
272
+ new FileUpdate({
273
+ syncVersion: syncFormatVersion,
274
+ encryptMeta,
275
+ name,
276
+ }),
277
+ );
278
+
279
+ res.send({ status: 'ok', groupId });
280
+ });
281
+
282
+ app.get('/download-user-file', async (req, res) => {
283
+ const fileId = req.headers['x-actual-file-id'];
284
+ if (typeof fileId !== 'string') {
285
+ // FIXME: Not sure how this cannot be a string when the header is
286
+ // set.
287
+ res.status(400).send('Single file ID is required');
288
+ return;
289
+ }
290
+
291
+ const filesService = new FilesService(getAccountDb());
292
+ if (!verifyFileExists(fileId, filesService, res, 'User or file not found')) {
293
+ return;
294
+ }
295
+
296
+ res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);
297
+ res.sendFile(getPathForUserFile(fileId));
298
+ });
299
+
300
+ app.post('/update-user-filename', (req, res) => {
301
+ const { fileId, name } = req.body;
302
+
303
+ const filesService = new FilesService(getAccountDb());
304
+
305
+ if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
306
+ return;
307
+ }
308
+
309
+ filesService.update(fileId, new FileUpdate({ name }));
310
+ res.send(OK_RESPONSE);
311
+ });
312
+
313
+ app.get('/list-user-files', (req, res) => {
314
+ const fileService = new FilesService(getAccountDb());
315
+ const rows = fileService.find({ userId: res.locals.user_id });
316
+ res.send({
317
+ status: 'ok',
318
+ data: rows.map(row => ({
319
+ deleted: boolToInt(row.deleted),
320
+ fileId: row.id,
321
+ groupId: row.groupId,
322
+ name: row.name,
323
+ encryptKeyId: row.encryptKeyId,
324
+ owner: row.owner,
325
+ usersWithAccess: fileService.findUsersWithAccess(row.id).map(access => ({
326
+ ...access,
327
+ owner: access.userId === row.owner,
328
+ })),
329
+ })),
330
+ });
331
+ });
332
+
333
+ app.get('/get-user-file-info', (req, res) => {
334
+ const fileId = req.headers['x-actual-file-id'];
335
+
336
+ // TODO: Return 422 if fileId is not provided. Need to make sure frontend can handle it
337
+ // if (!fileId) {
338
+ // return res.status(422).send({
339
+ // details: 'fileId-required',
340
+ // reason: 'unprocessable-entity',
341
+ // status: 'error',
342
+ // });
343
+ // }
344
+
345
+ const fileService = new FilesService(getAccountDb());
346
+
347
+ const file = verifyFileExists(fileId, fileService, res, {
348
+ status: 'error',
349
+ reason: 'file-not-found',
350
+ });
351
+
352
+ if (!file) {
353
+ return;
354
+ }
355
+
356
+ res.send({
357
+ status: 'ok',
358
+ data: {
359
+ deleted: boolToInt(file.deleted), // FIXME: convert to boolean, make sure it works in the frontend
360
+ fileId: file.id,
361
+ groupId: file.groupId,
362
+ name: file.name,
363
+ encryptMeta: file.encryptMeta ? JSON.parse(file.encryptMeta) : null,
364
+ usersWithAccess: fileService.findUsersWithAccess(file.id).map(access => ({
365
+ ...access,
366
+ owner: access.userId === file.owner,
367
+ })),
368
+ },
369
+ });
370
+ });
371
+
372
+ app.post('/delete-user-file', (req, res) => {
373
+ const { fileId } = req.body;
374
+
375
+ if (!fileId) {
376
+ return res.status(422).send({
377
+ details: 'fileId-required',
378
+ reason: 'unprocessable-entity',
379
+ status: 'error',
380
+ });
381
+ }
382
+
383
+ const filesService = new FilesService(getAccountDb());
384
+ if (!verifyFileExists(fileId, filesService, res, 'file-not-found')) {
385
+ return;
386
+ }
387
+
388
+ filesService.update(fileId, new FileUpdate({ deleted: true }));
389
+
390
+ res.send(OK_RESPONSE);
391
+ });