1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-29 07:46:13 +02:00

Compare commits

...

39 Commits

Author SHA1 Message Date
Laurent Cozic
93fe919300 Optimisation 2021-09-25 17:29:35 +01:00
Laurent Cozic
a756db2ef4 fix 2021-09-25 15:25:34 +01:00
Laurent Cozic
843b52000e allow counting number of shared items 2021-09-24 18:07:23 +01:00
Laurent Cozic
e8e8ea3780 typo 2021-09-24 15:14:00 +01:00
Laurent Cozic
3c13c8d080 Tools: Allow tagging a server release as "latest" 2021-09-23 17:13:54 +01:00
Laurent Cozic
97bfd5ef04 Tools: Add test unit for building Docker image 2021-09-23 16:48:26 +01:00
Laurent Cozic
e3fd34e5d6 Server: Security: Implement clickjacking defense 2021-09-23 15:56:40 +01:00
Laurent Cozic
f144daed96 Desktop, Cli: Allow importing certain corrupted ENEX files 2021-09-23 15:35:49 +01:00
Laurent Cozic
add9d884e6 Doc: Improved pre-release doc and link to it from Donate section 2021-09-23 14:18:31 +01:00
Laurent Cozic
62f81b4315 Chore: Converts ENEX import file to TypeScript 2021-09-23 13:16:22 +01:00
Laurent Cozic
f33088fbe0 Android 2.4.2 2021-09-22 18:12:29 +01:00
Laurent Cozic
31b6d06418 lock files 2021-09-22 17:38:35 +01:00
Laurent Cozic
06cd5ffa2d Server v2.4.9 2021-09-22 17:32:27 +01:00
Laurent Cozic
f3d4d8eaed Desktop release v2.4.8 2021-09-22 17:29:57 +01:00
Gen Neko
dc08e1ded5 All: Translation: Update ja_JP.po (#5488) 2021-09-22 05:57:58 -04:00
reportxx
31c3fec8d8 All: Translation: Update sv.po (#5484) 2021-09-22 05:57:22 -04:00
Laurent Cozic
4487cb85fc Tools: Trying to fix tests 2021-09-21 16:26:56 +01:00
Laurent Cozic
56cac1f729 Desktop: Fixes #5161: Improved plugin search and installing new plugins from China 2021-09-21 16:22:58 +01:00
Laurent Cozic
3ade7ed849 Doc: Clarified Joplin Cloud Education Discount 2021-09-21 13:29:29 +01:00
Laurent Cozic
a7eea9fc21 Fixed session swap handling on server 2021-09-21 12:31:53 +01:00
Laurent Cozic
7fac1941cd Server: Manage subscription entirely from Stripe 2021-09-21 10:51:10 +01:00
Laurent Cozic
061761f224 Server: Clear cookie when account has been deleted to allow viewing login page again 2021-09-21 10:49:41 +01:00
Laurent Cozic
63e88c05d9 Server: Add support for changing user own email 2021-09-20 19:48:17 +01:00
Laurent Cozic
a6b1cffd50 Server: Handle Joplin Cloud failed subscription payments 2021-09-20 17:04:09 +01:00
Laurent Cozic
8cc720963a Fixed typo 2021-09-20 17:04:08 +01:00
Laurent Cozic
da884752a8 Desktop: Fixed Sync Wizard logo images on Windows 2021-09-20 16:29:05 +01:00
Laurent Cozic
818c7d4640 Fixed CSRF handling when impersonating user 2021-09-20 15:38:26 +01:00
Laurent Cozic
4577c9c161 Server: Allow entering coupon code on Stripe checkout page 2021-09-20 15:25:59 +01:00
Laurent Cozic
03b4b6eb2d Server: Allow an admin to impersonate a user 2021-09-20 13:49:38 +01:00
Laurent Cozic
4d38397cd5 Server: Improved user list page 2021-09-20 12:20:18 +01:00
Laurent Cozic
37d446b970 Server: Rename admin button "Send reset password email" to more correct "Send account information email" 2021-09-20 11:53:35 +01:00
Laurent Cozic
c91d4bda3c Server: Redirect to user page after changing a user 2021-09-20 11:48:51 +01:00
Laurent Cozic
3e537967ee Tools: Added way to test creating Basic or Pro Joplin Server subscription 2021-09-20 11:46:24 +01:00
Laurent Cozic
0cbc261051 Chore: Remove last remnants of discontinued Nextcloud App 2021-09-19 19:15:10 +01:00
Laurent Cozic
542fdb496a Set default options 2021-09-19 18:54:14 +01:00
Laurent Cozic
d850eedd78 Server: Link to Joplin Cloud signup page on login page 2021-09-19 18:45:16 +01:00
Laurent Cozic
9429b51694 Chore: Fixed regression on mobile app 2021-09-19 15:27:23 +01:00
Laurent Cozic
72e58ee195 Tools: Trying to fix TaskService test on CI 2021-09-19 15:06:09 +01:00
Helmut K. C. Tessarek
56be4d59f4 Update translations 2021-09-19 08:37:08 -04:00
166 changed files with 27973 additions and 17933 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -504,47 +504,47 @@ Current translations:
<!-- LOCALE-TABLE-AUTO-GENERATED -->
&nbsp; | Language | Po File | Last translator | Percent done
---|---|---|---|---
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 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

View File

@@ -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",

View File

Before

Width:  |  Height:  |  Size: 441 B

After

Width:  |  Height:  |  Size: 441 B

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -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 });
}

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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({}, {

View File

@@ -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);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"
}

View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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 "]]&gt;", 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 "&gt;". It
// should be safe to do so because such content can only be within the body
// of a note - and ">" or "&gt;" is equivalent.
//
// Ref: https://discourse.joplinapp.org/t/20470/4
const newContent = content.replace(/<!\[CDATA\[>\]\]>/g, '&gt;');
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

View File

@@ -41,45 +41,45 @@ locales['uk_UA'] = require('./uk_UA.json');
locales['vi'] = require('./vi.json');
locales['zh_CN'] = require('./zh_CN.json');
locales['zh_TW'] = require('./zh_TW.json');
stats['ar'] = {"percentDone":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

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 => {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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',

View File

@@ -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

View 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();
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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 [];

View File

@@ -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()');
}
}

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -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> {

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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() {

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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>
`;

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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));
// });
});

View 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);
}

View File

@@ -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,

View File

@@ -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());
});
});

View File

@@ -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}`);

View File

@@ -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, '');
}

View File

@@ -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)}"/>`;
}

View File

@@ -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;

View File

@@ -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}`;
}
}

View File

@@ -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