1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

...

105 Commits

Author SHA1 Message Date
Laurent Cozic
f5bfdd8937 Knex max call size bug 2021-04-29 10:52:49 +02:00
Laurent Cozic
b8d5a8b025 script to test sharing 2021-04-28 19:06:14 +02:00
Laurent Cozic
425a17e777 fixed sharing 2021-04-28 18:43:17 +02:00
Laurent Cozic
8d5c538e18 Fixed sharing 2021-04-28 17:14:08 +02:00
Laurent Cozic
1c2d4306f9 ts 2021-04-28 12:00:39 +02:00
Laurent Cozic
99270bc1ea share 2021-04-28 11:56:00 +02:00
Laurent Cozic
6130fcc5a8 share service test 2021-04-26 16:35:34 +02:00
Laurent Cozic
614359c267 share service mainteannce 2021-04-26 11:54:07 +02:00
Laurent Cozic
f438fcb50d linter 2021-04-25 13:44:49 +02:00
Laurent Cozic
bff42b8a82 full sharing workflow 2021-04-25 12:49:32 +02:00
Laurent Cozic
c5a9208594 invitation status 2021-04-25 12:27:33 +02:00
Laurent Cozic
a43be838b5 added debug tols 2021-04-25 12:05:00 +02:00
Laurent Cozic
9ae31583af Merge branch 'dev' into server_app_share_3 2021-04-25 11:04:52 +02:00
Laurent Cozic
0fa141881b Merge branch 'dev' into server_app_share_3 2021-04-25 10:45:17 +02:00
Laurent Cozic
6152f19712 ui 2021-04-23 19:58:57 +02:00
Laurent Cozic
2ade4c7951 handle share_users 2021-04-23 18:28:22 +02:00
Laurent Cozic
85c887d004 ui 2021-04-23 16:40:07 +02:00
Laurent Cozic
47d49935bf Retrieve share info 2021-04-23 15:32:38 +02:00
Laurent Cozic
14d1d8ca72 fixed sharing 2021-04-23 09:58:09 +02:00
Laurent Cozic
e903b17a3f Share folder GUI 2021-04-22 12:38:47 +02:00
Laurent Cozic
10138260d9 fixed tests on CI 2021-04-21 23:37:33 +02:00
Laurent Cozic
6ddef009aa remove changes.parent_id 2021-04-21 22:22:34 +02:00
Laurent Cozic
425d8acb2d delete resource link when deleting item 2021-04-21 22:19:01 +02:00
Laurent Cozic
11b00ccb70 add support for sharing resoruces 2021-04-21 22:16:25 +02:00
Laurent Cozic
c9288e117f clean up 2021-04-21 16:54:59 +02:00
Laurent Cozic
da2bc801a6 share resources 2021-04-20 22:20:47 +02:00
Laurent Cozic
95a9dc0de4 wat 2021-04-20 18:41:56 +02:00
Laurent Cozic
f437b8ee60 fixed issue when shared item is moved then changed twice 2021-04-20 12:21:09 +02:00
Laurent Cozic
3e82fdab04 refactor dialog component 2021-04-20 09:59:17 +02:00
Laurent Cozic
9555875b53 update license 2021-04-19 20:01:49 +02:00
Laurent Cozic
9c9d30e73f fixed sharing 2021-04-19 20:01:38 +02:00
Laurent Cozic
9bb920a98a fixed sync and sharing link 2021-04-19 18:12:07 +02:00
Laurent Cozic
8e8ad3193c add indexes 2021-04-19 17:39:10 +02:00
Laurent Cozic
5eb4193451 fix 2021-04-19 17:31:03 +02:00
Laurent Cozic
2d373f0c31 remove share.folder_id 2021-04-19 17:21:09 +02:00
Laurent Cozic
e78d3cfd3d remove share.is_auto 2021-04-19 17:15:49 +02:00
Laurent Cozic
21b4ce97dc userShare ACL 2021-04-19 17:14:15 +02:00
Laurent Cozic
2e9192d10a fix sharing logic 2021-04-19 17:02:25 +02:00
Laurent Cozic
5820261577 clean up validation 2021-04-19 15:19:07 +02:00
Laurent Cozic
d86a4f7437 clean up validation 2021-04-19 15:07:52 +02:00
Laurent Cozic
13d77e44cf Remove item.owner_id 2021-04-19 15:04:24 +02:00
Laurent Cozic
5895c8364f linter 2021-04-19 12:45:55 +02:00
Laurent Cozic
88183a4660 making userId dependency explicit 2021-04-19 12:44:47 +02:00
Laurent Cozic
c6a1aaee72 making userId dependency explicit 2021-04-19 12:34:58 +02:00
Laurent Cozic
4f8539fceb making userId dependency explicit 2021-04-19 12:33:58 +02:00
Laurent Cozic
6a05349412 Added support for ACL 2021-04-19 12:24:02 +02:00
Laurent Cozic
81e71e08be making userId dependency explicit 2021-04-19 09:39:10 +02:00
Laurent Cozic
50bdbc165a making userId dependency explicit 2021-04-19 09:29:08 +02:00
Laurent Cozic
9261d9c36b Upgrade Knex to latest version 2021-04-19 09:18:15 +02:00
Laurent Cozic
a19549573d Moved item change tracking out of base class 2021-04-19 01:46:54 +02:00
Laurent Cozic
e4435f52fc making userId dependency explicit 2021-04-19 01:18:33 +02:00
Laurent Cozic
090518bb76 making userId dependency explicit 2021-04-18 22:30:23 +02:00
Laurent Cozic
856f45507c making userId dependency explicit 2021-04-18 22:21:13 +02:00
Laurent Cozic
5dc4551d60 making userId dependency explicit 2021-04-18 22:06:13 +02:00
Laurent Cozic
a3f59691e5 making userId dependency explicit 2021-04-18 22:01:35 +02:00
Laurent Cozic
e0aa8bd7fb making userId dependency explicit 2021-04-18 21:58:56 +02:00
Laurent Cozic
9333b37147 making userId dependency explicit 2021-04-18 21:53:03 +02:00
Laurent Cozic
bcbcf583c1 remove duplicate code 2021-04-18 20:17:23 +02:00
Laurent Cozic
189aef7f91 remove permissions 2021-04-18 20:05:24 +02:00
Laurent Cozic
1286716007 fixing sync 2021-04-18 19:27:31 +02:00
Laurent Cozic
003f60aff1 clean up 2021-04-18 11:16:04 +02:00
Laurent Cozic
9373aec3cf clean up 2021-04-18 11:06:54 +02:00
Laurent Cozic
e89836ff9d finished fixing tests 2021-04-18 01:18:20 +02:00
Laurent Cozic
c7996f66ee fix more tests 2021-04-17 21:07:19 +02:00
Laurent Cozic
b4bac8ff31 fixed change tracking logic 2021-04-17 20:41:17 +02:00
Laurent Cozic
c7c8631333 Fix sharing note by link 2021-04-16 22:18:00 +02:00
Laurent Cozic
48cda86f54 fixed delta sync 2021-04-16 12:37:00 +02:00
Laurent Cozic
fe54b383ca share folder 2021-04-16 12:07:53 +02:00
Laurent Cozic
e904f9d90a Handle case where shared item is moved between folder 2021-04-15 20:29:22 +02:00
Laurent Cozic
41a3f9c052 handle user share 2021-04-15 12:28:48 +02:00
Laurent Cozic
c40b4eab17 share tests 2021-04-14 18:00:25 +02:00
Laurent Cozic
5a186146ab started refactoring change system 2021-04-13 19:16:42 +02:00
Laurent Cozic
d389886394 Refactor to support native Joplin items 2021-04-13 18:17:09 +02:00
Laurent Cozic
09930bc8a6 Merge branch 'dev' into server_app_share_3 2021-04-12 11:05:49 +02:00
Laurent Cozic
67dcd41bf7 Merge branch 'dev' into server_app_share_3 2021-04-11 11:24:25 +02:00
Laurent Cozic
f2bf1375bf test 2021-04-11 11:08:31 +02:00
Laurent Cozic
7bb00e1338 clean up 2021-04-08 18:32:35 +02:00
Laurent Cozic
8562909a4f share folder 2021-04-08 18:27:57 +02:00
Laurent Cozic
48a499f741 share new note 2021-04-05 12:10:45 +02:00
Laurent Cozic
4702976ceb User share tests 2021-04-02 11:39:57 +02:00
Laurent Cozic
2006288108 Merge branch 'dev' into server_app_share_3 2021-03-30 10:22:47 +02:00
Laurent Cozic
8446693e91 fix 2021-03-29 10:33:41 +02:00
Laurent Cozic
b3579d70e9 Merge branch 'dev' into server_app_share_3 2021-03-26 17:56:42 +01:00
Laurent Cozic
73ce9b2443 fix tests 2021-03-26 17:55:30 +01:00
Laurent Cozic
15f5b90211 test 2021-03-26 14:31:48 +01:00
Laurent Cozic
0011b570aa second attempt 2021-03-24 12:13:55 +01:00
Laurent Cozic
aeb3c4a98d trying 2021-02-05 16:17:49 +00:00
Laurent Cozic
58a464d040 allow custom app routes 2021-02-05 12:12:53 +00:00
Laurent Cozic
8e13ccb665 fixed updated time handling 2021-02-05 10:34:12 +00:00
Laurent Cozic
6dd14ff04b clean up 2021-02-04 22:13:00 +00:00
Laurent Cozic
2022b5bc48 add indexes 2021-02-04 22:06:50 +00:00
Laurent Cozic
7ade9b2948 third user test 2021-02-04 21:57:58 +00:00
Laurent Cozic
4157dad9f1 prevent share from being shared again 2021-02-04 21:26:07 +00:00
Laurent Cozic
a088061de9 Fxied tests 2021-02-04 21:13:00 +00:00
Laurent Cozic
439d29387f no need for delta state 2021-02-04 17:35:36 +00:00
Laurent Cozic
2f15e4db59 tests 2021-02-04 17:06:40 +00:00
Laurent Cozic
0b37e99132 clean up 2021-02-04 16:46:01 +00:00
Laurent Cozic
6d41787a29 clean up 2021-02-04 15:54:21 +00:00
Laurent Cozic
28fc0374c5 testes 2021-02-04 12:03:50 +00:00
Laurent Cozic
726ee4a574 update 2021-02-04 10:11:21 +00:00
Laurent Cozic
25e32226ef Check linked file content 2021-02-03 12:05:40 +00:00
Laurent Cozic
9efdbf9854 refactor 2021-02-02 22:35:17 +00:00
Laurent Cozic
09c95f10f4 share 2021-02-02 18:20:19 +00:00
Laurent Cozic
a6453af3e5 Merge branch 'dev' into server_app_share 2021-02-02 11:06:50 +00:00
Laurent Cozic
b8c8178b26 update 2021-02-01 17:34:17 +00:00
139 changed files with 5955 additions and 2689 deletions

View File

@@ -18,6 +18,7 @@ packages/turndown-plugin-gfm/
node_modules/
packages/lib/lib/lib.js
packages/lib/locales/index.js
packages/lib/services/database/types.ts
packages/app-cli/build
packages/app-cli/build/
packages/app-cli/locales
@@ -106,6 +107,9 @@ packages/app-cli/tests/Synchronizer.sharing.js.map
packages/app-cli/tests/Synchronizer.tags.d.ts
packages/app-cli/tests/Synchronizer.tags.js
packages/app-cli/tests/Synchronizer.tags.js.map
packages/app-cli/tests/file-api-driver.d.ts
packages/app-cli/tests/file-api-driver.js
packages/app-cli/tests/file-api-driver.js.map
packages/app-cli/tests/fsDriver.d.ts
packages/app-cli/tests/fsDriver.js
packages/app-cli/tests/fsDriver.js.map
@@ -241,6 +245,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
packages/app-desktop/gui/Dialog.d.ts
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
packages/app-desktop/gui/DropboxLoginScreen.d.ts
packages/app-desktop/gui/DropboxLoginScreen.js
packages/app-desktop/gui/DropboxLoginScreen.js.map
@@ -328,6 +341,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
@@ -577,6 +593,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/SearchBar/SearchBar.js.map
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
packages/app-desktop/gui/ShareNoteDialog.d.ts
packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/ShareNoteDialog.js.map
@@ -640,9 +659,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
packages/app-desktop/gui/menuCommandNames.d.ts
packages/app-desktop/gui/menuCommandNames.js
packages/app-desktop/gui/menuCommandNames.js.map
packages/app-desktop/gui/style/StyledFormLabel.d.ts
packages/app-desktop/gui/style/StyledFormLabel.js
packages/app-desktop/gui/style/StyledFormLabel.js.map
packages/app-desktop/gui/style/StyledInput.d.ts
packages/app-desktop/gui/style/StyledInput.js
packages/app-desktop/gui/style/StyledInput.js.map
packages/app-desktop/gui/style/StyledMessage.d.ts
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledMessage.js.map
packages/app-desktop/gui/style/StyledTextInput.d.ts
packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/style/StyledTextInput.js.map
@@ -841,6 +866,9 @@ packages/lib/InMemoryCache.js.map
packages/lib/JoplinDatabase.d.ts
packages/lib/JoplinDatabase.js
packages/lib/JoplinDatabase.js.map
packages/lib/JoplinError.d.ts
packages/lib/JoplinError.js
packages/lib/JoplinError.js.map
packages/lib/JoplinServerApi.d.ts
packages/lib/JoplinServerApi.js
packages/lib/JoplinServerApi.js.map
@@ -871,6 +899,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts
packages/lib/database.js
packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js
packages/lib/dummy.test.js.map
@@ -1327,6 +1358,12 @@ packages/lib/services/searchengine/filterParser.js.map
packages/lib/services/searchengine/queryBuilder.d.ts
packages/lib/services/searchengine/queryBuilder.js
packages/lib/services/searchengine/queryBuilder.js.map
packages/lib/services/share/ShareService.d.ts
packages/lib/services/share/ShareService.js
packages/lib/services/share/ShareService.js.map
packages/lib/services/share/reducer.d.ts
packages/lib/services/share/reducer.js
packages/lib/services/share/reducer.js.map
packages/lib/services/spellChecker/SpellCheckerService.d.ts
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerService.js.map

36
.gitignore vendored
View File

@@ -93,6 +93,9 @@ packages/app-cli/tests/Synchronizer.sharing.js.map
packages/app-cli/tests/Synchronizer.tags.d.ts
packages/app-cli/tests/Synchronizer.tags.js
packages/app-cli/tests/Synchronizer.tags.js.map
packages/app-cli/tests/file-api-driver.d.ts
packages/app-cli/tests/file-api-driver.js
packages/app-cli/tests/file-api-driver.js.map
packages/app-cli/tests/fsDriver.d.ts
packages/app-cli/tests/fsDriver.js
packages/app-cli/tests/fsDriver.js.map
@@ -228,6 +231,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
packages/app-desktop/gui/Dialog.d.ts
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
packages/app-desktop/gui/DropboxLoginScreen.d.ts
packages/app-desktop/gui/DropboxLoginScreen.js
packages/app-desktop/gui/DropboxLoginScreen.js.map
@@ -315,6 +327,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
@@ -564,6 +579,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/SearchBar/SearchBar.js.map
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
packages/app-desktop/gui/ShareNoteDialog.d.ts
packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/ShareNoteDialog.js.map
@@ -627,9 +645,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
packages/app-desktop/gui/menuCommandNames.d.ts
packages/app-desktop/gui/menuCommandNames.js
packages/app-desktop/gui/menuCommandNames.js.map
packages/app-desktop/gui/style/StyledFormLabel.d.ts
packages/app-desktop/gui/style/StyledFormLabel.js
packages/app-desktop/gui/style/StyledFormLabel.js.map
packages/app-desktop/gui/style/StyledInput.d.ts
packages/app-desktop/gui/style/StyledInput.js
packages/app-desktop/gui/style/StyledInput.js.map
packages/app-desktop/gui/style/StyledMessage.d.ts
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledMessage.js.map
packages/app-desktop/gui/style/StyledTextInput.d.ts
packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/style/StyledTextInput.js.map
@@ -828,6 +852,9 @@ packages/lib/InMemoryCache.js.map
packages/lib/JoplinDatabase.d.ts
packages/lib/JoplinDatabase.js
packages/lib/JoplinDatabase.js.map
packages/lib/JoplinError.d.ts
packages/lib/JoplinError.js
packages/lib/JoplinError.js.map
packages/lib/JoplinServerApi.d.ts
packages/lib/JoplinServerApi.js
packages/lib/JoplinServerApi.js.map
@@ -858,6 +885,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts
packages/lib/database.js
packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js
packages/lib/dummy.test.js.map
@@ -1314,6 +1344,12 @@ packages/lib/services/searchengine/filterParser.js.map
packages/lib/services/searchengine/queryBuilder.d.ts
packages/lib/services/searchengine/queryBuilder.js
packages/lib/services/searchengine/queryBuilder.js.map
packages/lib/services/share/ShareService.d.ts
packages/lib/services/share/ShareService.js
packages/lib/services/share/ShareService.js.map
packages/lib/services/share/reducer.d.ts
packages/lib/services/share/reducer.js
packages/lib/services/share/reducer.js.map
packages/lib/services/spellChecker/SpellCheckerService.d.ts
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerService.js.map

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 0 B

12
LICENSE
View File

@@ -1,3 +1,15 @@
All code in this repository is licensed under the MIT License **unless a
directory contains a LICENSE file**, in which case that LICENSE file applies to
the code in that sub-directory.
For example, packages/fork-sax contains a ISC LICENSE file, thus all files
under the packages/fork-sax directory are licensed under ISC.
For example, packages/app-cli does NOT contain a LICENSE file, thus all files
under that directory are licensed under the default license, which is MIT.
* * *
MIT License
Copyright (c) 2016-2020 Laurent Cozic

View File

@@ -35,7 +35,6 @@ module.exports = {
'<rootDir>/build/',
'<rootDir>/tests/test-utils.js',
'<rootDir>/tests/test-utils-synchronizer.js',
'<rootDir>/tests/file_api_driver.js',
'<rootDir>/tests/tmp/',
'<rootDir>/tests/test data/',
],

View File

@@ -1,14 +1,14 @@
import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient } from './test-utils';
import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient, joplinServerApi } from './test-utils';
import Note from '@joplin/lib/models/Note';
import BaseItem from '@joplin/lib/models/BaseItem';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import Folder from '@joplin/lib/models/Folder';
describe('Synchronizer.sharing', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
done();
});
@@ -36,4 +36,27 @@ describe('Synchronizer.sharing', function() {
expect(sharedResourceIds[0]).toBe(resourceId1);
}));
it('should share items', (async () => {
await setupDatabaseAndSynchronizer(1, { userEmail: 'user1@example.com' });
await switchClient(1);
const api = joplinServerApi();
await api.exec('POST', 'api/debug', null, { action: 'createTestUsers' });
await api.clearSession();
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
await synchronizerStart();
await setupDatabaseAndSynchronizer(2, { userEmail: 'user2@example.com' });
await switchClient(2);
await synchronizerStart();
await switchClient(1);
console.info(await Note.all());
}));
});

View File

@@ -0,0 +1,69 @@
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, fileApi } from './test-utils';
describe('file-api-driver', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await fileApi().clearRoot();
done();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should create a file', (async () => {
await fileApi().put('test.txt', 'testing');
const content = await fileApi().get('test.txt');
expect(content).toBe('testing');
}));
it('should get a file info', (async () => {
await fileApi().put('test1.txt', 'testing');
await fileApi().mkdir('sub');
await fileApi().put('sub/test2.txt', 'testing');
// Note: Although the stat object includes an "isDir" property, this is
// not actually used by the synchronizer so not required by any sync
// target.
{
const stat = await fileApi().stat('test1.txt');
expect(stat.path).toBe('test1.txt');
expect(!!stat.updated_time).toBe(true);
expect(stat.isDir).toBe(false);
}
{
const stat = await fileApi().stat('sub/test2.txt');
expect(stat.path).toBe('sub/test2.txt');
expect(!!stat.updated_time).toBe(true);
expect(stat.isDir).toBe(false);
}
}));
it('should create a file in a subdirectory', (async () => {
await fileApi().mkdir('subdir');
await fileApi().put('subdir/test.txt', 'testing');
const content = await fileApi().get('subdir/test.txt');
expect(content).toBe('testing');
}));
it('should list files', (async () => {
await fileApi().mkdir('subdir');
await fileApi().put('subdir/test1.txt', 'testing1');
await fileApi().put('subdir/test2.txt', 'testing2');
const files = await fileApi().list('subdir');
expect(files.items.length).toBe(2);
expect(files.items.map((f: any) => f.path).sort()).toEqual(['test1.txt', 'test2.txt'].sort());
}));
it('should delete a file', (async () => {
await fileApi().put('test1.txt', 'testing1');
await fileApi().delete('test1.txt');
const files = await fileApi().list('');
expect(files.items.length).toBe(0);
}));
});

View File

@@ -1,135 +0,0 @@
/* eslint-disable no-unused-vars */
const uuid = require('@joplin/lib/uuid').default;
const time = require('@joplin/lib/time').default;
const { sleep, fileApi, fileContentEqual, checkThrowAsync } = require('./test-utils.js');
const shim = require('@joplin/lib/shim').default;
const fs = require('fs-extra');
const Setting = require('@joplin/lib/models/Setting').default;
const api = null;
// Adding empty test for Jest
it('will pass', () => {
expect(true).toBe(true);
});
// NOTE: These tests work with S3 and memory driver, but not
// with other targets like file system or Nextcloud.
// All this is tested in an indirect way in tests/synchronizer
// anyway.
// We keep the file here as it could be useful as a spec for
// what calls a sync target should support, but it would
// need to be fixed first.
// To test out an FileApi implementation:
// * add a SyncTarget for your driver in `test-utils.js`
// * set `syncTargetId_` to your New SyncTarget:
// `const syncTargetId_ = SyncTargetRegistry.nameToId('memory');`
// describe('fileApi', function() {
// beforeEach(async (done) => {
// api = new fileApi();
// api.clearRoot();
// done();
// });
// describe('list', function() {
// it('should return items with relative path', (async () => {
// await api.mkdir('.subfolder');
// await api.put('1', 'something on root 1');
// await api.put('.subfolder/1', 'something subfolder 1');
// await api.put('.subfolder/2', 'something subfolder 2');
// await api.put('.subfolder/3', 'something subfolder 3');
// sleep(0.8);
// const response = await api.list('.subfolder');
// const items = response.items;
// expect(items.length).toBe(3);
// expect(items[0].path).toBe('1');
// expect(items[0].updated_time).toMatch(/^\d+$/); // make sure it's using epoch timestamp
// }));
// it('should default to only files on root directory', (async () => {
// await api.mkdir('.subfolder');
// await api.put('.subfolder/1', 'something subfolder 1');
// await api.put('file1', 'something 1');
// await api.put('file2', 'something 2');
// sleep(0.6);
// const response = await api.list();
// expect(response.items.length).toBe(2);
// }));
// }); // list
// describe('delete', function() {
// it('should not error if file does not exist', (async () => {
// const hasThrown = await checkThrowAsync(async () => await api.delete('nonexistant_file'));
// expect(hasThrown).toBe(false);
// }));
// it('should delete specific file given full path', (async () => {
// await api.mkdir('deleteDir');
// await api.put('deleteDir/1', 'something 1');
// await api.put('deleteDir/2', 'something 2');
// sleep(0.4);
// await api.delete('deleteDir/1');
// let response = await api.list('deleteDir');
// expect(response.items.length).toBe(1);
// response = await api.list('deleteDir/1');
// expect(response.items.length).toBe(0);
// }));
// }); // delete
// describe('get', function() {
// it('should return null if object does not exist', (async () => {
// const response = await api.get('nonexistant_file');
// expect(response).toBe(null);
// }));
// it('should return UTF-8 encoded string by default', (async () => {
// await api.put('testnote.md', 'something 2');
// const response = await api.get('testnote.md');
// expect(response).toBe('something 2');
// }));
// it('should return a Response object and writes file to options.path, if options.target is "file"', (async () => {
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
// await api.put('testnote.md', 'something 2');
// sleep(0.2);
// const response = await api.get('testnote.md', { target: 'file', path: localFilePath });
// expect(typeof response).toBe('object');
// // expect(response.path).toBe(localFilePath);
// expect(fs.existsSync(localFilePath)).toBe(true);
// expect(fs.readFileSync(localFilePath, 'utf8')).toBe('something 2');
// }));
// }); // get
// describe('put', function() {
// it('should create file to remote path and content', (async () => {
// await api.put('putTest.md', 'I am your content');
// sleep(0.2);
// const response = await api.get('putTest.md');
// expect(response).toBe('I am your content');
// }));
// it('should upload file in options.path to remote path, if options.source is "file"', (async () => {
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
// fs.writeFileSync(localFilePath, 'I am the local file.');
// await api.put('testfile', 'ignore me', { source: 'file', path: localFilePath });
// sleep(0.2);
// const response = await api.get('testfile');
// expect(response).toBe('I am the local file.');
// }));
// }); // put
// });

View File

@@ -1,17 +0,0 @@
import { ExportModule, ImportModule } from './types';
/**
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
*
* To implement an import or export module, you would simply define an object with various event handlers that are called
* by the application during the import/export process.
*
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
*
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/api/references/rest_api/
*/
export default class JoplinInterop {
registerExportModule(module: ExportModule): Promise<void>;
registerImportModule(module: ImportModule): Promise<void>;
}

View File

