@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,23 @@
1
+ import fetch from 'node-fetch';
2
+
3
+ import { config } from '../load-config.js';
4
+
5
+ const protocol =
6
+ config.get('https.key') && config.get('https.cert') ? 'https' : 'http';
7
+ const hostname =
8
+ config.get('hostname') === '::' ? 'localhost' : config.get('hostname');
9
+
10
+ fetch(`${protocol}://${hostname}:${config.get('port')}/health`)
11
+ .then(res => res.json())
12
+ .then(res => {
13
+ if (res.status !== 'UP') {
14
+ throw new Error(
15
+ 'Health check failed: Server responded to health check with status ' +
16
+ res.status,
17
+ );
18
+ }
19
+ })
20
+ .catch(err => {
21
+ console.log('Health check failed:', err);
22
+ process.exit(1);
23
+ });
@@ -0,0 +1,51 @@
1
+ import { bootstrap, needsBootstrap } from '../account-db.js';
2
+ import { changePassword } from '../accounts/password.js';
3
+ import { promptPassword } from '../util/prompt.js';
4
+
5
+ if (needsBootstrap()) {
6
+ console.log(
7
+ 'It looks like you don’t have a password set yet. Let’s set one up now!',
8
+ );
9
+
10
+ try {
11
+ const password = await promptPassword();
12
+ const { error } = await bootstrap({ password });
13
+ if (error) {
14
+ console.log('Error setting password:', error);
15
+ console.log(
16
+ 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
17
+ );
18
+ process.exit(1);
19
+ }
20
+ console.log('Password set!');
21
+ } catch (err) {
22
+ console.log('Unexpected error:', err);
23
+ console.log(
24
+ 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
25
+ );
26
+ process.exit(1);
27
+ }
28
+ } else {
29
+ console.log('It looks like you already have a password set. Let’s reset it!');
30
+ try {
31
+ const password = await promptPassword();
32
+ const { error } = await changePassword(password);
33
+ if (error) {
34
+ console.log('Error changing password:', error);
35
+ console.log(
36
+ 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
37
+ );
38
+ process.exit(1);
39
+ }
40
+ console.log('Password changed!');
41
+ console.log(
42
+ 'Note: you will need to log in with the new password on any browsers or devices that are currently logged in.',
43
+ );
44
+ } catch (err) {
45
+ console.log('Unexpected error:', err);
46
+ console.log(
47
+ 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
48
+ );
49
+ process.exit(1);
50
+ }
51
+ }
@@ -0,0 +1,83 @@
1
+ import request from 'supertest';
2
+
3
+ import { handlers as app } from './app-secrets.js';
4
+ import { secretsService } from './services/secrets-service.js';
5
+ describe('secretsService', () => {
6
+ const testSecretName = 'testSecret';
7
+ const testSecretValue = 'testValue';
8
+
9
+ it('should set a secret', () => {
10
+ const result = secretsService.set(testSecretName, testSecretValue);
11
+ expect(result).toBeDefined();
12
+ expect(result.changes).toBe(1);
13
+ });
14
+
15
+ it('should get a secret', () => {
16
+ const result = secretsService.get(testSecretName);
17
+ expect(result).toBeDefined();
18
+ expect(result).toBe(testSecretValue);
19
+ });
20
+
21
+ it('should check if a secret exists', () => {
22
+ const exists = secretsService.exists(testSecretName);
23
+ expect(exists).toBe(true);
24
+
25
+ const nonExistent = secretsService.exists('nonExistentSecret');
26
+ expect(nonExistent).toBe(false);
27
+ });
28
+
29
+ it('should update a secret', () => {
30
+ const newValue = 'newValue';
31
+ const setResult = secretsService.set(testSecretName, newValue);
32
+ expect(setResult).toBeDefined();
33
+ expect(setResult.changes).toBe(1);
34
+
35
+ const getResult = secretsService.get(testSecretName);
36
+ expect(getResult).toBeDefined();
37
+ expect(getResult).toBe(newValue);
38
+ });
39
+
40
+ describe('secrets api', () => {
41
+ it('returns 401 if the user is not authenticated', async () => {
42
+ secretsService.set(testSecretName, testSecretValue);
43
+ const res = await request(app).get(`/${testSecretName}`);
44
+
45
+ expect(res.statusCode).toEqual(401);
46
+ expect(res.body).toEqual({
47
+ details: 'token-not-found',
48
+ reason: 'unauthorized',
49
+ status: 'error',
50
+ });
51
+ });
52
+
53
+ it('returns 404 if secret does not exist', async () => {
54
+ const res = await request(app)
55
+ .get(`/thiskeydoesnotexist`)
56
+ .set('x-actual-token', 'valid-token');
57
+
58
+ expect(res.statusCode).toEqual(404);
59
+ });
60
+
61
+ it('returns 204 if secret exists', async () => {
62
+ secretsService.set(testSecretName, testSecretValue);
63
+ const res = await request(app)
64
+ .get(`/${testSecretName}`)
65
+ .set('x-actual-token', 'valid-token');
66
+
67
+ expect(res.statusCode).toEqual(204);
68
+ });
69
+
70
+ it('returns 200 if secret was set', async () => {
71
+ secretsService.set(testSecretName, testSecretValue);
72
+ const res = await request(app)
73
+ .post(`/`)
74
+ .set('x-actual-token', 'valid-token')
75
+ .send({ name: testSecretName, value: testSecretValue });
76
+
77
+ expect(res.statusCode).toEqual(200);
78
+ expect(res.body).toEqual({
79
+ status: 'ok',
80
+ });
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,94 @@
1
+ import createDebug from 'debug';
2
+
3
+ import { getAccountDb } from '../account-db.js';
4
+
5
+ /**
6
+ * An enum of valid secret names.
7
+ * @readonly
8
+ * @enum {string}
9
+ */
10
+ export const SecretName = {
11
+ gocardless_secretId: 'gocardless_secretId',
12
+ gocardless_secretKey: 'gocardless_secretKey',
13
+ simplefin_token: 'simplefin_token',
14
+ simplefin_accessKey: 'simplefin_accessKey',
15
+ pluggyai_clientId: 'pluggyai_clientId',
16
+ pluggyai_clientSecret: 'pluggyai_clientSecret',
17
+ pluggyai_itemIds: 'pluggyai_itemIds',
18
+ };
19
+
20
+ class SecretsDb {
21
+ constructor() {
22
+ this.debug = createDebug('actual:secrets-db');
23
+ this.db = null;
24
+ }
25
+
26
+ open() {
27
+ return getAccountDb();
28
+ }
29
+
30
+ set(name, value) {
31
+ if (!this.db) {
32
+ this.db = this.open();
33
+ }
34
+
35
+ this.debug(`setting secret '${name}' to '${value}'`);
36
+ const result = this.db.mutate(
37
+ `INSERT OR REPLACE INTO secrets (name, value) VALUES (?,?)`,
38
+ [name, value],
39
+ );
40
+ return result;
41
+ }
42
+
43
+ get(name) {
44
+ if (!this.db) {
45
+ this.db = this.open();
46
+ }
47
+
48
+ this.debug(`getting secret '${name}'`);
49
+ const result = this.db.first(`SELECT value FROM secrets WHERE name =?`, [
50
+ name,
51
+ ]);
52
+ return result;
53
+ }
54
+ }
55
+
56
+ const secretsDb = new SecretsDb();
57
+ const _cachedSecrets = new Map();
58
+ /**
59
+ * A service for managing secrets stored in `secretsDb`.
60
+ */
61
+ export const secretsService = {
62
+ /**
63
+ * Retrieves the value of a secret by name.
64
+ * @param {SecretName} name - The name of the secret to retrieve.
65
+ * @returns {string|null} The value of the secret, or null if the secret does not exist.
66
+ */
67
+ get: name => {
68
+ return _cachedSecrets.get(name) ?? secretsDb.get(name)?.value ?? null;
69
+ },
70
+
71
+ /**
72
+ * Sets the value of a secret by name.
73
+ * @param {SecretName} name - The name of the secret to set.
74
+ * @param {string} value - The value to set for the secret.
75
+ * @returns {Object}
76
+ */
77
+ set: (name, value) => {
78
+ const result = secretsDb.set(name, value);
79
+
80
+ if (result.changes === 1) {
81
+ _cachedSecrets.set(name, value);
82
+ }
83
+ return result;
84
+ },
85
+
86
+ /**
87
+ * Determines whether a secret with the given name exists.
88
+ * @param {SecretName} name - The name of the secret to check for existence.
89
+ * @returns {boolean} True if a secret with the given name exists, false otherwise.
90
+ */
91
+ exists: name => {
92
+ return Boolean(secretsService.get(name));
93
+ },
94
+ };
@@ -0,0 +1,272 @@
1
+ import { getAccountDb } from '../account-db.js';
2
+
3
+ export function getUserByUsername(userName) {
4
+ if (!userName || typeof userName !== 'string') {
5
+ return null;
6
+ }
7
+ const { id } =
8
+ getAccountDb().first('SELECT id FROM users WHERE user_name = ?', [
9
+ userName,
10
+ ]) || {};
11
+ return id || null;
12
+ }
13
+
14
+ export function getUserById(userId) {
15
+ if (!userId) {
16
+ return null;
17
+ }
18
+ const { id } =
19
+ getAccountDb().first('SELECT * FROM users WHERE id = ?', [userId]) || {};
20
+ return id || null;
21
+ }
22
+
23
+ export function getFileById(fileId) {
24
+ if (!fileId) {
25
+ return null;
26
+ }
27
+ const { id } =
28
+ getAccountDb().first('SELECT * FROM files WHERE files.id = ?', [fileId]) ||
29
+ {};
30
+ return id || null;
31
+ }
32
+
33
+ export function validateRole(roleId) {
34
+ const possibleRoles = ['BASIC', 'ADMIN'];
35
+ return possibleRoles.some(a => a === roleId);
36
+ }
37
+
38
+ export function getOwnerCount() {
39
+ const { ownerCount } = getAccountDb().first(
40
+ `SELECT count(*) as ownerCount FROM users WHERE users.user_name <> '' and users.owner = 1`,
41
+ ) || { ownerCount: 0 };
42
+ return ownerCount;
43
+ }
44
+
45
+ export function getOwnerId() {
46
+ const { id } =
47
+ getAccountDb().first(
48
+ `SELECT users.id FROM users WHERE users.user_name <> '' and users.owner = 1`,
49
+ ) || {};
50
+ return id;
51
+ }
52
+
53
+ export function getFileOwnerId(fileId) {
54
+ const { owner } =
55
+ getAccountDb().first(`SELECT files.owner FROM files WHERE files.id = ?`, [
56
+ fileId,
57
+ ]) || {};
58
+ return owner;
59
+ }
60
+
61
+ export function getAllUsers() {
62
+ return getAccountDb().all(
63
+ `SELECT users.id, user_name as userName, display_name as displayName, enabled, ifnull(owner,0) as owner, role
64
+ FROM users
65
+ WHERE users.user_name <> ''`,
66
+ );
67
+ }
68
+
69
+ export function insertUser(userId, userName, displayName, enabled, role) {
70
+ getAccountDb().mutate(
71
+ 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, 0, ?)',
72
+ [userId, userName, displayName, enabled, role],
73
+ );
74
+ }
75
+
76
+ export function updateUser(userId, userName, displayName, enabled) {
77
+ if (!userId || !userName) {
78
+ throw new Error('Invalid user parameters');
79
+ }
80
+ try {
81
+ getAccountDb().mutate(
82
+ 'UPDATE users SET user_name = ?, display_name = ?, enabled = ? WHERE id = ?',
83
+ [userName, displayName, enabled, userId],
84
+ );
85
+ } catch (error) {
86
+ throw new Error(`Failed to update user: ${error.message}`);
87
+ }
88
+ }
89
+
90
+ export function updateUserWithRole(
91
+ userId,
92
+ userName,
93
+ displayName,
94
+ enabled,
95
+ roleId,
96
+ ) {
97
+ getAccountDb().transaction(() => {
98
+ getAccountDb().mutate(
99
+ 'UPDATE users SET user_name = ?, display_name = ?, enabled = ?, role = ? WHERE id = ?',
100
+ [userName, displayName, enabled, roleId, userId],
101
+ );
102
+ });
103
+ }
104
+
105
+ export function deleteUser(userId) {
106
+ return getAccountDb().mutate('DELETE FROM users WHERE id = ? and owner = 0', [
107
+ userId,
108
+ ]).changes;
109
+ }
110
+ export function deleteUserAccess(userId) {
111
+ try {
112
+ return getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [
113
+ userId,
114
+ ]).changes;
115
+ } catch (error) {
116
+ throw new Error(`Failed to delete user access: ${error.message}`);
117
+ }
118
+ }
119
+
120
+ export function transferAllFilesFromUser(ownerId, oldUserId) {
121
+ if (!ownerId || !oldUserId) {
122
+ throw new Error('Invalid user IDs');
123
+ }
124
+ try {
125
+ getAccountDb().transaction(() => {
126
+ const ownerExists = getUserById(ownerId);
127
+ if (!ownerExists) {
128
+ throw new Error('New owner not found');
129
+ }
130
+ getAccountDb().mutate('UPDATE files set owner = ? WHERE owner = ?', [
131
+ ownerId,
132
+ oldUserId,
133
+ ]);
134
+ });
135
+ } catch (error) {
136
+ throw new Error(`Failed to transfer files: ${error.message}`);
137
+ }
138
+ }
139
+
140
+ export function updateFileOwner(ownerId, fileId) {
141
+ if (!ownerId || !fileId) {
142
+ throw new Error('Invalid parameters');
143
+ }
144
+ try {
145
+ const result = getAccountDb().mutate(
146
+ 'UPDATE files set owner = ? WHERE id = ?',
147
+ [ownerId, fileId],
148
+ );
149
+ if (result.changes === 0) {
150
+ throw new Error('File not found');
151
+ }
152
+ } catch (error) {
153
+ throw new Error(`Failed to update file owner: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ export function getUserAccess(fileId, userId, isAdmin) {
158
+ return getAccountDb().all(
159
+ `SELECT users.id as userId, user_name as userName, files.owner, display_name as displayName
160
+ FROM users
161
+ JOIN user_access ON user_access.user_id = users.id
162
+ JOIN files ON files.id = user_access.file_id
163
+ WHERE files.id = ? and (files.owner = ? OR 1 = ?)`,
164
+ [fileId, userId, isAdmin ? 1 : 0],
165
+ );
166
+ }
167
+
168
+ export function countUserAccess(fileId, userId) {
169
+ const { accessCount } =
170
+ getAccountDb().first(
171
+ `SELECT COUNT(*) as accessCount
172
+ FROM files
173
+ WHERE files.id = ? AND (files.owner = ? OR EXISTS (
174
+ SELECT 1 FROM user_access
175
+ WHERE user_access.user_id = ? AND user_access.file_id = ?)
176
+ )`,
177
+ [fileId, userId, userId, fileId],
178
+ ) || {};
179
+
180
+ return accessCount || 0;
181
+ }
182
+
183
+ export function checkFilePermission(fileId, userId) {
184
+ return (
185
+ getAccountDb().first(
186
+ `SELECT 1 as granted
187
+ FROM files
188
+ WHERE files.id = ? and (files.owner = ?)`,
189
+ [fileId, userId],
190
+ ) || { granted: 0 }
191
+ );
192
+ }
193
+
194
+ export function addUserAccess(userId, fileId) {
195
+ if (!userId || !fileId) {
196
+ throw new Error('Invalid parameters');
197
+ }
198
+ try {
199
+ const userExists = getUserById(userId);
200
+ const fileExists = getFileById(fileId);
201
+ if (!userExists || !fileExists) {
202
+ throw new Error('User or file not found');
203
+ }
204
+ getAccountDb().mutate(
205
+ 'INSERT INTO user_access (user_id, file_id) VALUES (?, ?)',
206
+ [userId, fileId],
207
+ );
208
+ } catch (error) {
209
+ if (error.message.includes('UNIQUE constraint')) {
210
+ throw new Error('Access already exists');
211
+ }
212
+ throw new Error(`Failed to add user access: ${error.message}`);
213
+ }
214
+ }
215
+
216
+ export function deleteUserAccessByFileId(userIds, fileId) {
217
+ if (!Array.isArray(userIds) || userIds.length === 0) {
218
+ throw new Error('The provided userIds must be a non-empty array.');
219
+ }
220
+
221
+ const CHUNK_SIZE = 999;
222
+ let totalChanges = 0;
223
+
224
+ try {
225
+ getAccountDb().transaction(() => {
226
+ for (let i = 0; i < userIds.length; i += CHUNK_SIZE) {
227
+ const chunk = userIds.slice(i, i + CHUNK_SIZE);
228
+ const placeholders = chunk.map(() => '?').join(',');
229
+
230
+ const sql = `DELETE FROM user_access WHERE user_id IN (${placeholders}) AND file_id = ?`;
231
+
232
+ const result = getAccountDb().mutate(sql, [...chunk, fileId]);
233
+ totalChanges += result.changes;
234
+ }
235
+ });
236
+ } catch (error) {
237
+ throw new Error(`Failed to delete user access: ${error.message}`);
238
+ }
239
+
240
+ return totalChanges;
241
+ }
242
+
243
+ export function getAllUserAccess(fileId) {
244
+ //This can't be used here until we can create user invite links:
245
+ //const isLoginMode = config.get('userCreationMode') === 'login';
246
+ const isLoginMode = false;
247
+ const joinType = isLoginMode ? 'JOIN' : 'LEFT JOIN';
248
+
249
+ return getAccountDb().all(
250
+ `
251
+ SELECT
252
+ users.id as userId,
253
+ user_name as userName,
254
+ display_name as displayName,
255
+ CASE WHEN user_access.file_id IS NULL THEN 0 ELSE 1 END as haveAccess,
256
+ CASE WHEN files.id IS NULL THEN 0 ELSE 1 END as owner
257
+ FROM users
258
+ ${joinType} user_access ON user_access.file_id = ? AND user_access.user_id = users.id
259
+ ${joinType} files ON files.id = ? AND files.owner = users.id
260
+ WHERE users.enabled = 1
261
+ AND users.user_name <> ''
262
+ `,
263
+ [fileId, fileId],
264
+ );
265
+ }
266
+
267
+ export function getOpenIDConfig() {
268
+ return (
269
+ getAccountDb().first(`SELECT * FROM auth WHERE method = ?`, ['openid']) ||
270
+ null
271
+ );
272
+ }
@@ -0,0 +1,9 @@
1
+
2
+ CREATE TABLE messages_binary
3
+ (timestamp TEXT PRIMARY KEY,
4
+ is_encrypted BOOLEAN,
5
+ content bytea);
6
+
7
+ CREATE TABLE messages_merkles
8
+ (id INTEGER PRIMARY KEY,
9
+ merkle TEXT);
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
5
+
6
+ import { openDatabase } from './db.js';
7
+ import { sqlDir } from './load-config.js';
8
+ import { getPathForGroupFile } from './util/paths.js';
9
+
10
+ function getGroupDb(groupId) {
11
+ const path = getPathForGroupFile(groupId);
12
+ const needsInit = !existsSync(path);
13
+
14
+ const db = openDatabase(path);
15
+
16
+ if (needsInit) {
17
+ const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
18
+ db.exec(sql);
19
+ }
20
+
21
+ return db;
22
+ }
23
+
24
+ function addMessages(db, messages) {
25
+ let returnValue;
26
+ db.transaction(() => {
27
+ let trie = getMerkle(db);
28
+
29
+ if (messages.length > 0) {
30
+ for (const msg of messages) {
31
+ const info = db.mutate(
32
+ `INSERT OR IGNORE INTO messages_binary (timestamp, is_encrypted, content)
33
+ VALUES (?, ?, ?)`,
34
+ [
35
+ msg.getTimestamp(),
36
+ msg.getIsencrypted() ? 1 : 0,
37
+ Buffer.from(msg.getContent()),
38
+ ],
39
+ );
40
+
41
+ if (info.changes > 0) {
42
+ trie = merkle.insert(trie, Timestamp.parse(msg.getTimestamp()));
43
+ }
44
+ }
45
+ }
46
+
47
+ trie = merkle.prune(trie);
48
+
49
+ db.mutate(
50
+ 'INSERT INTO messages_merkles (id, merkle) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET merkle = ?',
51
+ [JSON.stringify(trie), JSON.stringify(trie)],
52
+ );
53
+
54
+ returnValue = trie;
55
+ });
56
+
57
+ return returnValue;
58
+ }
59
+
60
+ function getMerkle(db) {
61
+ const rows = db.all('SELECT * FROM messages_merkles');
62
+
63
+ if (rows.length > 0) {
64
+ return JSON.parse(rows[0].merkle);
65
+ } else {
66
+ // No merkle trie exists yet (first sync of the app), so create a
67
+ // default one.
68
+ return {};
69
+ }
70
+ }
71
+
72
+ export function sync(messages, since, groupId) {
73
+ const db = getGroupDb(groupId);
74
+ const newMessages = db.all(
75
+ `SELECT * FROM messages_binary
76
+ WHERE timestamp > ?
77
+ ORDER BY timestamp`,
78
+ [since],
79
+ );
80
+
81
+ const trie = addMessages(db, messages);
82
+
83
+ db.close();
84
+
85
+ return {
86
+ trie,
87
+ newMessages: newMessages.map(msg => {
88
+ const envelopePb = new SyncProtoBuf.MessageEnvelope();
89
+ envelopePb.setTimestamp(msg.timestamp);
90
+ envelopePb.setIsencrypted(msg.is_encrypted);
91
+ envelopePb.setContent(msg.content);
92
+ return envelopePb;
93
+ }),
94
+ };
95
+ }
@@ -0,0 +1,5 @@
1
+ import crypto from 'crypto';
2
+
3
+ export async function sha256String(str) {
4
+ return crypto.createHash('sha256').update(str).digest('base64');
5
+ }
@@ -0,0 +1,62 @@
1
+ import * as expressWinston from 'express-winston';
2
+ import * as winston from 'winston';
3
+
4
+ import { validateSession } from './validate-user.js';
5
+
6
+ /**
7
+ * @param {Error} err
8
+ * @param {import('express').Request} req
9
+ * @param {import('express').Response} res
10
+ * @param {import('express').NextFunction} next
11
+ */
12
+ async function errorMiddleware(err, req, res, next) {
13
+ if (res.headersSent) {
14
+ // If you call next() with an error after you have started writing the response
15
+ // (for example, if you encounter an error while streaming the response
16
+ // to the client), the Express default error handler closes
17
+ // the connection and fails the request.
18
+
19
+ // So when you add a custom error handler, you must delegate
20
+ // to the default Express error handler, when the headers
21
+ // have already been sent to the client
22
+ // Source: https://expressjs.com/en/guide/error-handling.html
23
+ return next(err);
24
+ }
25
+
26
+ console.log(`Error on endpoint %s`, {
27
+ requestUrl: req.url,
28
+ stacktrace: err.stack,
29
+ });
30
+ res.status(500).send({ status: 'error', reason: 'internal-error' });
31
+ }
32
+
33
+ /**
34
+ * @param {import('express').Request} req
35
+ * @param {import('express').Response} res
36
+ * @param {import('express').NextFunction} next
37
+ */
38
+ const validateSessionMiddleware = async (req, res, next) => {
39
+ const session = await validateSession(req, res);
40
+ if (!session) {
41
+ return;
42
+ }
43
+
44
+ res.locals = session;
45
+ next();
46
+ };
47
+
48
+ const requestLoggerMiddleware = expressWinston.logger({
49
+ transports: [new winston.transports.Console()],
50
+ format: winston.format.combine(
51
+ winston.format.colorize(),
52
+ winston.format.timestamp(),
53
+ winston.format.printf(args => {
54
+ const { timestamp, level, meta } = args;
55
+ const { res, req } = meta;
56
+
57
+ return `${timestamp} ${level}: ${req.method} ${res.statusCode} ${req.url}`;
58
+ }),
59
+ ),
60
+ });
61
+
62
+ export { validateSessionMiddleware, errorMiddleware, requestLoggerMiddleware };