You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-08 23:07:32 +02:00
Compare commits
105 Commits
server-v3.
...
knex_max_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bfdd8937 | ||
|
|
b8d5a8b025 | ||
|
|
425a17e777 | ||
|
|
8d5c538e18 | ||
|
|
1c2d4306f9 | ||
|
|
99270bc1ea | ||
|
|
6130fcc5a8 | ||
|
|
614359c267 | ||
|
|
f438fcb50d | ||
|
|
bff42b8a82 | ||
|
|
c5a9208594 | ||
|
|
a43be838b5 | ||
|
|
9ae31583af | ||
|
|
0fa141881b | ||
|
|
6152f19712 | ||
|
|
2ade4c7951 | ||
|
|
85c887d004 | ||
|
|
47d49935bf | ||
|
|
14d1d8ca72 | ||
|
|
e903b17a3f | ||
|
|
10138260d9 | ||
|
|
6ddef009aa | ||
|
|
425d8acb2d | ||
|
|
11b00ccb70 | ||
|
|
c9288e117f | ||
|
|
da2bc801a6 | ||
|
|
95a9dc0de4 | ||
|
|
f437b8ee60 | ||
|
|
3e82fdab04 | ||
|
|
9555875b53 | ||
|
|
9c9d30e73f | ||
|
|
9bb920a98a | ||
|
|
8e8ad3193c | ||
|
|
5eb4193451 | ||
|
|
2d373f0c31 | ||
|
|
e78d3cfd3d | ||
|
|
21b4ce97dc | ||
|
|
2e9192d10a | ||
|
|
5820261577 | ||
|
|
d86a4f7437 | ||
|
|
13d77e44cf | ||
|
|
5895c8364f | ||
|
|
88183a4660 | ||
|
|
c6a1aaee72 | ||
|
|
4f8539fceb | ||
|
|
6a05349412 | ||
|
|
81e71e08be | ||
|
|
50bdbc165a | ||
|
|
9261d9c36b | ||
|
|
a19549573d | ||
|
|
e4435f52fc | ||
|
|
090518bb76 | ||
|
|
856f45507c | ||
|
|
5dc4551d60 | ||
|
|
a3f59691e5 | ||
|
|
e0aa8bd7fb | ||
|
|
9333b37147 | ||
|
|
bcbcf583c1 | ||
|
|
189aef7f91 | ||
|
|
1286716007 | ||
|
|
003f60aff1 | ||
|
|
9373aec3cf | ||
|
|
e89836ff9d | ||
|
|
c7996f66ee | ||
|
|
b4bac8ff31 | ||
|
|
c7c8631333 | ||
|
|
48cda86f54 | ||
|
|
fe54b383ca | ||
|
|
e904f9d90a | ||
|
|
41a3f9c052 | ||
|
|
c40b4eab17 | ||
|
|
5a186146ab | ||
|
|
d389886394 | ||
|
|
09930bc8a6 | ||
|
|
67dcd41bf7 | ||
|
|
f2bf1375bf | ||
|
|
7bb00e1338 | ||
|
|
8562909a4f | ||
|
|
48a499f741 | ||
|
|
4702976ceb | ||
|
|
2006288108 | ||
|
|
8446693e91 | ||
|
|
b3579d70e9 | ||
|
|
73ce9b2443 | ||
|
|
15f5b90211 | ||
|
|
0011b570aa | ||
|
|
aeb3c4a98d | ||
|
|
58a464d040 | ||
|
|
8e13ccb665 | ||
|
|
6dd14ff04b | ||
|
|
2022b5bc48 | ||
|
|
7ade9b2948 | ||
|
|
4157dad9f1 | ||
|
|
a088061de9 | ||
|
|
439d29387f | ||
|
|
2f15e4db59 | ||
|
|
0b37e99132 | ||
|
|
6d41787a29 | ||
|
|
28fc0374c5 | ||
|
|
726ee4a574 | ||
|
|
25e32226ef | ||
|
|
9efdbf9854 | ||
|
|
09c95f10f4 | ||
|
|
a6453af3e5 | ||
|
|
b8c8178b26 |
@@ -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
36
.gitignore
vendored
@@ -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
12
LICENSE
@@ -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
|
||||
|
||||
@@ -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/',
|
||||
],
|
||||
|
||||
@@ -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());
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
69
packages/app-cli/tests/file-api-driver.ts
Normal file
69
packages/app-cli/tests/file-api-driver.ts
Normal 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);
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
// });
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
40
packages/app-desktop/gui/Dialog.tsx
Normal file
40
packages/app-desktop/gui/Dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
21
packages/app-desktop/gui/DialogTitle.tsx
Normal file
21
packages/app-desktop/gui/DialogTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
219
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal file
219
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal 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);
|
||||
@@ -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}/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal file
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal 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;
|
||||
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal file
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal 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;
|
||||
41
packages/app-desktop/runForSharing.sh
Executable file
41
packages/app-desktop/runForSharing.sh
Executable 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"
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
12
packages/lib/JoplinError.ts
Normal file
12
packages/lib/JoplinError.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
47
packages/lib/debug/DebugService.ts
Normal file
47
packages/lib/debug/DebugService.ts
Normal 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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
123
packages/lib/services/share/ShareService.ts
Normal file
123
packages/lib/services/share/ShareService.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
72
packages/lib/services/share/reducer.ts
Normal file
72
packages/lib/services/share/reducer.ts
Normal 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;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
7
packages/server/LICENSE
Normal 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.
|
||||
316
packages/server/package-lock.json
generated
316
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
BIN
packages/server/schema.sqlite
Normal file
BIN
packages/server/schema.sqlite
Normal file
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
58
packages/server/src/apps/joplin/joplinUtils.ts
Normal file
58
packages/server/src/apps/joplin/joplinUtils.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!',
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
34
packages/server/src/migrations/20210201143859_app_share.ts
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
109
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal file
109
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
70
packages/server/src/models/ItemModel.test.ts
Normal file
70
packages/server/src/models/ItemModel.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
464
packages/server/src/models/ItemModel.ts
Normal file
464
packages/server/src/models/ItemModel.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
43
packages/server/src/models/ItemResourceModel.ts
Normal file
43
packages/server/src/models/ItemResourceModel.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
39
packages/server/src/models/KeyValueModel.test.ts
Normal file
39
packages/server/src/models/KeyValueModel.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
64
packages/server/src/models/KeyValueModel.ts
Normal file
64
packages/server/src/models/KeyValueModel.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
116
packages/server/src/models/ShareUserModel.ts
Normal file
116
packages/server/src/models/ShareUserModel.ts
Normal 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');
|
||||
}
|
||||
|
||||
}
|
||||
138
packages/server/src/models/UserItemModel.ts
Normal file
138
packages/server/src/models/UserItemModel.ts
Normal 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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
26
packages/server/src/routes/api/debug.ts
Normal file
26
packages/server/src/routes/api/debug.ts
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
204
packages/server/src/routes/api/items.test.ts
Normal file
204
packages/server/src/routes/api/items.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
84
packages/server/src/routes/api/items.ts
Normal file
84
packages/server/src/routes/api/items.ts
Normal 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;
|
||||
56
packages/server/src/routes/api/share_users.test.ts
Normal file
56
packages/server/src/routes/api/share_users.test.ts
Normal 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
Reference in New Issue
Block a user