diff --git a/CliClient/run_test.sh b/CliClient/run_test.sh index a6703fcf3..89a6801eb 100755 --- a/CliClient/run_test.sh +++ b/CliClient/run_test.sh @@ -22,7 +22,6 @@ trap finish EXIT cd "$ROOT_DIR" npm test tests-build/ArrayUtils.js -npm test tests-build/encryption.js npm test tests-build/EnexToMd.js npm test tests-build/HtmlToMd.js npm test tests-build/markdownUtils.js @@ -32,5 +31,7 @@ npm test tests-build/models_Tag.js npm test tests-build/models_Setting.js npm test tests-build/services_InteropService.js npm test tests-build/services_ResourceService.js -npm test tests-build/synchronizer.js -npm test tests-build/urlUtils.js \ No newline at end of file +npm test tests-build/urlUtils.js +npm test tests-build/encryption.js +npm test tests-build/services_rest_Api.js +npm test tests-build/synchronizer.js \ No newline at end of file diff --git a/CliClient/tests/services_rest_Api.js b/CliClient/tests/services_rest_Api.js index a9c70eff4..e932b7567 100644 --- a/CliClient/tests/services_rest_Api.js +++ b/CliClient/tests/services_rest_Api.js @@ -6,6 +6,7 @@ const markdownUtils = require('lib/markdownUtils.js'); const Api = require('lib/services/rest/Api'); const Folder = require('lib/models/Folder'); const Note = require('lib/models/Note'); +const Tag = require('lib/models/Tag'); const Resource = require('lib/models/Resource'); jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; @@ -45,6 +46,60 @@ describe('services_rest_Api', function() { done(); }); + it('should update folders', async (done) => { + let f1 = await Folder.save({ title: "mon carnet" }); + const response = await api.route('PUT', 'folders/' + f1.id, null, JSON.stringify({ + title: 'modifié', + })); + + let f1b = await Folder.load(f1.id); + expect(f1b.title).toBe('modifié'); + + done(); + }); + + it('should delete folders', async (done) => { + let f1 = await Folder.save({ title: "mon carnet" }); + await api.route('DELETE', 'folders/' + f1.id); + + let f1b = await Folder.load(f1.id); + expect(!f1b).toBe(true); + + done(); + }); + + it('should create folders', async (done) => { + const response = await api.route('POST', 'folders', null, JSON.stringify({ + title: 'from api', + })); + + expect(!!response.id).toBe(true); + + let f = await Folder.all(); + expect(f.length).toBe(1); + expect(f[0].title).toBe('from api'); + + done(); + }); + + it('should get one folder', async (done) => { + let f1 = await Folder.save({ title: "mon carnet" }); + const response = await api.route('GET', 'folders/' + f1.id); + expect(response.id).toBe(f1.id); + + const hasThrown = await checkThrowAsync(async () => await api.route('GET', 'folders/doesntexist')); + expect(hasThrown).toBe(true); + + done(); + }); + + it('should fail on invalid paths', async (done) => { + const hasThrown = await checkThrowAsync(async () => await api.route('GET', 'schtroumpf')); + expect(hasThrown).toBe(true); + + done(); + }); + it('should get notes', async (done) => { let response = null; const f1 = await Folder.save({ title: "mon carnet" }); @@ -159,4 +214,44 @@ describe('services_rest_Api', function() { done(); }); + it('should add tags to notes', async (done) => { + const tag = await Tag.save({ title: "mon étiquette" }); + const note = await Note.save({ title: "ma note" }); + + const response = await api.route('POST', 'tags/' + tag.id + '/notes', null, JSON.stringify({ + id: note.id, + })); + + const noteIds = await Tag.noteIds(tag.id); + expect(noteIds[0]).toBe(note.id); + + done(); + }); + + it('should remove tags from notes', async (done) => { + const tag = await Tag.save({ title: "mon étiquette" }); + const note = await Note.save({ title: "ma note" }); + await Tag.addNote(tag.id, note.id); + + const response = await api.route('DELETE', 'tags/' + tag.id + '/notes/' + note.id); + + const noteIds = await Tag.noteIds(tag.id); + expect(noteIds.length).toBe(0); + + done(); + }); + + it('should list all tag notes', async (done) => { + const tag = await Tag.save({ title: "mon étiquette" }); + const note1 = await Note.save({ title: "ma note un" }); + const note2 = await Note.save({ title: "ma note deux" }); + await Tag.addNote(tag.id, note1.id); + await Tag.addNote(tag.id, note2.id); + + const response = await api.route('GET', 'tags/' + tag.id + '/notes'); + expect(response.length).toBe(2); + + done(); + }); + }); \ No newline at end of file diff --git a/ElectronClient/app/gui/ClipperConfigScreen.jsx b/ElectronClient/app/gui/ClipperConfigScreen.jsx index 471c1db06..1579bd985 100644 --- a/ElectronClient/app/gui/ClipperConfigScreen.jsx +++ b/ElectronClient/app/gui/ClipperConfigScreen.jsx @@ -7,9 +7,16 @@ const { themeStyle } = require('../theme.js'); const { _ } = require('lib/locale.js'); const ClipperServer = require('lib/ClipperServer'); const Setting = require('lib/models/Setting'); +const { clipboard } = require('electron'); class ClipperConfigScreenComponent extends React.Component { + constructor() { + super(); + + this.copyToken_click = this.copyToken_click.bind(this); + } + disableClipperServer_click() { Setting.setValue('clipperServer.autoStart', false); ClipperServer.instance().stop(); @@ -28,6 +35,12 @@ class ClipperConfigScreenComponent extends React.Component { bridge().openExternal("https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/"); } + copyToken_click() { + clipboard.writeText(this.props.apiToken); + + alert(_('Token has been copied to the clipboard!')); + } + render() { const style = this.props.style; const theme = themeStyle(this.props.theme); @@ -58,27 +71,43 @@ class ClipperConfigScreenComponent extends React.Component { webClipperStatusComps.push() } + const apiTokenStyle = Object.assign({}, theme.textStyle, { + color: theme.colorFaded, + wordBreak: 'break-all', + paddingTop: 10, + paddingBottom: 10, + }); + return (
-
-

{_('Joplin Web Clipper allows saving web pages and screenshots from your browser to Joplin.')}

-

{_('In order to use the web clipper, you need to do the following:')}

+
+
+

{_('Joplin Web Clipper allows saving web pages and screenshots from your browser to Joplin.')}

+

{_('In order to use the web clipper, you need to do the following:')}

-
-

{_('Step 1: Enable the clipper service')}

-

{_('This service allows the browser extension to communicate with Joplin. When enabling it your firewall may ask you to give permission to Joplin to listen to a particular port.')}

-
- {webClipperStatusComps} +
+

{_('Step 1: Enable the clipper service')}

+

{_('This service allows the browser extension to communicate with Joplin. When enabling it your firewall may ask you to give permission to Joplin to listen to a particular port.')}

+
+ {webClipperStatusComps} +
-
-
-

{_('Step 2: Install the extension')}

-

{_('Download and install the relevant extension for your browser:')}

-
-

-

+
+

{_('Step 2: Install the extension')}

+

{_('Download and install the relevant extension for your browser:')}

+
+

+

+
+
+ +
+

{_('Advanced options')}

+

{_('Authorisation token:')}

+

{this.props.apiToken} {_('Copy token')}

+

{_('This authorisation token is only needed to allow third-party applications to access Joplin.')}

@@ -93,6 +122,7 @@ const mapStateToProps = (state) => { theme: state.settings.theme, clipperServer: state.clipperServer, clipperServerAutoStart: state.settings['clipperServer.autoStart'], + apiToken: state.settings['api.token'], }; }; diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json index f59fc4be0..0b530818e 100644 --- a/ElectronClient/app/package-lock.json +++ b/ElectronClient/app/package-lock.json @@ -40,18 +40,31 @@ "integrity": "sha512-sY5AXXVZv4Y1VACTtR11UJCPHHudgY5i26Qj5TypE6DKlIApbwb5uqhXcJ5UUGbvZNRh7EeIoW+LrJumBsKp7w==" }, "acorn": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.2.tgz", - "integrity": "sha512-cJrKCNcr2kv8dlDnbw+JPUGjHZzo4myaxOLmpOX8a+rgX94YeTcTMv/LFJUSByRpc+i4GgVnnhLxvMu/2Y+rqw==" + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==" }, "acorn-globals": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.1.0.tgz", - "integrity": "sha512-KjZwU26uG3u6eZcfGbTULzFcsoz6pegNKtHPksZPOUsiKo5bUmiBPa38FuHZ/Eun+XYh/JCCkS9AS3Lu4McQOQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.0.tgz", + "integrity": "sha512-hMtHj3s5RnuhvHPowpBYvJVj3rAar82JiDQHvGs1zO0l10ocX/xEdBShNHTJaboucJUsScghp74pH3s7EnHHQw==", "requires": { - "acorn": "^5.0.0" + "acorn": "^6.0.1", + "acorn-walk": "^6.0.1" + }, + "dependencies": { + "acorn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.0.2.tgz", + "integrity": "sha512-GXmKIvbrN3TV7aVqAzVFaMW8F8wzVX7voEBRO3bDA64+EX37YSayggRJP5Xig6HYHBkWKpFg9W5gg6orklubhg==" + } } }, + "acorn-walk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.1.0.tgz", + "integrity": "sha512-ugTb7Lq7u4GfWSqqpwE0bGyoBZNMTok/zDBXxfEG0QM50jNlGhIWjRC1pPN7bvV1anhF+bs+/gNcRw+o55Evbg==" + }, "ajv": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.0.tgz", @@ -914,9 +927,9 @@ } }, "browser-process-hrtime": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.2.tgz", - "integrity": "sha1-Ql1opY00R/AqBKqJQYf86K+Le44=" + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", + "integrity": "sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw==" }, "buffer-from": { "version": "1.1.0", @@ -1783,6 +1796,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, "detect-indent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", @@ -3519,6 +3537,18 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz", "integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=" }, + "http-errors": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.1.tgz", + "integrity": "sha512-jWEUgtZWGSMba9I1N3gc1HmvpBUaNC9vDdA46yScAdp+C5rdEuKWUBLWTQpW9FwSWSbYYs++b6SDCxf9UEJzfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -3909,9 +3939,9 @@ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "joplin-turndown": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.8.tgz", - "integrity": "sha512-RPZJSZEplVPL3UiJNkaKsFAG8bCGofsKIiH24s8/4qcy1xYnEufvg++rHm7rxi/0VCtpSkRBlWHSs1/srJZvoA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.9.tgz", + "integrity": "sha512-8MOxX4t5Ai22muHhXPMGNoKc/AB7gSo0eUvNh6dyd6b3vcSiMIRZE8UHpMjS9ruJQ+8e+8TtJXc0nfbexeHwrA==", "requires": { "jsdom": "^11.9.0" } @@ -4483,6 +4513,32 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "multiparty": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.2.1.tgz", + "integrity": "sha512-AvESCnNoQlZiOfP9R4mxN8M9csy2L16EIbWIkt3l4FuGti9kXBS8QVzlfyg4HEnarJhrzZilgNFlZtqmoiAIIA==", + "requires": { + "fd-slicer": "1.1.0", + "http-errors": "~1.7.0", + "safe-buffer": "5.1.2", + "uid-safe": "2.1.5" + }, + "dependencies": { + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "requires": { + "pend": "~1.2.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "nan": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.7.0.tgz", @@ -4888,8 +4944,7 @@ "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", - "dev": true + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" }, "performance-now": { "version": "2.1.0", @@ -5059,6 +5114,11 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.0.0.tgz", "integrity": "sha512-eTPo5t/4bgaMNZxyjWx6N2a6AuE0mq51KWvpc7nU/MAqixcI6v6KrGUKES0HaomdnolQBBXU/++X6/QQ9KL4tw==" }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=" + }, "randomatic": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.0.0.tgz", @@ -5539,6 +5599,11 @@ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -6538,6 +6603,11 @@ } } }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, "stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -6816,6 +6886,11 @@ "repeat-string": "^1.6.1" } }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, "tough-cookie": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", @@ -6898,6 +6973,14 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==" }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", @@ -7182,9 +7265,9 @@ "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==" }, "whatwg-mimetype": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.1.0.tgz", - "integrity": "sha512-FKxhYLytBQiUKjkYteN71fAUA3g6KpNXoho1isLiLSB3N1G4F35Q5vUxWfKFhBwi5IWF27VE6WxhrnnC+m0Mew==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz", + "integrity": "sha512-5YSO1nMd5D1hY3WzAQV3PzZL83W3YeyR1yW9PcH26Weh1t+Vzh9B6XkDh7aXm83HBZ4nSMvkjvN2H2ySWIvBgw==" }, "whatwg-url": { "version": "6.5.0", diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json index b80ddd5db..99a2291a8 100644 --- a/ElectronClient/app/package.json +++ b/ElectronClient/app/package.json @@ -105,6 +105,7 @@ "mermaid": "^8.0.0-rc.8", "mime": "^2.3.1", "moment": "^2.22.2", + "multiparty": "^4.2.1", "node-fetch": "^1.7.3", "node-notifier": "^5.2.1", "promise": "^8.0.1", diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index f9ef0b1c4..ee5390b21 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -494,6 +494,12 @@ class BaseApplication { setLocale(Setting.value('locale')); } + if (!Setting.value('api.token')) { + EncryptionService.instance().randomHexString(64).then((token) => { + Setting.setValue('api.token', token); + }); + } + time.setDateFormat(Setting.value('dateFormat')); time.setTimeFormat(Setting.value('timeFormat')); diff --git a/ReactNativeClient/lib/ClipperServer.js b/ReactNativeClient/lib/ClipperServer.js index c79ada030..4527d2c67 100644 --- a/ReactNativeClient/lib/ClipperServer.js +++ b/ReactNativeClient/lib/ClipperServer.js @@ -5,6 +5,7 @@ const { Logger } = require('lib/logger.js'); const randomClipperPort = require('lib/randomClipperPort'); const enableServerDestroy = require('server-destroy'); const Api = require('lib/services/rest/Api'); +const multiparty = require('multiparty'); class ClipperServer { @@ -13,7 +14,9 @@ class ClipperServer { this.startState_ = 'idle'; this.server_ = null; this.port_ = null; - this.api_ = new Api(); + this.api_ = new Api(() => { + return Setting.value('api.token'); + }); } static instance() { @@ -122,32 +125,50 @@ class ClipperServer { const url = urlParser.parse(request.url, true); - const execRequest = async (request, body = '') => { + const execRequest = async (request, body = '', files = []) => { try { - const response = await this.api_.route(request.method, url.pathname, url.query, body); + const response = await this.api_.route(request.method, url.pathname, url.query, body, files); writeResponse(200, response); } catch (error) { - console.error(error); writeResponse(error.httpCode ? error.httpCode : 500, error.message); } } + const contentType = request.headers['content-type'] ? request.headers['content-type'] : ''; + if (request.method === 'OPTIONS') { writeCorsHeaders(200); response.end(); } else { - if (request.method === 'POST') { - let body = ''; + if (contentType.indexOf('multipart/form-data') === 0) { + const form = new multiparty.Form(); - request.on('data', (data) => { - body += data; - }); - - request.on('end', async () => { - execRequest(request, body); - }); + form.parse(request, function(error, fields, files) { + if (error) { + writeResponse(error.httpCode ? error.httpCode : 500, error.message); + return; + } else { + execRequest( + request, + fields && fields.props && fields.props.length ? fields.props[0] : '', + files && files.data ? files.data : [] + ); + } + }); } else { - execRequest(request); + if (request.method === 'POST') { + let body = ''; + + request.on('data', (data) => { + body += data; + }); + + request.on('end', async () => { + execRequest(request, body); + }); + } else { + execRequest(request); + } } } }); diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index d9c60a645..d8a20ac0e 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -154,6 +154,8 @@ class Setting extends BaseModel { 'net.customCertificates': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return [SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav')].indexOf(settings['sync.target']) >= 0 }, public: true, appTypes: ['desktop', 'cli'], label: () => _('Custom TLS certificates'), description: () => _('Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on "Check synchronisation configuration".') }, 'net.ignoreTlsErrors': { value: false, type: Setting.TYPE_BOOL, show: (settings) => { return [SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav')].indexOf(settings['sync.target']) >= 0 }, public: true, appTypes: ['desktop', 'cli'], label: () => _('Ignore TLS certificate errors') }, + + 'api.token': { value: null, type: Setting.TYPE_STRING, public: false }, }; return this.metadata_; diff --git a/ReactNativeClient/lib/services/EncryptionService.js b/ReactNativeClient/lib/services/EncryptionService.js index 9142e5e61..e298c0712 100644 --- a/ReactNativeClient/lib/services/EncryptionService.js +++ b/ReactNativeClient/lib/services/EncryptionService.js @@ -213,6 +213,11 @@ class EncryptionService { sjcl.random.addEntropy(hexSeed, 1024, 'shim.randomBytes'); } + async randomHexString(byteCount) { + const bytes = await shim.randomBytes(byteCount); + return bytes.map((a) => { return hexPad(a.toString(16), 2); }).join(''); + } + async generateMasterKey(password) { const bytes = await shim.randomBytes(256); const hexaBytes = bytes.map((a) => { return hexPad(a.toString(16), 2); }).join(''); diff --git a/ReactNativeClient/lib/services/rest/Api.js b/ReactNativeClient/lib/services/rest/Api.js index 6753a44f6..5b8669923 100644 --- a/ReactNativeClient/lib/services/rest/Api.js +++ b/ReactNativeClient/lib/services/rest/Api.js @@ -2,6 +2,8 @@ const { ltrimSlashes } = require('lib/path-utils.js'); const Folder = require('lib/models/Folder'); const Note = require('lib/models/Note'); const Tag = require('lib/models/Tag'); +const BaseItem = require('lib/models/BaseItem'); +const BaseModel = require('lib/BaseModel'); const Setting = require('lib/models/Setting'); const markdownUtils = require('lib/markdownUtils'); const mimeUtils = require('lib/mime-utils.js').mime; @@ -48,6 +50,14 @@ class ErrorForbidden extends ApiError { } +class ErrorBadRequest extends ApiError { + + constructor(message = 'Bad Request') { + super(message, 400); + } + +} + class Api { constructor(token = null) { @@ -56,25 +66,68 @@ class Api { } get token() { - return this.token_; + return typeof this.token_ === 'function' ? this.token_() : this.token_; } - async route(method, path, query = null, body = null) { + parsePath(path) { path = ltrimSlashes(path); - if (!path) throw new ErrorNotFound(); // Nothing at the root yet + if (!path) return { callName: '', params: [] }; const pathParts = path.split('/'); const callSuffix = pathParts.splice(0,1)[0]; - const callName = 'action_' + callSuffix; - if (!this[callName]) throw new ErrorNotFound(); + let callName = 'action_' + callSuffix; + return { + callName: callName, + params: pathParts, + }; + } + + async route(method, path, query = null, body = null, files = null) { + if (!files) files = []; + + const parsedPath = this.parsePath(path); + if (!parsedPath.callName) throw new ErrorNotFound(); // Nothing at the root yet + + const request = { + method: method, + path: ltrimSlashes(path), + query: query ? query : {}, + body: body, + bodyJson_: null, + bodyJson: function(disallowedProperties = null) { + if (!this.bodyJson_) this.bodyJson_ = JSON.parse(this.body); + + if (disallowedProperties) { + const filteredBody = Object.assign({}, this.bodyJson_); + for (let i = 0; i < disallowedProperties.length; i++) { + const n = disallowedProperties[i]; + delete filteredBody[n]; + } + return filteredBody; + } + + return this.bodyJson_; + }, + files: files, + } + + let id = null; + let link = null; + let params = parsedPath.params; + + if (params.length >= 1) { + id = params[0]; + params.splice(0, 1); + if (params.length >= 1) { + link = params[0]; + params.splice(0, 1); + } + } + + request.params = params; try { - return this[callName]({ - method: method, - query: query ? query : {}, - body: body, - params: pathParts, - }); + return this[parsedPath.callName](request, id, link); } catch (error) { if (!error.httpCode) error.httpCode = 500; throw error; @@ -89,6 +142,10 @@ class Api { return this.logger_; } + get readonlyProperties() { + return ['id', 'created_time', 'updated_time', 'encryption_blob_encrypted', 'encryption_applied', 'encryption_cipher_text']; + } + fields_(request, defaultFields) { const query = request.query; if (!query || !query.fields) return defaultFields; @@ -97,39 +154,139 @@ class Api { } checkToken_(request) { + // For now, whitelist some calls to allow the web clipper to work + // without an extra auth step + const whiteList = [ + [ 'GET', 'ping' ], + [ 'GET', 'tags' ], + [ 'GET', 'folders' ], + [ 'POST', 'notes' ], + ]; + + for (let i = 0; i < whiteList.length; i++) { + if (whiteList[i][0] === request.method && whiteList[i][1] === request.path) return; + } + if (!this.token) return; if (!request.query || !request.query.token) throw new ErrorForbidden('Missing "token" parameter'); if (request.query.token !== this.token) throw new ErrorForbidden('Invalid "token" parameter'); } - async action_ping(request) { + async defaultAction_(modelType, request, id = null, link = null) { + this.checkToken_(request); + + if (link) throw new ErrorNotFound(); // Default action doesn't support links at all for now + + const ModelClass = BaseItem.getClassByItemType(modelType); + + const getOneModel = async () => { + const model = await ModelClass.load(id); + if (!model) throw new ErrorNotFound(); + return model; + } + + if (request.method === 'GET') { + if (id) { + return getOneModel(); + } else { + const options = {}; + const fields = this.fields_(request, []); + if (fields.length) options.fields = fields; + return await ModelClass.all(options); + } + } + + if (request.method === 'PUT' && id) { + const model = await getOneModel(); + let newModel = Object.assign({}, model, request.bodyJson(this.readonlyProperties)); + newModel = await ModelClass.save(newModel, { userSideValidation: true }); + return newModel; + } + + if (request.method === 'DELETE' && id) { + const model = await getOneModel(); + await ModelClass.delete(model.id); + return; + } + + if (request.method === 'POST') { + const model = request.bodyJson(this.readonlyProperties); + const result = await ModelClass.save(model, { userSideValidation: true }); + return result; + } + + throw new ErrorMethodNotAllowed(); + } + + async action_ping(request, id = null, link = null) { if (request.method === 'GET') { return 'JoplinClipperServer'; } + throw new ErrorMethodNotAllowed(); } - async action_folders(request) { - if (request.method === 'GET') { + async action_folders(request, id = null, link = null) { + if (request.method === 'GET' && !id) { return await Folder.allAsTree({ fields: this.fields_(request, ['id', 'parent_id', 'title']) }); } - throw new ErrorMethodNotAllowed(); + return this.defaultAction_(BaseModel.TYPE_FOLDER, request, id, link); } - async action_tags(request) { - if (request.method === 'GET') { - return await Tag.all({ fields: this.fields_(request, ['id', 'title']) }) + async action_tags(request, id = null, link = null) { + if (link === 'notes') { + const tag = await Tag.load(id); + if (!tag) throw new ErrorNotFound(); + + if (request.method === 'POST') { + const note = request.bodyJson(); + if (!note || !note.id) throw new ErrorBadRequest('Missing note ID'); + return await Tag.addNote(tag.id, note.id); + } + + if (request.method === 'DELETE') { + const noteId = request.params.length ? request.params[0] : null; + if (!noteId) throw new ErrorBadRequest('Missing note ID'); + await Tag.removeNote(tag.id, noteId); + return; + } + + if (request.method === 'GET') { + return await Tag.noteIds(tag.id); + } } - throw new ErrorMethodNotAllowed(); + return this.defaultAction_(BaseModel.TYPE_TAG, request, id, link); } - async action_notes(request) { + async action_master_keys(request, id = null, link = null) { + return this.defaultAction_(BaseModel.TYPE_MASTER_KEY, request, id, link); + } + + async action_resources(request, id = null, link = null) { + // fieldName: "data" + // headers: Object + // originalFilename: "test.jpg" + // path: "C:\Users\Laurent\AppData\Local\Temp\BW77wkpP23iIGUstd0kDuXXC.jpg" + // size: 164394 + + if (request.method === 'POST') { + if (!request.files.length) throw new ErrorBadRequest('Resource cannot be created without a file'); + const filePath = request.files[0].path; + const resource = await shim.createResourceFromPath(filePath); + const newResource = Object.assign({}, resource, request.bodyJson(this.readonlyProperties)); + return await Resource.save(newResource); + } + + return this.defaultAction_(BaseModel.TYPE_RESOURCE, request, id, link); + } + + async action_notes(request, id = null, link = null) { if (request.method === 'GET') { this.checkToken_(request); - const noteId = request.params.length ? request.params[0] : null; + const noteId = id; const parentId = request.query.parent_id ? request.query.parent_id : null; const fields = this.fields_(request, []); // previews() already returns default fields const options = {}; @@ -177,7 +334,7 @@ class Api { return note; } - throw new ErrorMethodNotAllowed(); + return this.defaultAction_(BaseModel.TYPE_NOTE, request, id, link); } diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index f0dd83a50..5a36942da 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -143,9 +143,7 @@ function shimInit() { await fs.copy(filePath, targetPath, { overwrite: true }); } - await Resource.save(resource, { isNew: true }); - - return resource; + return await Resource.save(resource, { isNew: true }); } shim.attachFileToNote = async function(note, filePath, position = null) {