You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-29 23:48:19 +02:00
Compare commits
55 Commits
server-v2.
...
server_con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b5ef1886 | ||
|
|
7b3ad32103 | ||
|
|
3745cd7cb0 | ||
|
|
920f2d9655 | ||
|
|
f800ca0269 | ||
|
|
33be306d01 | ||
|
|
3782255c27 | ||
|
|
68f77f6bbc | ||
|
|
0ab235273b | ||
|
|
75256613cc | ||
|
|
b328094033 | ||
|
|
4f0f1af5d1 | ||
|
|
021ce14348 | ||
|
|
b402bc7ff7 | ||
|
|
0ed0690bf8 | ||
|
|
467b1156cc | ||
|
|
c4017e52dc | ||
|
|
ec2c1741a2 | ||
|
|
6a9d9f6542 | ||
|
|
3e5ad0a374 | ||
|
|
05e390d48b | ||
|
|
31ce0f46e0 | ||
|
|
d19551b984 | ||
|
|
dacd697f80 | ||
|
|
70d5c7a648 | ||
|
|
69b413ce2b | ||
|
|
42caab6bde | ||
|
|
01b63ad263 | ||
|
|
ae4013d2f7 | ||
|
|
e3d6334372 | ||
|
|
cc4c50c219 | ||
|
|
5d646f7ced | ||
|
|
fa3612405c | ||
|
|
20df46c066 | ||
|
|
9b0a659416 | ||
|
|
298e85f115 | ||
|
|
a00e0e7043 | ||
|
|
9e1cb9db2c | ||
|
|
373c041aa6 | ||
|
|
af19865865 | ||
|
|
a0d23046bf | ||
|
|
7ad73df170 | ||
|
|
ce5c5d6042 | ||
|
|
8c6d78e01c | ||
|
|
e6d3396f42 | ||
|
|
190550fe8e | ||
|
|
560523bdc2 | ||
|
|
a13242e803 | ||
|
|
72834fcfc4 | ||
|
|
731142218b | ||
|
|
17b580b71b | ||
|
|
f7be45c236 | ||
|
|
b298861dc3 | ||
|
|
2343de3763 | ||
|
|
abb37258d0 |
@@ -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
|
||||
|
||||
32
.github/scripts/run_ci.sh
vendored
32
.github/scripts/run_ci.sh
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/github-actions-main.yml
vendored
8
.github/workflows/github-actions-main.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -188,6 +188,11 @@ h2 {
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.front-page h1 {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
76
README.md
76
README.md
@@ -505,47 +505,47 @@ Current translations:
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
| 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
|
||||
|
||||
@@ -558,7 +558,6 @@ class Application extends BaseApplication {
|
||||
// });
|
||||
// }, 2000);
|
||||
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -162,7 +162,6 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
50
packages/app-mobile/package-lock.json
generated
50
packages/app-mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
2
packages/lib/package-lock.json
generated
2
packages/lib/package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"async-mutex": "^0.1.3",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
// }
|
||||
// }));
|
||||
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'onclick=javascript:alert(/1/);f='\' onclick=\'postMessage("https://www.goo%27onclick=javascript:alert(/1/);f=%27", { resourceId: "" }); return false;\'>');
|
||||
});
|
||||
|
||||
test('should handle resource links - downloaded status', () => {
|
||||
const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
2
packages/renderer/package-lock.json
generated
2
packages/renderer/package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"font-awesome-filetypes": "^2.1.0",
|
||||
|
||||
@@ -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';
|
||||
|
||||
3677
packages/server/package-lock.json
generated
3677
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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 } 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,12 +275,14 @@ 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) {
|
||||
appLogger().info('Auto-migrating database...');
|
||||
await migrateLatest(ctx.joplinBase.db);
|
||||
appLogger().info('Latest migration:', await latestMigration(ctx.joplinBase.db));
|
||||
} else {
|
||||
appLogger().info('Skipped database auto-migration.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,10 +347,10 @@ export function isUniqueConstraintError(error: any): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function latestMigration(db: DbConnection): Promise<any> {
|
||||
export async function latestMigration(db: DbConnection): Promise<Migration | null> {
|
||||
try {
|
||||
const result = await db('knex_migrations').select('name').orderBy('id', 'desc').first();
|
||||
return result;
|
||||
return { name: result.name, done: true };
|
||||
} catch (error) {
|
||||
// If the database has never been initialized, we return null, so
|
||||
// for this we need to check the error code, which will be
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal file
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
32
packages/server/src/migrations/20211105183559_storage.ts
Normal file
32
packages/server/src/migrations/20211105183559_storage.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user