@@ -386,7 +386,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
if (!synchronizers_[id]) {
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
const syncTarget = new SyncTargetClass(db(id));
await initFileApi(suiteName_);
await initFileApi();
syncTarget.setFileApi(fileApi());
syncTarget.setLogger(logger);
synchronizers_[id] = await syncTarget.synchronizer();
@@ -481,7 +481,13 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
return masterKey;
}
async function initFileApi(suiteName: string) {
function mustRunInBand() {
if (!process.argv.includes('--runInBand')) {
throw new Error('Tests must be run sequentially for this sync target, with the --runInBand arg. eg `npm test -- --runInBand`');
}
}
async function initFileApi() {
if (fileApis_[syncTargetId_]) return;
let fileApi = null;
@@ -521,9 +527,7 @@ async function initFileApi(suiteName: string) {
// OneDrive app directory, and it's not clear how to get that
// working.
if (!process.argv.includes('--runInBand')) {
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
}
mustRunInBand();
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
Setting.setConstant('env', 'dev');
@@ -547,6 +551,8 @@ async function initFileApi(suiteName: string) {
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
mustRunInBand();
// Note that to test the API in parallel mode, you need to use Postgres
// as database, as the SQLite database is not reliable when being
// read/write from multiple processes at the same time.
@@ -555,7 +561,8 @@ async function initFileApi(suiteName: string) {
username: () => 'admin@localhost',
password: () => 'admin',
});
fileApi = new FileApi(`Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
}
fileApi.setLogger(logger);

View File

@@ -13,6 +13,7 @@ import Logger, { TargetType } from '@joplin/lib/Logger';
import Setting from '@joplin/lib/models/Setting';
import actionApi from '@joplin/lib/services/rest/actionApi.desktop';
import BaseApplication from '@joplin/lib/BaseApplication';
import DebugService from '@joplin/lib/debug/DebugService';
import { _, setLocale } from '@joplin/lib/locale';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
@@ -58,6 +59,7 @@ const commands = [
require('./gui/MainScreen/commands/openTag'),
require('./gui/MainScreen/commands/print'),
require('./gui/MainScreen/commands/renameFolder'),
require('./gui/MainScreen/commands/showShareFolderDialog'),
require('./gui/MainScreen/commands/renameTag'),
require('./gui/MainScreen/commands/search'),
require('./gui/MainScreen/commands/selectTemplate'),
@@ -100,6 +102,7 @@ const globalCommands = [
];
import editorCommandDeclarations from './gui/NoteEditor/commands/editorCommandDeclarations';
import ShareService from '@joplin/lib/services/share/ShareService';
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -764,15 +767,16 @@ class Application extends BaseApplication {
RevisionService.instance().runInBackground();
// Make it available to the console window - useful to call revisionService.collectRevisions()
(window as any).joplin = () => {
return {
if (Setting.value('env') === 'dev') {
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
};
};
}
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
@@ -782,6 +786,9 @@ class Application extends BaseApplication {
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
await ShareService.instance().initialize(this.store().dispatch);
void ShareService.instance().runInBackground();
// await populateDatabase(reg.db());
// setTimeout(() => {

View File

@@ -0,0 +1,40 @@
import styled from 'styled-components';
const DialogModalLayer = styled.div`
z-index: 9999;
display: flex;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.6);
align-items: flex-start;
justify-content: center;
overflow: hidden;
`;
const DialogRoot = styled.div`
background-color: ${props => props.theme.backgroundColor};
padding: 16px;
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
margin-top: 20px;
min-height: fit-content;
display: flex;
flex-direction: column;
width: 50%;
`;
interface Props {
renderContent: Function;
}
export default function Dialog(props: Props) {
return (
<DialogModalLayer>
<DialogRoot>
{props.renderContent()}
</DialogRoot>
</DialogModalLayer>
);
}

View File

@@ -2,7 +2,16 @@ const React = require('react');
const { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('@joplin/lib/theme');
function DialogButtonRow(props) {
interface Props {
themeId: number;
onClick?: Function;
okButtonShow?: boolean;
cancelButtonShow?: boolean;
cancelButtonLabel?: string;
okButtonRef?: any;
}
export default function DialogButtonRow(props: Props) {
const theme = themeStyle(props.themeId);
const okButton_click = () => {
@@ -13,7 +22,7 @@ function DialogButtonRow(props) {
if (props.onClick) props.onClick({ buttonName: 'cancel' });
};
const onKeyDown = (event) => {
const onKeyDown = (event: any) => {
if (event.keyCode === 13) {
okButton_click();
} else if (event.keyCode === 27) {
@@ -41,5 +50,3 @@ function DialogButtonRow(props) {
return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>;
}
module.exports = DialogButtonRow;

View File

@@ -0,0 +1,21 @@
import styled from 'styled-components';
const Root = styled.div`
font-family: ${props => props.theme.fontFamily};
font-size: ${props => props.theme.fontSize * 1.5};
line-height: 1.6em;
color: ${props => props.theme.color};
font-weight: bold;
margin-bottom: 1.2em;
`;
interface Props {
title: string;
}
export default function DialogTitle(props: Props) {
return (
<Root>{props.title}</Root>
);
}

View File

@@ -29,12 +29,15 @@ import { themeStyle } from '@joplin/lib/theme';
import validateLayout from '../ResizableLayout/utils/validateLayout';
import iterateItems from '../ResizableLayout/utils/iterateItems';
import removeItem from '../ResizableLayout/utils/removeItem';
import EncryptionService from '@joplin/lib/services/EncryptionService';
import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import ShareService from '@joplin/lib/services/share/ShareService';
const { connect } = require('react-redux');
const { PromptDialog } = require('../PromptDialog.min.js');
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
const PluginManager = require('@joplin/lib/services/PluginManager');
import EncryptionService from '@joplin/lib/services/EncryptionService';
const ipcRenderer = require('electron').ipcRenderer;
interface LayerModalState {
@@ -63,15 +66,22 @@ interface Props {
settingEditorCodeView: boolean;
pluginsLegacy: any;
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
}
interface ShareFolderDialogOptions {
folderId: string;
visible: boolean;
}
interface State {
promptOptions: any;
modalLayer: LayerModalState;
notePropertiesDialogOptions: any;
noteContentPropertiesDialogOptions: any;
shareNoteDialogOptions: any;
shareFolderDialogOptions: ShareFolderDialogOptions;
}
const StyledUserWebviewDialogContainer = styled.div`
@@ -105,6 +115,7 @@ const commands = [
require('./commands/newTodo'),
require('./commands/print'),
require('./commands/renameFolder'),
require('./commands/showShareFolderDialog'),
require('./commands/renameTag'),
require('./commands/search'),
require('./commands/selectTemplate'),
@@ -144,6 +155,10 @@ class MainScreenComponent extends React.Component<Props, State> {
notePropertiesDialogOptions: {},
noteContentPropertiesDialogOptions: {},
shareNoteDialogOptions: {},
shareFolderDialogOptions: {
visible: false,
folderId: '',
},
};
this.updateMainLayout(this.buildLayout(props.plugins));
@@ -155,6 +170,7 @@ class MainScreenComponent extends React.Component<Props, State> {
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this);
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
this.shareFolderDialog_close = this.shareFolderDialog_close.bind(this);
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
@@ -204,6 +220,10 @@ class MainScreenComponent extends React.Component<Props, State> {
return newLayout !== layout ? validateLayout(newLayout) : layout;
}
private get showShareInvitationNotification(): boolean {
return !!this.props.shareInvitations.find(i => i.status === 0);
}
private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
@@ -268,10 +288,14 @@ class MainScreenComponent extends React.Component<Props, State> {
this.setState({ noteContentPropertiesDialogOptions: {} });
}
shareNoteDialog_close() {
private shareNoteDialog_close() {
this.setState({ shareNoteDialogOptions: {} });
}
private shareFolderDialog_close() {
this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } });
}
updateMainLayout(layout: LayoutItem) {
this.props.dispatch({
type: 'MAIN_LAYOUT_SET',
@@ -321,6 +345,13 @@ class MainScreenComponent extends React.Component<Props, State> {
});
}
if (this.state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) {
this.props.dispatch({
type: this.state.shareFolderDialogOptions && this.state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE',
name: 'shareFolder',
});
}
if (this.props.mainLayout !== prevProps.mainLayout) {
const toSave = saveLayout(this.props.mainLayout);
Setting.setValue('ui.layout', toSave);
@@ -496,6 +527,11 @@ class MainScreenComponent extends React.Component<Props, State> {
bridge().restart();
};
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
await ShareService.instance().respondInvitation(shareUserId, accept);
await ShareService.instance().refreshShareInvitations();
};
let msg = null;
if (this.props.isSafeMode) {
@@ -516,15 +552,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
</span>
);
} else if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.hasDisabledEncryptionItems) {
msg = (
<span>
@@ -534,15 +561,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
</span>
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('Set the password')}
</a>
</span>
);
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
msg = (
<span>
@@ -561,6 +579,40 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
</span>
);
} else if (this.showShareInvitationNotification) {
const invitation = this.props.shareInvitations[0];
const sharer = invitation.share.user;
msg = (
<span>
{_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email)}{' '}
<a href="#" onClick={() => onInvitationRespond(invitation.id, true)}>
{_('Accept')}
</a>
{' / '}
<a href="#" onClick={() => onInvitationRespond(invitation.id,true)}>
{_('Reject')}
</a>
</span>
);
} else if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('Set the password')}
</a>
</span>
);
}
return (
@@ -572,7 +624,7 @@ class MainScreenComponent extends React.Component<Props, State> {
messageBoxVisible(props: Props = null) {
if (!props) props = this.props;
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode;
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification;
}
registerCommands() {
@@ -739,6 +791,7 @@ class MainScreenComponent extends React.Component<Props, State> {
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const shareFolderDialogOptions = this.state.shareFolderDialogOptions;
const layoutComp = this.props.mainLayout ? (
<ResizableLayout
@@ -759,6 +812,7 @@ class MainScreenComponent extends React.Component<Props, State> {
{noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>}
{notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />}
{shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />}
{shareFolderDialogOptions.visible && <ShareFolderDialog themeId={this.props.themeId} folderId={shareFolderDialogOptions.folderId} onClose={this.shareFolderDialog_close} />}
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
@@ -794,6 +848,7 @@ const mapStateToProps = (state: AppState) => {
layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout,
startupPluginsLoaded: state.startupPluginsLoaded,
shareInvitations: state.shareService.shareInvitations,
isSafeMode: state.settings.isSafeMode,
};
};

View File

@@ -0,0 +1,22 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'showShareFolderDialog',
label: () => _('Share notebook...'),
};
export const runtime = (comp: any): CommandRuntime => {
return {
execute: async (context: CommandContext, folderId: string = null) => {
folderId = folderId || context.state.selectedFolderId;
comp.setState({
shareFolderDialogOptions: {
folderId,
visible: true,
},
});
},
};
};

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from './DialogButtonRow';
const { themeStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
const Countable = require('countable');
import markupLanguageUtils from '../utils/markupLanguageUtils';

View File

@@ -2,7 +2,7 @@ const React = require('react');
const { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('@joplin/lib/theme');
const time = require('@joplin/lib/time').default;
const DialogButtonRow = require('./DialogButtonRow.min');
const DialogButtonRow = require('./DialogButtonRow').default;
const Datetime = require('react-datetime');
const Note = require('@joplin/lib/models/Note').default;
const formatcoords = require('formatcoords');

View File

@@ -0,0 +1,219 @@
import Dialog from '../Dialog';
import DialogButtonRow from '../DialogButtonRow';
import DialogTitle from '../DialogTitle';
import { _ } from '@joplin/lib/locale';
import { useEffect, useState } from 'react';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import ShareService from '@joplin/lib/services/share/ShareService';
import styled from 'styled-components';
import StyledFormLabel from '../style/StyledFormLabel';
import StyledInput from '../style/StyledInput';
import Button from '../Button/Button';
import Logger from '@joplin/lib/Logger';
import StyledMessage from '../style/StyledMessage';
import { StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
import { State } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
const logger = Logger.create('ShareFolderDialog');
const StyledFolder = styled.div`
border: 1px solid ${(props) => props.theme.dividerColor};
padding: 0.5em;
margin-bottom: 1em;
display: flex;
align-items: center;
`;
const StyledRecipientControls = styled.div`
display: flex;
flex-direction: row;
`;
const StyledRecipientInput = styled(StyledInput)`
width: 100%;
margin-right: 10px;
`;
const StyledAddRecipient = styled.div`
margin-bottom: 1em;
`;
const StyledRecipients = styled.div`
`;
const StyledRecipientList = styled.div`
border: 1px solid ${(props: any) => props.theme.dividerColor};
border-radius: 3px;
height: 300px;
overflow-x: hidden;
overflow-y: scroll;
`;
const StyledError = styled(StyledMessage)`
word-break: break-all;
margin-bottom: 1em;
`;
const StyledIcon = styled.i`
margin-right: 8px;
`;
interface Props {
themeId: number;
folderId: string;
onClose(): void;
shares: StateShare[];
shareUsers: Record<string, StateShareUser[]>;
}
interface AsyncEffectEvent {
cancelled: boolean;
}
function useAsyncEffect(effect: Function, dependencies: any[]) {
useEffect(() => {
const event = { cancelled: false };
effect(event);
return () => {
event.cancelled = true;
};
}, dependencies);
}
function ShareFolderDialog(props: Props) {
const [folder, setFolder] = useState<FolderEntity>(null);
const [recipientEmail, setRecipientEmail] = useState<string>('');
const [latestError, setLatestError] = useState<Error>(null);
const [share, setShare] = useState<StateShare>(null);
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
useAsyncEffect(async (event: AsyncEffectEvent) => {
const f = await Folder.load(props.folderId);
if (event.cancelled) return;
setFolder(f);
}, [props.folderId]);
useEffect(() => {
void ShareService.instance().refreshShares();
}, []);
useEffect(() => {
const s = props.shares.find(s => s.folder_id === props.folderId);
setShare(s);
}, [props.shares]);
useEffect(() => {
if (!share) return;
void ShareService.instance().refreshShareUsers(share.id);
}, [share]);
useEffect(() => {
if (!share) return;
const sus = props.shareUsers[share.id];
if (!sus) return;
setShareUsers(sus);
}, [share, props.shareUsers]);
useEffect(() => {
void ShareService.instance().refreshShares();
}, [props.folderId]);
async function shareRecipient_click() {
try {
setLatestError(null);
const share = await ShareService.instance().shareFolder(props.folderId);
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
} catch (error) {
logger.error(error);
setLatestError(error);
}
}
async function recipientEmail_change(event: any) {
setRecipientEmail(event.target.value);
}
function renderFolder() {
return (
<StyledFolder>
<StyledIcon className="icon-notebooks"/>{folder ? folder.title : '...'}
</StyledFolder>
);
}
function renderAddRecipient() {
return (
<StyledAddRecipient>
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
<StyledRecipientControls>
<StyledRecipientInput type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
<Button title={_('Share')} onClick={shareRecipient_click}></Button>
</StyledRecipientControls>
</StyledAddRecipient>
);
}
function renderRecipient(shareUser: StateShareUser) {
return (
<StyledMessage key={shareUser.user.email}>
{shareUser.user.email}
</StyledMessage>
);
}
function renderRecipients() {
const listItems = shareUsers.map(su => renderRecipient(su));
return (
<StyledRecipients>
<StyledFormLabel>{_('Recipients:')}</StyledFormLabel>
<StyledRecipientList>
{listItems}
</StyledRecipientList>
</StyledRecipients>
);
}
function renderError() {
if (!latestError) return null;
return (
<StyledError type="error">
{latestError.message}
</StyledError>
);
}
function buttonRow_click() {
props.onClose();
}
function renderContent() {
return (
<div>
<DialogTitle title={_('Share Notebook')}/>
{renderFolder()}
{renderAddRecipient()}
{renderError()}
{renderRecipients()}
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
</div>
);
}
return (
<Dialog renderContent={renderContent}/>
);
}
const mapStateToProps = (state: State) => {
return {
shares: state.shareService.shares,
shareUsers: state.shareService.shareUsers,
};
};
export default connect(mapStateToProps)(ShareFolderDialog as any);

View File

@@ -6,10 +6,12 @@ import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import BaseItem from '@joplin/lib/models/BaseItem';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import DialogButtonRow from './DialogButtonRow';
const { themeStyle, buildStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
import { reg } from '@joplin/lib/registry';
import Dialog from './Dialog';
import DialogTitle from './DialogTitle';
const { clipboard } = require('electron');
interface ShareNoteDialogProps {
@@ -123,7 +125,7 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
for (const note of notes) {
const fullPath = (await fileApi()).fullPath(BaseItem.systemPath(note.id));
const share = await api.shareFile(fullPath);
const share = await api.shareItem(fullPath);
newShares[note.id] = share;
const changed = await BaseItem.updateShareStatus(note, true);
@@ -206,13 +208,10 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
return <div style={theme.textStyle}>{'Sharing notes via Joplin Server is a Beta feature and the API might change later on. What it means is that if you share a note, the link might become invalid after an upgrade, and you will have to share it again.'}</div>;
}
const rootStyle = Object.assign({}, theme.dialogBox);
rootStyle.width = '50%';
return (
<div style={theme.dialogModalLayer}>
<div style={rootStyle}>
<div style={theme.dialogTitle}>{_('Share Notes')}</div>
function renderContent() {
return (
<div>
<DialogTitle title={_('Share Notes')}/>
{renderNoteList(notes)}
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
@@ -220,6 +219,10 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
{renderBetaWarningMessage()}
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
</div>
</div>
);
}
return (
<Dialog renderContent={renderContent}/>
);
}

View File

@@ -294,6 +294,8 @@ class SidebarComponent extends React.Component<Props, State> {
);
}
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
menu.append(
new MenuItem({
label: _('Export'),

View File

@@ -0,0 +1,11 @@
import styled from 'styled-components';
const StyledFormLabel = styled.div`
font-size: ${props => props.theme.fontSize * 1.083333}px;
color: ${props => props.theme.color};
font-family: ${props => props.theme.fontFamily};
font-weight: 500;
margin-bottom: ${props => props.theme.mainPadding / 2}px;
`;
export default StyledFormLabel;

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const StyledMessage = styled.div`
border-radius: 3px;
background-color: ${props => props.type === 'error' ? props.theme.warningBackgroundColor : 'transparent'};
font-size: ${props => props.theme.fontSize}px;
color: ${props => props.theme.color};
font-family: ${props => props.theme.fontFamily};
padding: ${props => props.theme.mainPadding}px;
`;
export default StyledMessage;

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Setup the sync parameters for user X and create a few folders and notes to
# allow sharing. Also calls the API to create the test users and clear the data.
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$SCRIPT_DIR/../.."
if [ "$1" == "" ]; then
echo "User number is required"
exit 1
fi
USER_NUM=$1
USER_EMAIL="user$USER_NUM@example.com"
PROFILE_DIR=~/.config/joplindev-desktop-$USER_NUM
rm -rf "$PROFILE_DIR"
cd "$ROOT_DIR/packages/app-cli"
npm start -- --profile "$PROFILE_DIR" config keychain.supported 0
npm start -- --profile "$PROFILE_DIR" config sync.target 9
npm start -- --profile "$PROFILE_DIR" config sync.9.path http://localhost:22300
npm start -- --profile "$PROFILE_DIR" config sync.9.username $USER_EMAIL
npm start -- --profile "$PROFILE_DIR" config sync.9.password 123456
if [ "$1" == "1" ]; then
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug
npm start -- --profile "$PROFILE_DIR" mkbook "shared"
npm start -- --profile "$PROFILE_DIR" mkbook "other"
npm start -- --profile "$PROFILE_DIR" use "shared"
npm start -- --profile "$PROFILE_DIR" mknote "note 1"
fi
cd "$ROOT_DIR/packages/app-desktop"
npm start -- --profile "$PROFILE_DIR"

View File

@@ -1,4 +1,4 @@
import { Platform, NativeModules } from 'react-native';
const { Platform, NativeModules } = require('react-native');
export default async function setIgnoreTlsErrors(ignore: boolean): Promise<boolean> {
if (Platform.OS === 'android') {

View File

@@ -26,7 +26,7 @@ import BaseSyncTarget from './BaseSyncTarget';
const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware');
const os = require('os');
const fs = require('fs-extra');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const EventEmitter = require('events');
const syswidecas = require('./vendor/syswide-cas');
const SyncTargetRegistry = require('./SyncTargetRegistry.js');

View File

@@ -1,6 +1,6 @@
const Logger = require('./Logger').default;
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const time = require('./time').default;
const EventDispatcher = require('./EventDispatcher');

View File

@@ -138,20 +138,20 @@ export default class JoplinDatabase extends Database {
private tableFieldNames_: Record<string, string[]> = {};
private tableDescriptions_: any;
constructor(driver: any) {
public constructor(driver: any) {
super(driver);
}
initialized() {
public initialized() {
return this.initialized_;
}
async open(options: any) {
public async open(options: any) {
await super.open(options);
return this.initialize();
}
tableFieldNames(tableName: string) {
public tableFieldNames(tableName: string) {
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
const tf = this.tableFields(tableName);
@@ -164,7 +164,7 @@ export default class JoplinDatabase extends Database {
return output.slice();
}
tableFields(tableName: string, options: any = null) {
public tableFields(tableName: string, options: any = null) {
if (options === null) options = {};
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
@@ -180,7 +180,7 @@ export default class JoplinDatabase extends Database {
return output;
}
async clearForTesting() {
public async clearForTesting() {
const tableNames = [
'notes',
'folders',
@@ -220,7 +220,7 @@ export default class JoplinDatabase extends Database {
await this.transactionExecBatch(queries);
}
createDefaultRow(tableName: string) {
public createDefaultRow(tableName: string) {
const row: any = {};
const fields = this.tableFields(tableName);
for (let i = 0; i < fields.length; i++) {
@@ -230,7 +230,7 @@ export default class JoplinDatabase extends Database {
return row;
}
fieldByName(tableName: string, fieldName: string) {
public fieldByName(tableName: string, fieldName: string) {
const fields = this.tableFields(tableName);
for (const field of fields) {
if (field.name === fieldName) return field;
@@ -238,11 +238,11 @@ export default class JoplinDatabase extends Database {
throw new Error(`No such field: ${tableName}: ${fieldName}`);
}
fieldDefaultValue(tableName: string, fieldName: string) {
public fieldDefaultValue(tableName: string, fieldName: string) {
return this.fieldByName(tableName, fieldName).default;
}
fieldDescription(tableName: string, fieldName: string) {
public fieldDescription(tableName: string, fieldName: string) {
const sp = sprintf;
if (!this.tableDescriptions_) {
@@ -278,7 +278,7 @@ export default class JoplinDatabase extends Database {
return d && d[fieldName] ? d[fieldName] : '';
}
refreshTableFields(newVersion: number) {
public refreshTableFields(newVersion: number) {
this.logger().info('Initializing tables...');
const queries: SqlQuery[] = [];
queries.push(this.wrapQuery('DELETE FROM table_fields'));
@@ -323,12 +323,12 @@ export default class JoplinDatabase extends Database {
});
}
addMigrationFile(num: number) {
public addMigrationFile(num: number) {
const timestamp = Date.now();
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
}
async upgradeDatabase(fromVersion: number) {
public async upgradeDatabase(fromVersion: number) {
// INSTRUCTIONS TO UPGRADE THE DATABASE:
//
// 1. Add the new version number to the existingDatabaseVersions array
@@ -343,7 +343,7 @@ export default class JoplinDatabase extends Database {
// must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34];
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@@ -864,6 +864,11 @@ export default class JoplinDatabase extends Database {
queries.push('CREATE VIRTUAL TABLE notes_spellfix USING spellfix1');
}
if (targetVersion == 35) {
queries.push('ALTER TABLE folders ADD COLUMN is_linked_folder INT NOT NULL DEFAULT "0"');
queries.push('ALTER TABLE folders ADD COLUMN source_folder_owner_id TEXT NOT NULL DEFAULT ""');
}
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
queries.push(updateVersionQuery);
@@ -900,7 +905,7 @@ export default class JoplinDatabase extends Database {
return latestVersion;
}
async ftsEnabled() {
public async ftsEnabled() {
try {
await this.selectOne('SELECT count(*) FROM notes_fts');
} catch (error) {
@@ -913,7 +918,7 @@ export default class JoplinDatabase extends Database {
return true;
}
async fuzzySearchEnabled() {
public async fuzzySearchEnabled() {
try {
await this.selectOne('SELECT count(*) FROM notes_spellfix');
} catch (error) {
@@ -924,11 +929,11 @@ export default class JoplinDatabase extends Database {
return true;
}
version() {
public version() {
return this.version_;
}
async initialize() {
public async initialize() {
this.logger().info('Checking for database schema update...');
let versionRow = null;

View File

@@ -1,8 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class JoplinError extends Error {
constructor(message, code = null) {
super(message);
this.code = code;
}
constructor(message, code = null, details = null) {
super(message);
this.code = null;
this.details = '';
this.code = code;
this.details = details;
}
}
module.exports = JoplinError;
exports.default = JoplinError;
//# sourceMappingURL=JoplinError.js.map

View File

@@ -0,0 +1,12 @@
export default class JoplinError extends Error {
public code: any = null;
public details: string = '';
public constructor(message: string, code: any = null, details: string = null) {
super(message);
this.code = code;
this.details = details;
}
}

View File

@@ -1,7 +1,7 @@
import shim from './shim';
import { _ } from './locale';
const { rtrimSlashes } = require('./path-utils.js');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const { stringify } = require('query-string');
interface Options {
@@ -31,7 +31,7 @@ export default class JoplinServerApi {
private options_: Options;
private session_: any;
private debugRequests_: boolean = false;
private debugRequests_: boolean = true;
public constructor(options: Options) {
this.options_ = options;
@@ -58,9 +58,9 @@ export default class JoplinServerApi {
return session ? session.id : '';
}
public async shareFile(pathOrId: string) {
public async shareItem(itemName: string) {
return this.exec('POST', 'api/shares', null, {
file_id: pathOrId,
item_name: itemName,
type: 1, // ShareType.Link
});
}
@@ -85,8 +85,11 @@ export default class JoplinServerApi {
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
}
}
if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`);
output.push(url);
if (options.body) {
const serialized = typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body;
output.push(`${'--data ' + '\''}${serialized}'`);
}
output.push(`'${url}'`);
return output.join(' ');
}
@@ -157,7 +160,8 @@ export default class JoplinServerApi {
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = (`${responseText}`).substr(0, 1024);
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
// return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText}`);
};
let responseJson_: any = null;

View File

@@ -10,7 +10,6 @@ interface FileApiOptions {
path(): string;
username(): string;
password(): string;
directory(): string;
}
export default class SyncTargetJoplinServer extends BaseSyncTarget {
@@ -28,7 +27,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
}
public static label() {
return _('Joplin Server');
return `${_('Joplin Server')} (Beta)`;
}
public async isAuthenticated() {
@@ -48,7 +47,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
const api = new JoplinServerApi(apiOptions);
const driver = new FileApiDriverJoplinServer(api);
const fileApi = new FileApi(options.directory, driver);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(this.id());
await fileApi.initialize();
return fileApi;
@@ -64,8 +63,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
fileApi.requestRepeatCount_ = 0;
const result = await fileApi.stat('');
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
await fileApi.put('testing.txt', 'testing');
const result = await fileApi.get('testing.txt');
if (result !== 'testing') throw new Error(`Could not access data on server "${options.path()}"`);
await fileApi.delete('testing.txt');
output.ok = true;
} catch (error) {
output.errorMessage = error.message;
@@ -80,7 +81,6 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
path: () => Setting.value('sync.9.path'),
username: () => Setting.value('sync.9.username'),
password: () => Setting.value('sync.9.password'),
directory: () => Setting.value('sync.9.directory'),
});
fileApi.setLogger(this.logger());

View File

@@ -19,7 +19,7 @@ import time from './time';
import ResourceService from './services/ResourceService';
import EncryptionService from './services/EncryptionService';
import NoteResource from './models/NoteResource';
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const TaskQueue = require('./TaskQueue');
const { Dirnames } = require('./services/synchronizer/utils/types');

View File

@@ -1,7 +1,7 @@
const Logger = require('./Logger').default;
const shim = require('./shim').default;
const parseXmlString = require('xml2js').parseString;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const URL = require('url-parse');
const { rtrimSlashes } = require('./path-utils');
const base64 = require('base-64');

View File

@@ -0,0 +1,47 @@
import JoplinDatabase from '../JoplinDatabase';
import Setting from '../models/Setting';
import SyncTargetJoplinServer from '../SyncTargetJoplinServer';
export default class DebugService {
private db_: JoplinDatabase;
public constructor(db: JoplinDatabase) {
this.db_ = db;
}
private get db(): JoplinDatabase {
return this.db_;
}
public async clearSyncState() {
const tableNames = [
'item_changes',
'deleted_items',
'sync_items',
'key_values',
];
const queries = [];
for (const n of tableNames) {
queries.push(`DELETE FROM ${n}`);
queries.push(`DELETE FROM sqlite_sequence WHERE name="${n}"`); // Reset autoincremented IDs
}
for (let i = 0; i < 20; i++) {
queries.push(`DELETE FROM settings WHERE key="sync.${i}.context"`);
queries.push(`DELETE FROM settings WHERE key="sync.${i}.auth"`);
}
await this.db.transactionExecBatch(queries);
}
public async setupJoplinServerUser(num: number) {
const id = SyncTargetJoplinServer.id();
Setting.setValue('sync.target', id);
Setting.setValue(`sync.${id}.path`, 'http://localhost:22300');
Setting.setValue(`sync.${id}.username`, `user${num}@example.com`);
Setting.setValue(`sync.${id}.password`, '123456');
}
}

View File

@@ -1,7 +1,7 @@
const { basicDelta } = require('./file-api');
const { basename } = require('./path-utils');
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const { Buffer } = require('buffer');
const S3_MAX_DELETES = 1000;

View File

@@ -1,6 +1,6 @@
const time = require('./time').default;
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
class FileApiDriverDropbox {
constructor(api) {

View File

@@ -1,5 +1,5 @@
import JoplinServerApi from './JoplinServerApi';
import { trimSlashes, dirname, basename } from './path-utils';
import { trimSlashes } from './path-utils';
// All input paths should be in the format: "path/to/file". This is converted to
// "root:/path/to/file:" when doing the API call.
@@ -34,38 +34,35 @@ export default class FileApiDriverJoplinServer {
return 3;
}
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) {
private metadataToStat_(md: any, path: string, isDeleted: boolean = false, rootPath: string) {
const output = {
path: path,
path: rootPath ? path.substr(rootPath.length + 1) : path,
updated_time: md.updated_time,
isDir: !!md.is_directory,
isDir: false, // !!md.is_directory,
isDeleted: isDeleted,
};
// TODO - HANDLE DELETED
// if (md['.tag'] === 'deleted') output.isDeleted = true;
return output;
}
private metadataToStats_(mds: any[]) {
private metadataToStats_(mds: any[], rootPath: string) {
const output = [];
for (let i = 0; i < mds.length; i++) {
output.push(this.metadataToStat_(mds[i], mds[i].name));
output.push(this.metadataToStat_(mds[i], mds[i].name, false, rootPath));
}
return output;
}
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
// API URL path: "api/files/root:/Apps/Joplin/file.txt:"
// API URL path: "api/items/root:/Apps/Joplin/file.txt:"
private apiFilePath_(p: string) {
return `api/files/root:/${trimSlashes(p)}:`;
return `api/items/root:/${trimSlashes(p)}:`;
}
public async stat(path: string) {
try {
const response = await this.api().exec('GET', this.apiFilePath_(path));
return this.metadataToStat_(response, path);
return this.metadataToStat_(response, path, false, '');
} catch (error) {
if (error.code === 404) return null;
throw error;
@@ -80,9 +77,13 @@ export default class FileApiDriverJoplinServer {
try {
const query = cursor ? { cursor } : {};
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
const stats = response.items.map((item: any) => {
return this.metadataToStat_(item.item, item.item.name, item.type === 3);
});
const stats = response.items
.filter((item: any) => {
return item.item_name.indexOf('locks/') !== 0 && item.item_name.indexOf('temp/') !== 0;
})
.map((item: any) => {
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
});
const output = {
items: stats,
@@ -108,15 +109,22 @@ export default class FileApiDriverJoplinServer {
...options,
};
let isUsingWildcard = false;
let searchPath = path;
if (searchPath) {
searchPath += '/*';
isUsingWildcard = true;
}
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
const results = await this.api().exec('GET', `${this.apiFilePath_(path)}/children`, query);
const results = await this.api().exec('GET', `${this.apiFilePath_(searchPath)}/children`, query);
const newContext: any = {};
if (results.cursor) newContext.cursor = results.cursor;
return {
items: this.metadataToStats_(results.items),
items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
hasMore: results.has_more,
context: newContext,
} as any;
@@ -134,28 +142,9 @@ export default class FileApiDriverJoplinServer {
}
}
private parentPath_(path: string) {
return dirname(path);
}
private basename_(path: string) {
return basename(path);
}
public async mkdir(path: string) {
const parentPath = this.parentPath_(path);
const filename = this.basename_(path);
try {
const response = await this.api().exec('POST', `${this.apiFilePath_(parentPath)}/children`, null, {
name: filename,
is_directory: 1,
});
return response;
} catch (error) {
// 409 is OK - directory already exists
if (error.code !== 409) throw error;
}
public async mkdir(_path: string) {
// This is a no-op because all items technically are at the root, but
// they can have names such as ".resources/xxxxxxxxxx'
}
public async put(path: string, content: any, options: any = null) {
@@ -174,6 +163,5 @@ export default class FileApiDriverJoplinServer {
public async clearRoot(path: string) {
await this.delete(path);
await this.mkdir(path);
}
}

View File

@@ -1,6 +1,6 @@
const { basicDelta } = require('./file-api');
const { rtrimSlashes, ltrimSlashes } = require('./path-utils');
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
class FileApiDriverWebDav {
constructor(api) {

View File

@@ -4,7 +4,7 @@ import BaseItem from './models/BaseItem';
import time from './time';
const { isHidden } = require('./path-utils');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const ArrayUtils = require('./ArrayUtils');
const { sprintf } = require('sprintf-js');
const Mutex = require('async-mutex').Mutex;

View File

@@ -111,7 +111,7 @@ export default class Note extends BaseItem {
return BaseModel.TYPE_NOTE;
}
static linkedItemIds(body: string): string[] {
public static linkedItemIds(body: string): string[] {
if (!body || body.length <= 32) return [];
const links = urlUtils.extractResourceUrls(body);

View File

@@ -11,7 +11,7 @@ const pathUtils = require('../path-utils');
const { mime } = require('../mime-utils.js');
const { filename, safeFilename } = require('../path-utils');
const { FsDriverDummy } = require('../fs-driver-dummy.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
export default class Resource extends BaseItem {

View File

@@ -3,7 +3,7 @@ import { RevisionEntity } from '../services/database/types';
import BaseItem from './BaseItem';
const DiffMatchPatch = require('diff-match-patch');
const ArrayUtils = require('../ArrayUtils.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
const { sprintf } = require('sprintf-js');
const dmp = new DiffMatchPatch();

View File

@@ -1,6 +1,5 @@
import shim from '../shim';
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
import { ltrimSlashes } from '../path-utils';
import eventManager from '../eventManager';
import BaseModel from '../BaseModel';
import Database from '../database';
@@ -439,20 +438,20 @@ class Setting extends BaseModel {
description: () => emptyDirWarning,
storage: SettingStorage.File,
},
'sync.9.directory': {
value: 'Apps/Joplin',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
},
filter: value => {
return value ? ltrimSlashes(rtrimSlashes(value)) : '';
},
public: true,
label: () => _('Joplin Server Directory'),
storage: SettingStorage.File,
},
// 'sync.9.directory': {
// value: 'Apps/Joplin',
// type: SettingItemType.String,
// section: 'sync',
// show: (settings: any) => {
// return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
// },
// filter: value => {
// return value ? ltrimSlashes(rtrimSlashes(value)) : '';
// },
// public: true,
// label: () => _('Joplin Server Directory'),
// storage: SettingStorage.File,
// },
'sync.9.username': {
value: '',
type: SettingItemType.String,
@@ -461,7 +460,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
},
public: true,
label: () => _('Joplin Server username'),
label: () => _('Joplin Server email'),
storage: SettingStorage.File,
},
'sync.9.password': {

View File

@@ -1,5 +1,6 @@
import produce, { Draft } from 'immer';
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer';
import shareServiceReducer, { stateRootKey as shareServiceStateRootKey, defaultState as shareServiceDefaultState, State as ShareServiceState } from './services/share/reducer';
import Note from './models/Note';
import Folder from './models/Folder';
import BaseModel from './BaseModel';
@@ -16,6 +17,12 @@ additionalReducers.push({
reducer: pluginServiceReducer,
});
additionalReducers.push({
stateRootKey: shareServiceStateRootKey,
defaultState: shareServiceDefaultState,
reducer: shareServiceReducer,
});
interface StateLastSelectedNotesIds {
Folder: any;
Tag: any;
@@ -86,6 +93,7 @@ export interface State {
// Extra reducer keys go here:
pluginService: PluginServiceState;
shareService: ShareServiceState;
}
export const defaultState: State = {
@@ -153,6 +161,7 @@ export const defaultState: State = {
hasEncryptedItems: false,
pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState,
};
for (const additionalReducer of additionalReducers) {

View File

@@ -6,7 +6,7 @@ import MasterKey from '../models/MasterKey';
import BaseItem from '../models/BaseItem';
const { padLeft } = require('../string-utils.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
function hexPad(s: string, length: number) {
return padLeft(s, length, '0');

View File

@@ -1,227 +1,231 @@
// AUTO-GENERATED BY packages/tools/generate-database-types.js
// This file was generated by a tool.
// Rerun sql-ts to regenerate this file.
/*
* This file was generated by a tool.
* Rerun sql-ts to regenerate this file.
*/
export interface AlarmEntity {
'id'?: number | null;
'note_id'?: string;
'trigger_time'?: number;
'type_'?: number;
"id"?: number | null
"note_id"?: string
"trigger_time"?: number
"type_"?: number
}
export interface DeletedItemEntity {
'id'?: number | null;
'item_type'?: number;
'item_id'?: string;
'deleted_time'?: number;
'sync_target'?: number;
'type_'?: number;
"id"?: number | null
"item_type"?: number
"item_id"?: string
"deleted_time"?: number
"sync_target"?: number
"type_"?: number
}
export interface FolderEntity {
'id'?: string | null;
'title'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'parent_id'?: string;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"title"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"parent_id"?: string
"is_shared"?: number
"is_linked_folder"?: number
"source_folder_owner_id"?: string
"type_"?: number
}
export interface ItemChangeEntity {
'id'?: number | null;
'item_type'?: number;
'item_id'?: string;
'type'?: number;
'created_time'?: number;
'source'?: number;
'before_change_item'?: string;
'type_'?: number;
"id"?: number | null
"item_type"?: number
"item_id"?: string
"type"?: number
"created_time"?: number
"source"?: number
"before_change_item"?: string
"type_"?: number
}
export interface KeyValueEntity {
'id'?: number | null;
'key'?: string;
'value'?: string;
'type'?: number;
'updated_time'?: number;
'type_'?: number;
"id"?: number | null
"key"?: string
"value"?: string
"type"?: number
"updated_time"?: number
"type_"?: number
}
export interface MasterKeyEntity {
'id'?: string | null;
'created_time'?: number;
'updated_time'?: number;
'source_application'?: string;
'encryption_method'?: number;
'checksum'?: string;
'content'?: string;
'type_'?: number;
"id"?: string | null
"created_time"?: number
"updated_time"?: number
"source_application"?: string
"encryption_method"?: number
"checksum"?: string
"content"?: string
"type_"?: number
}
export interface MigrationEntity {
'id'?: number | null;
'number'?: number;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: number | null
"number"?: number
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface NoteResourceEntity {
'id'?: number | null;
'note_id'?: string;
'resource_id'?: string;
'is_associated'?: number;
'last_seen_time'?: number;
'type_'?: number;
"id"?: number | null
"note_id"?: string
"resource_id"?: string
"is_associated"?: number
"last_seen_time"?: number
"type_"?: number
}
export interface NoteTagEntity {
'id'?: string | null;
'note_id'?: string;
'tag_id'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"note_id"?: string
"tag_id"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"is_shared"?: number
"type_"?: number
}
export interface NoteEntity {
'id'?: string | null;
'parent_id'?: string;
'title'?: string;
'body'?: string;
'created_time'?: number;
'updated_time'?: number;
'is_conflict'?: number;
'latitude'?: number;
'longitude'?: number;
'altitude'?: number;
'author'?: string;
'source_url'?: string;
'is_todo'?: number;
'todo_due'?: number;
'todo_completed'?: number;
'source'?: string;
'source_application'?: string;
'application_data'?: string;
'order'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'markup_language'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"parent_id"?: string
"title"?: string
"body"?: string
"created_time"?: number
"updated_time"?: number
"is_conflict"?: number
"latitude"?: number
"longitude"?: number
"altitude"?: number
"author"?: string
"source_url"?: string
"is_todo"?: number
"todo_due"?: number
"todo_completed"?: number
"source"?: string
"source_application"?: string
"application_data"?: string
"order"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"markup_language"?: number
"is_shared"?: number
"type_"?: number
}
export interface NotesNormalizedEntity {
'id'?: string;
'title'?: string;
'body'?: string;
'user_created_time'?: number;
'user_updated_time'?: number;
'is_todo'?: number;
'todo_completed'?: number;
'parent_id'?: string;
'latitude'?: number;
'longitude'?: number;
'altitude'?: number;
'source_url'?: string;
'type_'?: number;
"id"?: string
"title"?: string
"body"?: string
"user_created_time"?: number
"user_updated_time"?: number
"is_todo"?: number
"todo_completed"?: number
"parent_id"?: string
"latitude"?: number
"longitude"?: number
"altitude"?: number
"source_url"?: string
"type_"?: number
}
export interface ResourceLocalStateEntity {
'id'?: number | null;
'resource_id'?: string;
'fetch_status'?: number;
'fetch_error'?: string;
'type_'?: number;
"id"?: number | null
"resource_id"?: string
"fetch_status"?: number
"fetch_error"?: string
"type_"?: number
}
export interface ResourceEntity {
'id'?: string | null;
'title'?: string;
'mime'?: string;
'filename'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'file_extension'?: string;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'encryption_blob_encrypted'?: number;
'size'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"title"?: string
"mime"?: string
"filename"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"file_extension"?: string
"encryption_cipher_text"?: string
"encryption_applied"?: number
"encryption_blob_encrypted"?: number
"size"?: number
"is_shared"?: number
"type_"?: number
}
export interface ResourcesToDownloadEntity {
'id'?: number | null;
'resource_id'?: string;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: number | null
"resource_id"?: string
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface RevisionEntity {
'id'?: string | null;
'parent_id'?: string;
'item_type'?: number;
'item_id'?: string;
'item_updated_time'?: number;
'title_diff'?: string;
'body_diff'?: string;
'metadata_diff'?: string;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: string | null
"parent_id"?: string
"item_type"?: number
"item_id"?: string
"item_updated_time"?: number
"title_diff"?: string
"body_diff"?: string
"metadata_diff"?: string
"encryption_cipher_text"?: string
"encryption_applied"?: number
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface SettingEntity {
'key'?: string | null;
'value'?: string | null;
'type_'?: number;
"key"?: string | null
"value"?: string | null
"type_"?: number
}
export interface SyncItemEntity {
'id'?: number | null;
'sync_target'?: number;
'sync_time'?: number;
'item_type'?: number;
'item_id'?: string;
'sync_disabled'?: number;
'sync_disabled_reason'?: string;
'force_sync'?: number;
'item_location'?: number;
'type_'?: number;
"id"?: number | null
"sync_target"?: number
"sync_time"?: number
"item_type"?: number
"item_id"?: string
"sync_disabled"?: number
"sync_disabled_reason"?: string
"force_sync"?: number
"item_location"?: number
"type_"?: number
}
export interface TableFieldEntity {
'id'?: number | null;
'table_name'?: string;
'field_name'?: string;
'field_type'?: number;
'field_default'?: string | null;
'type_'?: number;
"id"?: number | null
"table_name"?: string
"field_name"?: string
"field_type"?: number
"field_default"?: string | null
"type_"?: number
}
export interface TagEntity {
'id'?: string | null;
'title'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'is_shared'?: number;
'parent_id'?: string;
'type_'?: number;
"id"?: string | null
"title"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"is_shared"?: number
"parent_id"?: string
"type_"?: number
}
export interface TagsWithNoteCountEntity {
'id'?: string | null;
'title'?: string | null;
'created_time'?: number | null;
'updated_time'?: number | null;
'note_count'?: any | null;
'type_'?: number;
"id"?: string | null
"title"?: string | null
"created_time"?: number | null
"updated_time"?: number | null
"note_count"?: any | null
"type_"?: number
}
export interface VersionEntity {
'version'?: number;
'table_fields_version'?: number;
'type_'?: number;
"version"?: number
"table_fields_version"?: number
"type_"?: number
}

View File

@@ -18,11 +18,12 @@ export default class KeychainService extends BaseService {
this.driver = driver;
}
// This is to programatically disable the keychain service, regardless whether keychain
// is supported or not in the system (In other word, this might "enabled" but nothing
// will be saved to the keychain if there isn't one).
// This is to programatically disable the keychain service, whether keychain
// is supported or not in the system (In other word, this be might "enabled"
// but nothing will be saved to the keychain if there isn't one).
public get enabled(): boolean {
return this.enabled_;
if (!this.enabled_) return false;
return Setting.value('keychain.supported') === 1;
}
public set enabled(v: boolean) {

View File

@@ -0,0 +1,123 @@
import JoplinServerApi from '../../JoplinServerApi';
import Logger from '../../Logger';
import Setting from '../../models/Setting';
import shim from '../../shim';
import SyncTargetJoplinServer from '../../SyncTargetJoplinServer';
const logger = Logger.create('ShareService');
export default class ShareService {
private static instance_: ShareService;
private api_: JoplinServerApi = null;
private dispatch_: Function = null;
private isRunningInBackground_: boolean = false;
public static instance(): ShareService {
if (this.instance_) return this.instance_;
this.instance_ = new ShareService();
return this.instance_;
}
public initialize(dispatch: Function) {
this.dispatch_ = dispatch;
}
public get enabled(): boolean {
return Setting.value('sync.target') === SyncTargetJoplinServer.id();
}
private get dispatch(): Function {
return this.dispatch_;
}
private api(): JoplinServerApi {
if (this.api_) return this.api_;
this.api_ = new JoplinServerApi({
baseUrl: () => Setting.value('sync.9.path'),
username: () => Setting.value('sync.9.username'),
password: () => Setting.value('sync.9.password'),
});
return this.api_;
}
public async shareFolder(folderId: string) {
return this.api().exec('POST', 'api/shares', {}, {
folder_id: folderId,
type: 3, // JoplinRootFolder
});
}
public async addShareRecipient(shareId: string, recipientEmail: string) {
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
email: recipientEmail,
});
}
public async shares() {
return this.api().exec('GET', 'api/shares');
}
public async shareUsers(shareId: string) {
return this.api().exec('GET', `api/shares/${shareId}/users`);
}
public async shareInvitations() {
return this.api().exec('GET', 'api/share_users');
}
public async respondInvitation(shareUserId: string, accept: boolean) {
if (accept) {
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
} else {
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
}
}
public async refreshShareInvitations() {
const result = await this.shareInvitations();
this.dispatch({
type: 'SHARE_INVITATION_SET',
shareInvitations: result.items,
});
}
public async refreshShares() {
const result = await this.shares();
this.dispatch({
type: 'SHARE_SET',
shares: result.items,
});
}
public async refreshShareUsers(shareId: string) {
const result = await this.shareUsers(shareId);
this.dispatch({
type: 'SHARE_USER_SET',
shareId: shareId,
shareUsers: result.items,
});
}
public async runInBackground() {
if (this.isRunningInBackground_) return;
this.isRunningInBackground_ = true;
logger.info('Starting background service... Enabled:', this.enabled);
if (this.enabled) {
await this.refreshShareInvitations();
await this.refreshShares();
}
shim.setTimeout(() => {
if (this.enabled) void this.refreshShareInvitations();
}, 1000 * 60);
}
}

View File

@@ -0,0 +1,72 @@
import { State as RootState } from '../../reducer';
import { Draft } from 'immer';
interface StateShareUserUser {
email: string;
full_name?: string;
}
export interface StateShareUser {
status: number;
user: StateShareUserUser;
}
export interface StateShare {
id: string;
type: number;
folder_id: string;
note_id: string;
user?: StateShareUserUser;
}
export interface ShareInvitation {
id: string;
share: StateShare;
status: number;
}
export interface State {
shares: StateShare[];
shareUsers: Record<string, StateShareUser>;
shareInvitations: ShareInvitation[];
}
export const stateRootKey = 'shareService';
export const defaultState: State = {
shares: [],
shareUsers: {},
shareInvitations: [],
};
const reducer = (draftRoot: Draft<RootState>, action: any) => {
if (action.type.indexOf('SHARE_') !== 0) return;
const draft = draftRoot.shareService;
try {
switch (action.type) {
case 'SHARE_SET':
draft.shares = action.shares;
break;
case 'SHARE_USER_SET':
draft.shareUsers[action.shareId] = action.shareUsers;
break;
case 'SHARE_INVITATION_SET':
draft.shareInvitations = action.shareInvitations;
break;
}
} catch (error) {
error.message = `In share reducer: ${error.message} Action: ${JSON.stringify(action)}`;
throw error;
}
};
export default reducer;

View File

@@ -1,7 +1,7 @@
import { Dirnames } from './utils/types';
import shim from '../../shim';
const JoplinError = require('../../JoplinError');
import JoplinError from '../../JoplinError';
import time from '../../time';
const { fileExtension, filename } = require('../../path-utils');

View File

@@ -15,7 +15,7 @@ const migrations = [
import Setting from '../../models/Setting';
const { sprintf } = require('sprintf-js');
const JoplinError = require('../../JoplinError');
import JoplinError from '../../JoplinError';
interface SyncTargetInfo {
version: number;

7
packages/server/LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright (c) 2017-2021 Laurent Cozic
Personal Use License
Joplin Server is available for personal use only. For example you may host the software on your own server for non-commercial activity.
To obtain a license for commercial purposes, please contact us.

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,11 @@
"scripts": {
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
"start": "node dist/app.js",
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && rm -f db-buildTypes.sqlite",
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
"tsc": "tsc --project tsconfig.json",
"test": "jest",
"test": "jest --verbose=false",
"test-ci": "npm run test",
"clean": "rm -rf dist/",
"watch": "tsc --watch --project tsconfig.json"
},
"dependencies": {
@@ -22,7 +23,7 @@
"formidable": "^1.2.2",
"fs-extra": "^8.1.0",
"html-entities": "^1.3.1",
"knex": "^0.19.4",
"knex": "0.95.4",
"koa": "^2.8.1",
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",

Binary file not shown.

View File

@@ -14,6 +14,9 @@ import routeHandler from './middleware/routeHandler';
import notificationHandler from './middleware/notificationHandler';
import ownerHandler from './middleware/ownerHandler';
import setupAppContext from './utils/setupAppContext';
import { initializeJoplinUtils } from './apps/joplin/joplinUtils';
import startServices from './utils/startServices';
// import { createItemTree } from './utils/testing/testUtils';
const nodeEnvFile = require('node-env-file');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
@@ -125,13 +128,47 @@ async function main() {
appLogger().info('Connection check:', connectionCheckLogInfo);
const appContext = app.context as AppContext;
await initializeJoplinUtils(config());
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
appLogger().info('Migrating database...');
await migrateDb(appContext.db);
appLogger().info('Starting services...');
await startServices(appContext);
// if (env !== Env.Prod) {
// const done = await handleDebugCommands(argv, appContext.db, config());
// if (done) {
// appLogger().info('Debug command has been executed. Now starting server...');
// }
// }
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
// const tree: any = {
// '000000000000000000000000000000F1': {},
// '000000000000000000000000000000F2': {
// '00000000000000000000000000000001': null,
// '00000000000000000000000000000002': null,
// },
// '000000000000000000000000000000F3': {
// '00000000000000000000000000000003': null,
// '000000000000000000000000000000F4': {
// '00000000000000000000000000000004': null,
// '00000000000000000000000000000005': null,
// },
// },
// '00000000000000000000000000000006': null,
// '00000000000000000000000000000007': null,
// };
// const users = await appContext.models.user().all();
// const itemModel = appContext.models.item({ userId: users[0].id });
// await createItemTree(itemModel, '', tree);
app.listen(config().port);
}
}

View File

@@ -1,23 +1,18 @@
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Logger from '@joplin/lib/Logger';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import { File, Share, Uuid } from '../../db';
import { File, Item, Share, Uuid } from '../../db';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupToHtml } from '@joplin/renderer';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
import FileModel from '../../models/FileModel';
import { ErrorNotFound } from '../../utils/errors';
import BaseApplication from '../../services/BaseApplication';
import { formatDateTime } from '../../utils/time';
import ItemModel from '../../models/ItemModel';
import { resourceBlobPath } from './joplinUtils';
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
const { themeStyle } = require('@joplin/lib/theme');
const logger = Logger.create('JoplinApp');
export interface FileViewerResponse {
body: any;
mime: string;
@@ -40,30 +35,11 @@ type ResourceInfos = Record<Uuid, ResourceInfo>;
export default class Application extends BaseApplication {
// Although we don't use the database to store data, we still need to setup
// so that its schema can be accessed. This is needed for example by
// Note.unserialize to know what fields are valid for a note, and to format
// the field values correctly.
private db_: JoplinDatabase;
private pluginAssetRootDir_: string;
public async initialize() {
this.mustache.prefersDarkEnabled = false;
this.pluginAssetRootDir_ = require('path').resolve(__dirname, '../../..', 'node_modules/@joplin/renderer/assets');
const filePath = `${this.config.tempDir}/joplin.sqlite`;
this.db_ = new JoplinDatabase(new DatabaseDriverNode());
this.db_.setLogger(logger as Logger);
await this.db_.open({ name: filePath });
BaseModel.setDb(this.db_);
// Only load the classes that will be needed to render the notes and
// resources.
BaseItem.loadClass('Note', Note);
BaseItem.loadClass('Resource', Resource);
}
public async localFileFromUrl(url: string): Promise<string> {
@@ -76,26 +52,6 @@ export default class Application extends BaseApplication {
return null;
}
private itemIdFilename(itemId: string): string {
return `${itemId}.md`;
}
private async itemMetadataFile(parentId: Uuid, itemId: string): Promise<File | null> {
const file = await this.models.file().fileByName(parentId, this.itemIdFilename(itemId), { skipPermissionCheck: true });
if (!file) {
// We don't throw an error because it can happen if the note
// contains an invalid link to a resource or note.
logger.error(`Could not find item with ID "${itemId}" on parent "${parentId}"`);
return null;
}
return this.models.file().loadWithContent(file.id, { skipPermissionCheck: true });
}
private async unserializeItem(file: File): Promise<any> {
const content = file.content.toString();
return BaseItem.unserialize(content);
}
private async resourceInfos(linkedItemInfos: LinkedItemInfos): Promise<ResourceInfos> {
const output: Record<string, any> = {};
@@ -115,50 +71,28 @@ export default class Application extends BaseApplication {
return output;
}
private async noteLinkedItemInfos(noteFileParentId: string, note: NoteEntity): Promise<LinkedItemInfos> {
const itemIds = await Note.linkedItemIds(note.body);
private async noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, note: NoteEntity): Promise<LinkedItemInfos> {
const jopIds = await Note.linkedItemIds(note.body);
const output: LinkedItemInfos = {};
for (const itemId of itemIds) {
const itemFile = await this.itemMetadataFile(noteFileParentId, itemId);
if (!itemFile) continue;
for (const jopId of jopIds) {
const item = await itemModel.loadByJopId(userId, jopId, { fields: ['*'] });
if (!item) continue;
output[itemId] = {
item: await this.unserializeItem(itemFile),
file: itemFile,
output[jopId] = {
item: itemModel.itemToJoplinItem(item),
file: null,// itemFileWithContent.file,
};
}
return output;
}
private async resourceDir(fileModel: FileModel, parentId: Uuid): Promise<File> {
const parent = await fileModel.load(parentId);
const parentFullPath = await fileModel.itemFullPath(parent);
const dirPath = fileModel.resolve(parentFullPath, '.resource');
return fileModel.pathToFile(dirPath);
}
private async itemFile(fileModel: FileModel, parentId: Uuid, itemType: ModelType, itemId: string): Promise<File> {
let output: File = null;
if (itemType === ModelType.Resource) {
const resourceDir = await this.resourceDir(fileModel, parentId);
output = await fileModel.fileByName(resourceDir.id, itemId);
} else if (itemType === ModelType.Note) {
output = await fileModel.fileByName(parentId, this.itemIdFilename(itemId));
} else {
throw new Error(`Unsupported type: ${itemType}`);
}
return fileModel.loadWithContent(output.id);
}
private async renderResource(file: File): Promise<FileViewerResponse> {
private async renderResource(item: Item, content: any): Promise<FileViewerResponse> {
return {
body: file.content,
mime: file.mime_type,
size: file.size,
body: content,
mime: item.mime_type,
size: item.content_size,
};
}
@@ -222,39 +156,35 @@ export default class Application extends BaseApplication {
};
}
public async renderFile(file: File, share: Share, query: Record<string, any>): Promise<FileViewerResponse> {
const fileModel = this.models.file({ userId: file.owner_id });
public async renderItem(userId: Uuid, item: Item, share: Share, query: Record<string, any>): Promise<FileViewerResponse> {
const itemModel = this.models.item();
const rootNote: NoteEntity = await this.unserializeItem(file);
const linkedItemInfos = await this.noteLinkedItemInfos(file.parent_id, rootNote);
const rootNote: NoteEntity = itemModel.itemToJoplinItem(item); // await this.unserializeItem(content);
const linkedItemInfos: LinkedItemInfos = await this.noteLinkedItemInfos(userId, itemModel, rootNote);
const resourceInfos = await this.resourceInfos(linkedItemInfos);
const fileToRender = {
file: file,
item: item,
content: null as any,
itemId: rootNote.id,
};
if (query.resource_id) {
fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Resource, query.resource_id);
const resourceItem = await itemModel.loadByName(userId, resourceBlobPath(query.resource_id), { fields: ['*'] });
fileToRender.item = resourceItem;
fileToRender.content = resourceItem.content;
fileToRender.itemId = query.resource_id;
}
// No longer supported - need to decide what to do about note links.
// if (query.note_id) {
// fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Note, query.note_id);
// fileToRender.itemId = query.note_id;
// }
if (fileToRender.file !== file && !linkedItemInfos[fileToRender.itemId]) {
if (fileToRender.item !== item && !linkedItemInfos[fileToRender.itemId]) {
throw new ErrorNotFound(`Item "${fileToRender.itemId}" does not belong to this note`);
}
const itemToRender = fileToRender.file === file ? rootNote : linkedItemInfos[fileToRender.itemId].item;
const itemToRender = fileToRender.item === item ? rootNote : linkedItemInfos[fileToRender.itemId].item;
const itemType: ModelType = itemToRender.type_;
if (itemType === ModelType.Resource) {
return this.renderResource(fileToRender.file);
return this.renderResource(fileToRender.item, fileToRender.content);
} else if (itemType === ModelType.Note) {
return this.renderNote(share, itemToRender, resourceInfos, linkedItemInfos);
} else {
@@ -262,17 +192,4 @@ export default class Application extends BaseApplication {
}
}
public async isItemFile(file: File): Promise<boolean> {
if (file.mime_type !== 'text/markdown') return false;
try {
await this.unserializeItem(file);
} catch (error) {
// No need to log - it means it's not a note file
return false;
}
return true;
}
}

View File

@@ -0,0 +1,58 @@
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Logger from '@joplin/lib/Logger';
import BaseModel from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Resource from '@joplin/lib/models/Resource';
import NoteTag from '@joplin/lib/models/NoteTag';
import Tag from '@joplin/lib/models/Tag';
import MasterKey from '@joplin/lib/models/MasterKey';
import Revision from '@joplin/lib/models/Revision';
import { Config } from '../../utils/types';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
const logger = Logger.create('JoplinUtils');
let db_: JoplinDatabase = null;
export async function initializeJoplinUtils(config: Config) {
const filePath = `${config.tempDir}/joplin.sqlite`;
db_ = new JoplinDatabase(new DatabaseDriverNode());
db_.setLogger(logger as Logger);
await db_.open({ name: filePath });
BaseModel.setDb(db_);
// Only load the classes that will be needed to render the notes and
// resources.
BaseItem.loadClass('Folder', Folder);
BaseItem.loadClass('Note', Note);
BaseItem.loadClass('Resource', Resource);
BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
}
export function linkedResourceIds(body: string): string[] {
return Note.linkedItemIds(body);
}
export function isJoplinItemName(name: string): boolean {
return !!name.match(/^[0-9a-zA-Z]{32}\.md$/);
}
export async function unserializeJoplinItem(body: string): Promise<any> {
return BaseItem.unserialize(body);
}
export async function serializeJoplinItem(item: any): Promise<string> {
const ModelClass = BaseItem.itemClass(item);
return ModelClass.serialize(item);
}
export function resourceBlobPath(resourceId: string): string {
return `.resource/${resourceId}`;
}

View File

@@ -67,7 +67,7 @@ function baseUrlFromEnv(env: any, appPort: number): string {
let config_: Config = null;
export function initConfig(env: EnvVariables) {
export function initConfig(env: EnvVariables, overrides: any = null) {
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
const rootDir = pathUtils.dirname(__dirname);
@@ -83,6 +83,7 @@ export function initConfig(env: EnvVariables) {
database: databaseConfigFromEnv(runningInDocker_, env),
port: appPort,
baseUrl: baseUrlFromEnv(env, appPort),
...overrides,
};
}

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { knex, Knex } from 'knex';
import { DatabaseConfig } from './utils/types';
import * as pathUtils from 'path';
import time from '@joplin/lib/time';
@@ -98,7 +98,7 @@ export async function waitForConnection(dbConfig: DatabaseConfig): Promise<Conne
}
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
return require('knex')(makeKnexConfig(dbConfig));
return knex(makeKnexConfig(dbConfig));
}
export async function disconnectDb(db: DbConnection) {
@@ -148,7 +148,19 @@ function isNoSuchTableError(error: any): boolean {
if (error.code === '42P01') return true;
// Sqlite3 error
if (error.message && error.message.includes('no such table: knex_migrations')) return true;
if (error.message && error.message.includes('SQLITE_ERROR: no such table:')) return true;
}
return false;
}
export function isUniqueConstraintError(error: any): boolean {
if (error) {
// Postgres error: 23505: unique_violation
if (error.code === '23505') return true;
// Sqlite3 error
if (error.code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint')) return true;
}
return false;
@@ -201,8 +213,9 @@ export enum NotificationLevel {
}
export enum ItemType {
File = 1,
User,
Item = 1,
UserItem = 2,
User,
}
export enum ChangeType {
@@ -211,6 +224,11 @@ export enum ChangeType {
Delete = 3,
}
export enum FileContentType {
Any = 1,
JoplinItem = 2,
}
export function changeTypeToString(t: ChangeType): string {
if (t === ChangeType.Create) return 'create';
if (t === ChangeType.Update) return 'update';
@@ -221,6 +239,13 @@ export function changeTypeToString(t: ChangeType): string {
export enum ShareType {
Link = 1, // When a note is shared via a public link
App = 2, // When a note is shared with another user on the same server instance
JoplinRootFolder = 3,
}
export enum ShareUserStatus {
Waiting = 0,
Accepted = 1,
Rejected = 2,
}
export interface WithDates {
@@ -258,33 +283,21 @@ export interface Session extends WithDates, WithUuid {
auth_code?: string;
}
export interface Permission extends WithDates, WithUuid {
user_id?: Uuid;
item_type?: ItemType;
item_id?: Uuid;
can_read?: number;
can_write?: number;
}
export interface File extends WithDates, WithUuid {
export interface File {
id?: Uuid;
owner_id?: Uuid;
name?: string;
content?: Buffer;
content?: any;
mime_type?: string;
size?: number;
is_directory?: number;
is_root?: number;
parent_id?: Uuid;
}
export interface Change extends WithDates, WithUuid {
counter?: number;
owner_id?: Uuid;
item_type?: ItemType;
parent_id?: Uuid;
item_id?: Uuid;
item_name?: string;
type?: ChangeType;
updated_time?: string;
created_time?: string;
source_file_id?: Uuid;
content_type?: number;
content_id?: Uuid;
}
export interface ApiClient extends WithDates, WithUuid {
@@ -301,10 +314,59 @@ export interface Notification extends WithDates, WithUuid {
canBeDismissed?: number;
}
export interface ShareUser extends WithDates, WithUuid {
share_id?: Uuid;
user_id?: Uuid;
status?: ShareUserStatus;
}
export interface Item extends WithDates, WithUuid {
name?: string;
mime_type?: string;
content?: Buffer;
content_size?: number;
jop_id?: Uuid;
jop_parent_id?: Uuid;
jop_type?: number;
jop_encryption_applied?: number;
}
export interface UserItem extends WithDates {
id?: number;
user_id?: Uuid;
item_id?: Uuid;
share_id?: Uuid;
}
export interface ItemResource {
id?: number;
item_id?: Uuid;
resource_id?: Uuid;
}
export interface KeyValue {
id?: number;
key?: string;
type?: number;
value?: string;
}
export interface Share extends WithDates, WithUuid {
owner_id?: Uuid;
file_id?: Uuid;
item_id?: Uuid;
type?: ShareType;
folder_id?: Uuid;
note_id?: Uuid;
}
export interface Change extends WithDates, WithUuid {
counter?: number;
item_type?: ItemType;
item_id?: Uuid;
item_name?: string;
type?: ChangeType;
previous_item?: string;
user_id?: Uuid;
}
export const databaseSchema: DatabaseTables = {
@@ -324,16 +386,6 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
permissions: {
id: { type: 'string' },
user_id: { type: 'string' },
item_type: { type: 'number' },
item_id: { type: 'string' },
can_read: { type: 'number' },
can_write: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
files: {
id: { type: 'string' },
owner_id: { type: 'string' },
@@ -346,18 +398,9 @@ export const databaseSchema: DatabaseTables = {
parent_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
changes: {
counter: { type: 'number' },
id: { type: 'string' },
owner_id: { type: 'string' },
item_type: { type: 'number' },
parent_id: { type: 'string' },
item_id: { type: 'string' },
item_name: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
source_file_id: { type: 'string' },
content_type: { type: 'number' },
content_id: { type: 'string' },
},
api_clients: {
id: { type: 'string' },
@@ -377,13 +420,67 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
shares: {
share_users: {
id: { type: 'string' },
owner_id: { type: 'string' },
file_id: { type: 'string' },
type: { type: 'number' },
share_id: { type: 'string' },
user_id: { type: 'string' },
status: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
items: {
id: { type: 'string' },
name: { type: 'string' },
mime_type: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
content: { type: 'any' },
content_size: { type: 'number' },
jop_id: { type: 'string' },
jop_parent_id: { type: 'string' },
jop_type: { type: 'number' },
jop_encryption_applied: { type: 'number' },
},
user_items: {
id: { type: 'number' },
user_id: { type: 'string' },
item_id: { type: 'string' },
share_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
item_resources: {
id: { type: 'number' },
item_id: { type: 'string' },
resource_id: { type: 'string' },
},
key_values: {
id: { type: 'number' },
key: { type: 'string' },
type: { type: 'number' },
value: { type: 'string' },
},
shares: {
id: { type: 'string' },
owner_id: { type: 'string' },
item_id: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
folder_id: { type: 'string' },
note_id: { type: 'string' },
},
changes: {
counter: { type: 'number' },
id: { type: 'string' },
item_type: { type: 'number' },
item_id: { type: 'string' },
item_name: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
previous_item: { type: 'string' },
user_id: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@@ -17,9 +17,9 @@ describe('notificationHandler', function() {
});
test('should check admin password', async function() {
const { user, session } = await createUserAndSession(1, true);
const { session } = await createUserAndSession(1, true);
const admin = await models().user({ userId: user.id }).save({
const admin = await models().user().save({
email: defaultAdminEmail,
password: defaultAdminPassword,
is_admin: 1,
@@ -38,7 +38,7 @@ describe('notificationHandler', function() {
}
{
await models().user({ userId: admin.id }).save({
await models().user().save({
id: admin.id,
password: 'changed!',
});

View File

@@ -12,20 +12,22 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return;
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
const notificationModel = ctx.models.notification();
if (defaultAdmin) {
await notificationModel.add(
ctx.owner.id,
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead('change_admin_password');
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
}
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
@@ -36,10 +38,11 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
async function handleSqliteInProdNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return;
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
const notificationModel = ctx.models.notification();
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
@@ -50,7 +53,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
const markdownIt = new MarkdownIt();
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
const notificationModel = ctx.models.notification();
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
const views: NotificationView[] = [];
for (const n of notifications) {

View File

@@ -1,7 +1,5 @@
import routes from '../routes/routes';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { AppContext, Env, HttpMethod } from '../utils/types';
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import MustacheService, { isView, View } from '../services/MustacheService';
import config from '../config';
@@ -16,38 +14,21 @@ function mustache(): MustacheService {
export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
const match: MatchedRoute = null;
try {
const match = findMatchingRoute(ctx.path, routes);
const responseObject = await execRequest(ctx.routes, ctx);
if (match) {
let responseObject = null;
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
// This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action.
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
responseObject = await routeHandler(match.subPath, ctx);
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustache().renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
});
} else {
ctx.response.status = 200;
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
}
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustache().renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
});
} else {
throw new ErrorNotFound();
ctx.response.status = 200;
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
@@ -56,9 +37,12 @@ export default async function(ctx: AppContext) {
ctx.appLogger().error(error);
}
// Uncomment this when getting HTML blobs as errors while running tests.
// console.error(error);
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx);
const responseFormat = routeResponseFormat(ctx);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
import { hashPassword } from '../utils/auth';
import uuidgen from '../utils/uuidgen';

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {

View File

@@ -1,11 +1,11 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('shares', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.string('owner_id', 32).notNullable();
table.string('file_id', 32).notNullable();
table.string('item_id', 32).notNullable();
table.integer('type').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();

View File

@@ -0,0 +1,34 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('share_users', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.string('share_id', 32).notNullable();
table.string('user_id', 32).notNullable();
table.integer('status').defaultTo(0).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('share_users', function(table: Knex.CreateTableBuilder) {
table.unique(['share_id', 'user_id']);
});
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
table.string('source_file_id', 32).defaultTo('').notNullable();
});
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
table.index(['owner_id']);
table.index(['source_file_id']);
});
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
table.index(['item_id']);
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('share_users');
}

View File

@@ -0,0 +1,25 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('joplin_file_contents', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.string('owner_id', 32).notNullable();
table.string('item_id', 32).notNullable();
table.string('parent_id', 32).defaultTo('').notNullable();
table.integer('type', 2).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
table.integer('encryption_applied', 1).notNullable();
table.binary('content').defaultTo('').notNullable();
});
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
table.integer('content_type', 2).defaultTo(1).notNullable();
table.string('content_id', 32).defaultTo('').notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('joplin_file_contents');
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
table.string('folder_id', 32).defaultTo('').notNullable();
table.integer('is_auto', 1).defaultTo(0).notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('joplin_items');
}

View File

@@ -0,0 +1,109 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
// parent_id: ${'parent_id' in note ? note.parent_id : defaultFolderId}
// created_time: 2020-10-15T10:34:16.044Z
// updated_time: 2021-01-28T23:10:30.054Z
// is_conflict: 0
// latitude: 0.00000000
// longitude: 0.00000000
// altitude: 0.0000
// author:
// source_url:
// is_todo: 1
// todo_due: 1602760405000
// todo_completed: 0
// source: joplindev-desktop
// source_application: net.cozic.joplindev-desktop
// application_data:
// order: 0
// user_created_time: 2020-10-15T10:34:16.044Z
// user_updated_time: 2020-10-19T17:21:03.394Z
// encryption_cipher_text:
// encryption_applied: 0
// markup_language: 1
// is_shared: 1
// type_: 1`;
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('items', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.text('name').notNullable();
table.string('mime_type', 128).defaultTo('application/octet-stream').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
table.binary('content').defaultTo('').notNullable();
table.integer('content_size').defaultTo(0).notNullable();
table.string('jop_id', 32).defaultTo('').notNullable();
table.string('jop_parent_id', 32).defaultTo('').notNullable();
table.integer('jop_type', 2).defaultTo(0).notNullable();
table.integer('jop_encryption_applied', 1).defaultTo(0).notNullable();
});
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
table.index('name');
table.index('jop_id');
table.index('jop_parent_id');
table.index('jop_type');
});
await db.schema.createTable('user_items', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('user_id', 32).notNullable();
table.string('item_id', 32).notNullable();
table.string('share_id', 32).defaultTo('').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('user_items', function(table: Knex.CreateTableBuilder) {
table.unique(['user_id', 'item_id']);
table.index('user_id');
table.index('item_id');
table.index('share_id');
});
await db.schema.createTable('item_resources', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('item_id', 32).notNullable();
table.string('resource_id', 32).notNullable();
});
await db.schema.alterTable('item_resources', function(table: Knex.CreateTableBuilder) {
table.unique(['item_id', 'resource_id']);
table.index(['item_id', 'resource_id']);
});
await db.schema.createTable('key_values', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.text('key').notNullable();
table.integer('type').notNullable();
table.text('value').notNullable();
});
await db.schema.alterTable('key_values', function(table: Knex.CreateTableBuilder) {
table.index(['key']);
});
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
table.dropColumn('is_auto');
table.string('note_id', 32).defaultTo('').notNullable();
});
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
table.text('previous_item').defaultTo('').notNullable();
table.string('user_id', 32).defaultTo('').notNullable();
table.dropColumn('owner_id');
table.dropColumn('parent_id');
table.index('user_id');
});
await db.schema.dropTable('permissions');
await db.schema.dropTable('joplin_file_contents');
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('items');
}

View File

@@ -1,22 +1,25 @@
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, ChangeType } from '../db';
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, Uuid, User } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
export interface ModelOptions {
userId?: string;
}
import * as EventEmitter from 'events';
export interface SaveOptions {
isNew?: boolean;
skipValidation?: boolean;
validationRules?: any;
trackChanges?: boolean;
previousItem?: any;
}
export interface LoadOptions {
fields?: string[];
}
export interface DeleteOptions {
validationRules?: any;
allowNoOp?: boolean;
deletedItemUserIds?: Record<Uuid, Uuid[]>;
}
export interface ValidateOptions {
@@ -24,24 +27,29 @@ export interface ValidateOptions {
rules?: any;
}
export enum AclAction {
Create = 1,
Read = 2,
Update = 3,
Delete = 4,
List = 5,
}
export default abstract class BaseModel<T> {
private options_: ModelOptions = null;
private defaultFields_: string[] = [];
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
private baseUrl_: string;
private static eventEmitter_: EventEmitter = null;
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string, options: ModelOptions = null) {
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
this.db_ = db;
this.modelFactory_ = modelFactory;
this.baseUrl_ = baseUrl;
this.options_ = Object.assign({}, options);
this.transactionHandler_ = new TransactionHandler(db);
if ('userId' in this.options && !this.options.userId) throw new Error('If userId is set, it cannot be null');
}
// When a model create an instance of another model, the active
@@ -55,14 +63,6 @@ export default abstract class BaseModel<T> {
return this.baseUrl_;
}
protected get options(): ModelOptions {
return this.options_;
}
protected get userId(): string {
return this.options.userId;
}
protected get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_;
@@ -75,6 +75,34 @@ export default abstract class BaseModel<T> {
return this.defaultFields_.slice();
}
public static get eventEmitter(): EventEmitter {
if (!this.eventEmitter_) {
this.eventEmitter_ = new EventEmitter();
}
return this.eventEmitter_;
}
public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise<void> {
throw new Error('Must be overriden');
}
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = ''): string[] {
let output: string[] = [];
if (options && options.fields) {
output = options.fields;
} else if (defaultFields) {
output = defaultFields;
} else {
output = this.defaultFields;
}
if (mainTable) {
output = output.map(f => `${mainTable}.${f}`);
}
return output;
}
protected get tableName(): string {
throw new Error('Not implemented');
}
@@ -83,15 +111,11 @@ export default abstract class BaseModel<T> {
throw new Error('Not implemented');
}
protected get trackChanges(): boolean {
return false;
}
protected hasUuid(): boolean {
return true;
}
protected hasDateProperties(): boolean {
protected autoTimestampEnabled(): boolean {
return true;
}
@@ -121,7 +145,7 @@ export default abstract class BaseModel<T> {
//
// The `name` argument is only for debugging, so that any stuck transaction
// can be more easily identified.
protected async withTransaction(fn: Function, name: string = null): Promise<void> {
protected async withTransaction<T>(fn: Function, name: string = null): Promise<T> {
const debugTransaction = false;
const debugTimerId = debugTransaction ? setTimeout(() => {
@@ -132,8 +156,10 @@ export default abstract class BaseModel<T> {
if (debugTransaction) console.info('START', name, txIndex);
let output: T = null;
try {
await fn();
output = await fn();
} catch (error) {
await this.transactionHandler_.rollback(txIndex);
@@ -151,10 +177,12 @@ export default abstract class BaseModel<T> {
}
await this.transactionHandler_.commit(txIndex);
return output;
}
public async all(): Promise<T[]> {
return this.db(this.tableName).select(...this.defaultFields);
public async all(options: LoadOptions = {}): Promise<T[]> {
const rows: any[] = await this.db(this.tableName).select(this.selectFields(options));
return rows as T[];
}
public fromApiInput(object: T): T {
@@ -170,10 +198,18 @@ export default abstract class BaseModel<T> {
return output;
}
public toApiOutput(object: any): any {
protected objectToApiOutput(object: T): T {
return { ...object };
}
public toApiOutput(object: T | T[]): T | T[] {
if (Array.isArray(object)) {
return object.map(f => this.objectToApiOutput(f));
} else {
return this.objectToApiOutput(object);
}
}
protected async validate(object: T, options: ValidateOptions = {}): Promise<T> {
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
return object;
@@ -182,31 +218,10 @@ export default abstract class BaseModel<T> {
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
if (options.isNew === false) return false;
if (options.isNew === true) return true;
if ('id' in object && !(object as WithUuid).id) throw new Error('ID cannot be undefined or null');
return !(object as WithUuid).id;
}
private async handleChangeTracking(options: SaveOptions, item: T, changeType: ChangeType): Promise<void> {
const trackChanges = this.trackChanges && options.trackChanges !== false;
if (!trackChanges) return;
let parentId = null;
if (this.hasParentId) {
if (!('parent_id' in item)) {
const temp: any = await this.db(this.tableName).select(['parent_id']).where('id', '=', (item as WithUuid).id).first();
parentId = temp.parent_id;
} else {
parentId = (item as any).parent_id;
}
}
// Sanity check - shouldn't happen
// Parent ID can be an empty string for root folders, but it shouldn't be null or undefined
if (this.hasParentId && !parentId && parentId !== '') throw new Error(`Could not find parent ID for item: ${(item as WithUuid).id}`);
const changeModel = this.models().change({ userId: this.userId });
await changeModel.add(this.itemType, parentId, (item as WithUuid).id, (item as any).name || '', changeType);
}
public async save(object: T, options: SaveOptions = {}): Promise<T> {
if (!object) throw new Error('Object cannot be empty');
@@ -214,11 +229,11 @@ export default abstract class BaseModel<T> {
const isNew = await this.isNew(object, options);
if (isNew && !(toSave as WithUuid).id) {
if (this.hasUuid() && isNew && !(toSave as WithUuid).id) {
(toSave as WithUuid).id = uuidgen();
}
if (this.hasDateProperties()) {
if (this.autoTimestampEnabled()) {
const timestamp = Date.now();
if (isNew) {
(toSave as WithDates).created_time = timestamp;
@@ -231,7 +246,6 @@ export default abstract class BaseModel<T> {
await this.withTransaction(async () => {
if (isNew) {
await this.db(this.tableName).insert(toSave);
await this.handleChangeTracking(options, toSave, ChangeType.Create);
} else {
const objectId: string = (toSave as WithUuid).id;
if (!objectId) throw new Error('Missing "id" property');
@@ -239,8 +253,6 @@ export default abstract class BaseModel<T> {
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
(toSave as WithUuid).id = objectId;
await this.handleChangeTracking(options, toSave, ChangeType.Update);
// Sanity check:
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
}
@@ -249,31 +261,24 @@ export default abstract class BaseModel<T> {
return toSave;
}
public async loadByIds(ids: string[]): Promise<T[]> {
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return [];
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids);
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
}
public async load(id: string): Promise<T> {
public async load(id: string, options: LoadOptions = {}): Promise<T> {
if (!id) throw new Error('id cannot be empty');
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first();
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
}
public async delete(id: string | string[]): Promise<void> {
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
if (!id) throw new Error('id cannot be empty');
const ids = typeof id === 'string' ? [id] : id;
if (!ids.length) throw new Error('no id provided');
const trackChanges = this.trackChanges;
let itemsWithParentIds: T[] = null;
if (trackChanges) {
itemsWithParentIds = await this.db(this.tableName).select(['id', 'parent_id', 'name']).whereIn('id', ids);
}
await this.withTransaction(async () => {
const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) {
@@ -281,11 +286,7 @@ export default abstract class BaseModel<T> {
}
const deletedCount = await query.del();
if (deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted by ${deletedCount} row(s) were deleted`);
if (trackChanges) {
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
}
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
}, 'BaseModel::delete');
}

View File

@@ -1,13 +1,13 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { ChangeType, File } from '../db';
import FileModel from './FileModel';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow, createItem } from '../utils/testing/testUtils';
import { ChangeType, Item, Uuid } from '../db';
import { msleep } from '../utils/time';
import { ChangePagination } from './ChangeModel';
async function makeTestFile(fileModel: FileModel): Promise<File> {
return fileModel.save({
name: 'test',
parent_id: await fileModel.userRootFileId(),
let itemCounter_ = 0;
async function makeTestItem(userId: Uuid): Promise<Item> {
itemCounter_++;
return models().item().saveForUser(userId, {
name: `item${itemCounter_}`,
});
}
@@ -26,94 +26,110 @@ describe('ChangeModel', function() {
});
test('should track changes - create', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const { session, user } = await createUserAndSession(1, true);
const changeModel = models().change();
const file1 = await makeTestFile(fileModel);
const dirId = await fileModel.userRootFileId();
const item1 = await createItem(session.id, 'test.txt', 'testing');
{
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
const changes = (await changeModel.allForUser(user.id)).items;
expect(changes.length).toBe(1);
expect(changes[0].item.id).toBe(file1.id);
expect(changes[0].item_id).toBe(item1.id);
expect(changes[0].type).toBe(ChangeType.Create);
}
});
test('should track changes - create, then update', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const itemModel = models().item();
const changeModel = models().change();
let i = 1;
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
await msleep(1); await fileModel.delete(file1.id); // DELETE 1
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
await msleep(1); const item1 = await makeTestItem(user.id); // [1] CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: 'test_mod_1a' }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: 'test_mod_1b' }); // [3] UPDATE 1b
await msleep(1); const item2 = await makeTestItem(user.id); // [4] CREATE 2
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: 'test_mod_2a' }); // [5] UPDATE 2a
await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: 'test_mod_2b' }); // [7] UPDATE 2b
await msleep(1); const item3 = await makeTestItem(user.id); // [8] CREATE 3
const dirId = await fileModel.userRootFileId();
// Check that the 8 changes were created
const allUncompressedChanges = await changeModel.all();
expect(allUncompressedChanges.length).toBe(8);
{
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
// When we get all the changes, we only get CREATE 2 and CREATE 3.
// We don't get CREATE 1 because item 1 has been deleted. And we
// also don't get any UPDATE event since they've been compressed
// down to the CREATE events.
const changes = (await changeModel.allForUser(user.id)).items;
expect(changes.length).toBe(2);
expect(changes[0].item.id).toBe(file2.id);
expect(changes[0].item_id).toBe(item2.id);
expect(changes[0].type).toBe(ChangeType.Create);
expect(changes[1].item.id).toBe(file3.id);
expect(changes[1].item_id).toBe(item3.id);
expect(changes[1].type).toBe(ChangeType.Create);
}
{
const pagination: ChangePagination = { limit: 5 };
const pagination: ChangePagination = { limit: 3 };
// In this page, the "create" change for file1 will not appear
// because this file has been deleted. The "delete" change will
// however appear in the second page.
const page1 = (await changeModel.byDirectoryId(dirId, pagination));
// Internally, when we request the first three changes, we get back:
//
// - CREATE 1
// - CREATE 2
// - UPDATE 2a
//
// We don't get back UPDATE 1a and 1b because the associated item
// has been deleted.
//
// Unlike CREATE events, which come from "user_items" and are
// associated with a user, UPDATE events comes from "items" and are
// not associated with any specific user. Only if the user has a
// corresponding user_item do they get UPDATE events. But in this
// case, since the item has been deleted, there's no longer
// "user_items" objects.
//
// Then CREATE 1 is removed since item 1 has been deleted and UPDATE
// 2a is compressed down to CREATE 2.
const page1 = (await changeModel.allForUser(user.id, pagination));
let changes = page1.items;
expect(changes.length).toBe(1);
expect(page1.has_more).toBe(true);
expect(changes[0].item.id).toBe(file2.id);
expect(changes[0].item_id).toBe(item2.id);
expect(changes[0].type).toBe(ChangeType.Create);
const page2 = (await changeModel.byDirectoryId(dirId, { ...pagination, cursor: page1.cursor }));
// In the second page, we get all the expected events since nothing
// has been compressed.
const page2 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page1.cursor }));
changes = page2.items;
expect(changes.length).toBe(3);
expect(page2.has_more).toBe(false);
expect(changes[0].item.id).toBe(file1.id);
// Although there are no more changes, it's not possible to know
// that without running the next query
expect(page2.has_more).toBe(true);
expect(changes[0].item_id).toBe(item1.id);
expect(changes[0].type).toBe(ChangeType.Delete);
expect(changes[1].item.id).toBe(file2.id);
expect(changes[1].item_id).toBe(item2.id);
expect(changes[1].type).toBe(ChangeType.Update);
expect(changes[2].item.id).toBe(file3.id);
expect(changes[2].item_id).toBe(item3.id);
expect(changes[2].type).toBe(ChangeType.Create);
// Check that we indeed reached the end of the feed.
const page3 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page2.cursor }));
expect(page3.items.length).toBe(0);
expect(page3.has_more).toBe(false);
}
});
test('should throw an error if cursor is invalid', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const dirId = await fileModel.userRootFileId();
const itemModel = models().item();
const changeModel = models().change();
let i = 1;
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); const item1 = await makeTestItem(user.id); // CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
await expectThrow(async () => changeModel.byDirectoryId(dirId, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});
test('should throw an error if trying to do get changes for a file', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const file1 = await makeTestFile(fileModel);
await expectThrow(async () => changeModel.byDirectoryId(file1.id));
await expectThrow(async () => changeModel.allForUser(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});
});

View File

@@ -1,16 +1,18 @@
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel from './BaseModel';
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
import { Knex } from 'knex';
import { Change, ChangeType, Item, Uuid } from '../db';
import { md5 } from '../utils/crypto';
import { ErrorResyncRequired } from '../utils/errors';
import BaseModel, { SaveOptions } from './BaseModel';
import { PaginatedResults } from './utils/pagination';
export interface ChangeWithItem {
item: File;
item: Item;
updated_time: number;
type: ChangeType;
}
export interface PaginatedChanges extends PaginatedResults {
items: ChangeWithItem[];
items: Change[];
}
export interface ChangePagination {
@@ -18,6 +20,12 @@ export interface ChangePagination {
cursor?: string;
}
export interface ChangePreviousItem {
name: string;
jop_parent_id: string;
jop_resource_ids: string[];
}
export function defaultChangePagination(): ChangePagination {
return {
limit: 100,
@@ -32,44 +40,34 @@ export default class ChangeModel extends BaseModel<Change> {
}
protected hasUuid(): boolean {
return false;
return true;
}
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> {
const change: Change = {
item_type: itemType,
parent_id: parentId || '',
item_id: itemId,
item_name: itemName,
type: changeType,
owner_id: this.userId,
};
return this.save(change) as Change;
public serializePreviousItem(item: ChangePreviousItem): string {
return JSON.stringify(item);
}
private async countByUser(userId: string): Promise<number> {
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first();
return r.total;
public unserializePreviousItem(item: string): ChangePreviousItem {
if (!item) return null;
return JSON.parse(item);
}
public changeUrl(): string {
return `${this.baseUrl}/changes`;
}
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> {
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination);
const changeWithItems = await this.loadChangeItems(results.items);
return {
...results,
items: changeWithItems,
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit),
};
public async allFromId(id: string): Promise<Change[]> {
const startChange: Change = id ? await this.load(id) : null;
const query = this.db(this.tableName).select(...this.defaultFields);
if (startChange) void query.where('counter', '>', startChange.counter);
void query.limit(1000);
let results = await query;
results = await this.removeDeletedItems(results);
results = await this.compressChanges(results);
return results;
}
// Note: doesn't currently support checking for changes recursively but this
// is not needed for Joplin synchronisation.
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
public async allForUser(userId: Uuid, pagination: ChangePagination = null): Promise<PaginatedChanges> {
pagination = {
...defaultChangePagination(),
...pagination,
@@ -82,37 +80,93 @@ export default class ChangeModel extends BaseModel<Change> {
if (!changeAtCursor) throw new ErrorResyncRequired();
}
// Load the directory object to check that it exists and that we have
// the right permissions (loading will check permissions)
const fileModel = this.models().file({ userId: this.userId });
const directory = await fileModel.load(dirId);
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
// When need to get:
//
// - All the CREATE and DELETE changes associated with the user
// - All the UPDATE changes that applies to items associated with the
// user.
//
// UPDATE changes do not have the user_id set because they are specific
// to the item, not to a particular user.
// Rather than query the changes, then use JS to compress them, it might
// be possible to do both in one query.
// https://stackoverflow.com/questions/65348794
const query = this.db(this.tableName)
const query = this
.db('changes')
.select([
'counter',
'id',
'item_id',
'item_name',
'type',
'updated_time',
])
.where('parent_id', dirId)
.orderBy('counter', 'asc')
.limit(pagination.limit);
// -----------------------------------------------------------
// WORKS:
// -----------------------------------------------------------
// .where(function () {
// void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
// .orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
// });
// -----------------------------------------------------------
// Using "this" - Infinite loop
// -----------------------------------------------------------
// .where(function () {
// void this.where(function() {
// void this.where(function() {
// void this
// .where('type', '=', ChangeType.Create)
// .orWhere('type', '=', ChangeType.Delete)
// }).where('user_id', userId)
// }).orWhere(function() {
// void this
// .where('type', ChangeType.Update)
// .whereIn('item_id', this.select('item_id').from('user_items').where('user_id', userId))
// });
// });
// -----------------------------------------------------------
// Using "QueryBuilder" - Infinite loop
// -----------------------------------------------------------
.where(function (qb:Knex.QueryBuilder) {
void qb.where(function(qb:Knex.QueryBuilder) {
void qb.where(function() {
void qb
.where('type', '=', ChangeType.Create)
.orWhere('type', '=', ChangeType.Delete)
}).where('user_id', userId)
}).orWhere(function(qb:Knex.QueryBuilder) {
void qb
.where('type', ChangeType.Update)
.whereIn('item_id', qb.select('item_id').from('user_items').where('user_id', userId))
});
});
// If a cursor was provided, apply it to both queries.
if (changeAtCursor) {
void query.where('counter', '>', changeAtCursor.counter);
}
const changes: Change[] = await query;
const compressedChanges = this.compressChanges(changes);
const changeWithItems = await this.loadChangeItems(compressedChanges);
void query
.orderBy('counter', 'asc')
.limit(pagination.limit) as any[];
const changes = await query;
const compressedChanges = await this.removeDeletedItems(this.compressChanges(changes));
return {
items: changeWithItems,
items: compressedChanges,
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
// If there's no change, we return the previous cursor.
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
@@ -120,15 +174,19 @@ export default class ChangeModel extends BaseModel<Change> {
};
}
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
const itemIds = changes.map(c => c.item_id);
const fileModel = this.models().file({ userId: this.userId });
const items: File[] = await fileModel.loadByIds(itemIds);
const output: ChangeWithItem[] = [];
// We skip permission check here because, when an item is shared, we need
// to fetch files that don't belong to the current user. This check
// would not be needed anyway because the change items are generated in
// a context where permissions have already been checked.
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
const output: Change[] = [];
for (const change of changes) {
let item = items.find(f => f.id === change.item_id);
const item = items.find(f => f.id === change.item_id);
// If the item associated with this change has been deleted, we have
// two cases:
@@ -136,63 +194,78 @@ export default class ChangeModel extends BaseModel<Change> {
// - If it's anything else, skip it. The "delete" change will be
// sent on one of the next pages.
if (!item) {
if (change.type === ChangeType.Delete) {
item = {
id: change.item_id,
name: change.item_name,
};
} else {
continue;
}
if (!item && change.type !== ChangeType.Delete) {
continue;
}
output.push({
type: change.type,
updated_time: change.updated_time,
item: item,
});
output.push(change);
}
return output;
}
// Compresses the changes so that, for example, multiple updates on the same
// item are reduced down to one, because calling code usually only needs to
// know that the item has changed at least once. The reduction is basically:
//
// create - update => create
// create - delete => NOOP
// update - update => update
// update - delete => delete
//
// There's one exception for changes that include a "previous_item". This is
// used to save specific properties about the previous state of the item,
// such as "jop_parent_id" or "name", which is used by the share mechanism
// to know if an item has been moved from one folder to another. In that
// case, we need to know about each individual change, so they are not
// compressed.
private compressChanges(changes: Change[]): Change[] {
const itemChanges: Record<Uuid, Change> = {};
const uniqueUpdateChanges: Record<Uuid, Record<string, Change>> = {};
for (const change of changes) {
const previous = itemChanges[change.item_id];
const itemId = change.item_id;
const previous = itemChanges[itemId];
if (change.type === ChangeType.Update) {
const key = md5(itemId + change.previous_item);
if (!uniqueUpdateChanges[itemId]) uniqueUpdateChanges[itemId] = {};
uniqueUpdateChanges[itemId][key] = change;
}
if (previous) {
// create - update => create
// create - delete => NOOP
// update - update => update
// update - delete => delete
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
continue;
}
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
delete itemChanges[change.item_id];
delete itemChanges[itemId];
}
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
} else {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
}
const output = [];
const output: Change[] = [];
for (const itemId in itemChanges) {
output.push(itemChanges[itemId]);
const change = itemChanges[itemId];
if (change.type === ChangeType.Update) {
for (const key of Object.keys(uniqueUpdateChanges[itemId])) {
output.push(uniqueUpdateChanges[itemId][key]);
}
} else {
output.push(change);
}
}
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
@@ -200,4 +273,10 @@ export default class ChangeModel extends BaseModel<Change> {
return output;
}
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {
const savedChange = await super.save(change, options);
ChangeModel.eventEmitter.emit('saved');
return savedChange;
}
}

View File

@@ -1,93 +0,0 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
import { File } from '../db';
describe('FileModel', function() {
beforeAll(async () => {
await beforeAllDb('FileModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should compute item full path', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const rootId = await fileModel.userRootFileId();
const tree: any = {
folder1: {},
folder2: {
file2_1: null,
file2_2: null,
},
folder3: {
file3_1: null,
},
file1: null,
file2: null,
file3: null,
};
await createFileTree(fileModel, rootId, tree);
const testCases = Object.keys(tree)
.concat(Object.keys(tree.folder2))
.concat(Object.keys(tree.folder3));
for (const t of testCases) {
const file: File = await fileModel.loadByName(t);
const path = await fileModel.itemFullPath(file);
const fileBackId: string = await fileModel.pathToFileId(path);
expect(file.id).toBe(fileBackId);
}
const rootPath = await fileModel.itemFullPath(await fileModel.userRootFile());
expect(rootPath).toBe('root');
const fileBackId: string = await fileModel.pathToFileId(rootPath);
expect(fileBackId).toBe(rootId);
});
test('should resolve file paths', async function() {
const testCases = [
[
['root', '.resource', 'test'],
'root:/.resource/test:',
],
[
['root:/.resource:', 'test'],
'root:/.resource/test:',
],
[
['root:/.resource:', ''],
'root:/.resource:',
],
[
['root:/.resource:'],
'root:/.resource:',
],
[
['root:/.resource:'],
'root:/.resource:',
],
[
['root'],
'root',
],
];
const fileModel = models().file();
for (const t of testCases) {
const [input, expected] = t;
const actual = fileModel.resolve(...input);
expect(actual).toBe(expected);
}
});
});

View File

@@ -1,498 +0,0 @@
import BaseModel, { ValidateOptions, SaveOptions, DeleteOptions } from './BaseModel';
import { File, ItemType, databaseSchema, Uuid } from '../db';
import { ErrorForbidden, ErrorUnprocessableEntity, ErrorNotFound, ErrorBadRequest, ErrorConflict } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import { splitItemPath, filePathInfo } from '../utils/routeUtils';
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
import { setQueryParameters } from '../utils/urlUtils';
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
const nodeEnv = process.env.NODE_ENV || 'development';
const removeTrailingColonsRegex = /^(:|)(.*?)(:|)$/;
export interface PaginatedFiles extends PaginatedResults {
items: File[];
}
export interface PathToFileOptions {
mustExist?: boolean;
returnFullEntity?: boolean;
}
export interface LoadOptions {
skipPermissionCheck?: boolean;
}
export default class FileModel extends BaseModel<File> {
private readonly reservedCharacters = ['/', '\\', '*', '<', '>', '?', ':', '|', '#', '%'];
protected get tableName(): string {
return 'files';
}
protected get trackChanges(): boolean {
return true;
}
protected get itemType(): ItemType {
return ItemType.File;
}
protected get hasParentId(): boolean {
return true;
}
public async userRootFile(): Promise<File> {
const file: File = await this.db<File>(this.tableName).select(...this.defaultFields).from(this.tableName).where({
'owner_id': this.userId,
'is_root': 1,
}).first();
if (file) await this.checkCanReadPermissions(file);
return file;
}
public async userRootFileId(): Promise<string> {
const r = await this.userRootFile();
return r ? r.id : '';
}
private isSpecialDir(dirname: string): boolean {
return dirname === 'root';
}
private async specialDirId(dirname: string): Promise<string> {
if (dirname === 'root') return this.userRootFileId();
return null; // Not a special dir
}
public async itemFullPaths(items: File[]): Promise<Record<string, string>> {
const output: Record<string, string> = {};
const itemCache: Record<string, File> = {};
await this.withTransaction(async () => {
for (const item of items) {
const segments: string[] = [];
let current: File = item;
while (current) {
if (current.is_root) break;
segments.splice(0, 0, current.name);
if (current.parent_id) {
const id = current.parent_id;
current = itemCache[id] ? itemCache[id] : await this.load(id);
itemCache[id] = current;
} else {
current = null;
}
}
output[item.id] = segments.length ? (`root:/${segments.join('/')}:`) : 'root';
}
}, 'FileModel::itemFullPaths');
return output;
}
public async itemDisplayPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
const path = await this.itemFullPath(item, loadOptions);
return this.removeTrailingColons(path.replace(/root:\//, ''));
}
public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
const segments: string[] = [];
while (item) {
if (item.is_root) break;
segments.splice(0, 0, item.name);
item = item.parent_id ? await this.load(item.parent_id, loadOptions) : null;
}
return segments.length ? (`root:/${segments.join('/')}:`) : 'root';
}
// Remove first and last colon from a path element
private removeTrailingColons(path: string): string {
return path.replace(removeTrailingColonsRegex, '$2');
}
public resolve(...paths: string[]): string {
if (!paths.length) throw new Error('Path is empty');
let pathElements: string[] = [];
for (const p of paths) {
pathElements = pathElements.concat(p.split('/').map(s => this.removeTrailingColons(s)));
}
pathElements = pathElements.filter(p => !!p);
if (!this.isSpecialDir(pathElements[0])) throw new Error(`Path must start with a special dir: ${pathElements.join('/')}`);
if (pathElements.length === 1) return pathElements[0];
// If the output is just "root", we return that only. Otherwise we build the path, eg. `root:/.resource/file:'`
const specialDir = pathElements.splice(0, 1)[0];
return `${specialDir}:/${pathElements.join('/')}:`;
}
// Same as `pathToFile` but returns the ID only.
public async pathToFileId(idOrPath: string, options: PathToFileOptions = {}): Promise<Uuid> {
const file = await this.pathToFile(idOrPath, {
...options,
returnFullEntity: false,
});
return file ? file.id : null;
}
// Converts an ID such as "Ps2YtQ8Udi4eCYm1A5bLFDGhHCWWCR43" or a path such
// as "root:/path/to/file.txt:" to the actual file.
public async pathToFile(idOrPath: string, options: PathToFileOptions = {}): Promise<File> {
if (!idOrPath) throw new Error('ID cannot be null');
options = {
mustExist: true,
returnFullEntity: true,
...options,
};
const specialDirId = await this.specialDirId(idOrPath);
if (specialDirId) {
return options.returnFullEntity ? this.load(specialDirId) : { id: specialDirId };
} else if (idOrPath.indexOf(':') < 0) {
if (options.mustExist) {
const file = await this.load(idOrPath);
if (!file) throw new ErrorNotFound(`file not found: ${idOrPath}`);
return options.returnFullEntity ? file : { id: file.id };
} else {
return options.returnFullEntity ? this.load(idOrPath) : { id: idOrPath };
}
} else {
// When this input is a path, there can be two cases:
// - A path to an existing file - in which case we return the file
// - A path to a file that needs to be created - in which case we
// return a file with all the relevant properties populated. This
// file might then be created by the caller.
// The caller can check file.id to see if it's a new or existing file.
// In both cases the directories before the filename must exist.
const fileInfo = filePathInfo(idOrPath);
const parentFiles = await this.pathToFiles(fileInfo.dirname);
const parentId = parentFiles[parentFiles.length - 1].id;
if (!fileInfo.basename) {
const specialDirId = await this.specialDirId(fileInfo.dirname);
// The path simply refers to a special directory. Can happen
// for example with a path like `root:/:` (which is the same
// as just `root`).
if (specialDirId) return { id: specialDirId };
}
// This is an existing file
const existingFile = await this.fileByName(parentId, fileInfo.basename);
if (existingFile) return options.returnFullEntity ? existingFile : { id: existingFile.id };
if (options.mustExist) throw new ErrorNotFound(`file not found: ${idOrPath}`);
// This is a potentially new file
return {
name: fileInfo.basename,
parent_id: parentId,
};
}
}
protected get defaultFields(): string[] {
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
}
public async fileByName(parentId: string, name: string, options: LoadOptions = {}): Promise<File> {
const file = await this.db<File>(this.tableName).select(...this.defaultFields).where({
parent_id: parentId,
name: name,
}).first();
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
}
protected async validate(object: File, options: ValidateOptions = {}): Promise<File> {
const file: File = object;
const mustBeFile = options.rules.mustBeFile === true;
if (options.isNew) {
if (!file.is_root && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
if (file.is_directory && mustBeFile) throw new ErrorUnprocessableEntity('item must not be a directory');
} else {
if ('name' in file && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
if ('is_directory' in file) throw new ErrorUnprocessableEntity('cannot turn a file into a directory or vice-versa');
if (mustBeFile && !('is_directory' in file)) {
const existingFile = await this.load(file.id);
if (existingFile.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
} else {
if (file.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
}
}
let parentId = file.parent_id;
if (!parentId) parentId = await this.userRootFileId();
if ('parent_id' in file && !file.is_root) {
const invalidParentError = function(extraInfo: string) {
let msg = `Invalid parent ID or no permission to write to it: ${parentId}`;
if (nodeEnv !== 'production') msg += ` (${extraInfo})`;
return new ErrorForbidden(msg);
};
if (!parentId) throw invalidParentError('No parent ID');
try {
const parentFile: File = await this.load(parentId);
if (!parentFile) throw invalidParentError('Cannot load parent file');
if (!parentFile.is_directory) throw invalidParentError('Specified parent is not a directory');
await this.checkCanWritePermission(parentFile);
} catch (error) {
if (error.message.indexOf('Invalid parent') === 0) throw error;
throw invalidParentError(`Unknown: ${error.message}`);
}
}
if ('name' in file && !file.is_root) {
const existingFile = await this.fileByName(parentId, file.name);
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}"`);
if (existingFile && file.id === existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}"`);
}
if ('name' in file) {
if (this.includesReservedCharacter(file.name)) throw new ErrorUnprocessableEntity(`File name may not contain any of these characters: ${this.reservedCharacters.join('')}: ${file.name}`);
}
return file;
}
public fromApiInput(object: File): File {
const file: File = {};
if ('id' in object) file.id = object.id;
if ('name' in object) file.name = object.name;
if ('parent_id' in object) file.parent_id = object.parent_id;
if ('mime_type' in object) file.mime_type = object.mime_type;
if ('is_directory' in object) file.is_directory = object.is_directory;
return file;
}
public toApiOutput(object: any): any {
if (Array.isArray(object)) {
return object.map(f => this.toApiOutput(f));
} else {
const output: File = { ...object };
delete output.content;
return output;
}
}
public async createRootFile(): Promise<File> {
const existingRootFile = await this.userRootFile();
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
const id = uuidgen();
return this.save({
id: id,
is_directory: 1,
is_root: 1,
name: id, // Name must be unique so we set it to the ID
}, {
isNew: true,
trackChanges: false, // Root file always exist and never changes so we don't want any change event being generated
});
}
private async checkCanReadOrWritePermissions(methodName: 'canRead' | 'canWrite', file: File | File[]): Promise<void> {
const files = Array.isArray(file) ? file : [file];
if (!files.length || !files[0]) throw new ErrorNotFound();
const fileIds = files.map(f => f.id);
const permissionModel = this.models().permission();
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
for (const file of files) {
if (file.owner_id === this.userId) permissionGrantedMap[file.id] = true;
}
for (const fileId in permissionGrantedMap) {
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No read access to: ${fileId}`);
}
}
private async checkCanReadPermissions(file: File | File[]): Promise<void> {
await this.checkCanReadOrWritePermissions('canRead', file);
}
private async checkCanWritePermission(file: File): Promise<void> {
await this.checkCanReadOrWritePermissions('canWrite', file);
}
private includesReservedCharacter(path: string): boolean {
return this.reservedCharacters.some(c => path.indexOf(c) >= 0);
}
public async fileUrl(idOrPath: string, query: any = null): Promise<string> {
const file: File = await this.pathToFile(idOrPath);
const contentSuffix = !file.is_directory ? '/content' : '';
return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}${contentSuffix}`, query);
}
private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {
const filenames = splitItemPath(path);
const output: File[] = [];
let parent: File = null;
for (let i = 0; i < filenames.length; i++) {
const filename = filenames[i];
let file: File = null;
if (i === 0) {
// For now we only support "root" as a root component, but potentially it could
// be any special directory like "documents", "pictures", etc.
if (filename !== 'root') throw new ErrorBadRequest(`unknown path root component: ${filename}`);
file = await this.userRootFile();
} else {
file = await this.fileByName(parent.id, filename);
}
if (!file && mustExist) throw new ErrorNotFound(`file not found: "${filename}" on parent "${parent ? parent.name : ''}"`);
output.push(file);
parent = { ...file };
}
if (!output.length && mustExist) throw new ErrorBadRequest(`path without a base directory: ${path}`);
return output;
}
// Mostly makes sense for testing/debugging because the filename would
// have to globally unique, which is not a requirement.
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
const file: File = await this.db(this.tableName)
.select(this.defaultFields)
.where({ name: name })
.andWhere({ owner_id: this.userId })
.first();
if (!file) throw new ErrorNotFound(`No such file: ${name}`);
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
}
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
}
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<File[]> {
const files: File[] = await super.loadByIds(ids);
if (!files.length) return [];
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files);
return files;
}
public async load(id: string, options: LoadOptions = {}): Promise<File> {
const file: File = await super.load(id);
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
}
public async save(object: File, options: SaveOptions = {}): Promise<File> {
const isNew = await this.isNew(object, options);
const file: File = { ... object };
if ('content' in file) file.size = file.content ? file.content.byteLength : 0;
if (isNew) {
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
// Even if there's no content, set the mime type based on the extension
if (!file.is_directory) file.mime_type = mimeUtils.fromFilename(file.name);
// Make sure it's not NULL, which is not allowed
if (!file.mime_type) file.mime_type = '';
file.owner_id = this.userId;
}
return super.save(file, options);
}
public async childrenCount(id: string): Promise<number> {
const parent = await this.load(id);
await this.checkCanReadPermissions(parent);
const r: any = await this.db(this.tableName).where('parent_id', id).count('id', { as: 'total' }).first();
return r.total;
}
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
const parent = await this.load(id);
await this.checkCanReadPermissions(parent);
return paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
}
private async childrenIds(id: string): Promise<string[]> {
const output = await this.db(this.tableName).select('id').where('parent_id', id);
return output.map(r => r.id);
}
public async deleteChildren(id: string): Promise<void> {
const file: File = await this.load(id);
if (!file) return;
await this.checkCanWritePermission(file);
if (!file.is_directory) throw new ErrorBadRequest(`Not a directory: ${id}`);
await this.withTransaction(async () => {
const childrenIds = await this.childrenIds(file.id);
for (const childId of childrenIds) {
await this.delete(childId);
}
}, 'FileModel::deleteChildren');
}
public async delete(id: string, options: DeleteOptions = {}): Promise<void> {
const file: File = await this.load(id);
if (!file) return;
await this.checkCanWritePermission(file);
const canDeleteRoot = !!options.validationRules && !!options.validationRules.canDeleteRoot;
if (id === await this.userRootFileId() && !canDeleteRoot) throw new ErrorForbidden('the root directory may not be deleted');
await this.withTransaction(async () => {
await this.models().permission().deleteByFileId(id);
if (file.is_directory) {
const childrenIds = await this.childrenIds(file.id);
for (const childId of childrenIds) {
await this.delete(childId);
}
}
await super.delete(id);
}, 'FileModel::delete');
}
}

View File

@@ -0,0 +1,70 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createItem, createItemTree } from '../utils/testing/testUtils';
import { ShareType } from '../db';
import { shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
describe('ItemModel', function() {
beforeAll(async () => {
await beforeAllDb('ItemModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should find exclusively owned items', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1, true);
const { session: session2, user: user2 } = await createUserAndSession(2);
const tree: any = {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
};
const itemModel1 = models().item();
await createItemTree(user1.id, '', tree);
await createItem(session2.id, 'root:/test.txt:', 'testing');
{
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
expect(itemIds.length).toBe(2);
const item1 = await models().item().load(itemIds[0]);
const item2 = await models().item().load(itemIds[1]);
expect([item1.jop_id, item2.jop_id].sort()).toEqual(['000000000000000000000000000000F1', '00000000000000000000000000000001'].sort());
}
{
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
expect(itemIds.length).toBe(1);
}
const folderItem = await itemModel1.loadByJopId(user1.id, '000000000000000000000000000000F1');
await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.JoplinRootFolder, folderItem);
{
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
expect(itemIds.length).toBe(0);
}
{
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
expect(itemIds.length).toBe(1);
}
await models().user().delete(user2.id);
{
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
expect(itemIds.length).toBe(2);
}
});
});

View File

@@ -0,0 +1,464 @@
import BaseModel, { SaveOptions, LoadOptions, DeleteOptions, ValidateOptions } from './BaseModel';
import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType } from '../db';
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
import { isJoplinItemName, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../apps/joplin/joplinUtils';
import { ModelType } from '@joplin/lib/BaseModel';
import { ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors';
import { Knex } from 'knex';
import { ChangePreviousItem } from './ChangeModel';
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
// Converts "root:/myfile.txt:" to "myfile.txt"
const extractNameRegex = /^root:\/(.*):$/;
export interface PaginatedItems extends PaginatedResults {
items: Item[];
}
export interface SharedRootInfo {
item: Item;
share: Share;
}
export default class ItemModel extends BaseModel<Item> {
protected get tableName(): string {
return 'items';
}
protected get itemType(): ItemType {
return ItemType.Item;
}
protected get hasParentId(): boolean {
return false;
}
protected get defaultFields(): string[] {
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
}
// public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise<void> {
// if (action === AclAction.Create) {
// }
// if (action === AclAction.Read) {
// if (user.is_admin) return;
// if (user.id !== resource.id) throw new ErrorForbidden('cannot view other users');
// }
// if (action === AclAction.Update) {
// if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
// if (!user.is_admin && 'is_admin' in resource) throw new ErrorForbidden('non-admin user cannot make themselves an admin');
// if (user.is_admin && user.id === resource.id && 'is_admin' in resource && !resource.is_admin) throw new ErrorForbidden('admin user cannot make themselves a non-admin');
// }
// if (action === AclAction.Delete) {
// if (!user.is_admin) throw new ErrorForbidden('only admins can delete users');
// }
// if (action === AclAction.List) {
// if (!user.is_admin) throw new ErrorForbidden('non-admin cannot list users');
// }
// }
public fromApiInput(item: Item): Item {
const output: Item = {};
if ('id' in item) item.id = output.id;
if ('name' in item) item.name = output.name;
if ('mime_type' in item) item.mime_type = output.mime_type;
return output;
}
protected objectToApiOutput(object: Item): Item {
const output: Item = {};
const propNames = ['id', 'name', 'updated_time', 'created_time'];
for (const k of Object.keys(object)) {
if (propNames.includes(k)) (output as any)[k] = (object as any)[k];
}
return output;
}
public async userHasItem(userId: Uuid, itemId: Uuid): Promise<boolean> {
const r = await this
.db('user_items')
.select('user_items.id')
.where('user_items.user_id', '=', userId)
.where('user_items.item_id', '=', itemId)
.first();
return !!r;
}
// Remove first and last colon from a path element
public pathToName(path: string): string {
if (path === 'root') return '';
return path.replace(extractNameRegex, '$1');
}
public async loadByJopIds(userId: Uuid, jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
if (!jopIds.length) return [];
return this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, null, 'items'))
.where('user_items.user_id', '=', userId)
.whereIn('jop_id', jopIds);
}
public async loadByJopId(userId: Uuid, jopId: string, options: LoadOptions = {}): Promise<Item> {
const items = await this.loadByJopIds(userId, [jopId], options);
return items.length ? items[0] : null;
}
public async loadByNames(userId: Uuid, names: string[], options: LoadOptions = {}): Promise<Item[]> {
if (!names.length) return [];
return this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, null, 'items'))
.where('user_items.user_id', '=', userId)
.whereIn('name', names);
}
public async loadByName(userId: Uuid, name: string, options: LoadOptions = {}): Promise<Item> {
const items = await this.loadByNames(userId, [name], options);
return items.length ? items[0] : null;
}
public async loadWithContent(id: Uuid, options: LoadOptions = {}): Promise<Item> {
return this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, ['*'], 'items'))
.where('items.id', '=', id)
.first();
}
public async loadAsSerializedJoplinItem(id: Uuid): Promise<string> {
return serializeJoplinItem(await this.loadAsJoplinItem(id));
}
public async serializedContent(item: Item | Uuid): Promise<Buffer> {
item = typeof item === 'string' ? await this.loadWithContent(item) : item;
if (item.jop_type > 0) {
return Buffer.from(await serializeJoplinItem(this.itemToJoplinItem(item)));
} else {
return item.content;
}
}
private async folderChildrenItems(userId: Uuid, folderId: string): Promise<Item[]> {
let output: Item[] = [];
const rows: Item[] = await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select('items.id', 'items.jop_id', 'items.jop_type')
.where('items.jop_parent_id', '=', folderId)
.where('user_items.user_id', '=', userId);
for (const row of rows) {
output.push(row);
if (row.jop_type === ModelType.Folder) {
const children = await this.folderChildrenItems(userId, row.jop_id);
output = output.concat(children);
}
}
return output;
}
public async shareJoplinFolderAndContent(shareId: Uuid, fromUserId: Uuid, toUserId: Uuid, folderId: string) {
const folderItem = await this.loadByJopId(fromUserId, folderId, { fields: ['id'] });
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
const items = [folderItem].concat(await this.folderChildrenItems(fromUserId, folderId));
const alreadySharedItemIds: string[] = await this
.db('user_items')
.pluck('item_id')
.whereIn('item_id', items.map(i => i.id))
.where('user_id', '=', toUserId);
await this.withTransaction(async () => {
for (const item of items) {
if (alreadySharedItemIds.includes(item.id)) continue;
await this.models().userItem().add(toUserId, item.id, shareId);
if (item.jop_type === ModelType.Note) {
const resourceIds = await this.models().itemResource().byItemId(item.id);
await this.models().share().updateResourceShareStatus(true, shareId, fromUserId, toUserId, resourceIds);
}
}
});
}
public itemToJoplinItem(itemRow: Item): any {
if (itemRow.jop_type <= 0) throw new Error(`Not a Joplin item: ${itemRow.id}`);
if (!itemRow.content) throw new Error('Item content is missing');
const item = JSON.parse(itemRow.content.toString());
item.id = itemRow.jop_id;
item.parent_id = itemRow.jop_parent_id;
item.type_ = itemRow.jop_type;
item.encryption_applied = itemRow.jop_encryption_applied;
return item;
}
public async loadAsJoplinItem(id: Uuid): Promise<any> {
const raw = await this.loadWithContent(id);
return this.itemToJoplinItem(raw);
}
public async saveFromRawContent(userId: Uuid, name: string, buffer: Buffer): Promise<Item> {
const existingItem = await this.loadByName(userId, name);
const isJoplinItem = isJoplinItemName(name);
let isNote = false;
const item: Item = {
name,
};
let resourceIds: string[] = [];
if (isJoplinItem) {
const joplinItem = await unserializeJoplinItem(buffer.toString());
isNote = joplinItem.type_ === ModelType.Note;
resourceIds = isNote ? linkedResourceIds(joplinItem.body) : [];
item.jop_id = joplinItem.id;
item.jop_parent_id = joplinItem.parent_id || '';
item.jop_type = joplinItem.type_;
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
delete joplinItem.id;
delete joplinItem.parent_id;
delete joplinItem.type_;
delete joplinItem.encryption_applied;
item.content = Buffer.from(JSON.stringify(joplinItem));
} else {
item.content = buffer;
}
if (existingItem) item.id = existingItem.id;
return this.withTransaction<Item>(async () => {
const savedItem = await this.saveForUser(userId, item);
if (isNote) {
await this.models().itemResource().deleteByItemId(savedItem.id);
await this.models().itemResource().addResourceIds(savedItem.id, resourceIds);
}
return savedItem;
});
}
protected async validate(item: Item, options: ValidateOptions = {}): Promise<Item> {
if (options.isNew) {
if (!item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
} else {
if ('name' in item && !item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
}
return super.validate(item, options);
}
private childrenQuery(userId: Uuid, pathQuery: string = '', options: LoadOptions = {}): Knex.QueryBuilder {
const query = this
.db('user_items')
.leftJoin('items', 'user_items.item_id', 'items.id')
.select(this.selectFields(options, ['id', 'name', 'updated_time'], 'items'))
.where('user_items.user_id', '=', userId);
if (pathQuery) {
// We support /* as a prefix only. Anywhere else would have
// performance issue or requires a revert index.
const sqlLike = pathQuery.replace(/\/\*$/g, '/%');
void query.where('name', 'like', sqlLike);
}
return query;
}
public itemUrl(): string {
return `${this.baseUrl}/items`;
}
public itemContentUrl(itemId: Uuid): string {
return `${this.baseUrl}/items/${itemId}/content`;
}
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: LoadOptions = {}): Promise<PaginatedItems> {
pagination = pagination || defaultPagination();
const query = this.childrenQuery(userId, pathQuery, options);
return paginateDbQuery(query, pagination, 'items');
}
public async childrenCount(userId: Uuid, pathQuery: string = ''): Promise<number> {
const query = this.childrenQuery(userId, pathQuery);
return query.count();
}
private async joplinItemPath(jopId: string): Promise<Item[]> {
// Use Recursive Common Table Expression to find path to given item
// https://www.sqlite.org/lang_with.html#recursivecte
// with recursive paths(id, jop_id, jop_parent_id) as (
// select id, jop_id, jop_parent_id from items where jop_id = '000000000000000000000000000000F1'
// union
// select items.id, items.jop_id, items.jop_parent_id
// from items join paths where items.jop_id = paths.jop_parent_id
// )
// select id, jop_id, jop_parent_id from paths;
return this.db.withRecursive('paths', (qb: Knex.QueryBuilder) => {
void qb.select('id', 'jop_id', 'jop_parent_id')
.from('items')
.where('jop_id', '=', jopId)
.whereIn('jop_type', [ModelType.Note, ModelType.Folder])
.union((qb: Knex.QueryBuilder) => {
void qb
.select('items.id', 'items.jop_id', 'items.jop_parent_id')
.from('items')
.join('paths', 'items.jop_id', 'paths.jop_parent_id')
.whereIn('jop_type', [ModelType.Note, ModelType.Folder]);
});
}).select('id', 'jop_id', 'jop_parent_id').from('paths');
}
// If the note or folder is within a shared folder, this function returns
// that shared folder. It returns null otherwise.
public async joplinItemSharedRootInfo(jopId: string): Promise<SharedRootInfo | null> {
const path = await this.joplinItemPath(jopId);
if (!path.length) throw new Error(`Cannot retrieve path for item: ${jopId}`);
const rootFolderItem = path[path.length - 1];
const share = await this.models().share().itemShare(ShareType.JoplinRootFolder, rootFolderItem.id);
if (!share) return null;
return {
item: await this.load(rootFolderItem.id),
share,
};
}
public async allForDebug(): Promise<any[]> {
const items = await this.all({ fields: ['*'] });
return items.map(i => {
if (!i.content) return i;
i.content = i.content.toString() as any;
return i;
});
}
// Returns the item IDs that are owned only by the given user. In other
// words, the items that are not shared with anyone else. Such items
// can be safely deleted when the user is deleted.
public async exclusivelyOwnedItemIds(userId: Uuid): Promise<Uuid[]> {
const query = this
.db('items')
.select(this.db.raw('items.id, count(user_items.item_id) as user_item_count'))
.leftJoin('user_items', 'user_items.item_id', 'items.id')
.whereIn('items.id', this.db('user_items').select('user_items.item_id').where('user_id', '=', userId))
.groupBy('items.id');
const rows: any[] = await query;
return rows.filter(r => r.user_item_count === 1).map(r => r.id);
}
public async deleteExclusivelyOwnedItems(userId: Uuid) {
const itemIds = await this.exclusivelyOwnedItemIds(userId);
await this.delete(itemIds);
}
public async deleteAll(userId: Uuid): Promise<void> {
while (true) {
const page = await this.children(userId, '', { ...defaultPagination(), limit: 1000 });
await this.delete(page.items.map(c => c.id));
if (!page.has_more) break;
}
}
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
const ids = typeof id === 'string' ? [id] : id;
if (!ids.length) return;
const shares = await this.models().share().byItemIds(ids);
await this.withTransaction(async () => {
await this.models().share().delete(shares.map(s => s.id));
await this.models().userItem().deleteByItemIds(ids);
await this.models().itemResource().deleteByItemIds(ids);
await super.delete(ids, options);
}, 'ItemModel::delete');
}
public async saveForUser(userId: Uuid, item: Item, options: SaveOptions = {}): Promise<Item> {
if (!userId) throw new Error('userId is required');
item = { ... item };
const isNew = await this.isNew(item, options);
if (item.content) {
item.content_size = item.content.byteLength;
}
let previousItem: ChangePreviousItem = null;
if (isNew) {
if (!item.mime_type) item.mime_type = mimeUtils.fromFilename(item.name) || '';
} else {
const beforeSaveItem = (await this.load(item.id, { fields: ['name', 'jop_type', 'jop_parent_id'] }));
const resourceIds = beforeSaveItem.jop_type === ModelType.Note ? await this.models().itemResource().byItemId(item.id) : [];
previousItem = {
jop_parent_id: beforeSaveItem.jop_parent_id,
name: beforeSaveItem.name,
jop_resource_ids: resourceIds,
};
}
return this.withTransaction(async () => {
item = await super.save(item, options);
if (isNew) await this.models().userItem().add(userId, item.id);
// We only record updates. This because Create and Update events are
// per user, whenever a user_item is created or deleted.
if (!isNew) {
const changeModel = this.models().change();
await changeModel.save({
item_type: this.itemType,
item_id: item.id,
item_name: item.name || previousItem.name,
type: isNew ? ChangeType.Create : ChangeType.Update,
previous_item: previousItem ? changeModel.serializePreviousItem(previousItem) : '',
user_id: userId,
});
}
return item;
});
}
public async save(_item: Item, _options: SaveOptions = {}): Promise<Item> {
throw new Error('Use saveForUser()');
// return this.saveForUser('', item, options);
}
}

View File

@@ -0,0 +1,43 @@
import { ItemResource, Uuid } from '../db';
import BaseModel from './BaseModel';
export default class ItemResourceModel extends BaseModel<ItemResource> {
public get tableName(): string {
return 'item_resources';
}
protected hasUuid(): boolean {
return false;
}
protected autoTimestampEnabled(): boolean {
return false;
}
public async deleteByItemIds(itemIds: Uuid[]): Promise<void> {
await this.db(this.tableName).whereIn('item_id', itemIds).delete();
}
public async deleteByItemId(itemId: Uuid): Promise<void> {
await this.deleteByItemIds([itemId]);
}
public async addResourceIds(itemId: Uuid, resourceIds: string[]): Promise<void> {
if (!resourceIds.length) return;
await this.withTransaction(async () => {
for (const resourceId of resourceIds) {
await this.save({
item_id: itemId,
resource_id: resourceId,
});
}
});
}
public async byItemId(itemId: Uuid): Promise<string[]> {
return this.db(this.tableName).pluck('resource_id').where('item_id', '=', itemId);
}
}

View File

@@ -0,0 +1,39 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
describe('KeyValueModel', function() {
beforeAll(async () => {
await beforeAllDb('KeyValueModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should set and get value', async function() {
const m = models().keyValue();
await m.setValue('testing1', 'something');
await m.setValue('testing2', 1234);
expect(await m.value('testing1')).toBe('something');
expect(await m.value('testing2')).toBe(1234);
await m.setValue('testing1', 456);
expect(await m.value('testing1')).toBe(456);
});
test('should delete value', async function() {
const m = models().keyValue();
await m.setValue('testing1', 'something');
await m.deleteValue('testing1');
expect(await m.value('testing1')).toBe(null);
});
});

View File

@@ -0,0 +1,64 @@
import { KeyValue } from '../db';
import BaseModel from './BaseModel';
export enum ValueType {
Integer = 1,
String = 2,
}
type Value = number | string;
export default class NotificationModel extends BaseModel<KeyValue> {
protected get tableName(): string {
return 'key_values';
}
protected hasUuid(): boolean {
return false;
}
protected autoTimestampEnabled(): boolean {
return false;
}
private valueType(value: Value): ValueType {
if (typeof value === 'number') return ValueType.Integer;
if (typeof value === 'string') return ValueType.String;
throw new Error(`Unsupported value type: ${typeof value}`);
}
private serializeValue(value: Value): string {
return value.toString();
}
private unserializeValue(type: ValueType, value: string): Value {
if (type === ValueType.Integer) return Number(value);
if (type === ValueType.String) return `${value}`;
throw new Error(`Unsupported type: ${type}`);
}
public async setValue(key: string, value: Value): Promise<void> {
const type = this.valueType(value);
await this.withTransaction(async () => {
await this.db(this.tableName).where('key', '=', key).delete();
await this.db(this.tableName).insert({
key,
value: this.serializeValue(value),
type,
});
});
}
public async value<T>(key: string, defaultValue: Value = null): Promise<T> {
const row: KeyValue = await this.db(this.tableName).where('key', '=', key).first();
if (!row) return defaultValue as any;
return this.unserializeValue(row.type, row.value) as any;
}
public async deleteValue(key: string): Promise<void> {
await this.db(this.tableName).where('key', '=', key).delete();
}
}

View File

@@ -16,14 +16,14 @@ describe('NotificationModel', function() {
});
test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('test', NotificationLevel.Normal, 'test'));
await expectThrow(async () => models().notification().add('', 'test', NotificationLevel.Normal, 'test'));
});
test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey('test');
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, 'test');
expect(n.key).toBe('test');
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
@@ -31,19 +31,19 @@ describe('NotificationModel', function() {
test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
await model.add('test', NotificationLevel.Important, 'testing');
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
expect((await model.all()).length).toBe(1);
});
test('should mark a notification as read', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
expect((await model.loadByKey('test')).read).toBe(0);
await model.markAsRead('test');
expect((await model.loadByKey('test')).read).toBe(1);
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, 'test')).read).toBe(0);
await model.markAsRead(user.id, 'test');
expect((await model.loadByKey(user.id, 'test')).read).toBe(1);
});
});

View File

@@ -1,5 +1,6 @@
import { Notification, NotificationLevel, Uuid } from '../db';
import BaseModel from './BaseModel';
import { ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel, { ValidateOptions } from './BaseModel';
export default class NotificationModel extends BaseModel<Notification> {
@@ -7,27 +8,32 @@ export default class NotificationModel extends BaseModel<Notification> {
return 'notifications';
}
public async add(key: string, level: NotificationLevel, message: string): Promise<Notification> {
const n: Notification = await this.loadByKey(key);
if (n) return n;
return this.save({ key, message, level, owner_id: this.userId });
protected async validate(notification: Notification, options: ValidateOptions = {}): Promise<Notification> {
if ('owner_id' in notification && !notification.owner_id) throw new ErrorUnprocessableEntity('Missing owner_id');
return super.validate(notification, options);
}
public async markAsRead(key: string): Promise<void> {
const n = await this.loadByKey(key);
public async add(userId: Uuid, key: string, level: NotificationLevel, message: string): Promise<Notification> {
const n: Notification = await this.loadByKey(userId, key);
if (n) return n;
return this.save({ key, message, level, owner_id: userId });
}
public async markAsRead(userId: Uuid, key: string): Promise<void> {
const n = await this.loadByKey(userId, key);
if (!n) return;
await this.db(this.tableName)
.update({ read: 1 })
.where('key', '=', key)
.andWhere('owner_id', '=', this.userId);
.andWhere('owner_id', '=', userId);
}
public loadByKey(key: string): Promise<Notification> {
public loadByKey(userId: Uuid, key: string): Promise<Notification> {
return this.db(this.tableName)
.select(this.defaultFields)
.where('key', '=', key)
.andWhere('owner_id', '=', this.userId)
.andWhere('owner_id', '=', userId)
.first();
}
@@ -47,8 +53,11 @@ export default class NotificationModel extends BaseModel<Notification> {
return this.db(this.tableName)
.select(this.defaultFields)
.where({ id: id })
.andWhere('owner_id', '=', this.userId)
.first();
}
public async deleteByUserId(userId: Uuid) {
await this.db(this.tableName).where('owner_id', '=', userId).delete();
}
}

View File

@@ -1,82 +0,0 @@
import BaseModel from './BaseModel';
import { Permission, ItemType, User, Uuid } from '../db';
enum ReadOrWriteKeys {
CanRead = 'can_read',
CanWrite = 'can_write',
}
// Tells whether the given item has the permission to do the required operation
// (can be read or write).
export type PermissionGrantedMap = Record<Uuid, boolean>;
export type PermissionMap = Record<Uuid, Permission[]>;
export default class PermissionModel extends BaseModel<Permission> {
protected get tableName(): string {
return 'permissions';
}
private async filePermissions(fileId: string, userId: string = null): Promise<Permission[]> {
const p = await this.filesPermissions([fileId], userId);
return p[fileId];
}
private async filesPermissions(fileIds: string[], userId: string = null): Promise<PermissionMap> {
const p: Permission = {
item_type: ItemType.File,
};
if (userId) p.user_id = userId;
const permissions: Permission[] = await this.db<Permission>(this.tableName).where(p).whereIn('item_id', fileIds).select();
const output: PermissionMap = {};
for (const fileId of fileIds) {
output[fileId] = [];
}
for (const permission of permissions) {
output[permission.item_id].push(permission);
}
return output;
}
private async canReadOrWrite(fileIds: string[], userId: string, method: ReadOrWriteKeys): Promise<PermissionGrantedMap> {
const output: PermissionGrantedMap = {};
if (!fileIds.length) throw new Error('No files specified');
if (!userId) throw new Error('No user specified');
const permissionMap = await this.filesPermissions(fileIds, userId);
const userModel = this.models().user({ userId: userId });
const user: User = await userModel.load(userId);
for (const fileId in permissionMap) {
const permissions = permissionMap[fileId];
output[fileId] = !!user.is_admin || !!permissions.find(p => !!p[method]);
}
return output;
}
public async canRead(fileId: string | string[], userId: string): Promise<PermissionGrantedMap> {
fileId = Array.isArray(fileId) ? fileId : [fileId];
return this.canReadOrWrite(fileId, userId, ReadOrWriteKeys.CanRead);
}
public async canWrite(fileId: string | string[], userId: string): Promise<PermissionGrantedMap> {
fileId = Array.isArray(fileId) ? fileId : [fileId];
return this.canReadOrWrite(fileId, userId, ReadOrWriteKeys.CanWrite);
}
public async deleteByFileId(fileId: string): Promise<void> {
const permissions = await this.filePermissions(fileId);
if (!permissions.length) return;
const ids = permissions.map(m => m.id);
await super.delete(ids);
}
}

View File

@@ -1,5 +1,5 @@
import BaseModel from './BaseModel';
import { User, Session } from '../db';
import { User, Session, Uuid } from '../db';
import uuidgen from '../utils/uuidgen';
import { ErrorForbidden } from '../utils/errors';
@@ -12,7 +12,7 @@ export default class SessionModel extends BaseModel<Session> {
public async sessionUser(sessionId: string): Promise<User> {
const session: Session = await this.load(sessionId);
if (!session) return null;
const userModel = this.models().user({ userId: session.user_id });
const userModel = this.models().user();
return userModel.load(session.user_id);
}
@@ -34,4 +34,8 @@ export default class SessionModel extends BaseModel<Session> {
await this.delete(sessionId);
}
public async deleteByUserId(userId: Uuid) {
await this.db(this.tableName).where('user_id', '=', userId).delete();
}
}

View File

@@ -1,6 +1,6 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
import { ShareType } from '../db';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
import { ShareType } from '../db';
describe('ShareModel', function() {
@@ -17,19 +17,16 @@ describe('ShareModel', function() {
});
test('should validate share objects', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const file = await fileModel.save({
name: 'test',
parent_id: await fileModel.userRootFileId(),
});
const { user, session } = await createUserAndSession(1, true);
const item = await createItem(session.id, 'root:/test.txt:', 'testing');
let error = null;
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).add(20 as ShareType, file.id));
error = await checkThrowAsync(async () => await models().share().createShare(user.id, 20 as ShareType, item.id));
expect(error instanceof ErrorBadRequest).toBe(true);
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).add(ShareType.Link, 'doesntexist'));
error = await checkThrowAsync(async () => await models().share().createShare(user.id, ShareType.Link, 'doesntexist'));
expect(error instanceof ErrorNotFound).toBe(true);
});

View File

@@ -1,7 +1,12 @@
import { Share, ShareType, Uuid } from '../db';
import { ErrorBadRequest } from '../utils/errors';
import { ModelType } from '@joplin/lib/BaseModel';
import { resourceBlobPath } from '../apps/joplin/joplinUtils';
import { Change, ChangeType, isUniqueConstraintError, Item, Share, ShareType, User, Uuid } from '../db';
import { unique } from '../utils/array';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { setQueryParameters } from '../utils/urlUtils';
import BaseModel, { ValidateOptions } from './BaseModel';
import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseModel';
import { ChangePreviousItem } from './ChangeModel';
import { SharedRootInfo } from './ItemModel';
export default class ShareModel extends BaseModel<Share> {
@@ -9,26 +14,353 @@ export default class ShareModel extends BaseModel<Share> {
return 'shares';
}
protected async validate(share: Share, _options: ValidateOptions = {}): Promise<Share> {
if ('type' in share && ![ShareType.Link, ShareType.App].includes(share.type)) throw new ErrorBadRequest(`Invalid share type: ${share.type}`);
public async checkIfAllowed(user: User, action: AclAction, resource: Share = null): Promise<void> {
if (action === AclAction.Create) {
if (!await this.models().item().userHasItem(user.id, resource.item_id)) throw new ErrorForbidden('cannot share an item not owned by the user');
}
return share;
if (action === AclAction.Read) {
if (user.id !== resource.owner_id) throw new ErrorForbidden('no access to this share');
}
}
public async add(type: ShareType, path: string): Promise<Share> {
const fileId: Uuid = await this.models().file({ userId: this.userId }).pathToFileId(path);
protected objectToApiOutput(object: Share): Share {
const output: Share = {};
if (object.id) output.id = object.id;
if (object.type) output.type = object.type;
if (object.folder_id) output.folder_id = object.folder_id;
if (object.note_id) output.note_id = object.note_id;
return output;
}
protected async validate(share: Share, options: ValidateOptions = {}): Promise<Share> {
if ('type' in share && ![ShareType.Link, ShareType.App, ShareType.JoplinRootFolder].includes(share.type)) throw new ErrorBadRequest(`Invalid share type: ${share.type}`);
if (share.type !== ShareType.Link && await this.itemIsShared(share.type, share.item_id)) throw new ErrorBadRequest('A shared item cannot be shared again');
const item = await this.models().item().load(share.item_id);
if (!item) throw new ErrorNotFound(`Could not find item: ${share.item_id}`);
return super.validate(share, options);
}
public async createShare(userId: Uuid, shareType: ShareType, itemId: Uuid): Promise<Share> {
const toSave: Share = {
type: type,
file_id: fileId,
owner_id: this.userId,
type: shareType,
item_id: itemId,
owner_id: userId,
};
return this.save(toSave);
}
public async itemShare(shareType: ShareType, itemId: string): Promise<Share> {
return this
.db(this.tableName)
.select(this.defaultFields)
.where('item_id', '=', itemId)
.where('type', '=', shareType)
.first();
}
public async itemIsShared(shareType: ShareType, itemId: string): Promise<boolean> {
const r = await this.itemShare(shareType, itemId);
return !!r;
}
public shareUrl(id: Uuid, query: any = null): string {
return setQueryParameters(`${this.baseUrl}/shares/${id}`, query);
}
public async byItemIds(itemIds: Uuid[]): Promise<Share[]> {
return this.db(this.tableName).select(this.defaultFields).whereIn('item_id', itemIds);
}
public async byUserAndItemId(userId: Uuid, itemId: Uuid): Promise<Share> {
return this.db(this.tableName).select(this.defaultFields)
.where('owner_id', '=', userId)
.where('item_id', '=', itemId)
.first();
}
public async sharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
const query = this.db(this.tableName)
.select(this.defaultFields)
.where('owner_id', '=', userId);
if (type) void query.andWhere('type', '=', type);
return query;
}
// Returns all user IDs concerned by the share. That includes all the users
// the folder has been shared with, as well as the folder owner.
private async allShareUserIds(share:Share) {
const shareUsers = await this.models().shareUser().byShareId(share.id);
const userIds = shareUsers.map(su => su.user_id);
userIds.push(share.owner_id);
return userIds;
}
public async updateSharedItems() {
interface ResourceChanges {
share: Share;
added: string[];
removed: string[];
}
type ResourceChangesPerShareId = Record<Uuid, ResourceChanges>;
let resourceChanges: ResourceChangesPerShareId = {};
const handleAddedToSharedFolder = async (item: Item, shareInfo: SharedRootInfo) => {
const userIds = await this.allShareUserIds(shareInfo.share);
for (const userId of userIds) {
try {
await this.models().userItem().add(userId, item.id);
} catch (error) {
if (isUniqueConstraintError(error)) {
// Ignore - it means this user already has this item
} else {
throw error;
}
}
}
};
const handleRemovedFromSharedFolder = async (change:Change, item: Item, shareInfo: SharedRootInfo) => {
// This is called when a note parent ID changes and is moved out of
// the shared folder. In that case, we need to unshare the item from
// all users, except the one who did the action.
//
// - User 1 shares a folder with user 2
// - User 2 moves a note out of the shared folder
// - User 1 should no longer see the note. User 2 still sees it
// since they have moved it to one of their own folders.
const userIds = await this.allShareUserIds(shareInfo.share);
for (const userId of userIds) {
if (change.user_id !== userId) {
await this.models().userItem().remove(userId, item.id);
}
}
};
const handleResourceSharing = async (previousItem: ChangePreviousItem, item: Item, previousShareInfo: SharedRootInfo, currentShareInfo: SharedRootInfo) => {
// Not a note - we can exit
if (item.jop_type !== ModelType.Note) return;
// Item was not in a shared folder and is still not in one - nothing to do
if (!previousShareInfo && !currentShareInfo) return;
if (currentShareInfo && !resourceChanges[currentShareInfo.share.id]) {
resourceChanges[currentShareInfo.share.id] = {
share: currentShareInfo.share,
added: [],
removed: [],
};
}
if (previousShareInfo && !resourceChanges[previousShareInfo.share.id]) {
resourceChanges[previousShareInfo.share.id] = {
share: previousShareInfo.share,
added: [],
removed: [],
};
}
// Item was moved out of a shared folder to a non-shared folder - unshare all resources
if (previousShareInfo && !currentShareInfo) {
resourceChanges[previousShareInfo.share.id].removed = resourceChanges[previousShareInfo.share.id].removed.concat(previousItem.jop_resource_ids);
return;
}
// Item was moved from a non-shared folder to a shared one - share all resources
if (!previousShareInfo && currentShareInfo) {
resourceChanges[currentShareInfo.share.id].added = resourceChanges[currentShareInfo.share.id].added.concat(await this.models().itemResource().byItemId(item.id));
return;
}
// Note either stayed in the same shared folder, or moved to another
// shared folder. In that case, we check the note content before and
// after and see if resources have been added or removed from it,
// then we share/unshare resources based on this.
const previousResourceIds = previousItem ? previousItem.jop_resource_ids : [];
const currentResourceIds = await this.models().itemResource().byItemId(item.id);
for (const resourceId of previousResourceIds) {
if (!currentResourceIds.includes(resourceId)) resourceChanges[currentShareInfo.share.id].removed.push(resourceId);
}
for (const resourceId of currentResourceIds) {
if (!previousResourceIds.includes(resourceId)) resourceChanges[currentShareInfo.share.id].added.push(resourceId);
}
};
const handleCreatedItem = async (_change: Change, item: Item) => {
if (!item.jop_parent_id) return;
const shareInfo = await this.models().item().joplinItemSharedRootInfo(item.jop_parent_id);
if (!shareInfo) return;
await handleAddedToSharedFolder(item, shareInfo);
};
const handleUpdatedItem = async (change: Change, item: Item) => {
if (![ModelType.Note, ModelType.Folder].includes(item.jop_type)) return;
const previousItem = this.models().change().unserializePreviousItem(change.previous_item);
const previousShareInfo = previousItem?.jop_parent_id ? await this.models().item().joplinItemSharedRootInfo(previousItem.jop_parent_id) : null;
const currentShareInfo = item.jop_parent_id ? await this.models().item().joplinItemSharedRootInfo(item.jop_parent_id) : null;
await handleResourceSharing(previousItem, item, previousShareInfo, currentShareInfo);
// Item was not in a shared folder and is still not in one
if (!previousShareInfo && !currentShareInfo) return;
// Item was in a shared folder and is still in the same shared folder
if (previousShareInfo && currentShareInfo && previousShareInfo.item.jop_parent_id === currentShareInfo.item.jop_parent_id) return;
// Item was not previously in a shared folder but has been moved to one
if (!previousShareInfo && currentShareInfo) {
await handleAddedToSharedFolder(item, currentShareInfo);
return;
}
// Item was in a shared folder and is no longer in one
if (previousShareInfo && !currentShareInfo) {
await handleRemovedFromSharedFolder(change, item, previousShareInfo);
return;
}
// Item was in a shared folder and has been moved to a different shared folder
if (previousShareInfo && currentShareInfo && previousShareInfo.item.jop_parent_id !== currentShareInfo.item.jop_parent_id) {
await handleRemovedFromSharedFolder(change, item, previousShareInfo);
await handleAddedToSharedFolder(item, currentShareInfo);
return;
}
// Sanity check - because normally all cases are covered above
throw new Error('Unreachable');
};
while (true) {
const latestProcessedChange = await this.models().keyValue().value<string>('ShareService::latestProcessedChange');
const changes = await this.models().change().allFromId(latestProcessedChange || '');
if (!changes.length) break;
const items = await this.models().item().loadByIds(changes.map(c => c.item_id));
await this.withTransaction(async () => {
for (const change of changes) {
if (change.type === ChangeType.Create) {
await handleCreatedItem(change, items.find(i => i.id === change.item_id));
}
if (change.type === ChangeType.Update) {
await handleUpdatedItem(change, items.find(i => i.id === change.item_id));
}
// We don't need to handle ChangeType.Delete because when an
// item is deleted, all its associated userItems are deleted
// too.
}
for (const shareId in resourceChanges) {
const rc = resourceChanges[shareId];
rc.added = unique(rc.added);
rc.removed = unique(rc.removed);
const shareUsers = await this.models().shareUser().byShareId(rc.share.id);
for (const shareUser of shareUsers) {
await this.updateResourceShareStatus(true, rc.share.id, rc.share.owner_id, shareUser.user_id, rc.added);
}
for (const shareUser of shareUsers) {
await this.updateResourceShareStatus(false, rc.share.id, rc.share.owner_id, shareUser.user_id, rc.removed);
}
}
resourceChanges = {};
await this.models().keyValue().setValue('ShareService::latestProcessedChange', changes[changes.length - 1].id);
});
}
}
public async updateResourceShareStatus(doShare: boolean, shareId: Uuid, fromUserId: Uuid, toUserId: Uuid, resourceIds: string[]) {
const resourceItems = await this.models().item().loadByJopIds(fromUserId, resourceIds);
const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id));
const resourceBlobItems = await this.models().item().loadByNames(fromUserId, resourceBlobNames);
for (const resourceItem of resourceItems) {
if (doShare) {
await this.models().userItem().add(toUserId, resourceItem.id, shareId);
} else {
await this.models().userItem().remove(toUserId, resourceItem.id);
}
}
for (const resourceBlobItem of resourceBlobItems) {
if (doShare) {
await this.models().userItem().add(toUserId, resourceBlobItem.id, shareId);
} else {
await this.models().userItem().remove(toUserId, resourceBlobItem.id);
}
}
}
public async shareFolder(owner:User, folderId:string):Promise<Share> {
const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
if (share) return share;
const shareToSave = {
type: ShareType.JoplinRootFolder,
item_id: folderItem.id,
owner_id: owner.id,
folder_id: folderId,
};
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
return super.save(shareToSave);
}
public async shareNote(owner:User, noteId:string):Promise<Share> {
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
const shareToSave = {
type: ShareType.Link,
item_id: noteItem.id,
owner_id: owner.id,
note_id: noteId,
};
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
return this.save(shareToSave);
}
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
const ids = typeof id === 'string' ? [id] : id;
const shares = await this.loadByIds(ids);
await this.withTransaction(async () => {
for (const share of shares) {
await this.models().shareUser().deleteByShare(share);
await this.models().userItem().deleteByShareId(share.id);
await super.delete(share.id, options);
}
}, 'ShareModel::delete');
}
}

View File

@@ -0,0 +1,116 @@
import { Item, Share, ShareType, ShareUser, ShareUserStatus, User, Uuid } from '../db';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import BaseModel, { AclAction } from './BaseModel';
export default class ShareUserModel extends BaseModel<ShareUser> {
public get tableName(): string {
return 'share_users';
}
public async checkIfAllowed(user: User, action: AclAction, resource: ShareUser = null): Promise<void> {
if (action === AclAction.Create) {
const share = await this.models().share().load(resource.share_id);
if (share.owner_id !== user.id) throw new ErrorForbidden('no access to the share object');
}
if (action === AclAction.Update) {
if (user.id !== resource.user_id) throw new ErrorForbidden('cannot change share user');
}
}
public async byUserId(userId: Uuid): Promise<ShareUser[]> {
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId);
}
public async byShareId(shareId: Uuid): Promise<ShareUser[]> {
const r = await this.byShareIds([shareId]);
return Object.keys(r).length > 0 ? r[shareId] : null;
}
public async byShareIds(shareIds: Uuid[]): Promise<Record<Uuid, ShareUser[]>> {
const rows: ShareUser[] = await this.db(this.tableName).select(this.defaultFields).whereIn('share_id', shareIds);
const output: Record<Uuid, ShareUser[]> = {};
for (const row of rows) {
if (!(row.share_id in output)) output[row.share_id] = [];
output[row.share_id].push(row);
}
return output;
}
public async loadByShareIdAndUser(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
const link: ShareUser = {
share_id: shareId,
user_id: userId,
};
return this.db(this.tableName).where(link).first();
}
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
await this.models().shareUser().addById(share.id, shareeId);
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
}
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
const user = await this.models().user().load(userId);
return this.addByEmail(shareId, user.email);
}
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
const user = await this.models().user().loadByEmail(userEmail);
if (!user) throw new ErrorNotFound(`No such user: ${userEmail}`);
return this.db(this.tableName).select(this.defaultFields)
.where('share_id', '=', shareId)
.where('user_id', '=', user.id)
.first();
}
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
// TODO: check that user can access this share
const share = await this.models().share().load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
const user = await this.models().user().loadByEmail(userEmail);
if (!user) throw new ErrorNotFound(`No such user: ${userEmail}`);
return this.save({
share_id: shareId,
user_id: user.id,
});
}
public async setStatus(shareId: Uuid, userId: Uuid, status: ShareUserStatus): Promise<Item> {
const shareUser = await this.loadByShareIdAndUser(shareId, userId);
if (!shareUser) throw new ErrorNotFound(`Item has not been shared with this user: ${shareId} / ${userId}`);
const share = await this.models().share().load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
const item = await this.models().item().load(share.item_id);
return this.withTransaction<Item>(async () => {
await this.save({ ...shareUser, status });
if (status === ShareUserStatus.Accepted) {
if (share.type === ShareType.JoplinRootFolder) {
await this.models().item().shareJoplinFolderAndContent(share.id, share.owner_id, userId, item.jop_id);
} else if (share.type === ShareType.App) {
await this.models().userItem().add(userId, share.item_id, share.id);
}
}
});
}
public async deleteByShare(share: Share): Promise<void> {
const shareUsers = await this.byShareId(share.id);
await this.withTransaction(async () => {
await this.delete(shareUsers.map(s => s.id));
}, 'ShareUserModel::delete');
}
}

View File

@@ -0,0 +1,138 @@
import { ChangeType, ItemType, UserItem, Uuid } from '../db';
import BaseModel, { DeleteOptions, SaveOptions } from './BaseModel';
import { unique } from '../utils/array';
import { ErrorNotFound } from '../utils/errors';
export interface UserItemDeleteOptions extends DeleteOptions {
byItemIds?: string[],
byShareId?: string,
byUserId?: string;
byUserItem?: UserItem,
}
export default class UserItemModel extends BaseModel<UserItem> {
public get tableName(): string {
return 'user_items';
}
protected hasUuid(): boolean {
return false;
}
public async add(userId: Uuid, itemId: Uuid, shareId: Uuid = ''): Promise<UserItem> {
return this.save({
user_id: userId,
item_id: itemId,
share_id: shareId,
});
}
public async remove(userId: Uuid, itemId: Uuid): Promise<void> {
await this.deleteByUserItem(userId, itemId);
}
public async userIdsByItemIds(itemIds: Uuid[]): Promise<Record<Uuid, Uuid[]>> {
const rows: UserItem[] = await this.db(this.tableName).select('item_id', 'user_id').whereIn('item_id', itemIds);
const output: Record<Uuid, Uuid[]> = {};
for (const row of rows) {
if (!output[row.item_id]) output[row.item_id] = [];
output[row.item_id].push(row.user_id);
}
return output;
}
public async byItemIds(itemIds: Uuid[]): Promise<UserItem[]> {
return await this.db(this.tableName).select(this.defaultFields).whereIn('item_id', itemIds);
}
public async byShareId(shareId: Uuid): Promise<UserItem[]> {
return await this.db(this.tableName).select(this.defaultFields).where('share_id', '=', shareId);
}
public async byUserId(userId: Uuid): Promise<UserItem[]> {
return this.db(this.tableName).where('user_id', '=', userId);
}
public async byUserAndItemId(userId: Uuid, itemId:Uuid): Promise<UserItem> {
return this.db(this.tableName).where('user_id', '=', userId).where('item_id', '=', itemId).first();
}
public async deleteByUserItem(userId: Uuid, itemId: Uuid): Promise<void> {
const userItem = await this.byUserAndItemId(userId, itemId);
if (!userItem) throw new ErrorNotFound('No such user_item: ' + userId + ' / ' + itemId);
await this.deleteBy({ byUserItem: userItem });
}
public async deleteByItemIds(itemIds: Uuid[]): Promise<void> {
await this.deleteBy({ byItemIds: itemIds });
}
public async deleteByShareId(shareId: Uuid): Promise<void> {
await this.deleteBy({ byShareId: shareId });
}
public async deleteByUserId(userId: Uuid): Promise<void> {
await this.deleteBy({ byUserId: userId });
}
public async save(userItem: UserItem, options: SaveOptions = {}): Promise<UserItem> {
if (userItem.id) throw new Error('User items cannot be modified (only created or deleted)'); // Sanity check - shouldn't happen
const item = await this.models().item().load(userItem.item_id, { fields: ['id', 'name'] });
return this.withTransaction(async () => {
await this.models().change().save({
item_type: ItemType.UserItem,
item_id: userItem.item_id,
item_name: item.name,
type: ChangeType.Create,
previous_item: '',
user_id: userItem.user_id,
});
return super.save(userItem, options);
});
}
public async delete(_id: string | string[], _options: DeleteOptions = {}): Promise<void> {
throw new Error('Use one of the deleteBy methods');
}
private async deleteBy(options: UserItemDeleteOptions = {}): Promise<void> {
let userItems:UserItem[] = []
if (options.byItemIds) {
userItems = await this.byItemIds(options.byItemIds);
} else if (options.byShareId) {
userItems = await this.byShareId(options.byShareId);
} else if (options.byUserId) {
userItems = await this.byUserId(options.byUserId);
} else if (options.byUserItem) {
userItems = [options.byUserItem];
} else {
throw new Error('Invalid options');
}
const itemIds = unique(userItems.map(ui => ui.item_id));
const items = await this.models().item().loadByIds(itemIds, { fields: ['id', 'name'] });
await this.withTransaction(async () => {
for (const userItem of userItems) {
const item = items.find(i => i.id === userItem.item_id);
await this.models().change().save({
item_type: ItemType.UserItem,
item_id: userItem.item_id,
item_name: item.name,
type: ChangeType.Delete,
previous_item: '',
user_id: userItem.user_id,
});
}
await this.db(this.tableName).whereIn('id', userItems.map(ui => ui.id)).delete();
}, 'ItemModel::delete');
}
}

View File

@@ -1,6 +1,6 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
import { File } from '../db';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { User } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors';
describe('UserModel', function() {
@@ -17,82 +17,55 @@ describe('UserModel', function() {
});
test('should validate user objects', async function() {
const { user: admin } = await createUserAndSession(1, true);
const { user: user1 } = await createUserAndSession(2, false);
const { user: user2 } = await createUserAndSession(3, false);
let error = null;
// Non-admin user can't create a user
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ email: 'newone@example.com', password: '1234546' }));
expect(error instanceof ErrorForbidden).toBe(true);
// Email must be set
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ email: '', password: '1234546' }));
error = await checkThrowAsync(async () => await models().user().save({ email: '', password: '1234546' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// Password must be set
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ email: 'newone@example.com', password: '' }));
error = await checkThrowAsync(async () => await models().user().save({ email: 'newone@example.com', password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot modify another user
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user2.id, email: 'newone@example.com' }));
expect(error instanceof ErrorForbidden).toBe(true);
// email must be set
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: '' }));
error = await checkThrowAsync(async () => await models().user().save({ id: user1.id, email: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// password must be set
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot make a user an admin
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, is_admin: 1 }));
expect(error instanceof ErrorForbidden).toBe(true);
// non-admin user cannot remove admin bit from themselves
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ id: admin.id, is_admin: 0 }));
error = await checkThrowAsync(async () => await models().user().save({ id: user1.id, password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// there is already a user with this email
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: user2.email }));
error = await checkThrowAsync(async () => await models().user().save({ id: user1.id, email: user2.email }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// check that the email is valid
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: 'ohno' }));
error = await checkThrowAsync(async () => await models().user().save({ id: user1.id, email: 'ohno' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
});
test('should delete a user', async function() {
const { user: admin } = await createUserAndSession(1, true);
const { user: user1 } = await createUserAndSession(2, false);
const { user: user2 } = await createUserAndSession(3, false);
const { session: session1, user: user1 } = await createUserAndSession(2, false);
const userModel = models().user({ userId: admin.id });
const userModel = models().user();
const allUsers: File[] = await userModel.all();
const allUsers: User[] = await userModel.all();
const beforeCount: number = allUsers.length;
// Can't delete someone else user
const error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).delete(user2.id));
expect(error instanceof ErrorForbidden).toBe(true);
expect((await userModel.all()).length).toBe(beforeCount);
await createItem(session1.id, 'root:/test.txt:', 'testing');
// Admin can delete any user
await models().user({ userId: admin.id }).delete(user1.id);
expect(!!(await models().session().load(session1.id))).toBe(true);
expect((await models().item().all()).length).toBe(1);
expect((await models().userItem().all()).length).toBe(1);
await models().user().delete(user1.id);
expect((await userModel.all()).length).toBe(beforeCount - 1);
const allFiles = await models().file().all() as File[];
expect(allFiles.length).toBe(2);
expect(!!allFiles.find(f => f.owner_id === admin.id)).toBe(true);
expect(!!allFiles.find(f => f.owner_id === user2.id)).toBe(true);
// Can delete own user
const fileModel = models().file({ userId: user2.id });
expect(!!(await fileModel.userRootFile())).toBe(true);
await models().user({ userId: user2.id }).delete(user2.id);
expect((await userModel.all()).length).toBe(beforeCount - 2);
expect(!!(await fileModel.userRootFile())).toBe(false);
expect(!!(await models().session().load(session1.id))).toBe(false);
expect((await models().item().all()).length).toBe(0);
expect((await models().userItem().all()).length).toBe(0);
});
});

View File

@@ -1,4 +1,4 @@
import BaseModel, { SaveOptions, ValidateOptions } from './BaseModel';
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
import { User } from '../db';
import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden } from '../utils/errors';
@@ -33,27 +33,47 @@ export default class UserModel extends BaseModel<User> {
return user;
}
public toApiOutput(object: User): User {
protected objectToApiOutput(object: User): User {
const output: User = { ...object };
delete output.password;
return output;
}
public async checkIfAllowed(user: User, action: AclAction, resource: User = null): Promise<void> {
if (action === AclAction.Create) {
if (!user.is_admin) throw new ErrorForbidden('non-admin user cannot create a new user');
}
if (action === AclAction.Read) {
if (user.is_admin) return;
if (user.id !== resource.id) throw new ErrorForbidden('cannot view other users');
}
if (action === AclAction.Update) {
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
if (!user.is_admin && 'is_admin' in resource) throw new ErrorForbidden('non-admin user cannot make themselves an admin');
if (user.is_admin && user.id === resource.id && 'is_admin' in resource && !resource.is_admin) throw new ErrorForbidden('admin user cannot make themselves a non-admin');
}
if (action === AclAction.Delete) {
if (!user.is_admin) throw new ErrorForbidden('only admins can delete users');
if (user.id === resource.id) throw new ErrorForbidden('cannot delete own user');
}
if (action === AclAction.List) {
if (!user.is_admin) throw new ErrorForbidden('non-admin cannot list users');
}
}
protected async validate(object: User, options: ValidateOptions = {}): Promise<User> {
const user: User = await super.validate(object, options);
const owner: User = await this.load(this.userId);
if (options.isNew) {
if (!owner.is_admin) throw new ErrorForbidden('non-admin user cannot create a new user');
if (!user.email) throw new ErrorUnprocessableEntity('email must be set');
if (!user.password) throw new ErrorUnprocessableEntity('password must be set');
} else {
if (!owner.is_admin && user.id !== owner.id) throw new ErrorForbidden('non-admin user cannot modify another user');
if ('email' in user && !user.email) throw new ErrorUnprocessableEntity('email must be set');
if ('password' in user && !user.password) throw new ErrorUnprocessableEntity('password must be set');
if (!owner.is_admin && 'is_admin' in user) throw new ErrorForbidden('non-admin user cannot make a user an admin');
if (owner.is_admin && owner.id === user.id && 'is_admin' in user && !user.is_admin) throw new ErrorUnprocessableEntity('non-admin user cannot remove admin bit from themselves');
}
if ('email' in user) {
@@ -62,7 +82,7 @@ export default class UserModel extends BaseModel<User> {
if (!this.validateEmail(user.email)) throw new ErrorUnprocessableEntity(`Invalid email: ${user.email}`);
}
return user;
return super.validate(user, options);
}
private validateEmail(email: string): boolean {
@@ -75,27 +95,15 @@ export default class UserModel extends BaseModel<User> {
return `${this.baseUrl}/users/me`;
}
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
if (!this.userId) throw new ErrorForbidden('no user is active');
if (userId === this.userId) return;
const owner = await this.load(this.userId);
if (!owner.is_admin) throw new ErrorForbidden();
}
public async load(id: string): Promise<User> {
await this.checkIsOwnerOrAdmin(id);
return super.load(id);
}
public async delete(id: string): Promise<void> {
await this.checkIsOwnerOrAdmin(id);
const shares = await this.models().share().sharesByUser(id);
await this.withTransaction(async () => {
const fileModel = this.models().file({ userId: id });
const rootFile = await fileModel.userRootFile();
await fileModel.delete(rootFile.id, { validationRules: { canDeleteRoot: true } });
await this.models().item().deleteExclusivelyOwnedItems(id);
await this.models().share().delete(shares.map(s => s.id));
await this.models().userItem().deleteByUserId(id);
await this.models().session().deleteByUserId(id);
await this.models().notification().deleteByUserId(id);
await super.delete(id);
}, 'UserModel::delete');
}
@@ -108,22 +116,9 @@ export default class UserModel extends BaseModel<User> {
//
// Because the password would be hashed twice.
public async save(object: User, options: SaveOptions = {}): Promise<User> {
const isNew = await this.isNew(object, options);
let newUser = { ...object };
if (newUser.password) newUser.password = auth.hashPassword(newUser.password);
await this.withTransaction(async () => {
newUser = await super.save(newUser, options);
if (isNew) {
const fileModel = this.models().file({ userId: newUser.id });
await fileModel.createRootFile();
}
}, 'UserModel::save');
return newUser;
const user = { ...object };
if (user.password) user.password = auth.hashPassword(user.password);
return super.save(user, options);
}
}

View File

@@ -56,14 +56,16 @@
import { DbConnection } from '../db';
import ApiClientModel from './ApiClientModel';
import { ModelOptions } from './BaseModel';
import FileModel from './FileModel';
import ItemModel from './ItemModel';
import UserModel from './UserModel';
import PermissionModel from './PermissionModel';
import UserItemModel from './UserItemModel';
import SessionModel from './SessionModel';
import ChangeModel from './ChangeModel';
import NotificationModel from './NotificationModel';
import ShareModel from './ShareModel';
import ItemResourceModel from './ItemResourceModel';
import ShareUserModel from './ShareUserModel';
import KeyValueModel from './KeyValueModel';
export class Models {
@@ -75,36 +77,48 @@ export class Models {
this.baseUrl_ = baseUrl;
}
public file(options: ModelOptions = null) {
return new FileModel(this.db_, newModelFactory, this.baseUrl_, options);
public item() {
return new ItemModel(this.db_, newModelFactory, this.baseUrl_);
}
public user(options: ModelOptions = null) {
return new UserModel(this.db_, newModelFactory, this.baseUrl_, options);
public user() {
return new UserModel(this.db_, newModelFactory, this.baseUrl_);
}
public apiClient(options: ModelOptions = null) {
return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_, options);
public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
}
public permission(options: ModelOptions = null) {
return new PermissionModel(this.db_, newModelFactory, this.baseUrl_, options);
public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
}
public session(options: ModelOptions = null) {
return new SessionModel(this.db_, newModelFactory, this.baseUrl_, options);
public apiClient() {
return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_);
}
public change(options: ModelOptions = null) {
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_, options);
public session() {
return new SessionModel(this.db_, newModelFactory, this.baseUrl_);
}
public notification(options: ModelOptions = null) {
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_, options);
public change() {
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_);
}
public share(options: ModelOptions = null) {
return new ShareModel(this.db_, newModelFactory, this.baseUrl_, options);
public notification() {
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_);
}
public share() {
return new ShareModel(this.db_, newModelFactory, this.baseUrl_);
}
public shareUser() {
return new ShareUserModel(this.db_, newModelFactory, this.baseUrl_);
}
public keyValue() {
return new KeyValueModel(this.db_, newModelFactory, this.baseUrl_);
}
}

View File

@@ -1,7 +1,7 @@
import { ErrorBadRequest } from '../../utils/errors';
import { decodeBase64, encodeBase64 } from '../../utils/base64';
import { ChangePagination, defaultChangePagination } from '../ChangeModel';
import Knex = require('knex');
import { Knex } from 'knex';
export enum PaginationOrderDir {
ASC = 'asc',
@@ -209,12 +209,25 @@ export function createPaginationLinks(page: number, pageCount: number, urlTempla
return output;
}
export async function paginateDbQuery(query: Knex.QueryBuilder, pagination: Pagination): Promise<PaginatedResults> {
// function applyMainTablePrefix(pagination:Pagination, mainTable:string):Pagination {
// if (!mainTable) return pagination;
// const output:Pagination = JSON.parse(JSON.stringify(pagination));
// output.order = output.order.map(o => {
// o.by = mainTable + '.' + o.by;
// return o;
// });
// return output;
// }
export async function paginateDbQuery(query: Knex.QueryBuilder, pagination: Pagination, mainTable: string = ''): Promise<PaginatedResults> {
pagination = processCursor(pagination);
const orderSql: any[] = pagination.order.map(o => {
return {
column: o.by,
column: (mainTable ? `${mainTable}.` : '') + o.by,
order: o.dir,
};
});

View File

@@ -0,0 +1,26 @@
import config from '../../config';
import { createTestUsers } from '../../tools/debugTools';
import { bodyFields } from '../../utils/requestUtils';
import Router from '../../utils/Router';
import { SubPath } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
const router = new Router();
router.public = true;
interface Query {
action: string;
}
router.post('api/debug', async (_path: SubPath, ctx:AppContext) => {
const query: Query = (await bodyFields(ctx.req)) as Query;
console.info('Action: ' + query.action);
if (query.action === 'createTestUsers') {
await createTestUsers(ctx.db, config());
}
});
export default router;

View File

@@ -1,419 +0,0 @@
import { testAssetDir, beforeAllDb, randomHash, afterAllTests, beforeEachDb, createUserAndSession, models, tempDir } from '../../utils/testing/testUtils';
import { testFilePath, getFileMetadataContext, getFileMetadata, deleteFileContent, deleteFileContext, deleteFile, postDirectoryContext, postDirectory, getDirectoryChildren, putFileContentContext, putFileContent, getFileContent, patchFileContext, patchFile, getDelta } from '../../utils/testing/fileApiUtils';
import * as fs from 'fs-extra';
import { ChangeType, File } from '../../db';
import { Pagination, PaginationOrderDir } from '../../models/utils/pagination';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorNotFound, ErrorConflict } from '../../utils/errors';
import { msleep } from '../../utils/time';
async function makeTempFileWithContent(content: string): Promise<string> {
const d = await tempDir();
const filePath = `${d}/${randomHash()}`;
await fs.writeFile(filePath, content, 'utf8');
return filePath;
}
async function makeTestFile(ownerId: string, id: number = 1, ext: string = 'jpg', parentId: string = ''): Promise<File> {
const basename = ext === 'jpg' ? 'photo' : 'poster';
const file: File = {
name: id > 1 ? `${basename}-${id}.${ext}` : `${basename}.${ext}`,
content: await fs.readFile(`${testAssetDir}/${basename}.${ext}`),
// mime_type: `image/${ext}`,
parent_id: parentId,
};
return models().file({ userId: ownerId }).save(file);
}
describe('api_files', function() {
beforeAll(async () => {
await beforeAllDb('api_files');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const filePath = testFilePath();
const newFile = await putFileContent(session.id, 'root:/photo.jpg:', filePath);
expect(!!newFile.id).toBe(true);
expect(newFile.name).toBe('photo.jpg');
expect(newFile.mime_type).toBe('image/jpeg');
expect(!!newFile.parent_id).toBe(true);
expect(!newFile.content).toBe(true);
expect(newFile.size > 0).toBe(true);
const fileModel = models().file({ userId: user.id });
const newFileReload = await fileModel.loadWithContent(newFile.id);
expect(!!newFileReload).toBe(true);
const fileContent = await fs.readFile(filePath);
const newFileHex = fileContent.toString('hex');
const newFileReloadHex = (newFileReload.content as Buffer).toString('hex');
expect(newFileReloadHex.length > 0).toBe(true);
expect(newFileReloadHex).toBe(newFileHex);
});
test('should create sub-directories', async function() {
const { session } = await createUserAndSession(1, true);
const newDir = await postDirectory(session.id, 'root:/:', 'subdir');
expect(!!newDir.id).toBe(true);
expect(newDir.is_directory).toBe(1);
const newDir2 = await postDirectory(session.id, 'root:/subdir:', 'subdir2');
const newDirReload2 = await getFileMetadata(session.id, 'root:/subdir/subdir2:');
expect(newDirReload2.id).toBe(newDir2.id);
expect(newDirReload2.name).toBe(newDir2.name);
});
test('should create files in sub-directory', async function() {
const { session } = await createUserAndSession(1, true);
await postDirectory(session.id, 'root:/:', 'subdir');
const newFile = await putFileContent(session.id, 'root:/subdir/photo.jpg:', testFilePath());
const newFileReload = await getFileMetadata(session.id, 'root:/subdir/photo.jpg:');
expect(newFileReload.id).toBe(newFile.id);
expect(newFileReload.name).toBe('photo.jpg');
});
test('should not create a file with an invalid path', async function() {
const { session } = await createUserAndSession(1, true);
const context = await putFileContentContext(session.id, 'root:/does/not/exist/photo.jpg:', testFilePath());
expect(context.response.status).toBe(ErrorNotFound.httpCode);
});
test('should get files', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
let file1: File = await makeTestFile(user1.id, 1);
const file2: File = await makeTestFile(user1.id, 2);
const file3: File = await makeTestFile(user2.id, 3);
const fileId1 = file1.id;
const fileId2 = file2.id;
// Can't get someone else file
const context = await getFileMetadataContext(session1.id, file3.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
file1 = await getFileMetadata(session1.id, file1.id);
expect(file1.id).toBe(fileId1);
const fileModel = models().file({ userId: user1.id });
const paginatedResults = await getDirectoryChildren(session1.id, await fileModel.userRootFileId());
const allFiles: File[] = paginatedResults.items;
expect(allFiles.length).toBe(2);
expect(JSON.stringify(allFiles.map(f => f.id).sort())).toBe(JSON.stringify([fileId1, fileId2].sort()));
});
test('should not let create a file in a directory not owned by user', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const file = await putFileContent(session2.id, 'root:/test.jpg:', testFilePath());
const context = await getFileMetadataContext(session1.id, file.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should update file properties', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
let file = await putFileContent(session.id, 'root:/test.jpg:', testFilePath());
// Can't have file with empty name
const context = await patchFileContext(session.id, file.id, { name: '' });
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
await patchFile(session.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await patchFile(session.id, file.id, { mime_type: 'image/png' });
file = await fileModel.load(file.id);
expect(file.mime_type).toBe('image/png');
});
test('should not allow duplicate filenames', async function() {
const { session } = await createUserAndSession(1, true);
const c1 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c1.response.status).toBe(200);
const c2 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c2.response.status).toBe(ErrorConflict.httpCode);
});
test('should change the file parent', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const fileModel = models().file({ userId: user1.id });
let file: File = await makeTestFile(user1.id);
const file2: File = await makeTestFile(user1.id, 2);
const dir: File = await postDirectory(session1.id, 'root', 'mydir');
// Can't set parent to another non-directory file
const context1 = await patchFileContext(session1.id, file.id, { parent_id: file2.id });
expect(context1.response.status).toBe(ErrorForbidden.httpCode);
// Can't set parent to someone else directory
const fileModel2 = models().file({ userId: user2.id });
const userRoot2 = await fileModel2.userRootFile();
const context2 = await patchFileContext(session1.id, file.id, { parent_id: userRoot2.id });
expect(context2.response.status).toBe(ErrorForbidden.httpCode);
// Finally, change the parent
await patchFile(session1.id, file.id, { parent_id: dir.id });
file = await fileModel.load(file.id);
expect(!!file.parent_id).toBe(true);
expect(file.parent_id).toBe(dir.id);
});
test('should delete a file', async function() {
const { user, session } = await createUserAndSession(1, true);
await makeTestFile(user.id, 1);
const file2: File = await makeTestFile(user.id, 2);
const fileModel = models().file({ userId: user.id });
let allFiles: File[] = await fileModel.all();
const beforeCount: number = allFiles.length;
await deleteFile(session.id, file2.id);
allFiles = await fileModel.all();
expect(allFiles.length).toBe(beforeCount - 1);
});
test('should create and delete directories', async function() {
const { user, session } = await createUserAndSession(1, true);
const dir1 = await postDirectory(session.id, 'root', 'dir1');
const dir2 = await postDirectory(session.id, dir1.id, 'dir2');
const dirReload2: File = await getFileMetadata(session.id, 'root:/dir1/dir2:');
expect(dirReload2.id).toBe(dir2.id);
// Delete one directory
await deleteFile(session.id, 'root:/dir1/dir2:');
const dirNotFoundContext = await getFileMetadataContext(session.id, 'root:/dir1/dir2:');
expect(dirNotFoundContext.response.status).toBe(ErrorNotFound.httpCode);
// Delete a directory and its sub-directories and files
const dir3 = await postDirectory(session.id, 'root:/dir1:', 'dir3');
const file1 = await putFileContent(session.id, 'root:/dir1/file1:', testFilePath());
const file2 = await putFileContent(session.id, 'root:/dir1/dir3/file2:', testFilePath());
await deleteFile(session.id, 'root:/dir1:');
const fileModel = models().file({ userId: user.id });
expect(!(await fileModel.load(dir1.id))).toBe(true);
expect(!(await fileModel.load(dir3.id))).toBe(true);
expect(!(await fileModel.load(file1.id))).toBe(true);
expect(!(await fileModel.load(file2.id))).toBe(true);
});
test('should not change the parent when updating a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const dir1: File = await postDirectory(session.id, 'root', 'dir1');
const file1: File = await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('testing'));
await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('new content'));
const fileReloaded1 = await fileModel.load(file1.id);
expect(fileReloaded1.parent_id).toBe(dir1.id);
});
test('should not delete someone else file', async function() {
const { session: session1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const file2: File = await makeTestFile(user2.id, 2);
const context = await deleteFileContext(session1.id, file2.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should let admin change or delete files', async function() {
const { session: adminSession } = await createUserAndSession(1, true);
const { user } = await createUserAndSession(2);
let file: File = await makeTestFile(user.id);
const fileModel = models().file({ userId: user.id });
await patchFile(adminSession.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await deleteFile(adminSession.id, file.id);
expect(!(await fileModel.load(file.id))).toBe(true);
});
test('should update a file content', async function() {
const { session } = await createUserAndSession(1, true);
const contentPath1 = await makeTempFileWithContent('test1');
const contentPath2 = await makeTempFileWithContent('test2');
await putFileContent(session.id, 'root:/file.txt:', contentPath1);
const originalContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(originalContent).toBe('test1');
await putFileContent(session.id, 'root:/file.txt:', contentPath2);
const modContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(modContent).toBe('test2');
});
test('should delete a file content', async function() {
const { user, session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(user.id, 1);
await putFileContent(session.id, file.id, await makeTempFileWithContent('test1'));
await deleteFileContent(session.id, file.id);
const modFile = await getFileMetadata(session.id, file.id);
expect(modFile.size).toBe(0);
});
test('should not allow reserved characters', async function() {
const { session } = await createUserAndSession(1, true);
const filenames = [
'invalid*invalid',
'invalid#invalid',
'invalid\\invalid',
];
for (const filename of filenames) {
const context = await putFileContentContext(session.id, `root:/${filename}:`, testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
});
test('should not allow a directory with the same name', async function() {
const { session } = await createUserAndSession(1, true);
{
await postDirectory(session.id, 'root', 'somedir');
const context = await putFileContentContext(session.id, 'root:/somedir:', testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
{
await putFileContent(session.id, 'root:/somefile.md:', testFilePath());
const context = await postDirectoryContext(session.id, 'root', 'somefile.md');
expect(context.response.status).toBe(ErrorConflict.httpCode);
}
});
test('should not be possible to delete the root directory', async function() {
const { session } = await createUserAndSession(1, true);
const context = await deleteFileContext(session.id, 'root');
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should support root:/: format, which means root', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const root = await getFileMetadata(session.id, 'root:/:');
expect(root.id).toBe(await fileModel.userRootFileId());
});
test('should paginate results', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user1.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user1.id, 2);
await msleep(1);
const file3: File = await makeTestFile(user1.id, 3);
const fileModel = models().file({ userId: user1.id });
const rootId = await fileModel.userRootFileId();
const pagination: Pagination = {
limit: 2,
order: [
{
by: 'updated_time',
dir: PaginationOrderDir.ASC,
},
],
page: 1,
};
for (const method of ['page', 'cursor']) {
const page1 = await getDirectoryChildren(session1.id, rootId, pagination);
expect(page1.items.length).toBe(2);
expect(page1.has_more).toBe(true);
expect(page1.items[0].id).toBe(file1.id);
expect(page1.items[1].id).toBe(file2.id);
const p = method === 'page' ? { ...pagination, page: 2 } : { cursor: page1.cursor };
const page2 = await getDirectoryChildren(session1.id, rootId, p);
expect(page2.items.length).toBe(1);
expect(page2.has_more).toBe(false);
expect(page2.items[0].id).toBe(file3.id);
}
});
test('should track file changes', async function() {
// We only do a basic check because most of the tests for this are in
// ChangeModel.test.ts
const { user, session: session1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user.id, 2);
const page1 = await getDelta(session1.id, file1.parent_id, { limit: 1 });
expect(page1.has_more).toBe(true);
expect(page1.items.length).toBe(1);
expect(page1.items[0].type).toBe(ChangeType.Create);
expect(page1.items[0].item.id).toBe(file1.id);
const page2 = await getDelta(session1.id, file1.parent_id, { cursor: page1.cursor, limit: 1 });
expect(page2.has_more).toBe(true);
expect(page2.items.length).toBe(1);
expect(page2.items[0].type).toBe(ChangeType.Create);
expect(page2.items[0].item.id).toBe(file2.id);
const page3 = await getDelta(session1.id, file1.parent_id, { cursor: page2.cursor, limit: 1 });
expect(page3.has_more).toBe(false);
expect(page3.items.length).toBe(0);
});
test('should not allow creating file without auth', async function() {
const context = await putFileContentContext('', 'root:/photo.jpg:', testFilePath());
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
});

View File

@@ -1,98 +0,0 @@
import { ErrorNotFound } from '../../utils/errors';
import { File, Uuid } from '../../db';
import { bodyFields, formParse } from '../../utils/requestUtils';
import { SubPath, respondWithFileContent } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra';
import { requestChangePagination, requestPagination } from '../../models/utils/pagination';
const router = new Router();
router.get('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const file: File = await fileModel.pathToFile(path.id);
return fileModel.toApiOutput(file);
});
router.patch('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const inputFile: File = await bodyFields(ctx.req);
const existingFileId: Uuid = await fileModel.pathToFileId(fileId);
const newFile = fileModel.fromApiInput(inputFile);
newFile.id = existingFileId;
return fileModel.toApiOutput(await fileModel.save(newFile));
});
router.del('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
// const fileId = path.id;
try {
const fileId: Uuid = await fileModel.pathToFileId(path.id, { mustExist: false });
if (!fileId) return;
await fileModel.delete(fileId);
} catch (error) {
if (error instanceof ErrorNotFound) {
// That's ok - a no-op
} else {
throw error;
}
}
});
router.get('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId: Uuid = await fileModel.pathToFileId(path.id);
const file = await fileModel.loadWithContent(fileId);
if (!file) throw new ErrorNotFound();
return respondWithFileContent(ctx.response, file);
});
router.put('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const result = await formParse(ctx.req);
// When an app PUTs an empty file, `result.files` will be an emtpy object
// (could be the way Formidable parses the data?), but we still need to
// process the file so we set its content to an empty buffer.
// https://github.com/laurent22/joplin/issues/4402
const buffer = result?.files?.file ? await fs.readFile(result.files.file.path) : Buffer.alloc(0);
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
file.content = buffer;
return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } }));
});
router.del('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
if (!file) return;
file.content = Buffer.alloc(0);
await fileModel.save(file, { validationRules: { mustBeFile: true } });
});
router.get('api/files/:id/delta', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const dirId: Uuid = await fileModel.pathToFileId(path.id);
const changeModel = ctx.models.change({ userId: ctx.owner.id });
return changeModel.byDirectoryId(dirId, requestChangePagination(ctx.query));
});
router.get('api/files/:id/children', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const parentId: Uuid = await fileModel.pathToFileId(path.id);
return fileModel.toApiOutput(await fileModel.childrens(parentId, requestPagination(ctx.query)));
});
router.post('api/files/:id/children', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const child: File = fileModel.fromApiInput(await bodyFields(ctx.req));
const parentId: Uuid = await fileModel.pathToFileId(path.id);
child.parent_id = parentId;
return fileModel.toApiOutput(await fileModel.save(child));
});
export default router;

View File

@@ -0,0 +1,204 @@
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createItem, makeTempFileWithContent, makeNoteSerializedBody, createItemTree } from '../../utils/testing/testUtils';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel';
import { deleteApi, getApi, putApi } from '../../utils/testing/apiUtils';
import { Item } from '../../db';
import { PaginatedItems } from '../../models/ItemModel';
describe('api_items', function() {
beforeAll(async () => {
await beforeAllDb('api_items');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create an item', async function() {
const { user, session } = await createUserAndSession(1, true);
const noteId = '00000000000000000000000000000001';
const folderId = '000000000000000000000000000000F1';
const noteTitle = 'Title';
const noteBody = 'Body';
const filename = `${noteId}.md`;
let item = await createItem(session.id, `root:/${filename}:`, makeNoteSerializedBody({
id: noteId,
title: noteTitle,
body: noteBody,
}));
item = await models().item().loadByName(user.id, filename);
const itemId = item.id;
expect(!!item.id).toBe(true);
expect(item.name).toBe(filename);
expect(item.mime_type).toBe('text/markdown');
expect(item.jop_id).toBe(noteId);
expect(item.jop_parent_id).toBe(folderId);
expect(item.jop_encryption_applied).toBe(0);
expect(item.jop_type).toBe(ModelType.Note);
expect(!item.content).toBe(true);
expect(item.content_size).toBeGreaterThan(0);
{
const item: NoteEntity = await models().item().loadAsJoplinItem(itemId);
expect(item.title).toBe(noteTitle);
expect(item.body).toBe(noteBody);
}
});
test('should modify an item', async function() {
const { session } = await createUserAndSession(1, true);
const noteId = '00000000000000000000000000000001';
const filename = `${noteId}.md`;
const item = await createItem(session.id, `root:/${filename}:`, makeNoteSerializedBody({
id: noteId,
}));
const newParentId = '000000000000000000000000000000F2';
const tempFilePath = await makeTempFileWithContent(makeNoteSerializedBody({
parent_id: newParentId,
title: 'new title',
}));
await putApi(session.id, `items/root:/${filename}:/content`, null, { filePath: tempFilePath });
const note: NoteEntity = await models().item().loadAsJoplinItem(item.id);
expect(note.parent_id).toBe(newParentId);
expect(note.title).toBe('new title');
});
test('should delete an item', async function() {
const { user, session } = await createUserAndSession(1, true);
const tree: any = {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
};
const itemModel = models().item();
await createItemTree(user.id, '', tree);
await deleteApi(session.id, 'items/root:/00000000000000000000000000000001.md:');
expect((await itemModel.all()).length).toBe(1);
expect((await itemModel.all())[0].jop_id).toBe('000000000000000000000000000000F1');
});
test('should delete all items', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1, true);
const { user: user2 } = await createUserAndSession(2, true);
await createItemTree(user1.id, '', {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
});
const itemModel2 = models().item();
await createItemTree(user2.id, '', {
'000000000000000000000000000000F2': {
'00000000000000000000000000000002': null,
},
});
await deleteApi(session1.id, 'items/root');
const allItems = await itemModel2.all();
expect(allItems.length).toBe(2);
const ids = allItems.map(i => i.jop_id);
expect(ids.sort()).toEqual(['000000000000000000000000000000F2', '00000000000000000000000000000002'].sort());
});
test('should get back the serialized note', async function() {
const { session } = await createUserAndSession(1, true);
const noteId = '00000000000000000000000000000001';
const filename = `${noteId}.md`;
const serializedNote = makeNoteSerializedBody({
id: noteId,
});
await createItem(session.id, `root:/${filename}:`, serializedNote);
const result = await getApi(session.id, `items/root:/${filename}:/content`);
expect(result).toBe(serializedNote);
});
test('should get back the item metadata', async function() {
const { session } = await createUserAndSession(1, true);
const noteId = '00000000000000000000000000000001';
await createItem(session.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
id: noteId,
}));
const result: Item = await getApi(session.id, `items/root:/${noteId}.md:`);
expect(result.name).toBe(`${noteId}.md`);
});
test('should list children', async function() {
const { session } = await createUserAndSession(1, true);
const itemNames = [
'.resource/r1',
'locks/1.json',
'locks/2.json',
];
for (const itemName of itemNames) {
await createItem(session.id, `root:/${itemName}:`, `Content for :${itemName}`);
}
const noteIds: string[] = [];
for (let i = 1; i <= 3; i++) {
const noteId = `0000000000000000000000000000000${i}`;
noteIds.push(noteId);
await createItem(session.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
id: noteId,
}));
}
// Get all children
{
const result1: PaginatedItems = await getApi(session.id, 'items/root:/:/children', { query: { limit: 4 } });
expect(result1.items.length).toBe(4);
expect(result1.has_more).toBe(true);
const result2: PaginatedItems = await getApi(session.id, 'items/root:/:/children', { query: { cursor: result1.cursor } });
expect(result2.items.length).toBe(2);
expect(result2.has_more).toBe(false);
const items = result1.items.concat(result2.items);
for (const itemName of itemNames) {
expect(!!items.find(it => it.name === itemName)).toBe(true);
}
for (const noteId of noteIds) {
expect(!!items.find(it => it.name === `${noteId}.md`)).toBe(true);
}
}
// Get sub-children
{
const result: PaginatedItems = await getApi(session.id, 'items/root:/locks/*:/children');
expect(result.items.length).toBe(2);
expect(!!result.items.find(it => it.name === 'locks/1.json')).toBe(true);
expect(!!result.items.find(it => it.name === 'locks/2.json')).toBe(true);
}
});
});

View File

@@ -0,0 +1,84 @@
import { Item, Uuid } from '../../db';
import { formParse } from '../../utils/requestUtils';
import { respondWithItemContent, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra';
import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
import ItemModel from '../../models/ItemModel';
import { requestChangePagination, requestPagination } from '../../models/utils/pagination';
const router = new Router();
// Note about access control:
//
// - All these calls are scoped to a user, which is derived from the session
// - All items are accessed by userId/itemName
// - In other words, it is not possible for a user to access another user's
// items, thus the lack of checkIfAllowed() calls as that would not be
// necessary, and would be slower.
async function itemFromPath(userId: Uuid, itemModel: ItemModel, path: SubPath, mustExists: boolean = true): Promise<Item> {
const name = itemModel.pathToName(path.id);
const item = await itemModel.loadByName(userId, name);
if (mustExists && !item) throw new ErrorNotFound(`Not found: ${path.id}`);
return item;
}
router.get('api/items/:id', async (path: SubPath, ctx: AppContext) => {
const itemModel = ctx.models.item();
const item = await itemFromPath(ctx.owner.id, itemModel, path);
return itemModel.toApiOutput(item);
});
router.del('api/items/:id', async (path: SubPath, ctx: AppContext) => {
const itemModel = ctx.models.item();
try {
if (path.id === 'root' || path.id === 'root:/:') {
// We use this for testing only and for safety reasons it's probably
// best to disable it on production.
if (ctx.env !== 'dev') throw new ErrorMethodNotAllowed('Deleting the root is not allowed');
await itemModel.deleteAll(ctx.owner.id);
} else {
const item = await itemFromPath(ctx.owner.id, itemModel, path);
await itemModel.delete(item.id);
}
} catch (error) {
if (error instanceof ErrorNotFound) {
// That's ok - a no-op
} else {
throw error;
}
}
});
router.get('api/items/:id/content', async (path: SubPath, ctx: AppContext) => {
const itemModel = ctx.models.item();
const item = await itemFromPath(ctx.owner.id, itemModel, path);
const serializedContent = await itemModel.serializedContent(item.id);
return respondWithItemContent(ctx.response, item, serializedContent);
});
router.put('api/items/:id/content', async (path: SubPath, ctx: AppContext) => {
const itemModel = ctx.models.item();
const name = itemModel.pathToName(path.id);
const parsedBody = await formParse(ctx.req);
const buffer = parsedBody?.files?.file ? await fs.readFile(parsedBody.files.file.path) : Buffer.alloc(0);
const item = await itemModel.saveFromRawContent(ctx.owner.id, name, buffer);
return itemModel.toApiOutput(item);
});
router.get('api/items/:id/delta', async (_path: SubPath, ctx: AppContext) => {
const changeModel = ctx.models.change();
return changeModel.allForUser(ctx.owner.id, requestChangePagination(ctx.query));
});
router.get('api/items/:id/children', async (path: SubPath, ctx: AppContext) => {
const itemModel = ctx.models.item();
const parentName = itemModel.pathToName(path.id);
const result = await itemModel.children(ctx.owner.id, parentName, requestPagination(ctx.query));
return result;
});
export default router;

View File

@@ -0,0 +1,56 @@
import { ShareType, ShareUserStatus } from '../../db';
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createItemTree, expectHttpError } from '../../utils/testing/testUtils';
import { getApi, patchApi } from '../../utils/testing/apiUtils';
import { shareWithUserAndAccept } from '../../utils/testing/shareApiUtils';
import { ErrorForbidden } from '../../utils/errors';
import { PaginatedResults } from '../../models/utils/pagination';
describe('share_users', function() {
beforeAll(async () => {
await beforeAllDb('share_users');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should list user invitations', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
await createItemTree(user1.id, '', {
'000000000000000000000000000000F1': {},
'000000000000000000000000000000F2': {},
});
const folderItem1 = await models().item().loadByJopId(user1.id, '000000000000000000000000000000F1');
const folderItem2 = await models().item().loadByJopId(user1.id, '000000000000000000000000000000F2');
const { share: share1 } = await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.JoplinRootFolder, folderItem1);
const { share: share2 } = await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.JoplinRootFolder, folderItem2);
const shareUsers = await getApi<PaginatedResults>(session2.id, 'share_users');
expect(shareUsers.items.length).toBe(2);
expect(shareUsers.items.find(su => su.share.id === share1.id)).toBeTruthy();
expect(shareUsers.items.find(su => su.share.id === share2.id)).toBeTruthy();
});
test('should not change someone else shareUser object', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
await createItemTree(user1.id, '', { '000000000000000000000000000000F1': {} });
const folderItem = await models().item().loadByJopId(user1.id, '000000000000000000000000000000F1');
const { shareUser } = await shareWithUserAndAccept(session1.id, session2.id, user2, ShareType.JoplinRootFolder, folderItem);
// User can modify own UserShare object
await patchApi(session2.id, `share_users/${shareUser.id}`, { status: ShareUserStatus.Rejected });
// User cannot modify someone else UserShare object
await expectHttpError(async () => patchApi(session1.id, `share_users/${shareUser.id}`, { status: ShareUserStatus.Accepted }), ErrorForbidden.httpCode);
});
});

Some files were not shown because too many files have changed in this diff Show More