You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-29 07:46:13 +02:00
Compare commits
39 Commits
v2.4.7
...
server_sha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fe919300 | ||
|
|
a756db2ef4 | ||
|
|
843b52000e | ||
|
|
e8e8ea3780 | ||
|
|
3c13c8d080 | ||
|
|
97bfd5ef04 | ||
|
|
e3fd34e5d6 | ||
|
|
f144daed96 | ||
|
|
add9d884e6 | ||
|
|
62f81b4315 | ||
|
|
f33088fbe0 | ||
|
|
31b6d06418 | ||
|
|
06cd5ffa2d | ||
|
|
f3d4d8eaed | ||
|
|
dc08e1ded5 | ||
|
|
31c3fec8d8 | ||
|
|
4487cb85fc | ||
|
|
56cac1f729 | ||
|
|
3ade7ed849 | ||
|
|
a7eea9fc21 | ||
|
|
7fac1941cd | ||
|
|
061761f224 | ||
|
|
63e88c05d9 | ||
|
|
a6b1cffd50 | ||
|
|
8cc720963a | ||
|
|
da884752a8 | ||
|
|
818c7d4640 | ||
|
|
4577c9c161 | ||
|
|
03b4b6eb2d | ||
|
|
4d38397cd5 | ||
|
|
37d446b970 | ||
|
|
c91d4bda3c | ||
|
|
3e537967ee | ||
|
|
0cbc261051 | ||
|
|
542fdb496a | ||
|
|
d850eedd78 | ||
|
|
9429b51694 | ||
|
|
72e58ee195 | ||
|
|
56be4d59f4 |
@@ -1000,6 +1000,9 @@ packages/lib/import-enex-md-gen.js.map
|
||||
packages/lib/import-enex-md-gen.test.d.ts
|
||||
packages/lib/import-enex-md-gen.test.js
|
||||
packages/lib/import-enex-md-gen.test.js.map
|
||||
packages/lib/import-enex.d.ts
|
||||
packages/lib/import-enex.js
|
||||
packages/lib/import-enex.js.map
|
||||
packages/lib/locale.d.ts
|
||||
packages/lib/locale.js
|
||||
packages/lib/locale.js.map
|
||||
@@ -1813,6 +1816,9 @@ packages/renderer/utils.js.map
|
||||
packages/tools/buildServerDocker.d.ts
|
||||
packages/tools/buildServerDocker.js
|
||||
packages/tools/buildServerDocker.js.map
|
||||
packages/tools/buildServerDocker.test.d.ts
|
||||
packages/tools/buildServerDocker.test.js
|
||||
packages/tools/buildServerDocker.test.js.map
|
||||
packages/tools/convertThemesToCss.d.ts
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/convertThemesToCss.js.map
|
||||
@@ -1846,6 +1852,9 @@ packages/tools/release-server.js.map
|
||||
packages/tools/setupNewRelease.d.ts
|
||||
packages/tools/setupNewRelease.js
|
||||
packages/tools/setupNewRelease.js.map
|
||||
packages/tools/tagServerLatest.d.ts
|
||||
packages/tools/tagServerLatest.js
|
||||
packages/tools/tagServerLatest.js.map
|
||||
packages/tools/tool-utils.d.ts
|
||||
packages/tools/tool-utils.js
|
||||
packages/tools/tool-utils.js.map
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -985,6 +985,9 @@ packages/lib/import-enex-md-gen.js.map
|
||||
packages/lib/import-enex-md-gen.test.d.ts
|
||||
packages/lib/import-enex-md-gen.test.js
|
||||
packages/lib/import-enex-md-gen.test.js.map
|
||||
packages/lib/import-enex.d.ts
|
||||
packages/lib/import-enex.js
|
||||
packages/lib/import-enex.js.map
|
||||
packages/lib/locale.d.ts
|
||||
packages/lib/locale.js
|
||||
packages/lib/locale.js.map
|
||||
@@ -1798,6 +1801,9 @@ packages/renderer/utils.js.map
|
||||
packages/tools/buildServerDocker.d.ts
|
||||
packages/tools/buildServerDocker.js
|
||||
packages/tools/buildServerDocker.js.map
|
||||
packages/tools/buildServerDocker.test.d.ts
|
||||
packages/tools/buildServerDocker.test.js
|
||||
packages/tools/buildServerDocker.test.js.map
|
||||
packages/tools/convertThemesToCss.d.ts
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/convertThemesToCss.js.map
|
||||
@@ -1831,6 +1837,9 @@ packages/tools/release-server.js.map
|
||||
packages/tools/setupNewRelease.d.ts
|
||||
packages/tools/setupNewRelease.js
|
||||
packages/tools/setupNewRelease.js.map
|
||||
packages/tools/tagServerLatest.d.ts
|
||||
packages/tools/tagServerLatest.js
|
||||
packages/tools/tagServerLatest.js.map
|
||||
packages/tools/tool-utils.d.ts
|
||||
packages/tools/tool-utils.js
|
||||
packages/tools/tool-utils.js.map
|
||||
|
||||
38
README.md
38
README.md
@@ -504,47 +504,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) | 91%
|
||||
<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) | | 56%
|
||||
<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) | jmontane, 2019 | 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) | 96%
|
||||
<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) | 95%
|
||||
<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/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) | [Atalanttore](mailto:atalanttore@googlemail.com) | 95%
|
||||
<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/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 | 90%
|
||||
<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) | Laurent Cozic | 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) | 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) | 96%
|
||||
<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) | 96%
|
||||
<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) | 84%
|
||||
<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) | | 87%
|
||||
<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) | 90%
|
||||
<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) | 90%
|
||||
<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) | 63%
|
||||
<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) | 91%
|
||||
<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) | 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) | 89%
|
||||
<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) | | 81%
|
||||
<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) | 99%
|
||||
<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) | [Po-Chiang Chao](mailto:BobChao%29%20%28bobchao@gmail.com) | 94%
|
||||
<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) | 95%
|
||||
<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) | 95%
|
||||
<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%
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
|
||||
# Contributors
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"releaseIOS": "node packages/tools/release-ios.js",
|
||||
"releasePluginGenerator": "node packages/tools/release-plugin-generator.js",
|
||||
"releaseServer": "node packages/tools/release-server.js",
|
||||
"tagServerLatest": "node packages/tools/tagServerLatest.js",
|
||||
"buildServerDocker": "node packages/tools/buildServerDocker.js",
|
||||
"setupNewRelease": "node ./packages/tools/setupNewRelease",
|
||||
"test-ci": "lerna run test-ci --stream",
|
||||
|
||||
|
Before Width: | Height: | Size: 441 B After Width: | Height: | Size: 441 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -42,9 +42,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
this.sidebar_selectionChange = this.sidebar_selectionChange.bind(this);
|
||||
this.checkSyncConfig_ = this.checkSyncConfig_.bind(this);
|
||||
// this.checkNextcloudAppButton_click = this.checkNextcloudAppButton_click.bind(this);
|
||||
this.showLogButton_click = this.showLogButton_click.bind(this);
|
||||
this.nextcloudAppHelpLink_click = this.nextcloudAppHelpLink_click.bind(this);
|
||||
this.onCancelClick = this.onCancelClick.bind(this);
|
||||
this.onSaveClick = this.onSaveClick.bind(this);
|
||||
this.onApplyClick = this.onApplyClick.bind(this);
|
||||
@@ -58,19 +55,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
await shared.checkSyncConfig(this, this.state.settings);
|
||||
}
|
||||
|
||||
// async checkNextcloudAppButton_click() {
|
||||
// this.setState({ showNextcloudAppLog: true });
|
||||
// await shared.checkNextcloudApp(this, this.state.settings);
|
||||
// }
|
||||
|
||||
showLogButton_click() {
|
||||
this.setState({ showNextcloudAppLog: true });
|
||||
}
|
||||
|
||||
nextcloudAppHelpLink_click() {
|
||||
bridge().openExternal('https://joplinapp.org/nextcloud_app');
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
this.setState({ settings: this.props.settings });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import PluginBox, { InstallState } from './PluginBox';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useOnInstallHandler from './useOnInstallHandler';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
|
||||
const Root = styled.div`
|
||||
`;
|
||||
@@ -100,6 +101,13 @@ export default function(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderContentSourceInfo = () => {
|
||||
if (props.repoApi().isUsingDefaultContentUrl) return null;
|
||||
const theme = themeStyle(props.themeId);
|
||||
const url = new URL(props.repoApi().contentBaseUrl);
|
||||
return <div style={{ ...theme.textStyleMinor, marginTop: 5, fontSize: theme.fontSize }}>{_('Content provided by %s', url.hostname)}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ marginBottom: 10, width: props.maxWidth }}>
|
||||
@@ -112,6 +120,7 @@ export default function(props: Props) {
|
||||
placeholder={props.disabled ? _('Please wait...') : _('Search for plugins...')}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
{renderContentSourceInfo()}
|
||||
</div>
|
||||
|
||||
<ResultsRoot>
|
||||
|
||||
@@ -4,7 +4,7 @@ const Folder = require('@joplin/lib/models/Folder').default;
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { filename, basename } = require('@joplin/lib/path-utils');
|
||||
const { importEnex } = require('@joplin/lib/import-enex');
|
||||
const importEnex = require('@joplin/lib/import-enex').default;
|
||||
|
||||
class ImportScreenComponent extends React.Component {
|
||||
UNSAFE_componentWillMount() {
|
||||
|
||||
@@ -152,6 +152,11 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
});
|
||||
|
||||
const allAssets = useCallback(async (markupLanguage: number, options: AllAssetsOptions = null): Promise<any[]> => {
|
||||
options = {
|
||||
contentMaxWidthTarget: '',
|
||||
...options,
|
||||
};
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const markupToHtml = markupLanguageUtils.newMarkupToHtml({}, {
|
||||
|
||||
@@ -138,9 +138,9 @@ const syncTargetNames: string[] = [
|
||||
|
||||
|
||||
const logosImageNames: Record<string, string> = {
|
||||
'dropbox': 'Dropbox.svg',
|
||||
'joplinCloud': 'JoplinCloud.svg',
|
||||
'onedrive': 'OneDrive.svg',
|
||||
'dropbox': 'SyncTarget_Dropbox.svg',
|
||||
'joplinCloud': 'SyncTarget_JoplinCloud.svg',
|
||||
'onedrive': 'SyncTarget_OneDrive.svg',
|
||||
};
|
||||
|
||||
export default function(props: Props) {
|
||||
@@ -274,7 +274,7 @@ export default function(props: Props) {
|
||||
const height = info.name !== 'joplinCloud' ? descriptionHeight : null;
|
||||
|
||||
const logoImageName = logosImageNames[info.name];
|
||||
const logoImageSrc = logoImageName ? `${bridge().buildDir()}/images/syncTargetLogos/${logoImageName}` : '';
|
||||
const logoImageSrc = logoImageName ? `${bridge().buildDir()}/images/${logoImageName}` : '';
|
||||
const logo = logoImageSrc ? <SyncTargetLogo src={logoImageSrc}/> : null;
|
||||
const descriptionComp = <SyncTargetDescription height={height} ref={info.name === 'joplinCloud' ? joplinCloudDescriptionRef : null}>{info.description}</SyncTargetDescription>;
|
||||
const featuresComp = showJoplinCloudForm && info.name === 'joplinCloud' ? null : renderFeatures(info.name);
|
||||
|
||||
4
packages/app-desktop/package-lock.json
generated
4
packages/app-desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.7",
|
||||
"version": "2.4.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.7",
|
||||
"version": "2.4.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.7",
|
||||
"version": "2.4.8",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -141,8 +141,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097649
|
||||
versionName "2.4.1"
|
||||
versionCode 2097650
|
||||
versionName "2.4.2"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ dialogs.confirm = (parentComponent, message) => {
|
||||
if (!parentComponent) throw new Error('parentComponent is required');
|
||||
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
return dialogs.confirmRef(parentComponent.dialogBox, message);
|
||||
return dialogs.confirmRef(parentComponent.dialogbox, message);
|
||||
};
|
||||
|
||||
dialogs.pop = (parentComponent, message, buttons, options = null) => {
|
||||
|
||||
@@ -9,10 +9,8 @@ const shared = {};
|
||||
shared.init = function(comp) {
|
||||
if (!comp.state) comp.state = {};
|
||||
comp.state.checkSyncConfigResult = null;
|
||||
comp.state.checkNextcloudAppResult = null;
|
||||
comp.state.settings = {};
|
||||
comp.state.changedSettingKeys = [];
|
||||
comp.state.showNextcloudAppLog = false;
|
||||
comp.state.showAdvancedSettings = false;
|
||||
};
|
||||
|
||||
@@ -35,7 +33,6 @@ shared.checkSyncConfig = async function(comp, settings) {
|
||||
comp.setState({ checkSyncConfigResult: result });
|
||||
|
||||
if (result.ok) {
|
||||
// await shared.checkNextcloudApp(comp, settings);
|
||||
// Users often expect config to be auto-saved at this point, if the config check was successful
|
||||
shared.saveSettings(comp);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const os = require('os');
|
||||
const { filename } = require('./path-utils');
|
||||
import { setupDatabaseAndSynchronizer, switchClient, expectNotThrow, supportDir } from './testing/test-utils';
|
||||
const { enexXmlToMd } = require('./import-enex-md-gen.js');
|
||||
const { importEnex } = require('./import-enex');
|
||||
import importEnex from './import-enex';
|
||||
import Note from './models/Note';
|
||||
import Tag from './models/Tag';
|
||||
import Resource from './models/Resource';
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
const uuid = require('./uuid').default;
|
||||
import uuid from './uuid';
|
||||
import BaseModel from './BaseModel';
|
||||
import Note from './models/Note';
|
||||
import Tag from './models/Tag';
|
||||
import Resource from './models/Resource';
|
||||
import Setting from './models/Setting';
|
||||
import time from './time';
|
||||
import shim from './shim';
|
||||
import { NoteEntity } from './services/database/types';
|
||||
import { enexXmlToMd } from './import-enex-md-gen';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
const moment = require('moment');
|
||||
const BaseModel = require('./BaseModel').default;
|
||||
const Note = require('./models/Note').default;
|
||||
const Tag = require('./models/Tag').default;
|
||||
const Resource = require('./models/Resource').default;
|
||||
const Setting = require('./models/Setting').default;
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
const { wrapError } = require('./errorUtils');
|
||||
const { enexXmlToMd } = require('./import-enex-md-gen.js');
|
||||
const { enexXmlToHtml } = require('./import-enex-html-gen.js');
|
||||
const time = require('./time').default;
|
||||
const Levenshtein = require('levenshtein');
|
||||
const md5 = require('md5');
|
||||
const { Base64Decode } = require('base64-stream');
|
||||
const md5File = require('md5-file');
|
||||
const shim = require('./shim').default;
|
||||
const { mime } = require('./mime-utils');
|
||||
|
||||
// const Promise = require('promise');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
function dateToTimestamp(s, defaultValue = null) {
|
||||
function dateToTimestamp(s: string, defaultValue: number = null): number {
|
||||
// Most dates seem to be in this format
|
||||
let m = moment(s, 'YYYYMMDDTHHmmssZ');
|
||||
|
||||
@@ -36,12 +37,12 @@ function dateToTimestamp(s, defaultValue = null) {
|
||||
return m.toDate().getTime();
|
||||
}
|
||||
|
||||
function extractRecognitionObjId(recognitionXml) {
|
||||
function extractRecognitionObjId(recognitionXml: string) {
|
||||
const r = recognitionXml.match(/objID="(.*?)"/);
|
||||
return r && r.length >= 2 ? r[1] : null;
|
||||
}
|
||||
|
||||
async function decodeBase64File(sourceFilePath, destFilePath) {
|
||||
async function decodeBase64File(sourceFilePath: string, destFilePath: string) {
|
||||
// When something goes wrong with streams you can get an error "EBADF, Bad file descriptor"
|
||||
// with no strack trace to tell where the error happened.
|
||||
|
||||
@@ -73,17 +74,17 @@ async function decodeBase64File(sourceFilePath, destFilePath) {
|
||||
destStream.on('finish', () => {
|
||||
fs.fdatasyncSync(destFile);
|
||||
fs.closeSync(destFile);
|
||||
resolve();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
sourceStream.on('error', (error) => reject(error));
|
||||
destStream.on('error', (error) => reject(error));
|
||||
sourceStream.on('error', (error: any) => reject(error));
|
||||
destStream.on('error', (error: any) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function md5FileAsync(filePath) {
|
||||
async function md5FileAsync(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
md5File(filePath, (error, hash) => {
|
||||
md5File(filePath, (error: any, hash: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
@@ -94,24 +95,24 @@ async function md5FileAsync(filePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function removeUndefinedProperties(note) {
|
||||
const output = {};
|
||||
function removeUndefinedProperties(note: NoteEntity) {
|
||||
const output: any = {};
|
||||
for (const n in note) {
|
||||
if (!note.hasOwnProperty(n)) continue;
|
||||
const v = note[n];
|
||||
const v = (note as any)[n];
|
||||
if (v === undefined || v === null) continue;
|
||||
output[n] = v;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function levenshteinPercent(s1, s2) {
|
||||
function levenshteinPercent(s1: string, s2: string) {
|
||||
const l = new Levenshtein(s1, s2);
|
||||
if (!s1.length || !s2.length) return 1;
|
||||
return Math.abs(l.distance / s1.length);
|
||||
}
|
||||
|
||||
async function fuzzyMatch(note) {
|
||||
async function fuzzyMatch(note: ExtractedNote) {
|
||||
if (note.created_time < time.unixMs() - 1000 * 60 * 60 * 24 * 360) {
|
||||
const notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ? AND title = ?', [note.created_time, note.title]);
|
||||
return notes.length !== 1 ? null : notes[0];
|
||||
@@ -137,9 +138,30 @@ async function fuzzyMatch(note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ExtractedResource {
|
||||
hasData?: boolean;
|
||||
id?: string;
|
||||
size?: number;
|
||||
dataFilePath?: string;
|
||||
dataEncoding?: string;
|
||||
data?: string;
|
||||
filename?: string;
|
||||
sourceUrl?: string;
|
||||
mime?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ExtractedNote extends NoteEntity {
|
||||
resources?: ExtractedResource[];
|
||||
tags?: string[];
|
||||
title?: string;
|
||||
bodyXml?: string;
|
||||
// is_todo?: boolean;
|
||||
}
|
||||
|
||||
// At this point we have the resource has it's been parsed from the XML, but additional
|
||||
// processing needs to be done to get the final resource file, its size, MD5, etc.
|
||||
async function processNoteResource(resource) {
|
||||
async function processNoteResource(resource: ExtractedResource) {
|
||||
if (!resource.hasData) {
|
||||
// Some resources have no data, go figure, so we need a special case for this.
|
||||
resource.id = md5(Date.now() + Math.random());
|
||||
@@ -175,7 +197,7 @@ async function processNoteResource(resource) {
|
||||
return resource;
|
||||
}
|
||||
|
||||
async function saveNoteResources(note) {
|
||||
async function saveNoteResources(note: ExtractedNote) {
|
||||
let resourcesCreated = 0;
|
||||
for (let i = 0; i < note.resources.length; i++) {
|
||||
const resource = note.resources[i];
|
||||
@@ -198,7 +220,7 @@ async function saveNoteResources(note) {
|
||||
return resourcesCreated;
|
||||
}
|
||||
|
||||
async function saveNoteTags(note) {
|
||||
async function saveNoteTags(note: ExtractedNote) {
|
||||
let notesTagged = 0;
|
||||
for (let i = 0; i < note.tags.length; i++) {
|
||||
const tagTitle = note.tags[i];
|
||||
@@ -213,12 +235,19 @@ async function saveNoteTags(note) {
|
||||
return notesTagged;
|
||||
}
|
||||
|
||||
async function saveNoteToStorage(note, importOptions) {
|
||||
interface ImportOptions {
|
||||
fuzzyMatching?: boolean;
|
||||
onProgress?: Function;
|
||||
onError?: Function;
|
||||
outputFormat?: string;
|
||||
}
|
||||
|
||||
async function saveNoteToStorage(note: ExtractedNote, importOptions: ImportOptions) {
|
||||
importOptions = Object.assign({}, {
|
||||
fuzzyMatching: false,
|
||||
}, importOptions);
|
||||
|
||||
note = Note.filter(note);
|
||||
note = Note.filter(note as any);
|
||||
|
||||
const existingNote = importOptions.fuzzyMatching ? await fuzzyMatch(note) : null;
|
||||
|
||||
@@ -230,7 +259,7 @@ async function saveNoteToStorage(note, importOptions) {
|
||||
notesTagged: 0,
|
||||
};
|
||||
|
||||
const resourcesCreated = await saveNoteResources(note, importOptions);
|
||||
const resourcesCreated = await saveNoteResources(note);
|
||||
result.resourcesCreated += resourcesCreated;
|
||||
|
||||
const notesTagged = await saveNoteTags(note);
|
||||
@@ -262,16 +291,50 @@ async function saveNoteToStorage(note, importOptions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
interface Node {
|
||||
name: string;
|
||||
attributes: Record<string, any>;
|
||||
}
|
||||
|
||||
interface NoteResourceRecognition {
|
||||
objID?: string;
|
||||
}
|
||||
|
||||
const preProcessFile = async (filePath: string): Promise<string> => {
|
||||
const content: string = await shim.fsDriver().readFile(filePath, 'utf8');
|
||||
|
||||
// The note content in an ENEX file is wrapped in a CDATA block so it means
|
||||
// that any "]]>" inside the note must be somehow escaped, or else the CDATA
|
||||
// block would be closed at the wrong point.
|
||||
//
|
||||
// The problem is that Evernote appears to encode "]]>" as "]]<![CDATA[>]]>"
|
||||
// instead of the more sensible "]]>", or perhaps they have nothing in
|
||||
// place to properly escape data imported from their web clipper. In any
|
||||
// case it results in invalid XML that Evernote cannot even import back.
|
||||
//
|
||||
// Handling that invalid XML with SAX would also be very tricky, so instead
|
||||
// we add a pre-processing step that converts this tags to just ">". It
|
||||
// should be safe to do so because such content can only be within the body
|
||||
// of a note - and ">" or ">" is equivalent.
|
||||
//
|
||||
// Ref: https://discourse.joplinapp.org/t/20470/4
|
||||
const newContent = content.replace(/<!\[CDATA\[>\]\]>/g, '>');
|
||||
if (content === newContent) return filePath;
|
||||
const newFilePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.enex`;
|
||||
await shim.fsDriver().writeFile(newFilePath, newContent, 'utf8');
|
||||
return newFilePath;
|
||||
};
|
||||
|
||||
export default async function importEnex(parentFolderId: string, filePath: string, importOptions: ImportOptions = null) {
|
||||
if (!importOptions) importOptions = {};
|
||||
if (!('fuzzyMatching' in importOptions)) importOptions.fuzzyMatching = false;
|
||||
if (!('onProgress' in importOptions)) importOptions.onProgress = function() {};
|
||||
if (!('onError' in importOptions)) importOptions.onError = function() {};
|
||||
|
||||
function handleSaxStreamEvent(fn) {
|
||||
return function(...args) {
|
||||
function handleSaxStreamEvent(fn: Function) {
|
||||
return function(...args: any[]) {
|
||||
// Pass the parser to the wrapped function for debugging purposes
|
||||
if (this._parser) fn._parser = this._parser;
|
||||
if (this._parser) (fn as any)._parser = this._parser;
|
||||
|
||||
try {
|
||||
fn.call(this, ...args);
|
||||
@@ -285,6 +348,9 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
};
|
||||
}
|
||||
|
||||
const fileToProcess = await preProcessFile(filePath);
|
||||
const needToDeleteFileToProcess = fileToProcess !== filePath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const progressState = {
|
||||
loaded: 0,
|
||||
@@ -295,22 +361,22 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
notesTagged: 0,
|
||||
};
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
const stream = fs.createReadStream(fileToProcess);
|
||||
|
||||
const options = {};
|
||||
const strict = true;
|
||||
const saxStream = require('@joplin/fork-sax').createStream(strict, options);
|
||||
|
||||
const nodes = []; // LIFO list of nodes so that we know in which node we are in the onText event
|
||||
let note = null;
|
||||
let noteAttributes = null;
|
||||
let noteResource = null;
|
||||
let noteResourceAttributes = null;
|
||||
let noteResourceRecognition = null;
|
||||
const notes = [];
|
||||
const nodes: Node[] = []; // LIFO list of nodes so that we know in which node we are in the onText event
|
||||
let note: ExtractedNote = null;
|
||||
let noteAttributes: Record<string, any> = null;
|
||||
let noteResource: ExtractedResource = null;
|
||||
let noteResourceAttributes: Record<string, any> = null;
|
||||
let noteResourceRecognition: NoteResourceRecognition = null;
|
||||
const notes: ExtractedNote[] = [];
|
||||
let processingNotes = false;
|
||||
|
||||
const createErrorWithNoteTitle = (fnThis, error) => {
|
||||
const createErrorWithNoteTitle = (fnThis: any, error: any) => {
|
||||
const line = [];
|
||||
|
||||
const parser = fnThis ? fnThis._parser : null;
|
||||
@@ -329,7 +395,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
return error;
|
||||
};
|
||||
|
||||
stream.on('error', function(error) {
|
||||
stream.on('error', function(error: any) {
|
||||
importOptions.onError(createErrorWithNoteTitle(this, error));
|
||||
});
|
||||
|
||||
@@ -417,11 +483,11 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
saxStream.on('error', function(error) {
|
||||
saxStream.on('error', function(error: any) {
|
||||
importOptions.onError(createErrorWithNoteTitle(this, error));
|
||||
});
|
||||
|
||||
saxStream.on('text', handleSaxStreamEvent(function(text) {
|
||||
saxStream.on('text', handleSaxStreamEvent(function(text: string) {
|
||||
const n = currentNodeName();
|
||||
|
||||
if (noteAttributes) {
|
||||
@@ -443,8 +509,8 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
|
||||
fs.appendFileSync(noteResource.dataFilePath, text);
|
||||
} else {
|
||||
if (!(n in noteResource)) noteResource[n] = '';
|
||||
noteResource[n] += text;
|
||||
if (!(n in noteResource)) (noteResource as any)[n] = '';
|
||||
(noteResource as any)[n] += text;
|
||||
}
|
||||
} else if (note) {
|
||||
if (n == 'title') {
|
||||
@@ -465,7 +531,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
}
|
||||
}));
|
||||
|
||||
saxStream.on('opentag', handleSaxStreamEvent(function(node) {
|
||||
saxStream.on('opentag', handleSaxStreamEvent(function(node: Node) {
|
||||
const n = node.name.toLowerCase();
|
||||
nodes.push(node);
|
||||
|
||||
@@ -488,7 +554,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
}
|
||||
}));
|
||||
|
||||
saxStream.on('cdata', handleSaxStreamEvent(function(data) {
|
||||
saxStream.on('cdata', handleSaxStreamEvent(function(data: any) {
|
||||
const n = currentNodeName();
|
||||
|
||||
if (noteResourceRecognition) {
|
||||
@@ -500,7 +566,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
}
|
||||
}));
|
||||
|
||||
saxStream.on('closetag', handleSaxStreamEvent(function(n) {
|
||||
saxStream.on('closetag', handleSaxStreamEvent(function(n: string) {
|
||||
nodes.pop();
|
||||
|
||||
if (n == 'note') {
|
||||
@@ -529,7 +595,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
note.longitude = noteAttributes.longitude;
|
||||
note.altitude = noteAttributes.altitude;
|
||||
note.author = noteAttributes.author ? noteAttributes.author.trim() : '';
|
||||
note.is_todo = noteAttributes['reminder-order'] !== '0' && !!noteAttributes['reminder-order'];
|
||||
note.is_todo = noteAttributes['reminder-order'] !== '0' && !!noteAttributes['reminder-order'] as any;
|
||||
note.todo_due = dateToTimestamp(noteAttributes['reminder-time'], 0);
|
||||
note.todo_completed = dateToTimestamp(noteAttributes['reminder-done-time'], 0);
|
||||
note.order = dateToTimestamp(noteAttributes['reminder-order'], 0);
|
||||
@@ -572,10 +638,11 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
saxStream.on('end', handleSaxStreamEvent(function() {
|
||||
// Wait till there is no more notes to process.
|
||||
const iid = shim.setInterval(() => {
|
||||
processNotes().then(allDone => {
|
||||
void processNotes().then(allDone => {
|
||||
if (allDone) {
|
||||
shim.clearTimeout(iid);
|
||||
resolve();
|
||||
if (needToDeleteFileToProcess) void shim.fsDriver().remove(fileToProcess);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
@@ -584,5 +651,3 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
stream.pipe(saxStream);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { importEnex };
|
||||
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":91};
|
||||
stats['ar'] = {"percentDone":99};
|
||||
stats['eu'] = {"percentDone":28};
|
||||
stats['bs_BA'] = {"percentDone":71};
|
||||
stats['bg_BG'] = {"percentDone":56};
|
||||
stats['ca'] = {"percentDone":95};
|
||||
stats['hr_HR'] = {"percentDone":96};
|
||||
stats['cs_CZ'] = {"percentDone":95};
|
||||
stats['bg_BG'] = {"percentDone":55};
|
||||
stats['ca'] = {"percentDone":99};
|
||||
stats['hr_HR'] = {"percentDone":95};
|
||||
stats['cs_CZ'] = {"percentDone":94};
|
||||
stats['da_DK'] = {"percentDone":99};
|
||||
stats['de_DE'] = {"percentDone":95};
|
||||
stats['de_DE'] = {"percentDone":99};
|
||||
stats['et_EE'] = {"percentDone":54};
|
||||
stats['en_GB'] = {"percentDone":100};
|
||||
stats['en_US'] = {"percentDone":100};
|
||||
stats['es_ES'] = {"percentDone":95};
|
||||
stats['eo'] = {"percentDone":31};
|
||||
stats['fi_FI'] = {"percentDone":90};
|
||||
stats['fi_FI'] = {"percentDone":99};
|
||||
stats['fr_FR'] = {"percentDone":96};
|
||||
stats['gl_ES'] = {"percentDone":36};
|
||||
stats['id_ID'] = {"percentDone":96};
|
||||
stats['it_IT'] = {"percentDone":96};
|
||||
stats['hu_HU'] = {"percentDone":84};
|
||||
stats['nl_BE'] = {"percentDone":87};
|
||||
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":90};
|
||||
stats['pl_PL'] = {"percentDone":89};
|
||||
stats['pt_BR'] = {"percentDone":96};
|
||||
stats['pt_PT'] = {"percentDone":90};
|
||||
stats['ro'] = {"percentDone":63};
|
||||
stats['sl_SI'] = {"percentDone":91};
|
||||
stats['sv'] = {"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":89};
|
||||
stats['sr_RS'] = {"percentDone":81};
|
||||
stats['sr_RS'] = {"percentDone":80};
|
||||
stats['zh_CN'] = {"percentDone":99};
|
||||
stats['zh_TW'] = {"percentDone":94};
|
||||
stats['zh_TW'] = {"percentDone":95};
|
||||
stats['ja_JP'] = {"percentDone":95};
|
||||
stats['ko'] = {"percentDone":95};
|
||||
stats['ko'] = {"percentDone":94};
|
||||
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
1
packages/lib/package-lock.json
generated
1
packages/lib/package-lock.json
generated
@@ -15703,6 +15703,7 @@
|
||||
},
|
||||
"uslug": {
|
||||
"version": "git+ssh://git@github.com/laurent22/uslug.git#ba2834d79beb0435318709958b2f5e817d96674d",
|
||||
"integrity": "sha512-6zzxOsQp+hbOW4zeplEUhKXnBzYIrqYAVlPepBFz/u5q2OulN7tCmBKyWEzDxaiZOLYnUCTViDLazNoq1J6ciA==",
|
||||
"from": "uslug@git+https://github.com/laurent22/uslug.git#emoji-support",
|
||||
"requires": {
|
||||
"node-emoji": "^1.10.0",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ImportExportResult } from './types';
|
||||
import InteropService_Importer_Base from './InteropService_Importer_Base';
|
||||
import Folder from '../../models/Folder';
|
||||
import importEnex from '../../import-enex';
|
||||
const { filename } = require('../../path-utils');
|
||||
|
||||
export default class InteropService_Importer_EnexToHtml extends InteropService_Importer_Base {
|
||||
async exec(result: ImportExportResult): Promise<ImportExportResult> {
|
||||
const { importEnex } = require('../../import-enex');
|
||||
|
||||
public async exec(result: ImportExportResult): Promise<ImportExportResult> {
|
||||
let folder = this.options_.destinationFolder;
|
||||
|
||||
if (!folder) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { ImportExportResult } from './types';
|
||||
|
||||
import importEnex from '../../import-enex';
|
||||
import InteropService_Importer_Base from './InteropService_Importer_Base';
|
||||
import Folder from '../../models/Folder';
|
||||
const { filename } = require('../../path-utils');
|
||||
|
||||
export default class InteropService_Importer_EnexToMd extends InteropService_Importer_Base {
|
||||
async exec(result: ImportExportResult) {
|
||||
const { importEnex } = require('../../import-enex');
|
||||
|
||||
public async exec(result: ImportExportResult) {
|
||||
let folder = this.options_.destinationFolder;
|
||||
|
||||
if (!folder) {
|
||||
|
||||
@@ -16,6 +16,36 @@ interface Release {
|
||||
assets: ReleaseAsset[];
|
||||
}
|
||||
|
||||
const findWorkingGitHubUrl = async (defaultContentUrl: string): Promise<string> => {
|
||||
// From: https://github.com/laurent22/joplin/issues/5161#issuecomment-921642721
|
||||
|
||||
const mirrorUrls = [
|
||||
defaultContentUrl,
|
||||
'https://cdn.staticaly.com/gh/joplin/plugins/master',
|
||||
'https://ghproxy.com/https://raw.githubusercontent.com/joplin/plugins/master',
|
||||
'https://cdn.jsdelivr.net/gh/joplin/plugins@master',
|
||||
'https://raw.fastgit.org/joplin/plugins/master',
|
||||
];
|
||||
|
||||
for (const mirrorUrl of mirrorUrls) {
|
||||
try {
|
||||
// We try to fetch .gitignore, which is smaller than the whole manifest
|
||||
await fetch(`${mirrorUrl}/.gitignore`);
|
||||
} catch (error) {
|
||||
logger.info(`findWorkingMirror: Could not connect to ${mirrorUrl}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`findWorkingMirror: Using: ${mirrorUrl}`);
|
||||
|
||||
return mirrorUrl;
|
||||
}
|
||||
|
||||
logger.info('findWorkingMirror: Could not find any working GitHub URL');
|
||||
|
||||
return defaultContentUrl;
|
||||
};
|
||||
|
||||
export default class RepositoryApi {
|
||||
|
||||
// As a base URL, this class can support either a remote repository or a
|
||||
@@ -29,6 +59,9 @@ export default class RepositoryApi {
|
||||
private tempDir_: string;
|
||||
private release_: Release = null;
|
||||
private manifests_: PluginManifest[] = null;
|
||||
private githubApiUrl_: string;
|
||||
private contentBaseUrl_: string;
|
||||
private isUsingDefaultContentUrl_: boolean = true;
|
||||
|
||||
public constructor(baseUrl: string, tempDir: string) {
|
||||
this.baseUrl_ = baseUrl;
|
||||
@@ -36,6 +69,14 @@ export default class RepositoryApi {
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
// https://github.com/joplin/plugins
|
||||
// https://api.github.com/repos/joplin/plugins/releases
|
||||
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
|
||||
const defaultContentBaseUrl = `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
||||
this.contentBaseUrl_ = await findWorkingGitHubUrl(defaultContentBaseUrl);
|
||||
|
||||
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
|
||||
|
||||
await this.loadManifests();
|
||||
await this.loadRelease();
|
||||
}
|
||||
@@ -45,18 +86,33 @@ export default class RepositoryApi {
|
||||
try {
|
||||
const manifests = JSON.parse(manifestsText);
|
||||
if (!manifests) throw new Error('Invalid or missing JSON');
|
||||
|
||||
this.manifests_ = Object.keys(manifests).map(id => {
|
||||
return manifests[id];
|
||||
const m: PluginManifest = manifests[id];
|
||||
// If we don't control the repository, we can't recommend
|
||||
// anything on it since it could have been modified.
|
||||
if (!this.isUsingDefaultContentUrl) m._recommended = false;
|
||||
return m;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public get isUsingDefaultContentUrl() {
|
||||
return this.isUsingDefaultContentUrl_;
|
||||
}
|
||||
|
||||
private get githubApiUrl(): string {
|
||||
// https://github.com/joplin/plugins
|
||||
// https://api.github.com/repos/joplin/plugins/releases
|
||||
return this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
|
||||
return this.githubApiUrl_;
|
||||
}
|
||||
|
||||
public get contentBaseUrl(): string {
|
||||
if (this.isLocalRepo) {
|
||||
return this.baseUrl_;
|
||||
} else {
|
||||
return this.contentBaseUrl_;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRelease() {
|
||||
@@ -78,14 +134,6 @@ export default class RepositoryApi {
|
||||
return this.baseUrl_.indexOf('http') !== 0;
|
||||
}
|
||||
|
||||
private get contentBaseUrl(): string {
|
||||
if (this.isLocalRepo) {
|
||||
return this.baseUrl_;
|
||||
} else {
|
||||
return `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
||||
}
|
||||
}
|
||||
|
||||
private assetFileUrl(pluginId: string): string {
|
||||
if (this.release_) {
|
||||
const asset = this.release_.assets.find(asset => {
|
||||
|
||||
1
packages/renderer/package-lock.json
generated
1
packages/renderer/package-lock.json
generated
@@ -14935,6 +14935,7 @@
|
||||
},
|
||||
"uslug": {
|
||||
"version": "git+ssh://git@github.com/laurent22/uslug.git#ba2834d79beb0435318709958b2f5e817d96674d",
|
||||
"integrity": "sha512-6zzxOsQp+hbOW4zeplEUhKXnBzYIrqYAVlPepBFz/u5q2OulN7tCmBKyWEzDxaiZOLYnUCTViDLazNoq1J6ciA==",
|
||||
"from": "uslug@git+https://github.com/laurent22/uslug.git#emoji-support",
|
||||
"requires": {
|
||||
"node-emoji": "^1.10.0",
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.9",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@koa/cors": "^3.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
||||
@@ -45,6 +45,10 @@ table.table th .sort-button i {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
table.table tr.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { initializeJoplinUtils } from './utils/joplinUtils';
|
||||
import startServices from './utils/startServices';
|
||||
import { credentialFile } from './utils/testing/testUtils';
|
||||
import apiVersionHandler from './middleware/apiVersionHandler';
|
||||
import clickJackingHandler from './middleware/clickJackingHandler';
|
||||
|
||||
const cors = require('@koa/cors');
|
||||
const nodeEnvFile = require('node-env-file');
|
||||
@@ -90,14 +91,6 @@ async function main() {
|
||||
|
||||
const app = new Koa();
|
||||
|
||||
// app.use(async function responseTime(ctx:AppContext, next:Function) {
|
||||
// const start = Date.now();
|
||||
// await next();
|
||||
// const ms = Date.now() - start;
|
||||
// console.info('Response time', ms)
|
||||
// //ctx.set('X-Response-Time', `${ms}ms`);
|
||||
// });
|
||||
|
||||
// Note: the order of middlewares is important. For example, ownerHandler
|
||||
// loads the user, which is then used by notificationHandler. And finally
|
||||
// routeHandler uses data from both previous middlewares. It would be good to
|
||||
@@ -179,6 +172,7 @@ async function main() {
|
||||
app.use(apiVersionHandler);
|
||||
app.use(ownerHandler);
|
||||
app.use(notificationHandler);
|
||||
app.use(clickJackingHandler);
|
||||
app.use(routeHandler);
|
||||
|
||||
await initConfig(env, envVariables);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface EnvVariables {
|
||||
APP_BASE_URL?: string;
|
||||
USER_CONTENT_BASE_URL?: string;
|
||||
API_BASE_URL?: string;
|
||||
JOPLINAPP_BASE_URL?: string;
|
||||
|
||||
APP_PORT?: string;
|
||||
DB_CLIENT?: string;
|
||||
@@ -59,11 +60,15 @@ export function runningInDocker(): boolean {
|
||||
return runningInDocker_;
|
||||
}
|
||||
|
||||
function envParseBool(s: string): boolean {
|
||||
function envReadString(s: string, defaultValue: string = ''): string {
|
||||
return s === undefined || s === null ? defaultValue : s;
|
||||
}
|
||||
|
||||
function envReadBool(s: string): boolean {
|
||||
return s === '1';
|
||||
}
|
||||
|
||||
function envParseInt(s: string, defaultValue: number = null): number {
|
||||
function envReadInt(s: string, defaultValue: number = null): number {
|
||||
if (!s) return defaultValue === null ? 0 : defaultValue;
|
||||
const output = Number(s);
|
||||
if (isNaN(output)) throw new Error(`Invalid number: ${s}`);
|
||||
@@ -89,8 +94,8 @@ function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): Dat
|
||||
const baseConfig: DatabaseConfig = {
|
||||
client: DatabaseConfigClient.Null,
|
||||
name: '',
|
||||
slowQueryLogEnabled: envParseBool(env.SLOW_QUERY_LOG_ENABLED),
|
||||
slowQueryLogMinDuration: envParseInt(env.SLOW_QUERY_LOG_MIN_DURATION, 10000),
|
||||
slowQueryLogEnabled: envReadBool(env.SLOW_QUERY_LOG_ENABLED),
|
||||
slowQueryLogMinDuration: envReadInt(env.SLOW_QUERY_LOG_MIN_DURATION, 10000),
|
||||
};
|
||||
|
||||
if (env.DB_CLIENT === 'pg') {
|
||||
@@ -187,6 +192,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
||||
showErrorStackTraces: (env.ERROR_STACK_TRACES === undefined && envType === Env.Dev) || env.ERROR_STACK_TRACES === '1',
|
||||
apiBaseUrl,
|
||||
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
|
||||
joplinAppBaseUrl: envReadString(env.JOPLINAPP_BASE_URL, 'https://joplinapp.org'),
|
||||
signupEnabled: env.SIGNUP_ENABLED === '1',
|
||||
termsEnabled: env.TERMS_ENABLED === '1',
|
||||
accountTypesEnabled: env.ACCOUNT_TYPES_ENABLED === '1',
|
||||
|
||||
@@ -15,6 +15,14 @@ require('pg').types.setTypeParser(20, function(val: any) {
|
||||
return parseInt(val, 10);
|
||||
});
|
||||
|
||||
// Also need this to get integers for count() queries.
|
||||
// https://knexjs.org/#Builder-count
|
||||
declare module 'knex/types/result' {
|
||||
interface Registry {
|
||||
Count: number;
|
||||
}
|
||||
}
|
||||
|
||||
const logger = Logger.create('db');
|
||||
|
||||
// To prevent error "SQLITE_ERROR: too many SQL variables", SQL statements with
|
||||
|
||||
8
packages/server/src/middleware/clickJackingHandler.ts
Normal file
8
packages/server/src/middleware/clickJackingHandler.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AppContext, KoaNext } from '../utils/types';
|
||||
|
||||
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html
|
||||
ctx.response.set('Content-Security-Policy', 'frame-ancestors \'none\'');
|
||||
ctx.response.set('X-Frame-Options', 'DENY');
|
||||
return next();
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { defaultAdminEmail, defaultAdminPassword } from '../db';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import * as MarkdownIt from 'markdown-it';
|
||||
import config from '../config';
|
||||
import { NotificationKey } from '../models/NotificationModel';
|
||||
import { profileUrl } from '../utils/urlUtils';
|
||||
|
||||
@@ -29,18 +28,18 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSqliteInProdNotification(ctx: AppContext) {
|
||||
if (!ctx.joplin.owner.is_admin) return;
|
||||
// async function handleSqliteInProdNotification(ctx: AppContext) {
|
||||
// if (!ctx.joplin.owner.is_admin) return;
|
||||
|
||||
const notificationModel = ctx.joplin.models.notification();
|
||||
// const notificationModel = ctx.joplin.models.notification();
|
||||
|
||||
if (config().database.client === 'sqlite3' && ctx.joplin.env === 'prod') {
|
||||
await notificationModel.add(
|
||||
ctx.joplin.owner.id,
|
||||
NotificationKey.UsingSqliteInProd
|
||||
);
|
||||
}
|
||||
}
|
||||
// if (config().database.client === 'sqlite3' && ctx.joplin.env === 'prod') {
|
||||
// await notificationModel.add(
|
||||
// ctx.joplin.owner.id,
|
||||
// NotificationKey.UsingSqliteInProd
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
function levelClassName(level: NotificationLevel): string {
|
||||
if (level === NotificationLevel.Important) return 'is-warning';
|
||||
@@ -78,7 +77,7 @@ export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
if (!ctx.joplin.owner) return next();
|
||||
|
||||
await handleChangeAdminPasswordNotification(ctx);
|
||||
await handleSqliteInProdNotification(ctx);
|
||||
// await handleSqliteInProdNotification(ctx);
|
||||
ctx.joplin.notifications = await makeNotificationViews(ctx);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { AppContext, KoaNext } from '../utils/types';
|
||||
import { contextSessionId } from '../utils/requestUtils';
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
import { cookieSet } from '../utils/cookies';
|
||||
|
||||
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
const sessionId = contextSessionId(ctx, false);
|
||||
const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
|
||||
if (owner && !owner.enabled) throw new ErrorForbidden('This user account is disabled. Please contact support.');
|
||||
if (owner && !owner.enabled) {
|
||||
cookieSet(ctx, 'sessionId', ''); // Clear cookie, otherwise the user cannot login at all anymore
|
||||
throw new ErrorForbidden('This user account is disabled. Please contact support.');
|
||||
}
|
||||
ctx.joplin.owner = owner;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AppContext, Env } from '../utils/types';
|
||||
import { isView, View } from '../services/MustacheService';
|
||||
import config from '../config';
|
||||
import { userIp } from '../utils/requestUtils';
|
||||
import { createCsrfTag } from '../utils/csrf';
|
||||
import { getImpersonatorAdminSessionId } from '../routes/index/utils/users/impersonate';
|
||||
|
||||
export default async function(ctx: AppContext) {
|
||||
const requestStartTime = Date.now();
|
||||
@@ -13,6 +15,8 @@ export default async function(ctx: AppContext) {
|
||||
if (responseObject instanceof Response) {
|
||||
ctx.response = responseObject.response;
|
||||
} else if (isView(responseObject)) {
|
||||
const impersonatorAdminSessionId = getImpersonatorAdminSessionId(ctx);
|
||||
|
||||
const view = responseObject as View;
|
||||
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
||||
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
||||
@@ -20,6 +24,8 @@ export default async function(ctx: AppContext) {
|
||||
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
||||
owner: ctx.joplin.owner,
|
||||
supportEmail: config().supportEmail,
|
||||
impersonatorAdminSessionId,
|
||||
csrfTag: impersonatorAdminSessionId ? await createCsrfTag(ctx, false) : null,
|
||||
});
|
||||
} else {
|
||||
ctx.response.status = 200;
|
||||
|
||||
@@ -11,6 +11,8 @@ import Logger from '@joplin/lib/Logger';
|
||||
|
||||
const logger = Logger.create('BaseModel');
|
||||
|
||||
type SavePoint = string;
|
||||
|
||||
export interface SaveOptions {
|
||||
isNew?: boolean;
|
||||
skipValidation?: boolean;
|
||||
@@ -49,6 +51,7 @@ export default abstract class BaseModel<T> {
|
||||
private modelFactory_: Function;
|
||||
private static eventEmitter_: EventEmitter = null;
|
||||
private config_: Config;
|
||||
private savePoints_: SavePoint[] = [];
|
||||
|
||||
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
|
||||
this.db_ = db;
|
||||
@@ -289,6 +292,25 @@ export default abstract class BaseModel<T> {
|
||||
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
|
||||
}
|
||||
|
||||
public async setSavePoint(): Promise<SavePoint> {
|
||||
const name = `sp_${uuidgen()}`;
|
||||
await this.db.raw(`SAVEPOINT ${name}`);
|
||||
this.savePoints_.push(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
public async rollbackSavePoint(savePoint: SavePoint) {
|
||||
const last = this.savePoints_.pop();
|
||||
if (last !== savePoint) throw new Error('Rollback save point does not match');
|
||||
await this.db.raw(`ROLLBACK TO SAVEPOINT ${savePoint}`);
|
||||
}
|
||||
|
||||
public async releaseSavePoint(savePoint: SavePoint) {
|
||||
const last = this.savePoints_.pop();
|
||||
if (last !== savePoint) throw new Error('Rollback save point does not match');
|
||||
await this.db.raw(`RELEASE SAVEPOINT ${savePoint}`);
|
||||
}
|
||||
|
||||
public async exists(id: string): Promise<boolean> {
|
||||
const o = await this.load(id, { fields: ['id'] });
|
||||
return !!o;
|
||||
@@ -314,7 +336,7 @@ export default abstract class BaseModel<T> {
|
||||
}
|
||||
|
||||
const deletedCount = await query.del();
|
||||
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
|
||||
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted. ID: ${id}`);
|
||||
}, 'BaseModel::delete');
|
||||
}
|
||||
|
||||
|
||||
@@ -106,13 +106,18 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
return path.replace(extractNameRegex, '$1');
|
||||
}
|
||||
|
||||
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
|
||||
public byShareIdQuery(shareId: Uuid, options: LoadOptions = {}): 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[]> {
|
||||
const query = this.byShareIdQuery(shareId, options);
|
||||
return await query;
|
||||
}
|
||||
|
||||
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
|
||||
if (!jopIds.length) return [];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export enum ValueType {
|
||||
|
||||
type Value = number | string;
|
||||
|
||||
export default class NotificationModel extends BaseModel<KeyValue> {
|
||||
export default class KeyValueModel extends BaseModel<KeyValue> {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'key_values';
|
||||
@@ -61,4 +61,8 @@ export default class NotificationModel extends BaseModel<KeyValue> {
|
||||
await this.db(this.tableName).where('key', '=', key).delete();
|
||||
}
|
||||
|
||||
public async delete(_id: string | string[] | number | number[], _options: any = {}): Promise<void> {
|
||||
throw new Error('Call ::deleteValue()');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export enum NotificationKey {
|
||||
PasswordSet = 'passwordSet',
|
||||
EmailConfirmed = 'emailConfirmed',
|
||||
ChangeAdminPassword = 'change_admin_password',
|
||||
UsingSqliteInProd = 'using_sqlite_in_prod',
|
||||
// UsingSqliteInProd = 'using_sqlite_in_prod',
|
||||
UpgradedToPro = 'upgraded_to_pro',
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
}
|
||||
|
||||
public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise<Notification> {
|
||||
const n: Notification = await this.loadByKey(userId, key);
|
||||
if (n) return n;
|
||||
|
||||
const notificationTypes: Record<string, NotificationType> = {
|
||||
[NotificationKey.ConfirmEmail]: {
|
||||
level: NotificationLevel.Normal,
|
||||
@@ -46,10 +43,10 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
level: NotificationLevel.Normal,
|
||||
message: `Welcome to ${this.appName}! Your password has been set successfully.`,
|
||||
},
|
||||
[NotificationKey.UsingSqliteInProd]: {
|
||||
level: NotificationLevel.Important,
|
||||
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
|
||||
},
|
||||
// [NotificationKey.UsingSqliteInProd]: {
|
||||
// level: NotificationLevel.Important,
|
||||
// message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
|
||||
// },
|
||||
[NotificationKey.UpgradedToPro]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: 'Thank you! Your account has been successfully upgraded to Pro.',
|
||||
@@ -60,6 +57,9 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
},
|
||||
};
|
||||
|
||||
const n: Notification = await this.loadUnreadByKey(userId, key);
|
||||
if (n) return n;
|
||||
|
||||
const type = notificationTypes[key];
|
||||
|
||||
if (level === null) {
|
||||
@@ -101,6 +101,15 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
.first();
|
||||
}
|
||||
|
||||
public loadUnreadByKey(userId: Uuid, key: NotificationKey): Promise<Notification> {
|
||||
return this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.where('key', '=', key)
|
||||
.andWhere('read', '=', 0)
|
||||
.andWhere('owner_id', '=', userId)
|
||||
.first();
|
||||
}
|
||||
|
||||
public allUnreadByUserId(userId: Uuid): Promise<Notification[]> {
|
||||
return this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, createItemTree } from '../utils/testing/testUtils';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, createItemTree, expectNotThrow, createNote } from '../utils/testing/testUtils';
|
||||
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
|
||||
import { ShareType } from '../services/database/types';
|
||||
import { shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
|
||||
import { inviteUserToShare, shareFolderWithUser, shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
|
||||
|
||||
describe('ShareModel', function() {
|
||||
|
||||
@@ -99,5 +99,80 @@ describe('ShareModel', function() {
|
||||
expect(await models().item().load(noteItem.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should count number of items in share', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||
const { session: session2 } = await createUserAndSession(2);
|
||||
|
||||
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||
'000000000000000000000000000000F1': {
|
||||
'00000000000000000000000000000001': null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await models().share().itemCountByShareId(share.id)).toBe(2);
|
||||
|
||||
await models().item().delete((await models().item().loadByJopId(user1.id, '00000000000000000000000000000001')).id);
|
||||
await models().item().delete((await models().item().loadByJopId(user1.id, '000000000000000000000000000000F1')).id);
|
||||
|
||||
expect(await models().share().itemCountByShareId(share.id)).toBe(0);
|
||||
});
|
||||
|
||||
test('should count number of items in share per recipient', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||
const { user: user2, session: session2 } = await createUserAndSession(2);
|
||||
const { user: user3 } = await createUserAndSession(3);
|
||||
await createUserAndSession(4); // To check that he's not included in the results since the items are not shared with him
|
||||
|
||||
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||
'000000000000000000000000000000F1': {
|
||||
'00000000000000000000000000000001': null,
|
||||
},
|
||||
});
|
||||
|
||||
await inviteUserToShare(share, session1.id, user3.email);
|
||||
|
||||
const rows = await models().share().itemCountByShareIdPerUser(share.id);
|
||||
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows.find(r => r.user_id === user1.id).item_count).toBe(2);
|
||||
expect(rows.find(r => r.user_id === user2.id).item_count).toBe(2);
|
||||
expect(rows.find(r => r.user_id === user3.id).item_count).toBe(2);
|
||||
});
|
||||
|
||||
test('should create user items for shared folder', async function() {
|
||||
const { session: session1 } = await createUserAndSession(1);
|
||||
const { session: session2 } = await createUserAndSession(2);
|
||||
const { user: user3 } = await createUserAndSession(3);
|
||||
await createUserAndSession(4); // To check that he's not included in the results since the items are not shared with him
|
||||
|
||||
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||
'000000000000000000000000000000F1': {
|
||||
'00000000000000000000000000000001': null,
|
||||
},
|
||||
});
|
||||
|
||||
// When running that function with a new user, it should get all the
|
||||
// share items
|
||||
expect((await models().userItem().byUserId(user3.id)).length).toBe(0);
|
||||
await models().share().createSharedFolderUserItems(share.id, user3.id);
|
||||
expect((await models().userItem().byUserId(user3.id)).length).toBe(2);
|
||||
|
||||
// Calling the function again should not throw - it should just ignore
|
||||
// the items that have already been added.
|
||||
await expectNotThrow(async () => models().share().createSharedFolderUserItems(share.id, user3.id));
|
||||
|
||||
// After adding a new note to the share, and calling the function, it
|
||||
// should add the note to the other user collection.
|
||||
expect(await models().share().itemCountByShareId(share.id)).toBe(2);
|
||||
|
||||
await createNote(session1.id, {
|
||||
id: '00000000000000000000000000000003',
|
||||
share_id: share.id,
|
||||
});
|
||||
|
||||
expect(await models().share().itemCountByShareId(share.id)).toBe(3);
|
||||
await models().share().createSharedFolderUserItems(share.id, user3.id);
|
||||
expect(await models().share().itemCountByShareId(share.id)).toBe(3);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseMode
|
||||
import { userIdFromUserContentUrl } from '../utils/routeUtils';
|
||||
import { getCanShareFolder } from './utils/user';
|
||||
import { isUniqueConstraintError } from '../db';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
|
||||
const logger = Logger.create('ShareModel');
|
||||
|
||||
export default class ShareModel extends BaseModel<Share> {
|
||||
|
||||
@@ -215,6 +218,32 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
}
|
||||
};
|
||||
|
||||
// This function add any missing item to a user's collection. Normally
|
||||
// it shouldn't be necessary since items are added or removed based on
|
||||
// the Change events, but it seems it can happen anyway, possibly due to
|
||||
// a race condition somewhere. So this function corrects this by
|
||||
// re-assigning any missing items.
|
||||
//
|
||||
// It should be relatively quick to call since it's restricted to shares
|
||||
// that have recently changed, and the performed SQL queries are
|
||||
// index-based.
|
||||
const checkForMissingUserItems = async (shares: Share[]) => {
|
||||
for (const share of shares) {
|
||||
const realShareItemCount = await this.itemCountByShareId(share.id);
|
||||
const shareItemCountPerUser = await this.itemCountByShareIdPerUser(share.id);
|
||||
|
||||
for (const row of shareItemCountPerUser) {
|
||||
if (row.item_count < realShareItemCount) {
|
||||
logger.warn(`checkForMissingUserItems: User is missing some items: Share ${share.id}: User ${row.user_id}`);
|
||||
await this.createSharedFolderUserItems(share.id, row.user_id);
|
||||
} else if (row.item_count > realShareItemCount) {
|
||||
// Shouldn't be possible but log it just in case
|
||||
logger.warn(`checkForMissingUserItems: User has too many items (??): Share ${share.id}: User ${row.user_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// This loop essentially applies the change made by one user to all the
|
||||
// other users in the share.
|
||||
//
|
||||
@@ -260,6 +289,8 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
// too.
|
||||
}
|
||||
|
||||
await checkForMissingUserItems(shares);
|
||||
|
||||
await this.models().keyValue().setValue('ShareService::latestProcessedChange', paginatedChanges.cursor);
|
||||
}, 'ShareService::updateSharedItems3');
|
||||
}
|
||||
@@ -304,18 +335,13 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
}
|
||||
}
|
||||
|
||||
// That should probably only be called when a user accepts the share
|
||||
// invitation. At this point, we want to share all the items immediately.
|
||||
// Afterwards, items that are added or removed are processed by the share
|
||||
// service.
|
||||
// The items that are added or removed from a share are processed by the
|
||||
// share service, and added as user_utems to each user. This function
|
||||
// however can be called after a user accept a share, or to correct share
|
||||
// errors, but re-assigning all items to a user.
|
||||
public async createSharedFolderUserItems(shareId: Uuid, userId: Uuid) {
|
||||
const items = await this.models().item().byShareId(shareId, { fields: ['id'] });
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
for (const item of items) {
|
||||
await this.models().userItem().add(userId, item.id);
|
||||
}
|
||||
}, 'ShareModel::createSharedFolderUserItems');
|
||||
const query = this.models().item().byShareIdQuery(shareId, { fields: ['id', 'name'] });
|
||||
await this.models().userItem().addMulti(userId, query);
|
||||
}
|
||||
|
||||
public async shareFolder(owner: User, folderId: string): Promise<Share> {
|
||||
@@ -368,4 +394,23 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
}, 'ShareModel::delete');
|
||||
}
|
||||
|
||||
public async itemCountByShareId(shareId: Uuid): Promise<number> {
|
||||
const r = await this
|
||||
.db('items')
|
||||
.count('id', { as: 'item_count' })
|
||||
.where('jop_share_id', '=', shareId);
|
||||
return r[0].item_count;
|
||||
}
|
||||
|
||||
public async itemCountByShareIdPerUser(shareId: Uuid): Promise<{ item_count: number; user_id: Uuid }[]> {
|
||||
return this.db('user_items')
|
||||
.select(this.db.raw('user_id, count(user_id) as item_count'))
|
||||
.whereIn('item_id',
|
||||
this.db('items')
|
||||
.select('id')
|
||||
.where('jop_share_id', '=', shareId)
|
||||
).groupBy('user_id') as any;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
await this.delete(shareUsers.map(s => s.id));
|
||||
}, 'ShareUserModel::delete');
|
||||
}, 'ShareUserModel::deleteByShare');
|
||||
}
|
||||
|
||||
public async delete(id: string | string[], _options: DeleteOptions = {}): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Knex } from 'knex';
|
||||
import { EmailSender, Subscription, User, UserFlagType, Uuid } from '../services/database/types';
|
||||
import { ErrorNotFound } from '../utils/errors';
|
||||
import { Day } from '../utils/time';
|
||||
@@ -6,8 +7,8 @@ import paymentFailedTemplate from '../views/emails/paymentFailedTemplate';
|
||||
import BaseModel from './BaseModel';
|
||||
import { AccountType } from './UserModel';
|
||||
|
||||
export const failedPaymentDisableUploadInterval = 7 * Day;
|
||||
export const failedPaymentDisableAccount = 14 * Day;
|
||||
export const failedPaymentWarningInterval = 7 * Day;
|
||||
export const failedPaymentFinalAccount = 14 * Day;
|
||||
|
||||
interface UserAndSubscription {
|
||||
user: User;
|
||||
@@ -48,24 +49,23 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
|
||||
};
|
||||
}
|
||||
|
||||
public async shouldDisableUploadSubscriptions(): Promise<Subscription[]> {
|
||||
const cutOffTime = Date.now() - failedPaymentDisableUploadInterval;
|
||||
|
||||
return this.db('users')
|
||||
private failedPaymentSubscriptionsBaseQuery(cutOffTime: number): Knex.QueryBuilder {
|
||||
const query = this.db('users')
|
||||
.leftJoin('subscriptions', 'users.id', 'subscriptions.user_id')
|
||||
.select('subscriptions.id', 'subscriptions.user_id', 'last_payment_failed_time')
|
||||
.where('users.can_upload', '=', 1)
|
||||
.andWhere('last_payment_failed_time', '>', this.db.ref('last_payment_time'))
|
||||
.andWhere('subscriptions.is_deleted', '=', 0)
|
||||
.andWhere('last_payment_failed_time', '<', cutOffTime);
|
||||
.where('last_payment_failed_time', '>', this.db.ref('last_payment_time'))
|
||||
.where('subscriptions.is_deleted', '=', 0)
|
||||
.where('last_payment_failed_time', '<', cutOffTime)
|
||||
.where('users.enabled', '=', 1);
|
||||
return query;
|
||||
}
|
||||
|
||||
public async shouldDisableAccountSubscriptions(): Promise<Subscription[]> {
|
||||
const cutOffTime = Date.now() - failedPaymentDisableAccount;
|
||||
public async failedPaymentWarningSubscriptions(): Promise<Subscription[]> {
|
||||
return this.failedPaymentSubscriptionsBaseQuery(Date.now() - failedPaymentWarningInterval);
|
||||
}
|
||||
|
||||
return this.db(this.tableName)
|
||||
.where('last_payment_failed_time', '>', 'last_payment_time')
|
||||
.andWhere('last_payment_failed_time', '<', cutOffTime);
|
||||
public async failedPaymentFinalSubscriptions(): Promise<Subscription[]> {
|
||||
return this.failedPaymentSubscriptionsBaseQuery(Date.now() - failedPaymentFinalAccount);
|
||||
}
|
||||
|
||||
public async handlePayment(stripeSubscriptionId: string, success: boolean) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChangeType, ItemType, UserItem, Uuid } from '../services/database/types';
|
||||
import { ChangeType, Item, ItemType, UserItem, Uuid } from '../services/database/types';
|
||||
import BaseModel, { DeleteOptions, LoadOptions, SaveOptions } from './BaseModel';
|
||||
import { unique } from '../utils/array';
|
||||
import { ErrorNotFound } from '../utils/errors';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
interface DeleteByShare {
|
||||
id: Uuid;
|
||||
@@ -123,25 +124,38 @@ export default class UserItemModel extends BaseModel<UserItem> {
|
||||
await this.deleteBy({ byShareId: shareId, byUserId: userId });
|
||||
}
|
||||
|
||||
public async addMulti(userId: Uuid, itemsQuery: Knex.QueryBuilder | Item[], options: SaveOptions = {}): Promise<void> {
|
||||
const items: Item[] = Array.isArray(itemsQuery) ? itemsQuery : await itemsQuery.whereNotIn('id', this.db('user_items').select('item_id').where('user_id', '=', userId));
|
||||
if (!items.length) return;
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
for (const item of items) {
|
||||
if (!('name' in item) || !('id' in item)) throw new Error('item.id and item.name must be set');
|
||||
|
||||
await super.save({
|
||||
user_id: userId,
|
||||
item_id: item.id,
|
||||
}, options);
|
||||
|
||||
if (this.models().item().shouldRecordChange(item.name)) {
|
||||
await this.models().change().save({
|
||||
item_type: ItemType.UserItem,
|
||||
item_id: item.id,
|
||||
item_name: item.name,
|
||||
type: ChangeType.Create,
|
||||
previous_item: '',
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 'UserItemModel::addMulti');
|
||||
}
|
||||
|
||||
public async save(userItem: UserItem, options: SaveOptions = {}): Promise<UserItem> {
|
||||
if (userItem.id) throw new Error('User items cannot be modified (only created or deleted)'); // Sanity check - shouldn't happen
|
||||
|
||||
const item = await this.models().item().load(userItem.item_id, { fields: ['id', 'name'] });
|
||||
|
||||
return this.withTransaction(async () => {
|
||||
if (this.models().item().shouldRecordChange(item.name)) {
|
||||
await this.models().change().save({
|
||||
item_type: ItemType.UserItem,
|
||||
item_id: userItem.item_id,
|
||||
item_name: item.name,
|
||||
type: ChangeType.Create,
|
||||
previous_item: '',
|
||||
user_id: userItem.user_id,
|
||||
});
|
||||
}
|
||||
|
||||
return super.save(userItem, options);
|
||||
}, 'UserItemModel::save');
|
||||
await this.addMulti(userItem.user_id, [item], options);
|
||||
return this.byUserAndItemId(userItem.user_id, item.id);
|
||||
}
|
||||
|
||||
public async delete(_id: string | string[], _options: DeleteOptions = {}): Promise<void> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EmailSender, User, UserFlagType } from '../services/database/types';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
import { accountByType, AccountType } from './UserModel';
|
||||
import { failedPaymentDisableUploadInterval } from './SubscriptionModel';
|
||||
import { failedPaymentFinalAccount, failedPaymentWarningInterval } from './SubscriptionModel';
|
||||
import { stripePortalUrl } from '../utils/urlUtils';
|
||||
|
||||
describe('UserModel', function() {
|
||||
@@ -163,9 +163,10 @@ describe('UserModel', function() {
|
||||
|
||||
const userFlag = await models().userFlag().byUserId(user1.id, UserFlagType.AccountWithoutSubscription);
|
||||
expect(userFlag).toBeTruthy();
|
||||
stripeConfig().enabled = false;
|
||||
});
|
||||
|
||||
test('should disable upload and send an email if payment failed', async function() {
|
||||
test('should disable upload and send an email if payment failed recently', async function() {
|
||||
stripeConfig().enabled = true;
|
||||
|
||||
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
|
||||
@@ -174,10 +175,10 @@ describe('UserModel', function() {
|
||||
const sub = await models().subscription().byUserId(user1.id);
|
||||
|
||||
const now = Date.now();
|
||||
const paymentFailedTime = now - failedPaymentDisableUploadInterval - 10;
|
||||
const paymentFailedTime = now - failedPaymentWarningInterval - 10;
|
||||
await models().subscription().save({
|
||||
id: sub.id,
|
||||
last_payment_time: now - failedPaymentDisableUploadInterval * 2,
|
||||
last_payment_time: now - failedPaymentWarningInterval * 2,
|
||||
last_payment_failed_time: paymentFailedTime,
|
||||
});
|
||||
|
||||
@@ -190,6 +191,7 @@ describe('UserModel', function() {
|
||||
const email = (await models().email().all()).pop();
|
||||
expect(email.key).toBe(`payment_failed_upload_disabled_${paymentFailedTime}`);
|
||||
expect(email.body).toContain(stripePortalUrl());
|
||||
expect(email.body).toContain('14 days');
|
||||
}
|
||||
|
||||
const beforeEmailCount = (await models().email().all()).length;
|
||||
@@ -201,6 +203,37 @@ describe('UserModel', function() {
|
||||
const user2 = await models().user().loadByEmail('tutu@example.com');
|
||||
expect(user2.can_upload).toBe(1);
|
||||
}
|
||||
|
||||
stripeConfig().enabled = false;
|
||||
});
|
||||
|
||||
test('should disable disable the account and send an email if payment failed for good', async function() {
|
||||
stripeConfig().enabled = true;
|
||||
|
||||
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
|
||||
|
||||
const sub = await models().subscription().byUserId(user1.id);
|
||||
|
||||
const now = Date.now();
|
||||
const paymentFailedTime = now - failedPaymentFinalAccount - 10;
|
||||
await models().subscription().save({
|
||||
id: sub.id,
|
||||
last_payment_time: now - failedPaymentFinalAccount * 2,
|
||||
last_payment_failed_time: paymentFailedTime,
|
||||
});
|
||||
|
||||
await models().user().handleFailedPaymentSubscriptions();
|
||||
|
||||
{
|
||||
const user1 = await models().user().loadByEmail('toto@example.com');
|
||||
expect(user1.enabled).toBe(0);
|
||||
|
||||
const email = (await models().email().all()).pop();
|
||||
expect(email.key).toBe(`payment_failed_account_disabled_${paymentFailedTime}`);
|
||||
expect(email.body).toContain(stripePortalUrl());
|
||||
}
|
||||
|
||||
stripeConfig().enabled = false;
|
||||
});
|
||||
|
||||
test('should send emails when the account is over the size limit', async function() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
|
||||
import { EmailSender, Item, User, UserFlagType, Uuid } from '../services/database/types';
|
||||
import { EmailSender, Item, NotificationLevel, Subscription, User, UserFlagType, Uuid } from '../services/database/types';
|
||||
import * as auth from '../utils/auth';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
@@ -19,6 +19,12 @@ import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUp
|
||||
import oversizedAccount1 from '../views/emails/oversizedAccount1';
|
||||
import oversizedAccount2 from '../views/emails/oversizedAccount2';
|
||||
import dayjs = require('dayjs');
|
||||
import { failedPaymentFinalAccount } from './SubscriptionModel';
|
||||
import { Day } from '../utils/time';
|
||||
import paymentFailedAccountDisabledTemplate from '../views/emails/paymentFailedAccountDisabledTemplate';
|
||||
import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirmationTemplate';
|
||||
import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate';
|
||||
import { NotificationKey } from './NotificationModel';
|
||||
|
||||
const logger = Logger.create('UserModel');
|
||||
|
||||
@@ -159,6 +165,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
|
||||
const canBeChangedByNonAdmin = [
|
||||
'full_name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
@@ -267,11 +274,57 @@ export default class UserModel extends BaseModel<User> {
|
||||
}, 'UserModel::delete');
|
||||
}
|
||||
|
||||
public async confirmEmail(userId: Uuid, token: string) {
|
||||
private async confirmEmail(user: User) {
|
||||
await this.save({ id: user.id, email_confirmed: 1 });
|
||||
}
|
||||
|
||||
public async processEmailConfirmation(userId: Uuid, token: string, beforeChangingEmailHandler: Function) {
|
||||
await this.models().token().checkToken(userId, token);
|
||||
const user = await this.models().user().load(userId);
|
||||
if (!user) throw new ErrorNotFound('No such user');
|
||||
await this.save({ id: user.id, email_confirmed: 1 });
|
||||
|
||||
const newEmail = await this.models().keyValue().value(`newEmail::${userId}`);
|
||||
if (newEmail) {
|
||||
await beforeChangingEmailHandler(newEmail);
|
||||
await this.completeEmailChange(user);
|
||||
} else {
|
||||
await this.confirmEmail(user);
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateEmailChange(userId: Uuid, newEmail: string) {
|
||||
const beforeSaveUser = await this.models().user().load(userId);
|
||||
|
||||
await this.models().notification().add(userId, NotificationKey.Any, NotificationLevel.Important, 'A confirmation email has been sent to your new address. Please follow the link in that email to confirm. Your email will only be updated after that.');
|
||||
|
||||
await this.models().keyValue().setValue(`newEmail::${userId}`, newEmail);
|
||||
|
||||
await this.models().user().sendChangeEmailConfirmationEmail(newEmail, beforeSaveUser);
|
||||
await this.models().user().sendChangeEmailNotificationEmail(beforeSaveUser.email, beforeSaveUser);
|
||||
}
|
||||
|
||||
public async completeEmailChange(user: User) {
|
||||
const newEmailKey = `newEmail::${user.id}`;
|
||||
const newEmail = await this.models().keyValue().value<string>(newEmailKey);
|
||||
|
||||
const oldEmail = user.email;
|
||||
|
||||
const userToSave: User = {
|
||||
id: user.id,
|
||||
email_confirmed: 1,
|
||||
email: newEmail,
|
||||
};
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
if (newEmail) {
|
||||
// We keep the old email just in case. Probably yagni but it's easy enough to do.
|
||||
await this.models().keyValue().setValue(`oldEmail::${user.id}_${Date.now()}`, oldEmail);
|
||||
await this.models().keyValue().deleteValue(newEmailKey);
|
||||
}
|
||||
await this.save(userToSave);
|
||||
}, 'UserModel::confirmEmail');
|
||||
|
||||
logger.info(`Changed email of user ${user.id} from "${oldEmail}" to "${newEmail}"`);
|
||||
}
|
||||
|
||||
private userEmailDetails(user: User): UserEmailDetails {
|
||||
@@ -293,6 +346,24 @@ export default class UserModel extends BaseModel<User> {
|
||||
});
|
||||
}
|
||||
|
||||
public async sendChangeEmailConfirmationEmail(recipientEmail: string, user: User) {
|
||||
const validationToken = await this.models().token().generate(user.id);
|
||||
const url = encodeURI(confirmUrl(user.id, validationToken));
|
||||
|
||||
await this.models().email().push({
|
||||
...changeEmailConfirmationTemplate({ url }),
|
||||
...this.userEmailDetails(user),
|
||||
recipient_email: recipientEmail,
|
||||
});
|
||||
}
|
||||
public async sendChangeEmailNotificationEmail(recipientEmail: string, user: User) {
|
||||
await this.models().email().push({
|
||||
...changeEmailNotificationTemplate(),
|
||||
...this.userEmailDetails(user),
|
||||
recipient_email: recipientEmail,
|
||||
});
|
||||
}
|
||||
|
||||
public async sendResetPasswordEmail(email: string) {
|
||||
const user = await this.loadByEmail(email);
|
||||
if (!user) throw new ErrorNotFound(`No such user: ${email}`);
|
||||
@@ -356,24 +427,54 @@ export default class UserModel extends BaseModel<User> {
|
||||
}
|
||||
|
||||
public async handleFailedPaymentSubscriptions() {
|
||||
const subscriptions = await this.models().subscription().shouldDisableUploadSubscriptions();
|
||||
const users = await this.loadByIds(subscriptions.map(s => s.user_id));
|
||||
interface SubInfo {
|
||||
subs: Subscription[];
|
||||
templateFn: Function;
|
||||
emailKeyPrefix: string;
|
||||
flagType: UserFlagType;
|
||||
}
|
||||
|
||||
const subInfos: SubInfo[] = [
|
||||
{
|
||||
subs: await this.models().subscription().failedPaymentWarningSubscriptions(),
|
||||
emailKeyPrefix: 'payment_failed_upload_disabled_',
|
||||
flagType: UserFlagType.FailedPaymentWarning,
|
||||
templateFn: () => paymentFailedUploadDisabledTemplate({ disabledInDays: Math.round(failedPaymentFinalAccount / Day) }),
|
||||
},
|
||||
{
|
||||
subs: await this.models().subscription().failedPaymentFinalSubscriptions(),
|
||||
emailKeyPrefix: 'payment_failed_account_disabled_',
|
||||
flagType: UserFlagType.FailedPaymentFinal,
|
||||
templateFn: () => paymentFailedAccountDisabledTemplate(),
|
||||
},
|
||||
];
|
||||
|
||||
let users: User[] = [];
|
||||
for (const subInfo of subInfos) {
|
||||
users = users.concat(await this.loadByIds(subInfo.subs.map(s => s.user_id)));
|
||||
}
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
for (const sub of subscriptions) {
|
||||
const user = users.find(u => u.id === sub.user_id);
|
||||
if (!user) {
|
||||
logger.error(`Could not find user for subscription ${sub.id}`);
|
||||
continue;
|
||||
for (const subInfo of subInfos) {
|
||||
for (const sub of subInfo.subs) {
|
||||
const user = users.find(u => u.id === sub.user_id);
|
||||
if (!user) {
|
||||
logger.error(`Could not find user for subscription ${sub.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingFlag = await this.models().userFlag().byUserId(user.id, subInfo.flagType);
|
||||
|
||||
if (!existingFlag) {
|
||||
await this.models().userFlag().add(user.id, subInfo.flagType);
|
||||
|
||||
await this.models().email().push({
|
||||
...subInfo.templateFn(),
|
||||
...this.userEmailDetails(user),
|
||||
key: `${subInfo.emailKeyPrefix}${sub.last_payment_failed_time}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.models().userFlag().add(user.id, UserFlagType.FailedPaymentWarning);
|
||||
|
||||
await this.models().email().push({
|
||||
...paymentFailedUploadDisabledTemplate(),
|
||||
...this.userEmailDetails(user),
|
||||
key: `payment_failed_upload_disabled_${sub.last_payment_failed_time}`,
|
||||
});
|
||||
}
|
||||
}, 'UserModel::handleFailedPaymentSubscriptions');
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function makeView(error: any = null): View {
|
||||
const view = defaultView('login', 'Login');
|
||||
view.content = {
|
||||
error,
|
||||
signupUrl: config().signupEnabled ? makeUrl(UrlType.Signup) : '',
|
||||
signupUrl: config().signupEnabled || config().isJoplinCloud ? makeUrl(UrlType.Signup) : '',
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export const postHandlers: PostHandlers = {
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
|
||||
// the actual Session ID is returned in the query parameter when your customer
|
||||
// is redirected to the success page.
|
||||
@@ -407,6 +408,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
|
||||
|
||||
checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
|
||||
const basicPrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly });
|
||||
const proPrice = findPrice(stripeConfig().prices, { accountType: 2, period: PricePeriod.Monthly });
|
||||
|
||||
return `
|
||||
<head>
|
||||
@@ -432,30 +434,36 @@ const getHandlers: Record<string, StripeRouteHandler> = {
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<button id="checkout">Subscribe</button>
|
||||
<button id="checkout_basic">Subscribe Basic</button>
|
||||
<button id="checkout_pro">Subscribe Pro</button>
|
||||
<script>
|
||||
var PRICE_ID = ${JSON.stringify(basicPrice.id)};
|
||||
var BASIC_PRICE_ID = ${JSON.stringify(basicPrice.id)};
|
||||
var PRO_PRICE_ID = ${JSON.stringify(proPrice.id)};
|
||||
|
||||
function handleResult() {
|
||||
console.info('Redirected to checkout');
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("checkout")
|
||||
.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
// You'll have to define PRICE_ID as a price ID before this code block
|
||||
createCheckoutSession(PRICE_ID).then(function(data) {
|
||||
// Call Stripe.js method to redirect to the new Checkout page
|
||||
stripe
|
||||
.redirectToCheckout({
|
||||
sessionId: data.sessionId
|
||||
})
|
||||
.then(handleResult);
|
||||
});
|
||||
function createSessionAndRedirect(priceId) {
|
||||
createCheckoutSession(priceId).then(function(data) {
|
||||
// Call Stripe.js method to redirect to the new Checkout page
|
||||
stripe
|
||||
.redirectToCheckout({
|
||||
sessionId: data.sessionId
|
||||
})
|
||||
.then(handleResult);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
document.getElementById("checkout_basic").addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
createSessionAndRedirect(BASIC_PRICE_ID);
|
||||
});
|
||||
|
||||
document.getElementById("checkout_pro").addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
createSessionAndRedirect(PRO_PRICE_ID);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { cookieGet } from '../../utils/cookies';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
|
||||
export async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
@@ -30,12 +30,12 @@ export async function postUser(sessionId: string, email: string, password: strin
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
export async function patchUser(sessionId: string, user: any): Promise<User> {
|
||||
export async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/users',
|
||||
url: url ? url : '/users',
|
||||
body: {
|
||||
...user,
|
||||
post_button: true,
|
||||
@@ -295,9 +295,9 @@ describe('index/users', function() {
|
||||
});
|
||||
|
||||
const email = (await models().email().all()).find(e => e.recipient_id === user1.id);
|
||||
const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
|
||||
const path = matches[1];
|
||||
const token = matches[3];
|
||||
const [, path, , token] = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
|
||||
// const path = matches[1];
|
||||
// const token = matches[3];
|
||||
|
||||
const context = await execRequestC('', 'GET', path, null, { query: { token } });
|
||||
|
||||
@@ -318,6 +318,33 @@ describe('index/users', function() {
|
||||
expect(notification.key).toBe(NotificationKey.EmailConfirmed);
|
||||
});
|
||||
|
||||
test('should allow changing an email', async function() {
|
||||
const { user, session } = await createUserAndSession();
|
||||
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
}, '/users/me');
|
||||
|
||||
// It's not immediately changed
|
||||
expect((await models().user().load(user.id)).email).toBe('user1@localhost');
|
||||
|
||||
// Grab the confirmation URL
|
||||
const email = (await models().email().all()).find(e => e.recipient_id === user.id);
|
||||
const [, path, , token] = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
|
||||
|
||||
await execRequest('', 'GET', path, null, { query: { token } });
|
||||
|
||||
// Now that it's confirmed, it should have been changed
|
||||
expect((await models().user().load(user.id)).email).toBe('changed@example.com');
|
||||
|
||||
const keys = await models().keyValue().all();
|
||||
expect(keys.length).toBe(1);
|
||||
expect(keys[0].value).toBe('user1@localhost'); // The old email has been saved
|
||||
|
||||
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
|
||||
});
|
||||
|
||||
test('should apply ACL', async function() {
|
||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||
@@ -356,7 +383,7 @@ describe('index/users', function() {
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share_folder: 1 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change non-whitelisted properties
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, email: 'candothat@example.com' }), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_upload: 0 }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SubPath, redirect } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { bodyFields, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, UserFlagType, userFlagTypeToLabel, Uuid } from '../../services/database/types';
|
||||
import config from '../../config';
|
||||
@@ -16,10 +16,11 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
|
||||
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { cancelSubscriptionByUserId, updateCustomerEmail, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { cookieSet } from '../../utils/cookies';
|
||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
@@ -62,6 +63,7 @@ function makeUser(isNew: boolean, fields: any): User {
|
||||
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
|
||||
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
|
||||
if ('account_type' in fields) user.account_type = Number(fields.account_type);
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
|
||||
const password = checkRepeatPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
@@ -107,6 +109,7 @@ router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
||||
view.content.users = users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
displayName: user.full_name ? user.full_name : '(not set)',
|
||||
formattedItemMaxSize: formatMaxItemSize(user),
|
||||
formattedTotalSize: formatTotalSize(user),
|
||||
formattedMaxTotalSize: formatMaxTotalSize(user),
|
||||
@@ -114,6 +117,7 @@ router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
||||
totalSizeClass: totalSizeClass(user),
|
||||
formattedAccountType: accountTypeToString(user.account_type),
|
||||
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
|
||||
rowClassName: user.enabled ? '' : 'is-disabled',
|
||||
};
|
||||
});
|
||||
return view;
|
||||
@@ -162,16 +166,16 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||
|
||||
view.content.subscription = subscription;
|
||||
view.content.showCancelSubscription = !isNew;
|
||||
view.content.showManageSubscription = !isNew;
|
||||
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
||||
}
|
||||
|
||||
view.content.showImpersonateButton = !isNew && !!owner.is_admin && user.enabled && user.id !== owner.id;
|
||||
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
|
||||
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
|
||||
view.content.canSetEmail = isNew || owner.is_admin;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
|
||||
view.content.userFlags = userFlags;
|
||||
@@ -194,11 +198,30 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
router.publicSchemas.push('users/:id/confirm');
|
||||
|
||||
router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Error = null) => {
|
||||
const models = ctx.joplin.models;
|
||||
const userId = path.id;
|
||||
const token = ctx.query.token;
|
||||
if (token) await ctx.joplin.models.user().confirmEmail(userId, token);
|
||||
|
||||
const user = await ctx.joplin.models.user().load(userId);
|
||||
if (token) {
|
||||
const beforeChangingEmailHandler = async (newEmail: string) => {
|
||||
if (config().stripe.enabled) {
|
||||
try {
|
||||
await updateCustomerEmail(models, userId, newEmail);
|
||||
} catch (error) {
|
||||
if (['no_sub', 'no_stripe_sub'].includes(error.code)) {
|
||||
// ok - the user just doesn't have a subscription
|
||||
} else {
|
||||
error.message = `Your Stripe subscription email could not be updated. As a result your account email has not been changed. Please try again or contact support. Error was: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
|
||||
}
|
||||
|
||||
const user = await models.user().load(userId);
|
||||
|
||||
if (user.must_set_password) {
|
||||
const view: View = {
|
||||
@@ -216,8 +239,8 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
|
||||
|
||||
return view;
|
||||
} else {
|
||||
await ctx.joplin.models.token().deleteByValue(userId, token);
|
||||
await ctx.joplin.models.notification().add(userId, NotificationKey.EmailConfirmed);
|
||||
await models.token().deleteByValue(userId, token);
|
||||
await models.notification().add(userId, NotificationKey.EmailConfirmed);
|
||||
|
||||
if (ctx.joplin.owner) {
|
||||
return redirect(ctx, `${config().baseUrl}/home`);
|
||||
@@ -265,15 +288,18 @@ interface FormFields {
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_reset_password_email: string;
|
||||
send_account_confirmation_email: string;
|
||||
update_subscription_basic_button: string;
|
||||
update_subscription_pro_button: string;
|
||||
user_cancel_subscription_button: string;
|
||||
// user_cancel_subscription_button: string;
|
||||
impersonate_button: string;
|
||||
stop_impersonate_button: string;
|
||||
}
|
||||
|
||||
router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
let user: User = {};
|
||||
const userId = userIsMe(path) ? ctx.joplin.owner.id : path.id;
|
||||
const owner = ctx.joplin.owner;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
@@ -286,43 +312,52 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
await models.user().save(userToSave);
|
||||
} else {
|
||||
if (userToSave.email && !owner.is_admin) {
|
||||
await models.user().initiateEmailChange(userId, userToSave.email);
|
||||
delete userToSave.email;
|
||||
}
|
||||
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
}
|
||||
} else if (fields.user_cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
const sessionId = contextSessionId(ctx, false);
|
||||
if (sessionId) {
|
||||
await models.session().logout(sessionId);
|
||||
// } else if (fields.user_cancel_subscription_button) {
|
||||
// await cancelSubscriptionByUserId(models, userId);
|
||||
// const sessionId = contextSessionId(ctx, false);
|
||||
// if (sessionId) {
|
||||
// await models.session().logout(sessionId);
|
||||
// return redirect(ctx, config().baseUrl);
|
||||
// }
|
||||
} else if (fields.stop_impersonate_button) {
|
||||
await stopImpersonating(ctx);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (owner.is_admin) {
|
||||
if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_account_confirmation_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.impersonate_button) {
|
||||
await startImpersonating(ctx, userId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
}
|
||||
} else {
|
||||
if (ctx.joplin.owner.is_admin) {
|
||||
if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_reset_password_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : ''}`);
|
||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
|
||||
} catch (error) {
|
||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||
if (error instanceof ErrorForbidden) throw error;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterAllTests, beforeAllDb, beforeEachDb, createUserAndSession, expectThrow, koaAppContext, models } from '../../../../utils/testing/testUtils';
|
||||
import { cookieGet, cookieSet } from '../../../../utils/cookies';
|
||||
import { startImpersonating, stopImpersonating } from './impersonate';
|
||||
|
||||
describe('users/impersonate', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('users/impersonate');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should impersonate a user', async function() {
|
||||
const ctx = await koaAppContext();
|
||||
|
||||
const { user: adminUser, session: adminSession } = await createUserAndSession(1, true);
|
||||
const { user } = await createUserAndSession(2);
|
||||
|
||||
cookieSet(ctx, 'sessionId', adminSession.id);
|
||||
|
||||
await startImpersonating(ctx, user.id);
|
||||
|
||||
{
|
||||
expect(cookieGet(ctx, 'adminSessionId')).toBe(adminSession.id);
|
||||
const sessionUser = await models().session().sessionUser(cookieGet(ctx, 'sessionId'));
|
||||
expect(sessionUser.id).toBe(user.id);
|
||||
}
|
||||
|
||||
await stopImpersonating(ctx);
|
||||
|
||||
{
|
||||
expect(cookieGet(ctx, 'adminSessionId')).toBeFalsy();
|
||||
const sessionUser = await models().session().sessionUser(cookieGet(ctx, 'sessionId'));
|
||||
expect(sessionUser.id).toBe(adminUser.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not impersonate if not admin', async function() {
|
||||
const ctx = await koaAppContext();
|
||||
|
||||
const { user: adminUser } = await createUserAndSession(1, true);
|
||||
const { session } = await createUserAndSession(2);
|
||||
|
||||
cookieSet(ctx, 'sessionId', session.id);
|
||||
|
||||
await expectThrow(async () => startImpersonating(ctx, adminUser.id));
|
||||
});
|
||||
|
||||
// test('should not stop impersonating if not admin', async function() {
|
||||
// const ctx = await koaAppContext();
|
||||
|
||||
// await createUserAndSession(1, true);
|
||||
// const { session } = await createUserAndSession(2);
|
||||
|
||||
// cookieSet(ctx, 'adminSessionId', session.id);
|
||||
|
||||
// await expectThrow(async () => stopImpersonating(ctx));
|
||||
// });
|
||||
|
||||
});
|
||||
33
packages/server/src/routes/index/utils/users/impersonate.ts
Normal file
33
packages/server/src/routes/index/utils/users/impersonate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Uuid } from '../../../../services/database/types';
|
||||
import { cookieDelete, cookieGet, cookieSet } from '../../../../utils/cookies';
|
||||
import { ErrorForbidden } from '../../../../utils/errors';
|
||||
import { contextSessionId } from '../../../../utils/requestUtils';
|
||||
import { AppContext } from '../../../../utils/types';
|
||||
|
||||
export function getImpersonatorAdminSessionId(ctx: AppContext): string {
|
||||
return cookieGet(ctx, 'adminSessionId');
|
||||
}
|
||||
|
||||
export async function startImpersonating(ctx: AppContext, userId: Uuid) {
|
||||
const adminSessionId = contextSessionId(ctx);
|
||||
const user = await ctx.joplin.models.session().sessionUser(adminSessionId);
|
||||
if (!user) throw new Error(`No user for session: ${adminSessionId}`);
|
||||
if (!user.is_admin) throw new ErrorForbidden('Impersonator must be an admin');
|
||||
|
||||
const impersonatedSession = await ctx.joplin.models.session().createUserSession(userId);
|
||||
cookieSet(ctx, 'adminSessionId', adminSessionId);
|
||||
cookieSet(ctx, 'sessionId', impersonatedSession.id);
|
||||
}
|
||||
|
||||
export async function stopImpersonating(ctx: AppContext) {
|
||||
const adminSessionId = cookieGet(ctx, 'adminSessionId');
|
||||
if (!adminSessionId) throw new Error('Missing cookie adminSessionId');
|
||||
|
||||
// This function simply moves the adminSessionId back to sessionId. There's
|
||||
// no need to check if anything is valid because that will be done by other
|
||||
// session checking routines. We also don't want this function to fail
|
||||
// because it would leave the cookies in an invalid state (for example if
|
||||
// the admin has lost their sessions, or the user no longer exists).
|
||||
cookieDelete(ctx, 'adminSessionId');
|
||||
cookieSet(ctx, 'sessionId', adminSessionId);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface View {
|
||||
|
||||
interface GlobalParams {
|
||||
baseUrl?: string;
|
||||
joplinAppBaseUrl?: string;
|
||||
prefersDarkEnabled?: boolean;
|
||||
notifications?: NotificationView[];
|
||||
hasNotifications?: boolean;
|
||||
@@ -42,6 +43,8 @@ interface GlobalParams {
|
||||
userDisplayName?: string;
|
||||
supportEmail?: string;
|
||||
isJoplinCloud?: boolean;
|
||||
impersonatorAdminSessionId?: string;
|
||||
csrfTag?: string;
|
||||
}
|
||||
|
||||
export function isView(o: any): boolean {
|
||||
@@ -92,6 +95,7 @@ export default class MustacheService {
|
||||
private get defaultLayoutOptions(): GlobalParams {
|
||||
return {
|
||||
baseUrl: config().baseUrl,
|
||||
joplinAppBaseUrl: config().joplinAppBaseUrl,
|
||||
prefersDarkEnabled: this.prefersDarkEnabled_,
|
||||
appVersion: config().appVersion,
|
||||
appName: config().appName,
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('TaskService', function() {
|
||||
clearInterval(iid);
|
||||
taskHasRan = true;
|
||||
}
|
||||
}, 1);
|
||||
}, 10);
|
||||
},
|
||||
schedule: '',
|
||||
};
|
||||
@@ -69,14 +69,13 @@ describe('TaskService', function() {
|
||||
expect(service.taskState('test').lastCompletionTime).toBeFalsy();
|
||||
expect(service.taskState('test').lastRunTime.getTime()).toBeGreaterThanOrEqual(startTime.getTime());
|
||||
|
||||
await msleep(1);
|
||||
await msleep(10);
|
||||
finishTask = true;
|
||||
await msleep(3);
|
||||
await msleep(10);
|
||||
|
||||
expect(taskHasRan).toBe(true);
|
||||
expect(service.taskState('test').running).toBe(false);
|
||||
expect(service.taskState('test').lastCompletionTime.getTime()).toBeGreaterThan(startTime.getTime());
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -63,8 +63,6 @@ export default class TaskService extends BaseService {
|
||||
return this.taskStates_[id];
|
||||
}
|
||||
|
||||
// TODO: add tests
|
||||
|
||||
public async runTask(id: TaskId, runType: RunType) {
|
||||
const state = this.taskState(id);
|
||||
if (state.running) throw new Error(`Task is already running: ${id}`);
|
||||
|
||||
@@ -15,3 +15,7 @@ export function cookieSet(ctx: AppContext, name: string, value: string) {
|
||||
export function cookieGet(ctx: AppContext, name: string) {
|
||||
return ctx.cookies.get(name);
|
||||
}
|
||||
|
||||
export function cookieDelete(ctx: AppContext, name: string) {
|
||||
return cookieSet(ctx, name, '');
|
||||
}
|
||||
|
||||
@@ -28,16 +28,19 @@ export async function csrfCheck(ctx: AppContext, isPublicRoute: boolean) {
|
||||
await ctx.joplin.models.token().deleteByValue(userId, fields._csrf);
|
||||
}
|
||||
|
||||
export async function createCsrfToken(models: Models, user: User) {
|
||||
if (!user) throw new Error('Cannot create CSRF token without a user');
|
||||
export async function createCsrfToken(models: Models, user: User, throwOnError = true) {
|
||||
if (!user) {
|
||||
if (!throwOnError) return '';
|
||||
throw new Error('Cannot create CSRF token without a user');
|
||||
}
|
||||
return models.token().generate(user.id);
|
||||
}
|
||||
|
||||
export async function createCsrfTokenFromContext(ctx: AppContext) {
|
||||
return createCsrfToken(ctx.joplin.models, ctx.joplin.owner);
|
||||
export async function createCsrfTokenFromContext(ctx: AppContext, throwOnError = true) {
|
||||
return createCsrfToken(ctx.joplin.models, ctx.joplin.owner, throwOnError);
|
||||
}
|
||||
|
||||
export async function createCsrfTag(ctx: AppContext) {
|
||||
const token = await createCsrfTokenFromContext(ctx);
|
||||
export async function createCsrfTag(ctx: AppContext, throwOnError = true) {
|
||||
const token = await createCsrfTokenFromContext(ctx, throwOnError);
|
||||
return `<input type="hidden" name="_csrf" value="${escapeHtml(token)}"/>`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorWithCode extends ApiError {
|
||||
public constructor(message: string, code: string) {
|
||||
super(message, null, code);
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorMethodNotAllowed extends ApiError {
|
||||
public static httpCode: number = 400;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { baseUrl } from '../config';
|
||||
import config, { baseUrl } from '../config';
|
||||
import { Item, ItemAddressingType, Uuid } from '../services/database/types';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
|
||||
import Router from './Router';
|
||||
@@ -275,5 +275,9 @@ export enum UrlType {
|
||||
}
|
||||
|
||||
export function makeUrl(urlType: UrlType): string {
|
||||
return `${baseUrl(RouteType.Web)}/${urlType}`;
|
||||
if (config().isJoplinCloud && urlType === UrlType.Signup) {
|
||||
return `${config().joplinAppBaseUrl}/plans`;
|
||||
} else {
|
||||
return `${baseUrl(RouteType.Web)}/${urlType}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Subscription, Uuid } from '../services/database/types';
|
||||
import { Models } from '../models/factory';
|
||||
import { AccountType } from '../models/UserModel';
|
||||
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
|
||||
import { ErrorWithCode } from './errors';
|
||||
const stripeLib = require('stripe');
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
@@ -32,11 +33,11 @@ export function accountTypeToPriceId(accountType: AccountType): string {
|
||||
|
||||
export async function subscriptionInfoByUserId(models: Models, userId: Uuid): Promise<SubscriptionInfo> {
|
||||
const sub = await models.subscription().byUserId(userId);
|
||||
if (!sub) throw new Error('Could not retrieve subscription info');
|
||||
if (!sub) throw new ErrorWithCode('Could not retrieve subscription info', 'no_sub');
|
||||
|
||||
const stripe = initStripe();
|
||||
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
|
||||
if (!stripeSub) throw new Error('Could not retrieve Stripe subscription');
|
||||
if (!stripeSub) throw new ErrorWithCode('Could not retrieve Stripe subscription', 'no_stripe_sub');
|
||||
|
||||
return { sub, stripeSub };
|
||||
}
|
||||
@@ -138,5 +139,13 @@ export function betaUserTrialPeriodDays(userCreatedTime: number, fromDateTime: n
|
||||
}
|
||||
|
||||
export function betaStartSubUrl(email: string, accountType: AccountType): string {
|
||||
return `https://joplinapp.org/plans/?email=${encodeURIComponent(email)}&account_type=${encodeURIComponent(accountType)}`;
|
||||
return `${globalConfig().joplinAppBaseUrl}/plans/?email=${encodeURIComponent(email)}&account_type=${encodeURIComponent(accountType)}`;
|
||||
}
|
||||
|
||||
export async function updateCustomerEmail(models: Models, userId: Uuid, newEmail: string) {
|
||||
const subInfo = await subscriptionInfoByUserId(models, userId);
|
||||
const stripe = initStripe();
|
||||
await stripe.customers.update(subInfo.sub.stripe_user_id, {
|
||||
email: newEmail,
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user