1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-29 23:48:19 +02:00

Compare commits

...

49 Commits

Author SHA1 Message Date
Laurent Cozic
a5b5ef1886 Merge branch 'dev' into server_content_drivers 2021-11-09 15:33:34 +00:00
Laurent Cozic
7b3ad32103 Update translations 2021-11-09 15:28:38 +00:00
Laurent Cozic
3745cd7cb0 Tools: Do not process context when running build-translation tool 2021-11-09 15:26:45 +00:00
Laurent Cozic
920f2d9655 Revert "Update translations"
This reverts commit f800ca0269.

Reverting for now due to some translations being incorrectly marked as
fuzzy.
2021-11-09 13:49:20 +00:00
Laurent Cozic
f800ca0269 Update translations 2021-11-09 13:16:09 +00:00
Laurent Cozic
33be306d01 Tools: Fixed missing translations when running build-translation tool 2021-11-09 13:14:41 +00:00
Laurent Cozic
3782255c27 Tools: Fixed build-translation script to handle .ts and .tsx files directly 2021-11-09 12:20:07 +00:00
Laurent Cozic
68f77f6bbc fix 2021-11-08 17:46:29 +00:00
Laurent
0ab235273b Tools: Detect missing translation strings on CI (#5688) 2021-11-08 17:10:33 +00:00
Laurent Cozic
75256613cc Security: Ensure Markdown links that contain single quotes are correctly escaped 2021-11-08 15:39:45 +00:00
Helmut K. C. Tessarek
b328094033 update en_US.po 2021-11-08 10:30:18 -05:00
Helmut K. C. Tessarek
4f0f1af5d1 Update translations 2021-11-08 10:22:16 -05:00
Laurent Cozic
021ce14348 Server v2.6.3 2021-11-08 15:21:20 +00:00
Laurent Cozic
b402bc7ff7 Merge branch 'dev' into server_content_drivers 2021-11-08 15:06:06 +00:00
Laurent Cozic
0ed0690bf8 rename 2021-11-08 14:58:10 +00:00
Laurent Cozic
467b1156cc s3 2021-11-08 14:24:42 +00:00
Laurent Cozic
c4017e52dc Fixed a few strings 2021-11-08 10:00:11 +00:00
Laurent Cozic
ec2c1741a2 Tools: Fixed package version numbers 2021-11-07 17:36:48 +00:00
Laurent Cozic
6a9d9f6542 Merge branch 'dev' into server_content_drivers 2021-11-07 17:30:04 +00:00
Laurent Cozic
3e5ad0a374 Desktop, Cli: Fixes #5653: Long resource filenames were being incorrectly cut 2021-11-07 16:41:39 +00:00
Laurent Cozic
05e390d48b Tools: Fixed build-translation script 2021-11-07 15:41:04 +00:00
Laurent Cozic
31ce0f46e0 Tools: Show more info in IOS release script 2021-11-07 11:55:34 +00:00
Laurent Cozic
d19551b984 Chore: Set correct iOS version ID 2021-11-07 11:55:11 +00:00
Laurent Cozic
dacd697f80 iOS: Fixes #5671: Fixed iOS 12 crash that prevents the app from starting 2021-11-07 11:53:39 +00:00
Laurent Cozic
70d5c7a648 Server: Set resource content size when viewing published note 2021-11-07 11:47:37 +00:00
Laurent Cozic
69b413ce2b storage table 2021-11-07 11:46:25 +00:00
Daniel Bretoi
42caab6bde Doc: CLI: update to include local filesystem sync (#5684)
* terminal doc update to include local filesystem sync

* fix typo
2021-11-07 00:32:18 -04:00
NiceYoyo
01b63ad263 All: Translation: Update de_DE.po (#5682)
* Update de_DE.po

* Update de_DE.po
2021-11-07 00:30:04 -04:00
felipeviggiano
ae4013d2f7 All: Translation: Update pt_BR.po (#5680)
* Updates on pt_BR.po file

* Updates on pt_BR.po file
2021-11-07 00:29:38 -04:00
Laurent Cozic
e3d6334372 id 2021-11-06 19:51:32 +00:00
Laurent Cozic
cc4c50c219 rename 2021-11-06 16:23:43 +00:00
Laurent Cozic
5d646f7ced env 2021-11-06 15:33:07 +00:00
Laurent Cozic
fa3612405c tests 2021-11-05 12:05:03 +00:00
Laurent Cozic
20df46c066 fallback 2021-11-05 11:43:27 +00:00
Laurent Cozic
9b0a659416 connection string 2021-11-04 18:00:37 +00:00
Laurent Cozic
298e85f115 iOS: Set min supported iOS version to 13.0 2021-11-04 16:43:34 +00:00
Laurent Cozic
a00e0e7043 Merge branch 'dev' into server_content_drivers 2021-11-04 15:46:55 +00:00
Laurent Cozic
9e1cb9db2c Server: Immediately ask user to set password after Stripe checkout 2021-11-04 12:49:51 +00:00
Laurent Cozic
373c041aa6 Doc: Fixed H3 header size 2021-11-03 17:39:30 +00:00
Laurent
af19865865 All, Server: Add support for sharing notes when E2EE is enabled (#5529) 2021-11-03 16:24:40 +00:00
Laurent Cozic
560523bdc2 tests 2021-10-26 19:09:33 +01:00
Laurent Cozic
a13242e803 Merge branch 'dev' into server_content_drivers 2021-10-26 17:58:47 +01:00
Laurent Cozic
72834fcfc4 clean up 2021-10-22 14:46:54 +01:00
Laurent Cozic
731142218b comment 2021-10-22 14:33:03 +01:00
Laurent Cozic
17b580b71b support fallback driver 2021-10-21 11:41:01 +01:00
Laurent Cozic
f7be45c236 tests 2021-10-20 12:18:56 +01:00
Laurent Cozic
b298861dc3 db driver 2021-10-19 19:44:43 +01:00
Laurent Cozic
2343de3763 Merge branch 'dev' into server_content_drivers 2021-10-19 17:52:46 +01:00
Laurent Cozic
abb37258d0 Content drivers 2021-10-12 18:56:42 +01:00
188 changed files with 220315 additions and 187112 deletions

View File

@@ -1320,6 +1320,9 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.test.js
packages/lib/services/interop/InteropService_Exporter_Md.test.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map

View File

@@ -81,7 +81,7 @@ fi
# release randomly fail.
# =============================================================================
if [ "$IS_PULL_REQUEST" == "1" ]; then
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
echo "Step: Running linter..."
npm run linter-ci ./
@@ -109,6 +109,27 @@ if [ "$IS_PULL_REQUEST" == "1" ]; then
fi
fi
# =============================================================================
# Check that we didn't lose any string due to gettext not being able to parse
# newly modified or added scripts. This is convenient to quickly view on GitHub
# what commit may have broken translation building. We run this on macOS because
# we need the latest version of gettext (and stable Ubuntu doesn't have it).
# =============================================================================
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
if [ "$IS_MACOS" == "1" ]; then
echo "Step: Checking for lost translation strings..."
xgettext --version
node packages/tools/build-translation.js --missing-strings-check-only
testResult=$?
if [ $testResult -ne 0 ]; then
exit $testResult
fi
fi
fi
# =============================================================================
# Find out if we should run the build or not. Electron-builder gets stuck when
# building PRs so we disable it in this case. The Linux build should provide
@@ -124,13 +145,12 @@ if [ "$IS_PULL_REQUEST" == "1" ]; then
fi
# =============================================================================
# Prepare the Electron app and build it
# Build the Electron app or Docker image depending on the current tag.
#
# If the current tag is a desktop release tag (starts with "v", such as
# "v1.4.7"), we build and publish to github
#
# Otherwise we only build but don't publish to GitHub. It helps finding
# out any issue in pull requests and dev branch.
# "v1.4.7"), we build and publish to GitHub. Otherwise we only build but don't
# publish to GitHub. It helps finding out any issue in pull requests and dev
# branch.
# =============================================================================
cd "$ROOT_DIR/packages/app-desktop"

View File

@@ -19,6 +19,14 @@ jobs:
sudo apt-get update || true
sudo apt-get install -y gettext
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y translate-toolkit
- name: Install macOS dependencies
if: runner.os == 'macOS'
run: |
brew update
brew install gettext
brew install translate-toolkit
- name: Install Docker Engine
if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')

3
.gitignore vendored
View File

@@ -1303,6 +1303,9 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.test.js
packages/lib/services/interop/InteropService_Exporter_Md.test.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map

View File

@@ -188,6 +188,11 @@ h2 {
padding-bottom: 0.5em;
}
h3 {
font-size: 1.3em;
margin-bottom: 0.8rem;
}
.front-page h1 {
font-size: 3em;
}

View File

@@ -505,47 +505,47 @@ Current translations:
<!-- LOCALE-TABLE-AUTO-GENERATED -->
&nbsp; | Language | Po File | Last translator | Percent done
---|---|---|---|---
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 99%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 28%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 71%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 55%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 95%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 27%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 68%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 54%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Michal Stanke](mailto:michal@stanke.cz) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | Mustafa Al-Dailemi (dailemi@hotmail.com)Language-Team: | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [marph91](mailto:martin.d@andix.de) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 54%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Michal Stanke](mailto:michal@stanke.cz) | 91%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [MrKanister](mailto:s.robin@tutanota.de) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 52%
<img src="https://joplinapp.org/images/flags/country-4x3/gb.png" width="16px"/> | English (United Kingdom) | [en_GB](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_GB.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Mora](mailto:francisco.m.collao@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 31%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Nicolas Viviani | 100%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 36%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Albano Battistella](mailto:albano_battistella@hotmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 83%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MetBril](mailto:metbril@users.noreply.github.com) | 90%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | Alexander Dawson | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 67%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Nicolas Suzuki](mailto:nicolas.suzuki@pm.me) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 62%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 90%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 42%
<img src="https://joplinapp.org/images/flags/country-4x3/vi.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 80%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [SiderealArt](mailto:nelson22768384@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Mora](mailto:francisco.m.collao@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 30%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Nicolas Viviani | 96%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 35%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Albano Battistella](mailto:albano_battistella@hotmail.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 80%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 83%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MetBril](mailto:metbril@users.noreply.github.com) | 87%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | Alexander Dawson | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 65%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Felipe Viggiano](mailto:felipeviggiano@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 60%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 43%
<img src="https://joplinapp.org/images/flags/country-4x3/vi.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 78%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [SiderealArt](mailto:nelson22768384@gmail.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 91%
<!-- LOCALE-TABLE-AUTO-GENERATED -->
# Contributors

View File

@@ -558,7 +558,6 @@ class Application extends BaseApplication {
// });
// }, 2000);
// setTimeout(() => {
// this.dispatch({
// type: 'DIALOG_OPEN',

View File

@@ -167,7 +167,7 @@ const EncryptionConfigScreen = (props: Props) => {
mkComps.push(renderMasterKey(mk));
}
const headerComp = isEnabledMasterKeys ? <h2>{_('Encryption Keys')}</h2> : <a onClick={() => toggleShowDisabledMasterKeys() } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled keys') : _('Show disabled keys')}</a>;
const headerComp = isEnabledMasterKeys ? <h2>{_('Encryption keys')}</h2> : <a onClick={() => toggleShowDisabledMasterKeys() } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled keys') : _('Show disabled keys')}</a>;
const infoComp: any = null; // isEnabledMasterKeys ? <p>{'Note: Only one key is going to be used for encryption (the one marked as "active"). Any of the keys might be used for decryption, depending on how the notes or notebooks were originally encrypted.'}</p> : null;
const tableComp = !showTable ? null : (
<table>
@@ -247,7 +247,7 @@ const EncryptionConfigScreen = (props: Props) => {
{_('Encryption:')} <strong>{props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
</p>
<p>
{_('Public-Private Key Pair:')} <strong>{props.ppk ? _('Generated') : _('Not generated')}</strong>
{_('Public-private key pair:')} <strong>{props.ppk ? _('Generated') : _('Not generated')}</strong>
</p>
{decryptedItemsInfo}
{toggleButton}
@@ -319,7 +319,7 @@ const EncryptionConfigScreen = (props: Props) => {
nonExistingMasterKeySection = (
<div className="section">
<h2>{_('Missing Keys')}</h2>
<h2>{_('Missing keys')}</h2>
<p>{_('The keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</p>
<table>
<tbody>

View File

@@ -37,6 +37,7 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
import commands from './commands/index';
import invitationRespond from '../../services/share/invitationRespond';
const { connect } = require('react-redux');
@@ -564,8 +565,8 @@ class MainScreenComponent extends React.Component<Props, State> {
bridge().restart();
};
const onInvitationRespond = async (shareUserId: string, folderId: string, accept: boolean) => {
await invitationRespond(shareUserId, folderId, accept);
const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
await invitationRespond(shareUserId, folderId, masterKey, accept);
};
let msg = null;
@@ -610,9 +611,9 @@ class MainScreenComponent extends React.Component<Props, State> {
msg = this.renderNotificationMessage(
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
_('Accept'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, true),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
_('Reject'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, false)
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false)
);
} else if (this.props.hasDisabledSyncItems) {
msg = this.renderNotificationMessage(

View File

@@ -10,6 +10,7 @@ import { getMasterPasswordStatus, getMasterPasswordStatusMessage, checkHasMaster
import { reg } from '@joplin/lib/registry';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import KvStore from '@joplin/lib/services/KvStore';
import ShareService from '@joplin/lib/services/share/ShareService';
interface Props {
themeId: number;
@@ -60,7 +61,7 @@ export default function(props: Props) {
if (mode === Mode.Set) {
await updateMasterPassword(currentPassword, password1);
} else if (mode === Mode.Reset) {
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), password1);
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), ShareService.instance(), password1);
} else {
throw new Error(`Unknown mode: ${mode}`);
}
@@ -200,7 +201,7 @@ export default function(props: Props) {
}
}
const dialogTitle = mode === Mode.Set ? _('Manager master password') : `⚠️ ${_('Reset master password')} ⚠️`;
const dialogTitle = mode === Mode.Set ? _('Manage master password') : `⚠️ ${_('Reset master password')} ⚠️`;
const okButtonLabel = mode === Mode.Set ? _('Save') : `⚠️ ${_('Reset master password')} ⚠️`;
function renderDialogWrapper() {

View File

@@ -171,7 +171,7 @@ function ShareFolderDialog(props: Props) {
try {
setLatestError(null);
const share = await ShareService.instance().shareFolder(props.folderId);
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail);
await Promise.all([
ShareService.instance().refreshShares(),
ShareService.instance().refreshShareUsers(share.id),

View File

@@ -162,7 +162,6 @@ h2 {
}
}
.form {
display: flex;
flex-direction: column;

View File

@@ -3,17 +3,18 @@ import Logger from '@joplin/lib/Logger';
import Folder from '@joplin/lib/models/Folder';
import { reg } from '@joplin/lib/registry';
import { _ } from '@joplin/lib/locale';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
const logger = Logger.create('invitationRespond');
export default async function(shareUserId: string, folderId: string, accept: boolean) {
export default async function(shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) {
// The below functions can take a bit of time to complete so in the
// meantime we hide the notification so that the user doesn't click
// multiple times on the Accept link.
ShareService.instance().setProcessingShareInvitationResponse(true);
try {
await ShareService.instance().respondInvitation(shareUserId, accept);
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
} catch (error) {
logger.error(error);
alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message));

View File

@@ -492,7 +492,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -521,7 +521,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
@@ -588,7 +588,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -641,7 +641,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -667,7 +667,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -698,7 +698,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -1,9 +1,12 @@
require_relative '../node_modules/react-native/scripts/react_native_pods'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
# Note: it was 13.4 to get @react-native-community/datetimepicker to work
# but it's probably not necessary actually. Just needed to upgrade XCode.
platform :ios, '11.0'
# Note: it was 13.4 to get @react-native-community/datetimepicker to work but
# it's probably not necessary actually. Just needed to upgrade XCode.
#
# 2021-11-04: Set to 13.0 because it crashes with 12.x
# https://github.com/laurent22/joplin/issues/5671
platform :ios, '13.0'
target 'Joplin' do
config = use_native_modules!

View File

@@ -324,7 +324,7 @@ PODS:
- React
- RNSecureRandom (1.0.0-rc.0):
- React
- RNShare (5.1.5):
- RNShare (7.2.1):
- React-Core
- RNVectorIcons (7.1.0):
- React
@@ -555,10 +555,10 @@ SPEC CHECKSUMS:
RNFS: 2bd9eb49dc82fa9676382f0585b992c424cd59df
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNSecureRandom: 1f19ad1492f7ed416b8fc79e92216a1f73f13a4c
RNShare: 9cdd23357981cf4dee275eb79239e860dccc0faf
RNShare: edd621a71124961e29a7ba43a84bd1c6f9980d88
RNVectorIcons: bc69e6a278b14842063605de32bec61f0b251a59
Yoga: 2b4a01651f42a32f82e6cef3830a3ba48088237f
PODFILE CHECKSUM: 9f8b595b05d63f54759fc6d9b1d2c5838fff9626
PODFILE CHECKSUM: 3ccf11f600ddb42a825b2bb9a341a19f5c891f2b
COCOAPODS: 1.10.2

View File

@@ -42,7 +42,7 @@
"react-native-quick-actions": "^0.3.13",
"react-native-rsa-native": "^2.0.4",
"react-native-securerandom": "^1.0.0-rc.0",
"react-native-share": "^5.1.5",
"react-native-share": "^7.2.1",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^5.0.0",
"react-native-vector-icons": "^7.1.0",
@@ -10206,9 +10206,9 @@
}
},
"node_modules/react-native-share": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-5.1.5.tgz",
"integrity": "sha512-DfWCsoJEdIM873OmI2L8DccMZ9xGSS78t/LETDdGGCw0lgDhoSJx6LOhM3m/h/R9AJykfpGssonwxb8hN45paQ=="
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.2.1.tgz",
"integrity": "sha512-fZIhwnPDWStjGOEFnbthAMvaldZ4kR1QHNbhr/fNsOcOxrS0gzU6djjg0pqdkPLrpXMl+CeaDU1Rzxl/cJjg2A=="
},
"node_modules/react-native-side-menu": {
"version": "1.1.3",
@@ -14278,7 +14278,8 @@
"@react-native-community/clipboard": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.0.tgz",
"integrity": "sha512-XoujTQuXhPgQLVLn7HPt7615jBEGzJm1Nhos0COdreBIz3qWIi5noYZth8jBFctf8FG5tpe24XTZNDz5udgqQQ=="
"integrity": "sha512-XoujTQuXhPgQLVLn7HPt7615jBEGzJm1Nhos0COdreBIz3qWIi5noYZth8jBFctf8FG5tpe24XTZNDz5udgqQQ==",
"requires": {}
},
"@react-native-community/datetimepicker": {
"version": "3.0.3",
@@ -14291,12 +14292,14 @@
"@react-native-community/geolocation": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@react-native-community/geolocation/-/geolocation-2.0.2.tgz",
"integrity": "sha512-tTNXRCgnhJBu79mulQwzabXRpDqfh/uaDqfHVpvF0nX4NTpolpy6mvTRiFg7eWFPGRArsnZz1EYp6rHfJWGgEA=="
"integrity": "sha512-tTNXRCgnhJBu79mulQwzabXRpDqfh/uaDqfHVpvF0nX4NTpolpy6mvTRiFg7eWFPGRArsnZz1EYp6rHfJWGgEA==",
"requires": {}
},
"@react-native-community/netinfo": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-6.0.0.tgz",
"integrity": "sha512-Z9M8VGcF2IZVOo2x+oUStvpCW/8HjIRi4+iQCu5n+PhC7OqCQX58KYAzdBr///alIfRXiu6oMb+lK+rXQH1FvQ=="
"integrity": "sha512-Z9M8VGcF2IZVOo2x+oUStvpCW/8HjIRi4+iQCu5n+PhC7OqCQX58KYAzdBr///alIfRXiu6oMb+lK+rXQH1FvQ==",
"requires": {}
},
"@react-native-community/push-notification-ios": {
"version": "1.6.0",
@@ -14309,7 +14312,8 @@
"@react-native-community/slider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-3.0.3.tgz",
"integrity": "sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw=="
"integrity": "sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw==",
"requires": {}
},
"@react-native/assets": {
"version": "1.0.0",
@@ -18529,7 +18533,8 @@
"joplin-rn-alarm-notification": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/joplin-rn-alarm-notification/-/joplin-rn-alarm-notification-1.0.3.tgz",
"integrity": "sha512-HZGDrLmYf6aMVgzk02w4DS9CjaTogE1hnOLdMDsrWkZzRskO6g3bZw+Bwlc63cCX4ZLZeeWIaABzHoWKAbLzpQ=="
"integrity": "sha512-HZGDrLmYf6aMVgzk02w4DS9CjaTogE1hnOLdMDsrWkZzRskO6g3bZw+Bwlc63cCX4ZLZeeWIaABzHoWKAbLzpQ==",
"requires": {}
},
"js-tokens": {
"version": "4.0.0",
@@ -18579,7 +18584,8 @@
"babel-core": {
"version": "7.0.0-bridge.0",
"resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz",
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg=="
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==",
"requires": {}
},
"braces": {
"version": "2.3.2",
@@ -20766,7 +20772,8 @@
"ws": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==",
"requires": {}
}
}
},
@@ -20857,7 +20864,8 @@
"react-native-document-picker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-4.0.0.tgz",
"integrity": "sha512-tjIOBBcyjv4j5E1MDL2OvEKNpXxQybLNkjjfpTyDUzek7grZ5eOvSlp6i/Y3EfuIGLByeaw++9R1SZtOij6R7w=="
"integrity": "sha512-tjIOBBcyjv4j5E1MDL2OvEKNpXxQybLNkjjfpTyDUzek7grZ5eOvSlp6i/Y3EfuIGLByeaw++9R1SZtOij6R7w==",
"requires": {}
},
"react-native-dropdownalert": {
"version": "3.1.2",
@@ -20908,7 +20916,8 @@
"react-native-file-viewer": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/react-native-file-viewer/-/react-native-file-viewer-2.1.4.tgz",
"integrity": "sha512-G3ko9lmqxT+lWhsDNy2K3Jes6xSMsUvlYwuwnRCNk2wC6hgYMeoeaiwDt8R3CdON781hB6Ej1eu3ir1QATtHXg=="
"integrity": "sha512-G3ko9lmqxT+lWhsDNy2K3Jes6xSMsUvlYwuwnRCNk2wC6hgYMeoeaiwDt8R3CdON781hB6Ej1eu3ir1QATtHXg==",
"requires": {}
},
"react-native-fs": {
"version": "2.16.6",
@@ -20927,7 +20936,8 @@
"react-native-image-resizer": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-native-image-resizer/-/react-native-image-resizer-1.3.0.tgz",
"integrity": "sha512-ymnx++RCZ8AJ1D/XAeK9tOLorYhFedeQvxeZSiisj4P49OP2mWdJ4z2uxxpVSb4SBA+i1hPYAtIAbOVObtUslQ=="
"integrity": "sha512-ymnx++RCZ8AJ1D/XAeK9tOLorYhFedeQvxeZSiisj4P49OP2mWdJ4z2uxxpVSb4SBA+i1hPYAtIAbOVObtUslQ==",
"requires": {}
},
"react-native-modal-datetime-picker": {
"version": "9.0.0",
@@ -20970,9 +20980,9 @@
}
},
"react-native-share": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-5.1.5.tgz",
"integrity": "sha512-DfWCsoJEdIM873OmI2L8DccMZ9xGSS78t/LETDdGGCw0lgDhoSJx6LOhM3m/h/R9AJykfpGssonwxb8hN45paQ=="
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.2.1.tgz",
"integrity": "sha512-fZIhwnPDWStjGOEFnbthAMvaldZ4kR1QHNbhr/fNsOcOxrS0gzU6djjg0pqdkPLrpXMl+CeaDU1Rzxl/cJjg2A=="
},
"react-native-side-menu": {
"version": "1.1.3",
@@ -20985,7 +20995,8 @@
"react-native-sqlite-storage": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-native-sqlite-storage/-/react-native-sqlite-storage-5.0.0.tgz",
"integrity": "sha512-c1Joq3/tO1nmIcP8SkRZNolPSbfvY8uZg5lXse0TmjIPC0qHVbk96IMvWGyly1TmYCIpxpuDRc0/xCffDbYIvg=="
"integrity": "sha512-c1Joq3/tO1nmIcP8SkRZNolPSbfvY8uZg5lXse0TmjIPC0qHVbk96IMvWGyly1TmYCIpxpuDRc0/xCffDbYIvg==",
"requires": {}
},
"react-native-vector-icons": {
"version": "7.1.0",
@@ -21005,7 +21016,8 @@
"react-native-version-info": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-native-version-info/-/react-native-version-info-1.1.0.tgz",
"integrity": "sha512-0QmJjdKyaW+G/TiOWkwzGVv1G3FPnWrPH5SYWloUpv8WA7onuQESYHdLyjfCUInYI/FHVeEynE2VomOOsda8wQ=="
"integrity": "sha512-0QmJjdKyaW+G/TiOWkwzGVv1G3FPnWrPH5SYWloUpv8WA7onuQESYHdLyjfCUInYI/FHVeEynE2VomOOsda8wQ==",
"requires": {}
},
"react-native-webview": {
"version": "10.9.2",

View File

@@ -15,8 +15,8 @@
"postinstall": "jetify && npm run build"
},
"dependencies": {
"@joplin/lib": "~2.5",
"@joplin/renderer": "~2.5",
"@joplin/lib": "~2.6",
"@joplin/renderer": "~2.6",
"@react-native-community/clipboard": "^1.5.0",
"@react-native-community/datetimepicker": "^3.0.3",
"@react-native-community/geolocation": "^2.0.2",
@@ -50,7 +50,7 @@
"react-native-quick-actions": "^0.3.13",
"react-native-rsa-native": "^2.0.4",
"react-native-securerandom": "^1.0.0-rc.0",
"react-native-share": "^5.1.5",
"react-native-share": "^7.2.1",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^5.0.0",
"react-native-vector-icons": "^7.1.0",
@@ -73,7 +73,7 @@
"@codemirror/lang-markdown": "^0.18.4",
"@codemirror/state": "^0.18.7",
"@codemirror/view": "^0.18.19",
"@joplin/tools": "~2.5",
"@joplin/tools": "~2.6",
"@rollup/plugin-node-resolve": "^13.0.0",
"@rollup/plugin-typescript": "^8.2.1",
"@types/node": "^14.14.6",

View File

@@ -561,7 +561,7 @@ async function initialize(dispatch: Function) {
// / E2EE SETUP
// ----------------------------------------------------------------
await ShareService.instance().initialize(store);
await ShareService.instance().initialize(store, EncryptionService.instance());
reg.logger().info('Loading folders...');

View File

@@ -637,7 +637,7 @@ export default class BaseApplication {
BaseSyncTarget.dispatch = this.store().dispatch;
DecryptionWorker.instance().dispatch = this.store().dispatch;
ResourceFetcher.instance().dispatch = this.store().dispatch;
ShareService.instance().initialize(this.store());
ShareService.instance().initialize(this.store(), EncryptionService.instance());
}
public deinitRedux() {

View File

@@ -351,7 +351,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, 35, 36, 37, 38, 39];
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, 36, 37, 38, 39, 40];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@@ -900,10 +900,11 @@ export default class JoplinDatabase extends Database {
queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""');
}
// if (targetVersion == 40) {
// queries.push('ALTER TABLE `folders` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
// queries.push('ALTER TABLE `notes` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
// }
if (targetVersion == 40) {
queries.push('ALTER TABLE `folders` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
queries.push('ALTER TABLE `notes` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
queries.push('ALTER TABLE `resources` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
}
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -41,45 +41,45 @@ locales['uk_UA'] = require('./uk_UA.json');
locales['vi'] = require('./vi.json');
locales['zh_CN'] = require('./zh_CN.json');
locales['zh_TW'] = require('./zh_TW.json');
stats['ar'] = {"percentDone":99};
stats['eu'] = {"percentDone":28};
stats['bs_BA'] = {"percentDone":71};
stats['bg_BG'] = {"percentDone":55};
stats['ca'] = {"percentDone":99};
stats['ar'] = {"percentDone":95};
stats['eu'] = {"percentDone":27};
stats['bs_BA'] = {"percentDone":68};
stats['bg_BG'] = {"percentDone":54};
stats['ca'] = {"percentDone":95};
stats['hr_HR'] = {"percentDone":95};
stats['cs_CZ'] = {"percentDone":94};
stats['da_DK'] = {"percentDone":99};
stats['de_DE'] = {"percentDone":99};
stats['et_EE'] = {"percentDone":54};
stats['cs_CZ'] = {"percentDone":91};
stats['da_DK'] = {"percentDone":96};
stats['de_DE'] = {"percentDone":96};
stats['et_EE'] = {"percentDone":52};
stats['en_GB'] = {"percentDone":100};
stats['en_US'] = {"percentDone":100};
stats['es_ES'] = {"percentDone":95};
stats['eo'] = {"percentDone":31};
stats['fi_FI'] = {"percentDone":99};
stats['fr_FR'] = {"percentDone":100};
stats['gl_ES'] = {"percentDone":36};
stats['id_ID'] = {"percentDone":95};
stats['it_IT'] = {"percentDone":95};
stats['hu_HU'] = {"percentDone":83};
stats['nl_BE'] = {"percentDone":86};
stats['nl_NL'] = {"percentDone":90};
stats['nb_NO'] = {"percentDone":96};
stats['fa'] = {"percentDone":67};
stats['pl_PL'] = {"percentDone":89};
stats['es_ES'] = {"percentDone":96};
stats['eo'] = {"percentDone":30};
stats['fi_FI'] = {"percentDone":95};
stats['fr_FR'] = {"percentDone":96};
stats['gl_ES'] = {"percentDone":35};
stats['id_ID'] = {"percentDone":94};
stats['it_IT'] = {"percentDone":92};
stats['hu_HU'] = {"percentDone":80};
stats['nl_BE'] = {"percentDone":83};
stats['nl_NL'] = {"percentDone":87};
stats['nb_NO'] = {"percentDone":93};
stats['fa'] = {"percentDone":65};
stats['pl_PL'] = {"percentDone":86};
stats['pt_BR'] = {"percentDone":96};
stats['pt_PT'] = {"percentDone":89};
stats['ro'] = {"percentDone":62};
stats['sl_SI'] = {"percentDone":90};
stats['sv'] = {"percentDone":99};
stats['th_TH'] = {"percentDone":42};
stats['vi'] = {"percentDone":96};
stats['tr_TR'] = {"percentDone":99};
stats['uk_UA'] = {"percentDone":89};
stats['el_GR'] = {"percentDone":92};
stats['ru_RU'] = {"percentDone":99};
stats['sr_RS'] = {"percentDone":80};
stats['zh_CN'] = {"percentDone":100};
stats['zh_TW'] = {"percentDone":95};
stats['ja_JP'] = {"percentDone":100};
stats['ko'] = {"percentDone":94};
stats['pt_PT'] = {"percentDone":86};
stats['ro'] = {"percentDone":60};
stats['sl_SI'] = {"percentDone":96};
stats['sv'] = {"percentDone":95};
stats['th_TH'] = {"percentDone":43};
stats['vi'] = {"percentDone":93};
stats['tr_TR'] = {"percentDone":95};
stats['uk_UA'] = {"percentDone":85};
stats['el_GR'] = {"percentDone":89};
stats['ru_RU'] = {"percentDone":95};
stats['sr_RS'] = {"percentDone":78};
stats['zh_CN'] = {"percentDone":96};
stats['zh_TW'] = {"percentDone":92};
stats['ja_JP'] = {"percentDone":96};
stats['ko'] = {"percentDone":91};
module.exports = { locales: locales, stats: stats };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@ import ItemChange from './ItemChange';
import ShareService from '../services/share/ShareService';
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
const JoplinError = require('../JoplinError.js');
import JoplinError from '../JoplinError';
const { sprintf } = require('sprintf-js');
const moment = require('moment');
@@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
type_: ModelType;
updated_time: number;
encryption_applied: number;
share_id: string;
}
export interface ItemsThatNeedSyncResult {
@@ -414,6 +415,7 @@ export default class BaseItem extends BaseModel {
const shownKeys = ItemClass.fieldNames();
shownKeys.push('type_');
const share = item.share_id ? await this.shareService().shareById(item.share_id) : null;
const serialized = await ItemClass.serialize(item, shownKeys);
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
@@ -431,7 +433,9 @@ export default class BaseItem extends BaseModel {
let cipherText = null;
try {
cipherText = await this.encryptionService().encryptString(serialized);
cipherText = await this.encryptionService().encryptString(serialized, {
masterKeyId: share && share.master_key_id ? share.master_key_id : '',
});
} catch (error) {
const msg = [`Could not encrypt item ${item.id}`];
if (error && error.message) msg.push(error.message);

View File

@@ -285,6 +285,10 @@ export default class Folder extends BaseItem {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""');
}
public static async rootShareFoldersByKeyId(keyId: string): Promise<FolderEntity[]> {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE master_key_id = ?', [keyId]);
}
public static async updateFolderShareIds(): Promise<void> {
// Get all the sub-folders of the shared folders, and set the share_id
// property.

View File

@@ -1,5 +1,5 @@
import { BaseItemEntity } from '../../services/database/types';
export default function(resource: BaseItemEntity): boolean {
return !resource.is_shared && !resource.share_id;
return !resource.is_shared;
}

View File

@@ -3,7 +3,12 @@ import { ModelType } from "../../BaseModel";
export interface BaseItemEntity {
id?: string;
encryption_applied?: number;
// Means the item (note or resource) is published
is_shared?: number;
// Means the item (note, folder or resource) is shared, as part of a shared
// notebook
share_id?: string;
type_?: ModelType;
updated_time?: number;
@@ -18,6 +23,10 @@ export interface BaseItemEntity {
// AUTO-GENERATED BY packages/tools/generate-database-types.js
/*
@@ -50,6 +59,7 @@ export interface FolderEntity {
"parent_id"?: string
"is_shared"?: number
"share_id"?: string
"master_key_id"?: string
"type_"?: number
}
export interface ItemChangeEntity {
@@ -126,6 +136,7 @@ export interface NoteEntity {
"is_shared"?: number
"share_id"?: string
"conflict_original_id"?: string
"master_key_id"?: string
"type_"?: number
}
export interface NotesNormalizedEntity {
@@ -167,6 +178,7 @@ export interface ResourceEntity {
"size"?: number
"is_shared"?: number
"share_id"?: string
"master_key_id"?: string
"type_"?: number
}
export interface ResourcesToDownloadEntity {

View File

@@ -139,7 +139,7 @@ describe('e2ee/utils', function() {
setPpk(await generateKeyPair(encryptionService(), masterPassword1));
const previousPpk = localSyncInfo().ppk;
await resetMasterPassword(encryptionService(), kvStore(), masterPassword2);
await resetMasterPassword(encryptionService(), kvStore(), null, masterPassword2);
expect(masterKeyEnabled(masterKeyById(mk1.id))).toBe(false);
expect(masterKeyEnabled(masterKeyById(mk2.id))).toBe(false);

View File

@@ -8,6 +8,8 @@ import { getActiveMasterKey, getActiveMasterKeyId, localSyncInfo, masterKeyEnabl
import JoplinError from '../../JoplinError';
import { generateKeyPair, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk';
import KvStore from '../KvStore';
import Folder from '../../models/Folder';
import ShareService from '../share/ShareService';
const logger = Logger.create('e2ee/utils');
@@ -240,7 +242,30 @@ export async function updateMasterPassword(currentPassword: string, newPassword:
Setting.setValue('encryption.masterPassword', newPassword);
}
export async function resetMasterPassword(encryptionService: EncryptionService, kvStore: KvStore, newPassword: string) {
const unshareEncryptedFolders = async (shareService: ShareService, masterKeyId: string) => {
const rootFolders = await Folder.rootShareFoldersByKeyId(masterKeyId);
for (const folder of rootFolders) {
const isOwner = shareService.isSharedFolderOwner(folder.id);
if (isOwner) {
await shareService.unshareFolder(folder.id);
} else {
await shareService.leaveSharedFolder(folder.id);
}
}
};
export async function resetMasterPassword(encryptionService: EncryptionService, kvStore: KvStore, shareService: ShareService, newPassword: string) {
// First thing we do is to unshare all shared folders. If that fails, which
// may happen in particular if no connection is available, then we don't
// proceed. `unshareEncryptedFolders` will throw if something cannot be
// done.
if (shareService) {
for (const mk of localSyncInfo().masterKeys) {
if (!masterKeyEnabled(mk)) continue;
await unshareEncryptedFolders(shareService, mk.id);
}
}
for (const mk of localSyncInfo().masterKeys) {
if (!masterKeyEnabled(mk)) continue;
mk.enabled = 0;
@@ -254,8 +279,6 @@ export async function resetMasterPassword(encryptionService: EncryptionService,
saveLocalSyncInfo(syncInfo);
}
// TODO: Unshare any folder associated with a disabled master key?
Setting.setValue('encryption.masterPassword', newPassword);
}

View File

@@ -1,15 +1,15 @@
/* eslint-disable no-unused-vars */
const fs = require('fs-extra');
const { setupDatabaseAndSynchronizer, switchClient, exportDir, supportDir } = require('../../testing/test-utils.js');
const InteropService_Exporter_Md = require('../../services/interop/InteropService_Exporter_Md').default;
const BaseModel = require('../../BaseModel').default;
const Folder = require('../../models/Folder').default;
const Resource = require('../../models/Resource').default;
const Note = require('../../models/Note').default;
const shim = require('../../shim').default;
const { MarkupToHtml } = require('@joplin/renderer');
import * as fs from 'fs-extra';
import { setupDatabaseAndSynchronizer, switchClient, exportDir, supportDir } from '../../testing/test-utils.js';
import InteropService_Exporter_Md from '../../services/interop/InteropService_Exporter_Md';
import BaseModel from '../../BaseModel';
import Folder from '../../models/Folder';
import Resource from '../../models/Resource';
import Note from '../../models/Note';
import shim from '../../shim';
import { MarkupToHtml } from '@joplin/renderer';
import { NoteEntity, ResourceEntity } from '../database/types.js';
import InteropService from './InteropService.js';
import { fileExtension } from '../../path-utils.js';
describe('interop/InteropService_Exporter_Md', function() {
@@ -33,8 +33,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -59,13 +59,13 @@ describe('interop/InteropService_Exporter_Md', function() {
queueExportItem(BaseModel.TYPE_NOTE, note3);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note3.body))[0]);
expect(!exporter.context() && !(exporter.context().notePaths || Object.keys(exporter.context().notePaths).length)).toBe(false, 'Context should be empty before processing.');
expect(!exporter.context() && !(exporter.context().notePaths || Object.keys(exporter.context().notePaths).length)).toBe(false);
await exporter.processItem(Folder.modelType(), folder1);
await exporter.processItem(Folder.modelType(), folder2);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(3, 'There should be 3 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(3);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note2.id]).toBe('folder1/note2.md');
expect(exporter.context().notePaths[note3.id]).toBe('folder2/note3.html');
@@ -75,8 +75,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -110,9 +110,9 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource1, Resource.fullPath(resource1));
await exporter.processResource(resource2, Resource.fullPath(resource2));
expect(!exporter.context() && !(exporter.context().destResourcePaths || Object.keys(exporter.context().destResourcePaths).length)).toBe(false, 'Context should be empty before processing.');
expect(!exporter.context() && !(exporter.context().destResourcePaths || Object.keys(exporter.context().destResourcePaths).length)).toBe(false);
expect(Object.keys(exporter.context().destResourcePaths).length).toBe(2, 'There should be 2 resource paths in the context.');
expect(Object.keys(exporter.context().destResourcePaths).length).toBe(2);
expect(exporter.context().destResourcePaths[resource1.id]).toBe(`${exportDir()}/_resources/photo.jpg`);
expect(exporter.context().destResourcePaths[resource2.id]).toBe(`${exportDir()}/_resources/photo-1.jpg`);
}));
@@ -121,8 +121,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -139,7 +139,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Folder.modelType(), folder1);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(2, 'There should be 2 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(2);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note1_2.id]).toBe('folder1/note1-1.md');
}));
@@ -148,8 +148,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -167,7 +167,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(1, 'There should be 1 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(1);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1-1.md');
}));
@@ -175,8 +175,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -204,16 +204,16 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource1, Resource.fullPath(resource1));
await exporter.processResource(resource2, Resource.fullPath(resource2));
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo.jpg`)).toBe(true, 'Resource file should be copied to _resources directory.');
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo-1.jpg`)).toBe(true, 'Resource file should be copied to _resources directory.');
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo.jpg`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo-1.jpg`)).toBe(true);
}));
it('should create folders in fs', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -234,17 +234,17 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
await exporter.processItem(Note.modelType(), note2);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder2`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder3`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder2`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder3`)).toBe(true);
}));
it('should save notes in fs', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -271,17 +271,17 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Note.modelType(), note2);
await exporter.processItem(Note.modelType(), note3);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note1.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note2.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note3.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note1.id]}`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note2.id]}`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note3.id]}`)).toBe(true);
}));
it('should replace resource ids with relative paths', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -325,7 +325,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource2, Resource.fullPath(resource2));
await exporter.processResource(resource3, Resource.fullPath(resource3));
await exporter.processResource(resource4, Resource.fullPath(resource3));
const context = {
const context: any = {
resourcePaths: {},
};
context.resourcePaths[resource1.id] = 'resource1.jpg';
@@ -343,25 +343,25 @@ describe('interop/InteropService_Exporter_Md', function() {
const note3_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note3.id]}`);
const note4_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note4.id]}`);
expect(note1_body).toContain('](../_resources/photo.jpg)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../_resources/photo-1.jpg)', 'Resource id should be replaced with a relative path.');
expect(note3_body).toContain('<img src="../../_resources/photo-2.jpg" alt="alt">', 'Resource id should be replaced with a relative path.');
expect(note4_body).toContain('](../../_resources/photo-3.jpg "title")', 'Resource id should be replaced with a relative path.');
expect(note1_body).toContain('](../_resources/photo.jpg)');
expect(note2_body).toContain('](../../_resources/photo-1.jpg)');
expect(note3_body).toContain('<img src="../../_resources/photo-2.jpg" alt="alt">');
expect(note4_body).toContain('](../../_resources/photo-3.jpg "title")');
}));
it('should replace note ids with relative paths', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
const changeNoteBodyAndReload = async (note, newBody) => {
const changeNoteBodyAndReload = async (note: NoteEntity, newBody: string) => {
note.body = newBody;
await Note.save(note);
return await Note.load(note.id);
@@ -395,18 +395,18 @@ describe('interop/InteropService_Exporter_Md', function() {
const note2_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note2.id]}`);
const note3_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note3.id]}`);
expect(note1_body).toContain('](../folder3/note3.md)', 'Note id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder3/note3.md)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder1/note1.md)', 'Resource id should be replaced with a relative path.');
expect(note3_body).toContain('](../folder1/folder2/note2.md)', 'Resource id should be replaced with a relative path.');
expect(note1_body).toContain('](../folder3/note3.md)');
expect(note2_body).toContain('](../../folder3/note3.md)');
expect(note2_body).toContain('](../../folder1/note1.md)');
expect(note3_body).toContain('](../folder1/folder2/note2.md)');
}));
it('should url encode relative note links', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -425,6 +425,26 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Note.modelType(), note2);
const note2_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note2.id]}`);
expect(note2_body).toContain('[link](../folder%20with%20space1/note1%20name%20with%20space.md)', 'Whitespace in URL should be encoded');
expect(note2_body).toContain('[link](../folder%20with%20space1/note1%20name%20with%20space.md)');
}));
it('should preserve resource file extension', (async () => {
const folder = await Folder.save({ title: 'testing' });
const note = await Note.save({ title: 'mynote', parent_id: folder.id });
await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
const resource: ResourceEntity = (await Resource.all())[0];
await Resource.save({ id: resource.id, title: 'veryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitle.jpg' });
const service = InteropService.instance();
await service.export({
path: exportDir(),
format: 'md',
});
const resourceFilename = (await fs.readdir(`${exportDir()}/_resources`))[0];
expect(fileExtension(resourceFilename)).toBe('jpg');
}));
});

View File

@@ -143,7 +143,7 @@ export default class InteropService_Exporter_Md extends InteropService_Exporter_
if (resource.filename) {
fileName = resource.filename;
} else if (resource.title) {
fileName = friendlySafeFilename(resource.title);
fileName = friendlySafeFilename(resource.title, null, true);
}
// Fall back on the resource filename saved in the users resource folder

View File

@@ -122,6 +122,7 @@ describe('interop/InteropService_Exporter_Md_frontmatter', function() {
const content = await exportAndLoad(`${exportDir()}/folder1/Source_title.md`);
expect(content).toContain('title: |-\n Source\n title');
}));
test('should not export coordinates if they\'re not available', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Coordinates', body: '**ma note**', parent_id: folder1.id });

View File

@@ -1,26 +1,20 @@
import Note from '../../models/Note';
import { msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import { encryptionService, msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import ShareService from './ShareService';
import reducer from '../../reducer';
import { createStore } from 'redux';
import { NoteEntity } from '../database/types';
import { FolderEntity, NoteEntity } from '../database/types';
import Folder from '../../models/Folder';
import { setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils';
import { generateKeyPair } from '../e2ee/ppk';
import MasterKey from '../../models/MasterKey';
import { MasterKeyEntity } from '../e2ee/types';
import { updateMasterPassword } from '../e2ee/utils';
function mockApi() {
return {
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
return null;
},
personalizedUserContentBaseUrl(_userId: string) {
},
};
}
function mockService() {
function mockService(api: any) {
const service = new ShareService();
const store = createStore(reducer as any);
service.initialize(store, mockApi() as any);
service.initialize(store, encryptionService(), api);
return service;
}
@@ -32,9 +26,17 @@ describe('ShareService', function() {
done();
});
it('should not change the note user timestamps when sharing or unsharing', (async () => {
it('should not change the note user timestamps when sharing or unsharing', async () => {
let note = await Note.save({});
const service = mockService();
const service = mockService({
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
return null;
},
personalizedUserContentBaseUrl(_userId: string) {
},
});
await msleep(1);
await service.shareNote(note.id);
@@ -61,6 +63,90 @@ describe('ShareService', function() {
const noteReloaded = await Note.load(note.id);
checkTimestamps(note, noteReloaded);
}
}));
});
function testShareFolderService(extraExecHandlers: Record<string, Function> = {}) {
return mockService({
exec: async (method: string, path: string, query: Record<string, any>, body: any) => {
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
if (method === 'POST' && path === 'api/shares') {
return {
id: 'share_1',
};
}
throw new Error(`Unhandled: ${method} ${path}`);
},
});
}
async function testShareFolder(service: ShareService) {
const folder = await Folder.save({});
const note = await Note.save({ parent_id: folder.id });
const share = await service.shareFolder(folder.id);
expect(share.id).toBe('share_1');
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
expect((await Note.load(note.id)).share_id).toBe('share_1');
return share;
}
it('should share a folder', async () => {
await testShareFolder(testShareFolderService());
});
it('should share a folder - E2EE', async () => {
setEncryptionEnabled(true);
await updateMasterPassword('', '111111');
const ppk = await generateKeyPair(encryptionService(), '111111');
setPpk(ppk);
await testShareFolder(testShareFolderService());
expect((await MasterKey.all()).length).toBe(1);
const mk = (await MasterKey.all())[0];
const folder: FolderEntity = (await Folder.all())[0];
expect(folder.master_key_id).toBe(mk.id);
});
it('should add a recipient', async () => {
setEncryptionEnabled(true);
await updateMasterPassword('', '111111');
const ppk = await generateKeyPair(encryptionService(), '111111');
setPpk(ppk);
const recipientPpk = await generateKeyPair(encryptionService(), '222222');
expect(ppk.id).not.toBe(recipientPpk.id);
let uploadedEmail: string = '';
let uploadedMasterKey: MasterKeyEntity = null;
const service = testShareFolderService({
'POST api/shares': (_query: Record<string, any>, body: any) => {
return {
id: 'share_1',
master_key_id: body.master_key_id,
};
},
'GET api/users/toto%40example.com/public_key': async (_query: Record<string, any>, _body: any) => {
return recipientPpk;
},
'POST api/shares/share_1/users': async (_query: Record<string, any>, body: any) => {
uploadedEmail = body.email;
uploadedMasterKey = JSON.parse(body.master_key);
},
});
const share = await testShareFolder(service);
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com');
expect(uploadedEmail).toBe('toto@example.com');
const content = JSON.parse(uploadedMasterKey.content);
expect(content.ppkId).toBe(recipientPpk.id);
});
});

View File

@@ -1,18 +1,41 @@
import { Store } from 'redux';
import JoplinServerApi from '../../JoplinServerApi';
import { _ } from '../../locale';
import Logger from '../../Logger';
import Folder from '../../models/Folder';
import MasterKey from '../../models/MasterKey';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import { State, stateRootKey, StateShare } from './reducer';
import { FolderEntity } from '../database/types';
import EncryptionService from '../e2ee/EncryptionService';
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk';
import { MasterKeyEntity } from '../e2ee/types';
import { getMasterPassword } from '../e2ee/utils';
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
const logger = Logger.create('ShareService');
export interface ApiShare {
id: string;
master_key_id: string;
}
function formatShareInvitations(invitations: any[]): ShareInvitation[] {
return invitations.map(inv => {
return {
...inv,
master_key: inv.master_key ? JSON.parse(inv.master_key) : null,
};
});
}
export default class ShareService {
private static instance_: ShareService;
private api_: JoplinServerApi = null;
private store_: Store<any> = null;
private encryptionService_: EncryptionService = null;
private initialized_ = false;
public static instance(): ShareService {
@@ -21,9 +44,10 @@ export default class ShareService {
return this.instance_;
}
public initialize(store: Store<any>, api: JoplinServerApi = null) {
public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
this.initialized_ = true;
this.store_ = store;
this.encryptionService_ = encryptionService;
this.api_ = api;
}
@@ -59,15 +83,41 @@ export default class ShareService {
return this.api_;
}
public async shareFolder(folderId: string) {
public async shareFolder(folderId: string): Promise<ApiShare> {
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
if (folder.parent_id) {
await Folder.save({ id: folder.id, parent_id: '' });
let folderMasterKey: MasterKeyEntity = null;
if (getEncryptionEnabled()) {
const syncInfo = localSyncInfo();
// Shouldn't happen
if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');
// TODO: handle "undefinedMasterPassword" error - show master password dialog
folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
folderMasterKey = await MasterKey.save(folderMasterKey);
addMasterKey(syncInfo, folderMasterKey);
}
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
const newFolderProps: FolderEntity = {};
if (folder.parent_id) newFolderProps.parent_id = '';
if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;
if (Object.keys(newFolderProps).length) {
await Folder.save({
id: folder.id,
...newFolderProps,
});
}
const share = await this.api().exec('POST', 'api/shares', {}, {
folder_id: folderId,
master_key_id: folderMasterKey ? folderMasterKey.id : '',
});
// Note: race condition if the share is created but the app crashes
// before setting share_id on the folder. See unshareFolder() for info.
@@ -181,6 +231,18 @@ export default class ShareService {
return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
}
public folderShare(folderId: string): StateShare {
return this.shares.find(s => s.folder_id === folderId);
}
public isSharedFolderOwner(folderId: string, userId: string = null): boolean {
if (userId === null) userId = this.userId;
const share = this.folderShare(folderId);
if (!share) throw new Error(`Cannot find share associated with folder: ${folderId}`);
return share.user.id === userId;
}
public get shares() {
return this.state.shares;
}
@@ -193,9 +255,34 @@ export default class ShareService {
return this.state.shareInvitations;
}
public async addShareRecipient(shareId: string, recipientEmail: string) {
private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair> {
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
}
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string) {
let recipientMasterKey: MasterKeyEntity = null;
if (getEncryptionEnabled()) {
const syncInfo = localSyncInfo();
const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
const recipientPublicKey: PublicPrivateKeyPair = await this.userPublicKey(recipientEmail);
if (!recipientPublicKey) throw new Error(_('Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end encryption. They may do so from the screen Configuration > Encryption.', recipientEmail));
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);
recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
this.encryptionService_,
masterKey,
getMasterPassword(),
recipientPublicKey
);
}
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
email: recipientEmail,
master_key: JSON.stringify(recipientMasterKey),
});
}
@@ -226,8 +313,24 @@ export default class ShareService {
});
}
public async respondInvitation(shareUserId: string, accept: boolean) {
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
logger.info('respondInvitation: ', shareUserId, accept);
if (accept) {
if (masterKey) {
const reencryptedMasterKey = await mkReencryptFromPublicKeyToPassword(
this.encryptionService_,
masterKey,
localSyncInfo().ppk,
getMasterPassword(),
getMasterPassword()
);
logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);
await MasterKey.save(reencryptedMasterKey);
}
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 });
@@ -237,15 +340,57 @@ export default class ShareService {
public async refreshShareInvitations() {
const result = await this.loadShareInvitations();
const invitations = formatShareInvitations(result.items);
logger.info('Refresh share invitations:', invitations);
this.store.dispatch({
type: 'SHARE_INVITATION_SET',
shareInvitations: result.items,
shareInvitations: invitations,
});
}
public async shareById(id: string) {
const stateShare = this.state.shares.find(s => s.id === id);
if (stateShare) return stateShare;
const refreshedShares = await this.refreshShares();
const refreshedShare = refreshedShares.find(s => s.id === id);
if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
return refreshedShare;
}
// In most cases the share objects will already be part of the state, so
// this function checks there first. If the required share objects are not
// present, it refreshes them from the API.
public async sharesByIds(ids: string[]) {
const buildOutput = async (shares: StateShare[]) => {
const output: Record<string, StateShare> = {};
for (const share of shares) {
if (ids.includes(share.id)) output[share.id] = share;
}
return output;
};
let output = await buildOutput(this.state.shares);
if (Object.keys(output).length === ids.length) return output;
const refreshedShares = await this.refreshShares();
output = await buildOutput(refreshedShares);
if (Object.keys(output).length !== ids.length) {
logger.error('sharesByIds: Need:', ids);
logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
throw new Error('Could not retrieve required share objects');
}
return output;
}
public async refreshShares(): Promise<StateShare[]> {
const result = await this.loadShares();
logger.info('Refreshed shares:', result);
this.store.dispatch({
type: 'SHARE_SET',
shares: result.items,
@@ -257,6 +402,8 @@ export default class ShareService {
public async refreshShareUsers(shareId: string) {
const result = await this.loadShareUsers(shareId);
logger.info('Refreshed share users:', result);
this.store.dispatch({
type: 'SHARE_USER_SET',
shareId: shareId,

View File

@@ -1,6 +1,7 @@
import { State as RootState } from '../../reducer';
import { Draft } from 'immer';
import { FolderEntity } from '../database/types';
import { MasterKeyEntity } from '../e2ee/types';
interface StateShareUserUser {
id: string;
@@ -25,11 +26,13 @@ export interface StateShare {
type: number;
folder_id: string;
note_id: string;
master_key_id: string;
user?: StateShareUserUser;
}
export interface ShareInvitation {
id: string;
master_key: MasterKeyEntity;
share: StateShare;
status: ShareUserStatus;
}

View File

@@ -1,14 +1,13 @@
import time from '../../time';
import shim from '../../shim';
import Setting from '../../models/Setting';
import { createFolderTree, syncTargetName, synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } from '../../testing/test-utils';
import { synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Resource from '../../models/Resource';
import ResourceFetcher from '../../services/ResourceFetcher';
import MasterKey from '../../models/MasterKey';
import BaseItem from '../../models/BaseItem';
import { ResourceEntity } from '../database/types';
import Synchronizer from '../../Synchronizer';
import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
@@ -366,153 +365,153 @@ describe('Synchronizer.e2ee', function() {
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
}));
it('should not encrypt notes that are shared by link', (async () => {
setEncryptionEnabled(true);
await loadEncryptionMasterKey();
// it('should not encrypt notes that are shared by link', (async () => {
// setEncryptionEnabled(true);
// await loadEncryptionMasterKey();
await createFolderTree('', [
{
title: 'folder1',
children: [
{
title: 'un',
},
{
title: 'deux',
},
],
},
]);
// await createFolderTree('', [
// {
// title: 'folder1',
// children: [
// {
// title: 'un',
// },
// {
// title: 'deux',
// },
// ],
// },
// ]);
let note1 = await Note.loadByTitle('un');
let note2 = await Note.loadByTitle('deux');
await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
note1 = await Note.loadByTitle('un');
note2 = await Note.loadByTitle('deux');
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
// let note1 = await Note.loadByTitle('un');
// let note2 = await Note.loadByTitle('deux');
// await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
// await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
// note1 = await Note.loadByTitle('un');
// note2 = await Note.loadByTitle('deux');
// const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
// const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
await synchronizerStart();
// await synchronizerStart();
await switchClient(2);
// await switchClient(2);
await synchronizerStart();
// await synchronizerStart();
await switchClient(1);
// await switchClient(1);
const origNote2 = Object.assign({}, note2);
await BaseItem.updateShareStatus(note2, true);
note2 = await Note.load(note2.id);
// const origNote2 = Object.assign({}, note2);
// await BaseItem.updateShareStatus(note2, true);
// note2 = await Note.load(note2.id);
// Sharing a note should not modify the timestamps
expect(note2.user_updated_time).toBe(origNote2.user_updated_time);
expect(note2.user_created_time).toBe(origNote2.user_created_time);
// // Sharing a note should not modify the timestamps
// expect(note2.user_updated_time).toBe(origNote2.user_updated_time);
// expect(note2.user_created_time).toBe(origNote2.user_created_time);
await synchronizerStart();
// await synchronizerStart();
await switchClient(2);
// await switchClient(2);
await synchronizerStart();
// await synchronizerStart();
// The shared note should be decrypted
const note2_2 = await Note.load(note2.id);
expect(note2_2.title).toBe('deux');
expect(note2_2.encryption_applied).toBe(0);
expect(note2_2.is_shared).toBe(1);
// // The shared note should be decrypted
// const note2_2 = await Note.load(note2.id);
// expect(note2_2.title).toBe('deux');
// expect(note2_2.encryption_applied).toBe(0);
// expect(note2_2.is_shared).toBe(1);
// The resource linked to the shared note should also be decrypted
const resource2: ResourceEntity = await Resource.load(resourceId2);
expect(resource2.is_shared).toBe(1);
expect(resource2.encryption_applied).toBe(0);
// // The resource linked to the shared note should also be decrypted
// const resource2: ResourceEntity = await Resource.load(resourceId2);
// expect(resource2.is_shared).toBe(1);
// expect(resource2.encryption_applied).toBe(0);
const fetcher = newResourceFetcher(synchronizer());
await fetcher.start();
await fetcher.waitForAllFinished();
// const fetcher = newResourceFetcher(synchronizer());
// await fetcher.start();
// await fetcher.waitForAllFinished();
// Because the resource is decrypted, the encrypted blob file should not
// exist, but the plain text one should.
expect(await shim.fsDriver().exists(Resource.fullPath(resource2, true))).toBe(false);
expect(await shim.fsDriver().exists(Resource.fullPath(resource2))).toBe(true);
// // Because the resource is decrypted, the encrypted blob file should not
// // exist, but the plain text one should.
// expect(await shim.fsDriver().exists(Resource.fullPath(resource2, true))).toBe(false);
// expect(await shim.fsDriver().exists(Resource.fullPath(resource2))).toBe(true);
// The non-shared note should be encrypted
const note1_2 = await Note.load(note1.id);
expect(note1_2.title).toBe('');
// // The non-shared note should be encrypted
// const note1_2 = await Note.load(note1.id);
// expect(note1_2.title).toBe('');
// The linked resource should also be encrypted
const resource1: ResourceEntity = await Resource.load(resourceId1);
expect(resource1.is_shared).toBe(0);
expect(resource1.encryption_applied).toBe(1);
// // The linked resource should also be encrypted
// const resource1: ResourceEntity = await Resource.load(resourceId1);
// expect(resource1.is_shared).toBe(0);
// expect(resource1.encryption_applied).toBe(1);
// And the plain text blob should not be present. The encrypted one
// shouldn't either because it can only be downloaded once the metadata
// has been decrypted.
expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
}));
// // And the plain text blob should not be present. The encrypted one
// // shouldn't either because it can only be downloaded once the metadata
// // has been decrypted.
// expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
// expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
// }));
it('should not encrypt items that are shared by folder', (async () => {
// We skip this test for Joplin Server because it's going to check if
// the share_id refers to an existing share.
if (syncTargetName() === 'joplinServer') {
expect(true).toBe(true);
return;
}
// it('should not encrypt items that are shared by folder', (async () => {
// // We skip this test for Joplin Server because it's going to check if
// // the share_id refers to an existing share.
// if (syncTargetName() === 'joplinServer') {
// expect(true).toBe(true);
// return;
// }
setEncryptionEnabled(true);
await loadEncryptionMasterKey();
// setEncryptionEnabled(true);
// await loadEncryptionMasterKey();
const folder1 = await createFolderTree('', [
{
title: 'folder1',
children: [
{
title: 'note1',
},
],
},
{
title: 'folder2',
children: [
{
title: 'note2',
},
],
},
]);
// const folder1 = await createFolderTree('', [
// {
// title: 'folder1',
// children: [
// {
// title: 'note1',
// },
// ],
// },
// {
// title: 'folder2',
// children: [
// {
// title: 'note2',
// },
// ],
// },
// ]);
await synchronizerStart();
// await synchronizerStart();
await switchClient(2);
// await switchClient(2);
await synchronizerStart();
// await synchronizerStart();
await switchClient(1);
// await switchClient(1);
// Simulate that the folder has been shared
await Folder.save({ id: folder1.id, share_id: 'abcd' });
// // Simulate that the folder has been shared
// await Folder.save({ id: folder1.id, share_id: 'abcd' });
await synchronizerStart();
// await synchronizerStart();
await switchClient(2);
// await switchClient(2);
await synchronizerStart();
// await synchronizerStart();
// The shared items should be decrypted
{
const folder1 = await Folder.loadByTitle('folder1');
const note1 = await Note.loadByTitle('note1');
expect(folder1.title).toBe('folder1');
expect(note1.title).toBe('note1');
}
// // The shared items should be decrypted
// {
// const folder1 = await Folder.loadByTitle('folder1');
// const note1 = await Note.loadByTitle('note1');
// expect(folder1.title).toBe('folder1');
// expect(note1.title).toBe('note1');
// }
// The non-shared items should be encrypted
{
const folder2 = await Folder.loadByTitle('folder2');
const note2 = await Note.loadByTitle('note2');
expect(folder2).toBeFalsy();
expect(note2).toBeFalsy();
}
}));
// // The non-shared items should be encrypted
// {
// const folder2 = await Folder.loadByTitle('folder2');
// const note2 = await Note.loadByTitle('note2');
// expect(folder2).toBeFalsy();
// expect(note2).toBeFalsy();
// }
// }));
});

View File

@@ -19,8 +19,8 @@
"author": "",
"license": "MIT",
"dependencies": {
"@joplin/lib": "^2.6.2",
"@joplin/tools": "^2.6.2",
"@joplin/lib": "~2.6",
"@joplin/tools": "~2.6",
"fs-extra": "^9.0.1",
"gh-release-assets": "^2.0.0",
"node-fetch": "^2.6.1",

View File

@@ -12,6 +12,13 @@ describe('linkReplacement', () => {
expect(r).toBe('<a data-from-md href=\'https://example.com/test\'>');
});
test('should handle non-resource links with single quotes in it', () => {
// Handles a link such as:
// [Google](https://www.goo'onclick=javascript:alert(/1/);f=')
const r = linkReplacement('https://www.goo\'onclick=javascript:alert(/1/);f=\'', { linkRenderingType: 1 }).html;
expect(r).toBe('<a data-from-md href=\'https://www.goo&apos;onclick=javascript:alert(/1/);f=&apos;\' onclick=\'postMessage("https://www.goo%27onclick=javascript:alert(/1/);f=%27", { resourceId: "" }); return false;\'>');
});
test('should handle resource links - downloaded status', () => {
const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c';

View File

@@ -122,7 +122,7 @@ export default function(href: string, options: Options = null): LinkReplacementR
icon = '';
attrHtml.push(`href='${htmlentities(href)}'`);
} else {
attrHtml.push(`href='${hrefAttr}'`);
attrHtml.push(`href='${htmlentities(hrefAttr)}'`);
if (js) attrHtml.push(js);
}

View File

@@ -4,7 +4,9 @@ const nodeSqlite = require('sqlite3');
shimInit({ nodeSqlite });
// We don't want the tests to fail due to timeout, especially on CI, and certain
// tests can take more time since we do integration testing too.
jest.setTimeout(30 * 1000);
// tests can take more time since we do integration testing too. The share tests
// in particular can take a while.
jest.setTimeout(60 * 1000);
process.env.JOPLIN_IS_TESTING = '1';

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "@joplin/server",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"scripts": {
"start-dev": "npm run build && nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev": "npm run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev-no-watch": "node dist/app.js --env dev",
"rebuild": "npm run clean && npm run build && npm run tsc",
"build": "gulp build",
@@ -21,6 +21,7 @@
"watch": "tsc --watch --project tsconfig.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.40.0",
"@fortawesome/fontawesome-free": "^5.15.1",
"@joplin/lib": "~2.6",
"@joplin/renderer": "~2.6",

Binary file not shown.

View File

@@ -5,7 +5,7 @@ import * as Koa from 'koa';
import * as fs from 'fs-extra';
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
import config, { initConfig, runningInDocker } from './config';
import { migrateLatest, waitForConnection, sqliteDefaultDir, latestMigration } from './db';
import { migrateLatest, waitForConnection, sqliteDefaultDir, latestMigration, DbConnection } from './db';
import { AppContext, Env, KoaNext } from './utils/types';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import routeHandler from './middleware/routeHandler';
@@ -17,10 +17,11 @@ import startServices from './utils/startServices';
import { credentialFile } from './utils/testing/testUtils';
import apiVersionHandler from './middleware/apiVersionHandler';
import clickJackingHandler from './middleware/clickJackingHandler';
import newModelFactory from './models/factory';
import newModelFactory, { Options } from './models/factory';
import setupCommands from './utils/setupCommands';
import { RouteResponseFormat, routeResponseFormat } from './utils/routeUtils';
import { parseEnv } from './env';
import storageDriverFromConfig from './models/items/storage/storageDriverFromConfig';
interface Argv {
env?: Env;
@@ -61,6 +62,8 @@ function appLogger(): LoggerWrapper {
}
function markPasswords(o: Record<string, any>): Record<string, any> {
if (!o) return o;
const output: Record<string, any> = {};
for (const k of Object.keys(o)) {
@@ -219,6 +222,13 @@ async function main() {
fs.writeFileSync(pidFile, `${process.pid}`);
}
const newModelFactoryOptions = async (db: DbConnection): Promise<Options> => {
return {
storageDriver: await storageDriverFromConfig(config().storageDriver, db, { assignDriverId: env !== 'buildTypes' }),
storageDriverFallback: await storageDriverFromConfig(config().storageDriverFallback, db, { assignDriverId: env !== 'buildTypes' }),
};
};
let runCommandAndExitApp = true;
if (selectedCommand) {
@@ -235,7 +245,7 @@ async function main() {
});
} else {
const connectionCheck = await waitForConnection(config().database);
const models = newModelFactory(connectionCheck.connection, config());
const models = newModelFactory(connectionCheck.connection, config(), await newModelFactoryOptions(connectionCheck.connection));
await selectedCommand.run(commandArgv, {
db: connectionCheck.connection,
@@ -253,6 +263,8 @@ async function main() {
appLogger().info('Log dir:', config().logDir);
appLogger().info('DB Config:', markPasswords(config().database));
appLogger().info('Mailer Config:', markPasswords(config().mailer));
appLogger().info('Content driver:', markPasswords(config().storageDriver));
appLogger().info('Content driver (fallback):', markPasswords(config().storageDriverFallback));
appLogger().info('Trying to connect to database...');
const connectionCheck = await waitForConnection(config().database);
@@ -263,7 +275,8 @@ async function main() {
appLogger().info('Connection check:', connectionCheckLogInfo);
const ctx = app.context as AppContext;
await setupAppContext(ctx, env, connectionCheck.connection, appLogger);
await setupAppContext(ctx, env, connectionCheck.connection, appLogger, await newModelFactoryOptions(connectionCheck.connection));
await initializeJoplinUtils(config(), ctx.joplinBase.models, ctx.joplinBase.services.mustache);
if (config().database.autoMigration) {

View File

@@ -3,6 +3,7 @@ import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteT
import * as pathUtils from 'path';
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import { EnvVariables } from './env';
import parseStorageDriverConnectionString from './models/items/storage/parseStorageDriverConnectionString';
interface PackageJson {
version: string;
@@ -130,6 +131,8 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
supportName: env.SUPPORT_NAME || appName,
businessEmail: env.BUSINESS_EMAIL || supportEmail,
cookieSecure: env.COOKIES_SECURE,
storageDriver: parseStorageDriverConnectionString(env.STORAGE_DRIVER),
storageDriverFallback: parseStorageDriverConnectionString(env.STORAGE_DRIVER_FALLBACK),
...overrides,
};
}

View File

@@ -1,70 +1,13 @@
export interface EnvVariables {
// The possible env variables and their defaults are listed below.
//
// The env variables can be of type string, integer or boolean. When the type is
// boolean, set the variable to "0" or "1" in your env file.
const defaultEnvValues: EnvVariables = {
// ==================================================
// General config
// ==================================================
APP_NAME: string;
APP_PORT: number;
SIGNUP_ENABLED: boolean;
TERMS_ENABLED: boolean;
ACCOUNT_TYPES_ENABLED: boolean;
ERROR_STACK_TRACES: boolean;
COOKIES_SECURE: boolean;
RUNNING_IN_DOCKER: boolean;
// ==================================================
// URL config
// ==================================================
APP_BASE_URL: string;
USER_CONTENT_BASE_URL: string;
API_BASE_URL: string;
JOPLINAPP_BASE_URL: string;
// ==================================================
// Database config
// ==================================================
DB_CLIENT: string;
DB_SLOW_QUERY_LOG_ENABLED: boolean;
DB_SLOW_QUERY_LOG_MIN_DURATION: number;
DB_AUTO_MIGRATION: boolean;
POSTGRES_PASSWORD: string;
POSTGRES_DATABASE: string;
POSTGRES_USER: string;
POSTGRES_HOST: string;
POSTGRES_PORT: number;
// This must be the full path to the database file
SQLITE_DATABASE: string;
// ==================================================
// Mailer config
// ==================================================
MAILER_ENABLED: boolean;
MAILER_HOST: string;
MAILER_PORT: number;
MAILER_SECURE: boolean;
MAILER_AUTH_USER: string;
MAILER_AUTH_PASSWORD: string;
MAILER_NOREPLY_NAME: string;
MAILER_NOREPLY_EMAIL: string;
SUPPORT_EMAIL: string;
SUPPORT_NAME: string;
BUSINESS_EMAIL: string;
// ==================================================
// Stripe config
// ==================================================
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}
const defaultEnvValues: EnvVariables = {
APP_NAME: 'Joplin Server',
APP_PORT: 22300,
SIGNUP_ENABLED: false,
@@ -74,11 +17,19 @@ const defaultEnvValues: EnvVariables = {
COOKIES_SECURE: false,
RUNNING_IN_DOCKER: false,
// ==================================================
// URL config
// ==================================================
APP_BASE_URL: '',
USER_CONTENT_BASE_URL: '',
API_BASE_URL: '',
JOPLINAPP_BASE_URL: 'https://joplinapp.org',
// ==================================================
// Database config
// ==================================================
DB_CLIENT: 'sqlite3',
DB_SLOW_QUERY_LOG_ENABLED: false,
DB_SLOW_QUERY_LOG_MIN_DURATION: 1000,
@@ -90,8 +41,20 @@ const defaultEnvValues: EnvVariables = {
POSTGRES_HOST: '',
POSTGRES_PORT: 5432,
// This must be the full path to the database file
SQLITE_DATABASE: '',
// ==================================================
// Content driver config
// ==================================================
STORAGE_DRIVER: 'Type=Database',
STORAGE_DRIVER_FALLBACK: '',
// ==================================================
// Mailer config
// ==================================================
MAILER_ENABLED: false,
MAILER_HOST: '',
MAILER_PORT: 587,
@@ -105,10 +68,62 @@ const defaultEnvValues: EnvVariables = {
SUPPORT_NAME: '',
BUSINESS_EMAIL: '',
// ==================================================
// Stripe config
// ==================================================
STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '',
};
export interface EnvVariables {
APP_NAME: string;
APP_PORT: number;
SIGNUP_ENABLED: boolean;
TERMS_ENABLED: boolean;
ACCOUNT_TYPES_ENABLED: boolean;
ERROR_STACK_TRACES: boolean;
COOKIES_SECURE: boolean;
RUNNING_IN_DOCKER: boolean;
APP_BASE_URL: string;
USER_CONTENT_BASE_URL: string;
API_BASE_URL: string;
JOPLINAPP_BASE_URL: string;
DB_CLIENT: string;
DB_SLOW_QUERY_LOG_ENABLED: boolean;
DB_SLOW_QUERY_LOG_MIN_DURATION: number;
DB_AUTO_MIGRATION: boolean;
POSTGRES_PASSWORD: string;
POSTGRES_DATABASE: string;
POSTGRES_USER: string;
POSTGRES_HOST: string;
POSTGRES_PORT: number;
SQLITE_DATABASE: string;
STORAGE_DRIVER: string;
STORAGE_DRIVER_FALLBACK: string;
MAILER_ENABLED: boolean;
MAILER_HOST: string;
MAILER_PORT: number;
MAILER_SECURE: boolean;
MAILER_AUTH_USER: string;
MAILER_AUTH_PASSWORD: string;
MAILER_NOREPLY_NAME: string;
MAILER_NOREPLY_EMAIL: string;
SUPPORT_EMAIL: string;
SUPPORT_NAME: string;
BUSINESS_EMAIL: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}
export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariables {
const output: EnvVariables = {
...defaultEnvValues,
@@ -125,7 +140,7 @@ export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariable
if (isNaN(v)) throw new Error(`Invalid number value for env variable ${key} = ${rawEnvValue}`);
(output as any)[key] = v;
} else if (typeof value === 'boolean') {
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean for for env variable ${key}: ${rawEnvValue}`);
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean value for env variable ${key}: ${rawEnvValue} (Should be either "0" or "1")`);
(output as any)[key] = rawEnvValue === '1';
} else if (typeof value === 'string') {
(output as any)[key] = `${rawEnvValue}`;

View File

@@ -2,6 +2,13 @@ import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models,
import { Notification, UserFlagType } from '../services/database/types';
import { defaultAdminEmail, defaultAdminPassword } from '../db';
import notificationHandler from './notificationHandler';
import { AppContext } from '../utils/types';
const runNotificationHandler = async (sessionId: string): Promise<AppContext> => {
const context = await koaAppContext({ sessionId: sessionId });
await notificationHandler(context, koaNext);
return context;
};
describe('notificationHandler', function() {
@@ -18,22 +25,25 @@ describe('notificationHandler', function() {
});
test('should check admin password', async function() {
const { session } = await createUserAndSession(1, true);
const r = await createUserAndSession(1, true);
const session = r.session;
let admin = r.user;
// The default admin password actually doesn't pass the complexity
// check, so we need to skip validation for testing here. Eventually, a
// better mechanism to set the initial default admin password should
// probably be implemented.
const admin = await models().user().save({
admin = await models().user().save({
id: admin.id,
email: defaultAdminEmail,
password: defaultAdminPassword,
is_admin: 1,
email_confirmed: 1,
}, { skipValidation: true });
{
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
@@ -49,8 +59,7 @@ describe('notificationHandler', function() {
password: 'changed!',
}, { skipValidation: true });
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
@@ -69,8 +78,7 @@ describe('notificationHandler', function() {
password: defaultAdminPassword,
});
const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(0);
@@ -81,10 +89,24 @@ describe('notificationHandler', function() {
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'accountDisabled')).toBeTruthy();
});
test('should display a banner if the email is not confirmed', async function() {
const { session, user } = await createUserAndSession(1);
{
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeTruthy();
}
{
await models().user().save({ id: user.id, email_confirmed: 1 });
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeFalsy();
}
});
});

View File

@@ -25,7 +25,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
_('The default admin password is insecure and has not been changed! [Change it now](%s)', profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
await notificationModel.setRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
}
}
@@ -57,6 +57,22 @@ async function handleUserFlags(ctx: AppContext): Promise<NotificationView> {
return null;
}
async function handleConfirmEmailNotification(ctx: AppContext): Promise<NotificationView> {
if (!ctx.joplin.owner) return null;
if (!ctx.joplin.owner.email_confirmed) {
return {
id: 'confirmEmail',
messageHtml: renderMarkdown('An email has been sent to you containing an activation link to complete your registration.\n\nMake sure you click it to secure your account and keep access to it.'),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};
}
return null;
}
// async function handleSqliteInProdNotification(ctx: AppContext) {
// if (!ctx.joplin.owner.is_admin) return;
@@ -104,11 +120,18 @@ export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
if (!ctx.joplin.owner) return next();
await handleChangeAdminPasswordNotification(ctx);
await handleConfirmEmailNotification(ctx);
// await handleSqliteInProdNotification(ctx);
const notificationViews = await makeNotificationViews(ctx);
const userFlagView = await handleUserFlags(ctx);
if (userFlagView) notificationViews.push(userFlagView);
const nonDismisableViews = [
await handleUserFlags(ctx),
await handleConfirmEmailNotification(ctx),
];
for (const nonDismisableView of nonDismisableViews) {
if (nonDismisableView) notificationViews.push(nonDismisableView);
}
ctx.joplin.notifications = notificationViews;
} catch (error) {

View File

@@ -73,6 +73,6 @@ export default async function(ctx: AppContext) {
// Technically this is not the total request duration because there are
// other middlewares but that should give a good approximation
const requestDuration = Date.now() - requestStartTime;
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${requestDuration}ms)`);
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${ctx.response.status}) (${requestDuration}ms)`);
}
}

View File

@@ -0,0 +1,22 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
table.text('master_key', 'mediumtext').defaultTo('').notNullable();
});
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
table.string('master_key_id', 32).defaultTo('').notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
table.dropColumn('master_key');
});
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
table.dropColumn('master_key_id');
});
}

View File

@@ -0,0 +1,32 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('storages', (table: Knex.CreateTableBuilder) => {
table.increments('id').unique().primary().notNullable();
table.text('connection_string').notNullable();
});
await db('storages').insert({
connection_string: 'Type=Database',
});
// First we create the column and set a default so as to populate the
// content_storage_id field.
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.integer('content_storage_id').defaultTo(1).notNullable();
});
// Once it's set, we remove the default as that should be explicitly set.
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.integer('content_storage_id').notNullable().alter();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('storages');
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.dropColumn('content_storage_id');
});
}

View File

@@ -3,7 +3,7 @@ import { DbConnection } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
import { Models, NewModelFactoryHandler } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
@@ -54,12 +54,12 @@ export default abstract class BaseModel<T> {
private defaultFields_: string[] = [];
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
private modelFactory_: NewModelFactoryHandler;
private static eventEmitter_: EventEmitter = null;
private config_: Config;
private savePoints_: SavePoint[] = [];
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
public constructor(db: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
this.db_ = db;
this.modelFactory_ = modelFactory;
this.config_ = config;
@@ -71,7 +71,7 @@ export default abstract class BaseModel<T> {
// connection is passed to it. That connection can be the regular db
// connection, or the active transaction.
protected models(db: DbConnection = null): Models {
return this.modelFactory_(db || this.db, this.config_);
return this.modelFactory_(db || this.db);
}
protected get baseUrl(): string {
@@ -90,7 +90,7 @@ export default abstract class BaseModel<T> {
return this.config_.appName;
}
protected get db(): DbConnection {
public get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_;
}

View File

@@ -38,12 +38,12 @@ describe('ChangeModel', function() {
const changeModel = models().change();
await msleep(1); const item1 = await models().item().makeTestItem(user.id, 1); // [1] CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md', content: Buffer.from('') }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md', content: Buffer.from('') }); // [3] UPDATE 1b
await msleep(1); const item2 = await models().item().makeTestItem(user.id, 2); // [4] CREATE 2
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md', content: Buffer.from('') }); // [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: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md', content: Buffer.from('') }); // [7] UPDATE 2b
await msleep(1); const item3 = await models().item().makeTestItem(user.id, 3); // [8] CREATE 3
// Check that the 8 changes were created
@@ -120,7 +120,7 @@ describe('ChangeModel', function() {
let i = 1;
await msleep(1); const item1 = await models().item().makeTestItem(user.id, 1); // CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}`, content: Buffer.from('') }); // UPDATE 1
await expectThrow(async () => changeModel.delta(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});

View File

@@ -7,6 +7,10 @@ import { ApiError, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/err
import { Knex } from 'knex';
import { ChangePreviousItem } from './ChangeModel';
import { unique } from '../utils/array';
import StorageDriverBase, { Context } from './items/storage/StorageDriverBase';
import { DbConnection } from '../db';
import { Config, StorageDriverMode } from '../utils/types';
import { NewModelFactoryHandler, Options } from './factory';
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
@@ -38,9 +42,22 @@ export interface ItemSaveOption extends SaveOptions {
shareId?: Uuid;
}
export interface ItemLoadOptions extends LoadOptions {
withContent?: boolean;
}
export default class ItemModel extends BaseModel<Item> {
private updatingTotalSizes_: boolean = false;
private storageDriver_: StorageDriverBase = null;
private storageDriverFallback_: StorageDriverBase = null;
public constructor(db: DbConnection, modelFactory: NewModelFactoryHandler, config: Config, options: Options) {
super(db, modelFactory, config);
this.storageDriver_ = options.storageDriver;
this.storageDriverFallback_ = options.storageDriverFallback;
}
protected get tableName(): string {
return 'items';
@@ -106,62 +123,106 @@ export default class ItemModel extends BaseModel<Item> {
return path.replace(extractNameRegex, '$1');
}
public byShareIdQuery(shareId: Uuid, options: LoadOptions = {}): Knex.QueryBuilder {
public byShareIdQuery(shareId: Uuid, options: ItemLoadOptions = {}): Knex.QueryBuilder {
return this
.db('items')
.select(this.selectFields(options, null, 'items'))
.where('jop_share_id', '=', shareId);
}
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
public async byShareId(shareId: Uuid, options: ItemLoadOptions = {}): Promise<Item[]> {
const query = this.byShareIdQuery(shareId, options);
return await query;
}
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
private async storageDriverWrite(itemId: Uuid, content: Buffer, context: Context) {
await this.storageDriver_.write(itemId, content, context);
if (this.storageDriverFallback_) {
if (this.storageDriverFallback_.mode === StorageDriverMode.ReadWrite) {
await this.storageDriverFallback_.write(itemId, content, context);
} else if (this.storageDriverFallback_.mode === StorageDriverMode.ReadOnly) {
await this.storageDriverFallback_.write(itemId, Buffer.from(''), context);
} else {
throw new Error(`Unsupported fallback mode: ${this.storageDriverFallback_.mode}`);
}
}
}
private async storageDriverRead(itemId: Uuid, context: Context) {
if (await this.storageDriver_.exists(itemId, context)) {
return this.storageDriver_.read(itemId, context);
} else {
if (!this.storageDriverFallback_) throw new Error(`Content does not exist but fallback content driver is not defined: ${itemId}`);
return this.storageDriverFallback_.read(itemId, context);
}
}
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
if (!jopIds.length) return [];
const userIds = Array.isArray(userId) ? userId : [userId];
if (!userIds.length) return [];
return this
const rows: Item[] = await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.distinct(this.selectFields(options, null, 'items'))
.whereIn('user_items.user_id', userIds)
.whereIn('jop_id', jopIds);
if (options.withContent) {
for (const row of rows) {
row.content = await this.storageDriverRead(row.id, { models: this.models() });
}
}
return rows;
}
public async loadByJopId(userId: Uuid, jopId: string, options: LoadOptions = {}): Promise<Item> {
public async loadByJopId(userId: Uuid, jopId: string, options: ItemLoadOptions = {}): Promise<Item> {
const items = await this.loadByJopIds(userId, [jopId], options);
return items.length ? items[0] : null;
}
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: LoadOptions = {}): Promise<Item[]> {
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
if (!names.length) return [];
const userIds = Array.isArray(userId) ? userId : [userId];
return this
const rows: Item[] = await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.distinct(this.selectFields(options, null, 'items'))
.whereIn('user_items.user_id', userIds)
.whereIn('name', names);
if (options.withContent) {
for (const row of rows) {
row.content = await this.storageDriverRead(row.id, { models: this.models() });
}
}
return rows;
}
public async loadByName(userId: Uuid, name: string, options: LoadOptions = {}): Promise<Item> {
public async loadByName(userId: Uuid, name: string, options: ItemLoadOptions = {}): 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 loadWithContent(id: Uuid, options: ItemLoadOptions = {}): Promise<Item> {
const content = await this.storageDriverRead(id, { models: this.models() });
return {
...await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, ['*'], 'items'))
.where('items.id', '=', id)
.first(),
content,
};
}
public async loadAsSerializedJoplinItem(id: Uuid): Promise<string> {
@@ -255,9 +316,11 @@ export default class ItemModel extends BaseModel<Item> {
return this.itemToJoplinItem(raw);
}
public async saveFromRawContent(user: User, rawContentItems: SaveFromRawContentItem[], options: ItemSaveOption = null): Promise<SaveFromRawContentResult> {
public async saveFromRawContent(user: User, rawContentItems: SaveFromRawContentItem[] | SaveFromRawContentItem, options: ItemSaveOption = null): Promise<SaveFromRawContentResult> {
options = options || {};
if (!Array.isArray(rawContentItems)) rawContentItems = [rawContentItems];
// In this function, first we process the input items, which may be
// serialized Joplin items or actual buffers (for resources) and convert
// them to database items. Once it's done those db items are saved in
@@ -349,11 +412,46 @@ export default class ItemModel extends BaseModel<Item> {
continue;
}
const itemToSave = o.item;
const itemToSave = { ...o.item };
try {
const content = itemToSave.content;
delete itemToSave.content;
itemToSave.content_storage_id = this.storageDriver_.storageId;
itemToSave.content_size = content ? content.byteLength : 0;
// Here we save the item row and content, and we want to
// make sure that either both are saved or none of them.
// This is done by setting up a save point before saving the
// row, and rollbacking if the content cannot be saved.
//
// Normally, since we are in a transaction, throwing an
// error should work, but since we catch all errors within
// this block it doesn't work.
// TODO: When an item is uploaded multiple times
// simultaneously there could be a race condition, where the
// content would not match the db row (for example, the
// content_size would differ).
//
// Possible solutions:
//
// - Row-level lock on items.id, and release once the
// content is saved.
// - Or external lock - eg. Redis.
const savePoint = await this.setSavePoint();
const savedItem = await this.saveForUser(user.id, itemToSave);
try {
await this.storageDriverWrite(savedItem.id, content, { models: this.models() });
await this.releaseSavePoint(savePoint);
} catch (error) {
await this.rollbackSavePoint(savePoint);
throw error;
}
if (o.isNote) {
await this.models().itemResource().deleteByItemId(savedItem.id);
await this.models().itemResource().addResourceIds(savedItem.id, o.resourceIds);
@@ -390,7 +488,7 @@ export default class ItemModel extends BaseModel<Item> {
}
private childrenQuery(userId: Uuid, pathQuery: string = '', count: boolean = false, options: LoadOptions = {}): Knex.QueryBuilder {
private childrenQuery(userId: Uuid, pathQuery: string = '', count: boolean = false, options: ItemLoadOptions = {}): Knex.QueryBuilder {
const query = this
.db('user_items')
.innerJoin('items', 'user_items.item_id', 'items.id')
@@ -420,7 +518,7 @@ export default class ItemModel extends BaseModel<Item> {
return `${this.baseUrl}/items/${itemId}/content`;
}
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: LoadOptions = {}): Promise<PaginatedItems> {
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: ItemLoadOptions = {}): Promise<PaginatedItems> {
pagination = pagination || defaultPagination();
const query = this.childrenQuery(userId, pathQuery, false, options);
return paginateDbQuery(query, pagination, 'items');
@@ -532,6 +630,8 @@ export default class ItemModel extends BaseModel<Item> {
await this.models().share().delete(shares.map(s => s.id));
await this.models().userItem().deleteByItemIds(ids);
await this.models().itemResource().deleteByItemIds(ids);
await this.storageDriver_.delete(ids, { models: this.models() });
if (this.storageDriverFallback_) await this.storageDriverFallback_.delete(ids, { models: this.models() });
await super.delete(ids, options);
}, 'ItemModel::delete');
@@ -552,6 +652,7 @@ export default class ItemModel extends BaseModel<Item> {
public async makeTestItem(userId: Uuid, num: number) {
return this.saveForUser(userId, {
name: `${num.toString().padStart(32, '0')}.md`,
content: Buffer.from(''),
});
}
@@ -560,23 +661,27 @@ export default class ItemModel extends BaseModel<Item> {
for (let i = 1; i <= count; i++) {
await this.saveForUser(userId, {
name: `${i.toString().padStart(32, '0')}.md`,
content: Buffer.from(''),
});
}
}, 'ItemModel::makeTestItems');
}
// This method should be private because items should only be saved using
// saveFromRawContent, which is going to deal with the content driver. But
// since it's used in various test units, it's kept public for now.
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 (item.content && !item.content_storage_id) {
item.content_storage_id = this.storageDriver_.storageId;
}
if (isNew) {
if (!item.mime_type) item.mime_type = mimeUtils.fromFilename(item.name) || '';
if (!item.owner_id) item.owner_id = userId;

View File

@@ -17,15 +17,15 @@ describe('NotificationModel', function() {
});
test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('', NotificationKey.ConfirmEmail, NotificationLevel.Normal, NotificationKey.ConfirmEmail));
await expectThrow(async () => models().notification().add('', NotificationKey.EmailConfirmed, NotificationLevel.Normal, NotificationKey.EmailConfirmed));
});
test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
expect(n.key).toBe(NotificationKey.ConfirmEmail);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.EmailConfirmed);
expect(n.key).toBe(NotificationKey.EmailConfirmed);
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
});
@@ -33,18 +33,18 @@ describe('NotificationModel', function() {
test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, 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();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(0);
await model.markAsRead(user.id, NotificationKey.ConfirmEmail);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(0);
await model.setRead(user.id, NotificationKey.EmailConfirmed);
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(1);
});
});

View File

@@ -5,7 +5,7 @@ import BaseModel, { ValidateOptions } from './BaseModel';
export enum NotificationKey {
Any = 'any',
ConfirmEmail = 'confirmEmail',
// ConfirmEmail = 'confirmEmail',
PasswordSet = 'passwordSet',
EmailConfirmed = 'emailConfirmed',
ChangeAdminPassword = 'change_admin_password',
@@ -31,10 +31,10 @@ export default class NotificationModel extends BaseModel<Notification> {
public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise<Notification> {
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
},
// [NotificationKey.ConfirmEmail]: {
// level: NotificationLevel.Normal,
// message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration. Make sure you click it to secure your account and keep access to it.`,
// },
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'Your email has been confirmed',
@@ -83,12 +83,12 @@ export default class NotificationModel extends BaseModel<Notification> {
return this.save({ key: actualKey, message, level, owner_id: userId });
}
public async markAsRead(userId: Uuid, key: NotificationKey): Promise<void> {
public async setRead(userId: Uuid, key: NotificationKey, read: boolean = true): Promise<void> {
const n = await this.loadByKey(userId, key);
if (!n) return;
await this.db(this.tableName)
.update({ read: 1 })
.update({ read: read ? 1 : 0 })
.where('key', '=', key)
.andWhere('owner_id', '=', userId);
}

View File

@@ -67,6 +67,20 @@ describe('ShareModel', function() {
expect(shares3.length).toBe(1);
expect(shares3.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy();
const participatedShares1 = await models().share().participatedSharesByUser(user1.id, ShareType.Folder);
const participatedShares2 = await models().share().participatedSharesByUser(user2.id, ShareType.Folder);
const participatedShares3 = await models().share().participatedSharesByUser(user3.id, ShareType.Folder);
expect(participatedShares1.length).toBe(1);
expect(participatedShares1[0].owner_id).toBe(user2.id);
expect(participatedShares1[0].folder_id).toBe('000000000000000000000000000000F2');
expect(participatedShares2.length).toBe(0);
expect(participatedShares3.length).toBe(1);
expect(participatedShares3[0].owner_id).toBe(user1.id);
expect(participatedShares3[0].folder_id).toBe('000000000000000000000000000000F1');
});
test('should generate only one link per shared note', async function() {
@@ -78,8 +92,8 @@ describe('ShareModel', function() {
},
});
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001');
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001');
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
expect(share1.id).toBe(share2.id);
});
@@ -93,7 +107,7 @@ describe('ShareModel', function() {
},
});
await models().share().shareNote(user1, '00000000000000000000000000000001');
await models().share().shareNote(user1, '00000000000000000000000000000001', '');
const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
await models().item().delete(noteItem.id);
expect(await models().item().load(noteItem.id)).toBeFalsy();

View File

@@ -63,6 +63,7 @@ export default class ShareModel extends BaseModel<Share> {
if (object.folder_id) output.folder_id = object.folder_id;
if (object.owner_id) output.owner_id = object.owner_id;
if (object.note_id) output.note_id = object.note_id;
if (object.master_key_id) output.master_key_id = object.master_key_id;
return output;
}
@@ -151,6 +152,20 @@ export default class ShareModel extends BaseModel<Share> {
return query;
}
public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
const query = this.db(this.tableName)
.select(this.defaultFields)
.whereIn('id', this.db('share_users')
.select('share_id')
.where('user_id', '=', userId)
.andWhere('status', '=', ShareUserStatus.Accepted
));
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.
public async allShareUserIds(share: Share): Promise<Uuid[]> {
@@ -344,36 +359,38 @@ export default class ShareModel extends BaseModel<Share> {
await this.models().userItem().addMulti(userId, query);
}
public async shareFolder(owner: User, folderId: string): Promise<Share> {
public async shareFolder(owner: User, folderId: string, masterKeyId: 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 = {
const shareToSave: Share = {
type: ShareType.Folder,
item_id: folderItem.id,
owner_id: owner.id,
folder_id: folderId,
master_key_id: masterKeyId,
};
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
return super.save(shareToSave);
}
public async shareNote(owner: User, noteId: string): Promise<Share> {
public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise<Share> {
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
const existingShare = await this.byItemId(noteItem.id);
if (existingShare) return existingShare;
const shareToSave = {
const shareToSave: Share = {
type: ShareType.Note,
item_id: noteItem.id,
owner_id: owner.id,
note_id: noteId,
master_key_id: masterKeyId,
};
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);

View File

@@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
return this.db(this.tableName).where(link).first();
}
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
await this.models().shareUser().addById(share.id, shareeId);
public async shareWithUserAndAccept(share: Share, shareeId: Uuid, masterKey: string = '') {
await this.models().shareUser().addById(share.id, shareeId, masterKey);
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
}
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise<ShareUser> {
const user = await this.models().user().load(userId);
return this.addByEmail(shareId, user.email);
return this.addByEmail(shareId, user.email, masterKey);
}
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
@@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
.first();
}
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
public async addByEmail(shareId: Uuid, userEmail: string, masterKey: string): Promise<ShareUser> {
const share = await this.models().share().load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
@@ -110,6 +110,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
return this.save({
share_id: shareId,
user_id: user.id,
master_key: masterKey,
});
}

View File

@@ -0,0 +1,18 @@
import { Storage } from '../services/database/types';
import BaseModel from './BaseModel';
export default class StorageModel extends BaseModel<Storage> {
public get tableName(): string {
return 'storages';
}
protected hasUuid(): boolean {
return false;
}
public async byConnectionString(connectionString: string): Promise<Storage> {
return this.db(this.tableName).where('connection_string', connectionString).first();
}
}

View File

@@ -134,7 +134,7 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
account_type: accountType,
email,
full_name: fullName,
email_confirmed: 1,
email_confirmed: 0, // Email is not confirmed, because Stripe doesn't check this
password: uuidgen(),
must_set_password: 1,
});

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