Compare commits

...

409 Commits
8.3.2 ... 8.4.2

Author SHA1 Message Date
Yuri Kuznetsov
2a64537da1 8.4.2 2024-10-10 18:09:49 +03:00
Yuri Kuznetsov
6d3c765ef6 kanban group change save handler 2024-10-10 17:58:41 +03:00
Yuri Kuznetsov
8f96c8c3e1 build cleanup modules 2024-10-07 19:26:29 +03:00
Yuri Kuznetsov
43749cbabb task email link no foreign name 2024-10-04 11:43:45 +03:00
Yuri Kuznetsov
803185b747 fix datepicker error 2024-09-30 09:56:06 +03:00
Yuri Kuznetsov
4f5886fbd7 remove from modal fixes 2024-09-28 16:45:45 +03:00
Yuri Kuznetsov
c28ba84f03 email: fix collection trash/archive sync 2024-09-27 19:20:38 +03:00
Yuri Kuznetsov
7f08df77f6 email afterSave not called fix 2024-09-26 10:01:43 +03:00
Yuri Kuznetsov
48116f8dcf 8.4.1 2024-09-17 11:09:08 +03:00
Yuri Kuznetsov
0784fd589a update dompurify 2024-09-17 09:59:54 +03:00
Yuri Kuznetsov
8b8d3ee158 auth token lifetime int conversion fix 2024-09-14 10:47:13 +03:00
Yuri Kuznetsov
628e7e8131 hmac api key check with hex 2024-09-12 13:00:56 +03:00
Eymen Elkum
408a161595 fix row action return undefined (#3140) 2024-09-11 15:23:53 +03:00
Yuri Kuznetsov
b46c6d76a5 case portal user follow acl check 2024-09-09 13:30:51 +03:00
Yuri Kuznetsov
d78f7d499a restore deleteId for UI upgrade only 2024-09-05 13:45:18 +03:00
Yuri Kuznetsov
5662fed5eb color changes 2024-09-05 09:56:08 +03:00
Yuri Kuznetsov
3f4cdf629f mass email hour limit required 2024-09-04 12:30:30 +03:00
Yuri Kuznetsov
bedab9c780 fix diff 2024-09-03 18:17:01 +03:00
Yuri Kuznetsov
291bcf1684 note replace app link fix 2024-09-03 11:37:41 +03:00
Yuri Kuznetsov
bd9e1bf353 fix tag 2024-09-03 09:08:56 +03:00
Yuri Kuznetsov
7d507460be fix header markup 2024-09-02 18:09:29 +03:00
Yuri Kuznetsov
99b4205181 lang 2024-09-02 09:08:40 +03:00
Yuri Kuznetsov
5ae56454fc color change 2024-09-01 10:50:37 +03:00
Yuri Kuznetsov
1e2369bba1 jsdoc 2024-09-01 10:35:18 +03:00
Yuri Kuznetsov
c42bfa3aab kanban reuse subCollections after rebuild 2024-09-01 10:30:21 +03:00
Yuri Kuznetsov
2b01cefb66 cleanup 2024-08-31 16:50:57 +03:00
Yuri Kuznetsov
0b72848e33 hide show detail modes 2024-08-31 16:41:24 +03:00
Yuri Kuznetsov
ecc7f80c80 schema 2024-08-31 14:32:28 +03:00
Yurii
be5fff011b change email folder order 2024-08-30 17:16:52 +03:00
Yuri Kuznetsov
d7a29fdc71 int field: support big int in frontend 2024-08-30 15:40:35 +03:00
Yurii
e1c2203750 int range validation 2024-08-30 10:42:51 +03:00
Yuri Kuznetsov
66cde8c86c email read notification websocket 2024-08-30 08:39:25 +03:00
Yuri Kuznetsov
10fb006fd8 comment 2024-08-30 08:39:09 +03:00
Osama Bashir
b94c5ff869 Get only active users in email address lookup (#3132)
* Get only active users in email address lookup

* fix isActive field name
2024-08-29 09:37:35 +03:00
Yuri Kuznetsov
39898bbbe8 preferences page title 2024-08-29 09:35:09 +03:00
Yuri Kuznetsov
62a6c2761f fix dashlet sort translation 2024-08-28 11:09:02 +03:00
Yuri Kuznetsov
6fe6a52f47 collapsed modal overlap fix 2 2024-08-28 10:48:46 +03:00
Yuri Kuznetsov
c5d7fa5b0b style fix 2024-08-28 10:37:54 +03:00
Yuri Kuznetsov
ceaac6c3dc collapsed modal z-index 2024-08-28 10:37:05 +03:00
Yuri Kuznetsov
490843e371 fix email body null 2024-08-28 10:27:14 +03:00
Yuri Kuznetsov
b290fbfeb2 fix email plain 2024-08-27 17:33:39 +03:00
Yuri Kuznetsov
ca1e649b11 emailReplyForceHtml true by default 2024-08-27 16:57:43 +03:00
Yuri Kuznetsov
5e74cd8d06 font change 2024-08-27 16:15:15 +03:00
Yuri Kuznetsov
63508423cc email archive row action 2024-08-27 11:03:48 +03:00
Yurii Kuznietsov
a5961811b4 Update feature_request.md 2024-08-27 00:05:30 +03:00
Yuri Kuznetsov
fdb1595cd5 sticky bar fix 2024-08-26 23:55:03 +03:00
Yuri Kuznetsov
4bfedf8db3 fix test 2024-08-26 15:02:24 +03:00
Yuri Kuznetsov
325429eb52 comment out email index 2024-08-26 13:34:31 +03:00
Yuri Kuznetsov
e558a3139c fix move to folder 2024-08-26 10:33:13 +03:00
Yuri Kuznetsov
e424c26963 fix select/create related from modal 2024-08-26 10:31:06 +03:00
dependabot[bot]
b2cc1f97c4 Bump axios from 1.7.2 to 1.7.5 (#3128)
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 10:11:12 +03:00
dependabot[bot]
d48ce0fc58 Bump micromatch from 4.0.5 to 4.0.8 (#3127)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/4.0.8/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 10:11:02 +03:00
Yuri Kuznetsov
b61462a1cd version 2024-08-26 09:15:55 +03:00
Andrew Fontana
1bb82151da Fix Typo (#3125)
Fix typo enabled to enable.
2024-08-25 11:05:14 +03:00
Anthony Andriano
9852e25c6e replaced literal with enum (#3124) 2024-08-25 09:26:52 +03:00
Yuri Kuznetsov
90fe358753 ics attendees 2024-08-23 19:18:16 +03:00
Yuri Kuznetsov
bd65e62fce ref, test 2024-08-23 18:37:21 +03:00
Yuri Kuznetsov
fff9d7a781 group email account is system read only field 2024-08-23 11:47:38 +03:00
Yuri Kuznetsov
8441fa05d2 calendar non working all-day timzone correction 2024-08-23 11:20:10 +03:00
Yuri Kuznetsov
9e86649b1c ref cs 2024-08-23 10:33:36 +03:00
Yuri Kuznetsov
3c50a0e058 backdrop click 2024-08-22 17:28:23 +03:00
Yuri Kuznetsov
e571a63a30 change user position action 2024-08-22 17:25:38 +03:00
Yuri Kuznetsov
14d405542b ref 2024-08-21 09:24:29 +03:00
Yuri Kuznetsov
429d03504c ref 2024-08-20 22:17:13 +03:00
Yuri Kuznetsov
fc24840a7b ref 2024-08-20 15:43:54 +03:00
Yuri Kuznetsov
603ca985df fix mass select 2024-08-20 12:05:44 +03:00
Yuri Kuznetsov
a12940e36c ref 2024-08-20 11:09:16 +03:00
Yuri Kuznetsov
dcab0ad7d0 ref 2024-08-20 09:42:42 +03:00
Yuri Kuznetsov
89124b354b idea code style change 2024-08-20 09:42:29 +03:00
Yuri Kuznetsov
03c275f495 pdf template: x-if attribute 2024-08-19 14:21:43 +03:00
Yuri Kuznetsov
da89e0a321 pdf templates: compare and logic helpers 2024-08-19 12:45:32 +03:00
Yuri Kuznetsov
90af51b8b9 ref 2024-08-19 10:03:49 +03:00
Yuri Kuznetsov
92b82b9d4c cs fix 2024-08-19 09:36:27 +03:00
Yuri Kuznetsov
c0aabe65d7 cleanup 2024-08-18 12:51:48 +03:00
Yuri Kuznetsov
6e6478bb6a change test 2024-08-18 11:19:14 +03:00
Yuri Kuznetsov
d457b461e7 config default databaseHandler 2024-08-17 10:16:34 +03:00
Yuri Kuznetsov
2f6673f8d9 fix test 2024-08-16 19:54:09 +03:00
Yuri Kuznetsov
ee94408394 fix redundant escaping in regexp 2024-08-16 19:33:19 +03:00
Yuri Kuznetsov
6d343cb564 ref 2024-08-16 19:31:47 +03:00
Yuri Kuznetsov
f5c07c9d6b htmllizer: apply access control for iterated related records 2024-08-16 19:21:38 +03:00
Yuri Kuznetsov
f9f2eaeb1f ref 2024-08-16 19:06:25 +03:00
Yuri Kuznetsov
16d8015d92 ref 2024-08-16 19:04:01 +03:00
Yuri Kuznetsov
2fa330d6f3 cs 2024-08-16 18:57:04 +03:00
Yuri Kuznetsov
3bd2c8a813 calendar 0 duration fix 2024-08-16 10:23:38 +03:00
Yuri Kuznetsov
12ba23e8ab jsdoc 2024-08-16 10:09:51 +03:00
Yuri Kuznetsov
e7c3314285 afterOrEqual datetime 2024-08-15 20:32:24 +03:00
Yuri Kuznetsov
13ec8dd49f validate call after 2024-08-15 20:17:39 +03:00
Yuri Kuznetsov
80146e8bca fix massActionDefs undefined 2024-08-15 16:13:18 +03:00
Yuri Kuznetsov
b974c673d2 orm event dispatched unsubscribe method 2024-08-15 10:05:28 +03:00
Yuri Kuznetsov
c7ae23fd01 type change 2024-08-15 09:46:13 +03:00
Yuri Kuznetsov
ccd21ebf76 comment 2024-08-14 19:08:50 +03:00
Yuri Kuznetsov
f5c1fdebf3 ref 2024-08-14 19:07:19 +03:00
Yuri Kuznetsov
863cd03eb3 fix call on undefined 2024-08-14 18:31:12 +03:00
Yuri Kuznetsov
d3eb08b171 clear runtime cache in entity factory 2024-08-14 16:42:29 +03:00
Yuri Kuznetsov
5a39450e6d ref 2024-08-14 09:55:13 +03:00
Yuri Kuznetsov
dd40786b6f ref 2024-08-14 09:41:06 +03:00
Yuri Kuznetsov
35e894a391 ref 2024-08-14 09:18:12 +03:00
Yuri Kuznetsov
8bd2b32908 prevent removal link fields 2024-08-13 16:31:50 +03:00
Yuri Kuznetsov
ce58cc262f disable kanban on status field unset 2024-08-13 12:32:47 +03:00
Yuri Kuznetsov
2dad119c3c ref 2024-08-13 09:47:46 +03:00
Yuri Kuznetsov
7e872e845c filters layout 2024-08-12 10:21:53 +03:00
Yuri Kuznetsov
678e39b0e5 jsdoc fix 2024-08-11 18:34:29 +03:00
Yuri Kuznetsov
ce10665bc3 ref, cs 2024-08-11 16:05:05 +03:00
Yuri Kuznetsov
d091ebbd23 fix regexp 2024-08-11 15:47:36 +03:00
Yuri Kuznetsov
68e495566d ref 2024-08-10 11:50:29 +03:00
Yuri Kuznetsov
9943e413c1 adjust header font size on button show hide 2024-08-10 11:38:40 +03:00
Yuri Kuznetsov
8f4f4b4ac1 suppress inspection 2024-08-10 11:14:33 +03:00
Yuri Kuznetsov
4a7f967d4f ref 2024-08-09 14:01:53 +03:00
Yuri Kuznetsov
abf5690239 ref 2024-08-09 13:41:07 +03:00
Yuri Kuznetsov
bc6946e6be cleanup 2024-08-09 13:18:46 +03:00
Yuri Kuznetsov
06001a4b4c fix mention regular expression 2024-08-09 11:46:42 +03:00
Yuri Kuznetsov
d5d913cf12 ref 2024-08-09 11:36:15 +03:00
Yuri Kuznetsov
37f7fa8464 layout set layout label change 2024-08-09 11:35:30 +03:00
Yuri Kuznetsov
27c98bdf0f ref 2024-08-09 11:35:01 +03:00
Yuri Kuznetsov
2c223a0739 supress inspection 2024-08-09 11:31:04 +03:00
Yuri Kuznetsov
3c756e6252 ref 2024-08-09 10:59:57 +03:00
Yuri Kuznetsov
e7331efcbe ref 2024-08-08 19:59:49 +03:00
Yuri Kuznetsov
62ae30aa8f ref 2024-08-08 18:45:05 +03:00
Yuri Kuznetsov
c49f6c8045 ref 2024-08-08 18:39:35 +03:00
Yuri Kuznetsov
16ce6eb0b9 ref 2024-08-08 17:06:22 +03:00
Yuri Kuznetsov
701422892a merge 2024-08-08 14:25:54 +03:00
Yuri Kuznetsov
abd2016444 8.3.6 2024-08-08 14:07:26 +03:00
Eymen Elkum
cc68bd640e improve admin index quick search 2024-08-08 13:54:07 +03:00
Yuri Kuznetsov
c247a24db4 fix email edit access error 2024-08-08 13:41:03 +03:00
Yuri Kuznetsov
b8a484cb7e ref 2024-08-08 11:22:57 +03:00
Yuri Kuznetsov
61d546a649 ref 2024-08-08 11:06:24 +03:00
Yuri Kuznetsov
23b9c08752 ref 2024-08-08 11:04:42 +03:00
Yuri Kuznetsov
191f834e0f ref 2024-08-08 10:39:06 +03:00
Yuri Kuznetsov
9fa9198963 delete has-children foreign links with entity deletion 2024-08-07 14:47:35 +03:00
Yuri Kuznetsov
f89e8acb79 tasks dashlet order 2024-08-07 13:58:10 +03:00
Yuri Kuznetsov
33fbe125fa ref 2024-08-07 10:35:55 +03:00
Yuri Kuznetsov
d93dd9f6df export all, select all 2024-08-06 15:09:18 +03:00
Yuri Kuznetsov
08841f7da5 avatar cache timestamp in no cache 2024-08-06 12:41:24 +03:00
Yuri Kuznetsov
725222e4c2 ref 2024-08-06 12:33:48 +03:00
Yuri Kuznetsov
003f7864b3 ref 2024-08-06 12:05:00 +03:00
Yuri Kuznetsov
48efcdb7a8 ref 2024-08-06 11:57:28 +03:00
Yuri Kuznetsov
7bc8c2f161 ref 2024-08-05 10:12:55 +03:00
Yuri Kuznetsov
4e3cb38477 ref 2024-08-05 10:00:04 +03:00
Yuri Kuznetsov
20ac2dd2d7 fix 2024-08-05 09:49:02 +03:00
Yuri Kuznetsov
0cabbaab01 ref 2024-08-05 09:48:08 +03:00
Yuri Kuznetsov
8cc7adee3f ref 2024-08-04 17:13:31 +03:00
Yuri Kuznetsov
9d297d5587 ref 2024-08-04 17:13:22 +03:00
Yuri Kuznetsov
6e2409d969 fix typo 2024-08-02 15:18:29 +03:00
Yuri Kuznetsov
b8cdbfb585 cs 2024-08-02 15:15:06 +03:00
Yuri Kuznetsov
5c06345424 range int ref, disable formatting 2024-08-02 15:14:32 +03:00
Yuri Kuznetsov
142cbfa4b8 replace bootstrap 2024-08-02 13:13:52 +03:00
Yuri Kuznetsov
04018bba93 afterOrEqual 2024-08-02 10:21:13 +03:00
Yuri Kuznetsov
e77143640b working time range dynamic logic 2024-08-02 10:07:22 +03:00
Yuri Kuznetsov
2964fff2cd working time range tooltips 2024-08-02 09:58:35 +03:00
Yuri Kuznetsov
0c8cdb61dc stream create shortcut 2024-08-01 19:48:28 +03:00
Yuri Kuznetsov
a9e0dad0dd log: load formatter for handlers loaded with loader 2024-08-01 18:47:57 +03:00
Yuri Kuznetsov
b34d8ebca8 mass email: allow create if only edit access to campaign 2024-08-01 15:02:02 +03:00
Yuri Kuznetsov
ed795d0d49 docs, ref 2024-08-01 14:37:54 +03:00
Yuri Kuznetsov
2673f60831 jsdocs, ref 2024-08-01 14:14:02 +03:00
Yuri Kuznetsov
09b56fd8c0 jsdocs 2024-08-01 14:11:31 +03:00
Yuri Kuznetsov
44175614d4 frontend tests update 2024-08-01 12:11:52 +03:00
Yuri Kuznetsov
1b7657bf0c readme fix 2024-08-01 11:35:55 +03:00
Yuri Kuznetsov
469fcdb8bc invitation, set parent 2024-08-01 10:59:11 +03:00
Yuri Kuznetsov
fde53deae6 ref 2024-08-01 10:50:04 +03:00
Yuri Kuznetsov
e470bf4eb1 strip prefixes from relationship name 2024-08-01 10:24:46 +03:00
Yuri Kuznetsov
5011f1e197 link manager strip c 2024-08-01 10:16:31 +03:00
Yuri Kuznetsov
bc37a83982 datepicker restrict after 2024-07-31 15:57:54 +03:00
Yuri Kuznetsov
d97b9be4c9 update mailmimeparser with dependencies 2024-07-31 15:03:58 +03:00
Yuri Kuznetsov
e51a9621f3 test connection fix 2024-07-31 15:01:08 +03:00
Yuri Kuznetsov
6dde915b7e comment 2024-07-31 14:14:42 +03:00
Yuri Kuznetsov
a109e1353d authentication: bypass second step 2024-07-31 14:12:09 +03:00
Yuri Kuznetsov
8018478b13 cleanup 2024-07-31 11:38:18 +03:00
Yuri Kuznetsov
9edb8bd59a cs 2024-07-31 11:32:42 +03:00
Yuri Kuznetsov
3b3b05286a address search for specific entity type 2024-07-31 11:26:29 +03:00
Yuri Kuznetsov
bb534e6c46 record list dashlet additional row actions 2024-07-30 15:04:04 +03:00
Yuri Kuznetsov
fce05fd5d2 user list view avatar 2024-07-29 17:05:45 +03:00
Yuri Kuznetsov
875be06d28 opp kanban layout change 2024-07-29 17:04:29 +03:00
Yuri Kuznetsov
0125ad0db9 fix link multiple fetched columns 2024-07-29 14:09:50 +03:00
Yuri Kuznetsov
4634c5d30f move selectRelated and createRelated to helpers 2024-07-29 11:35:12 +03:00
Yuri Kuznetsov
6bd717fe5d bottom view for small record views 2024-07-29 10:12:52 +03:00
Yuri Kuznetsov
be56156516 ref 2024-07-29 09:58:36 +03:00
Yuri Kuznetsov
3da90da35b fix panel label 2024-07-29 09:47:42 +03:00
Yuri Kuznetsov
6b9ebdc731 field change reRended fromField check 2024-07-27 09:57:39 +03:00
Yuri Kuznetsov
1d0d12649f modal edit headerText option 2024-07-27 09:20:29 +03:00
Yuri Kuznetsov
e2aa06f755 link multiple saver: skip not existing columns 2024-07-26 16:50:28 +03:00
Yuri Kuznetsov
19e4943410 reset to default color 2024-07-26 12:00:46 +03:00
Yuri Kuznetsov
668233a978 exclude personal addresses from cc when replying 2024-07-25 18:50:25 +03:00
Yuri Kuznetsov
5a3bfe5f68 create portal user lower case name 2024-07-25 18:35:13 +03:00
Yuri Kuznetsov
90ff998e5c ref 2024-07-25 18:34:00 +03:00
Yuri Kuznetsov
71b7c1af2b working time specific calendar 2024-07-25 09:26:50 +03:00
Yuri Kuznetsov
997e5cc44c ref 2024-07-25 08:59:28 +03:00
Yuri Kuznetsov
3f8e6b2854 fix 2024-07-23 21:24:13 +03:00
Yuri Kuznetsov
3bf7ec7e9d fix mid keys only for custom 2024-07-23 20:35:28 +03:00
Yuri Kuznetsov
1d7c47f005 do not load link multiple is loaded 2024-07-23 12:20:56 +03:00
Yuri Kuznetsov
b1d77bdca0 midKeys order fix 2024-07-23 11:21:17 +03:00
Yuri Kuznetsov
36a9563e69 cleanup 2024-07-23 08:36:51 +03:00
Yuri Kuznetsov
67e1917426 mid keys converter fix 2024-07-23 08:36:19 +03:00
Yuri Kuznetsov
344714fda0 ref 2024-07-21 15:05:03 +03:00
Yuri Kuznetsov
836de2d624 phpdocs 2024-07-21 09:52:36 +03:00
Yuri Kuznetsov
2c5e548c07 orm getRelation 2024-07-21 09:49:19 +03:00
Yuri Kuznetsov
f0ac0dc03e related list parent model 2024-07-19 15:20:50 +03:00
Yuri Kuznetsov
2bb53c5495 highlighted classes 2024-07-19 10:21:34 +03:00
Yuri Kuznetsov
e0256018de noDefaultFilters 2024-07-18 09:05:03 +03:00
Yuri Kuznetsov
019cea2641 kanban update total event 2024-07-18 07:41:09 +03:00
Yuri Kuznetsov
d8286a2de6 kanban topBarDisabled 2024-07-18 07:15:15 +03:00
Yuri Kuznetsov
685f034e15 enum set translated options method 2024-07-17 13:19:21 +03:00
Yuri Kuznetsov
3210733bc7 kanban methods 2024-07-17 12:56:45 +03:00
Yuri Kuznetsov
38ad6def8c fix selectable click 2024-07-17 12:22:06 +03:00
Yuri Kuznetsov
b2d31d0230 fix kanban show more 2024-07-17 00:07:57 +03:00
Yuri Kuznetsov
ddd9a1dc88 kanban createActionHandler 2024-07-16 23:51:19 +03:00
Yuri Kuznetsov
3355ee7192 fix 2024-07-16 19:44:54 +03:00
Yuri Kuznetsov
a4e694412c kanban group item more data 2024-07-16 19:40:36 +03:00
Yuri Kuznetsov
7222a5a546 kanban empty render 2024-07-16 19:38:38 +03:00
Yuri Kuznetsov
423f26cb29 jsdocs 2024-07-16 18:04:52 +03:00
Yuri Kuznetsov
fe4679cbc5 cleanup 2024-07-16 17:55:08 +03:00
Yuri Kuznetsov
df0c56bd9b kanban ref 2024-07-16 17:53:19 +03:00
Yuri Kuznetsov
269f38e07c deprecation 2024-07-16 17:36:02 +03:00
Yuri Kuznetsov
48c89aec97 kanban ref 2024-07-16 17:35:01 +03:00
Yuri Kuznetsov
2cca4fe429 docs 2024-07-16 12:45:40 +03:00
Yuri Kuznetsov
8672fd1dff ref 2024-07-16 12:31:38 +03:00
Yuri Kuznetsov
755d3b863a dev 2024-07-16 12:15:29 +03:00
Yuri Kuznetsov
453e2bd1a0 ref 2024-07-16 12:10:15 +03:00
Yuri Kuznetsov
9ab35f457c ref 2024-07-16 11:51:07 +03:00
Yuri Kuznetsov
0a60ffefa5 ref 2024-07-16 11:30:17 +03:00
Yuri Kuznetsov
47fad3eb07 docs 2024-07-16 11:25:33 +03:00
Yuri Kuznetsov
7cca2353d8 system time zone 2024-07-16 11:07:45 +03:00
Yuri Kuznetsov
089c8d56f8 Merge branch 'fix' 2024-07-15 23:39:19 +03:00
Yuri Kuznetsov
17c9379c15 fix dynamic logic date time zone 2024-07-15 23:39:00 +03:00
Yuri Kuznetsov
d6d83a209f label fixes 2024-07-15 23:12:43 +03:00
Yuri Kuznetsov
86b77266bb schema 2024-07-15 12:28:27 +03:00
Yuri Kuznetsov
9339082f9b link: set attributes at once 2024-07-15 11:48:05 +03:00
Yuri Kuznetsov
bac385545c fix defaults promise wait 2024-07-15 11:25:25 +03:00
Yuri Kuznetsov
3e4b4f2df8 detail mode buttons to the left 2024-07-14 14:50:14 +03:00
Yuri Kuznetsov
990406889b app param error log 2024-07-12 10:33:48 +03:00
Yuri Kuznetsov
487d8dc909 custom layout in another module 2024-07-12 10:17:21 +03:00
Yuri Kuznetsov
4945e19fdf docs 2024-07-11 18:55:32 +03:00
Yuri Kuznetsov
0eecaf3d5a select related entityType param 2024-07-11 18:21:02 +03:00
Yuri Kuznetsov
337dd67c36 fix 2024-07-11 18:04:04 +03:00
Yuri Kuznetsov
e8f0d38554 panel actions acl race condition fix 2024-07-11 18:00:21 +03:00
Yuri Kuznetsov
e7904b976b schema 2024-07-11 13:27:50 +03:00
Yuri Kuznetsov
2360b75f97 panel actions 2024-07-11 13:22:30 +03:00
Yuri Kuznetsov
785c3a8545 link multiple translate empty role 2024-07-10 19:08:09 +03:00
Yuri Kuznetsov
1d1fccaed9 user only me filter 2024-07-10 12:59:13 +03:00
Yuri Kuznetsov
fb921bb023 Merge branch 'fix' 2024-07-10 11:43:34 +03:00
Yuri Kuznetsov
0ffe39cec8 8.3.5 2024-07-10 11:04:51 +03:00
Yuri Kuznetsov
21034bfeb2 acl scope data resolve boolean to true if not false 2024-07-09 16:25:44 +03:00
Yuri Kuznetsov
94188f4256 ref 2024-07-09 15:15:08 +03:00
Yuri Kuznetsov
20f6d67f40 Merge branch 'master' of https://github.com/espocrm/espocrm 2024-07-09 15:07:34 +03:00
Yuri Kuznetsov
a3c289aee2 model defaults preparator 2024-07-09 15:04:06 +03:00
Yuri Kuznetsov
e56121bc18 ref 2024-07-09 14:25:56 +03:00
Arkadiy Asuratov
5b90d4c3f3 fix typos in databasePlatforms.json schema
* Updated descriptions for pre- and post-rebuild actions
2024-07-09 13:25:41 +03:00
Yuri Kuznetsov
f69eed63d7 acl resolve foreign to boolean 2024-07-09 13:11:56 +03:00
Yuri Kuznetsov
06c173486e collection parentModel 2024-07-09 07:56:27 +03:00
Yuri Kuznetsov
32f1bfb1c6 model sync event option 2024-07-09 07:01:28 +03:00
Yuri Kuznetsov
68fee2ec9f fix msg 2024-07-08 22:34:08 +03:00
Yuri Kuznetsov
d15ab2df31 jsdoc 2024-07-08 22:29:31 +03:00
Yuri Kuznetsov
cb7f87a3f1 archive email require import access 2024-07-08 22:18:15 +03:00
Yuri Kuznetsov
6783331aab email: user massUpdateDisabled 2024-07-08 22:11:58 +03:00
Yuri Kuznetsov
88d4b6f27c Merge branch 'fix' 2024-07-08 22:03:12 +03:00
Yuri Kuznetsov
3347b7fba8 email from address check 2024-07-08 18:43:32 +03:00
Yuri Kuznetsov
cff703db05 list rebuild 2024-07-08 16:46:26 +03:00
Yuri Kuznetsov
16ba6ce7cf settings helper injected 2024-07-08 16:34:55 +03:00
Yuri Kuznetsov
d4bda1fa9d panel heading text 2024-07-08 14:25:30 +03:00
Yuri Kuznetsov
06b5100e87 list update-total event 2024-07-08 14:11:19 +03:00
Yuri Kuznetsov
f1a3cd397a checkbox disable 2024-07-08 13:25:38 +03:00
Yuri Kuznetsov
2481e1a652 cf 2024-07-08 13:01:32 +03:00
Yuri Kuznetsov
e32ad76590 cleanup 2024-07-08 12:44:43 +03:00
Yuri Kuznetsov
81f1374f55 force sticky bar param 2024-07-08 11:58:20 +03:00
Yuri Kuznetsov
d3fd314e35 list sticky bar edge 2024-07-08 08:56:46 +03:00
Yuri Kuznetsov
20b1b06d5f mass action no aclScope fix 2024-07-07 19:45:29 +03:00
Yuri Kuznetsov
a364ae1923 massUpdateDisabled in entityDefs fields 2024-07-07 19:00:47 +03:00
Yuri Kuznetsov
bb2ce37a38 ref 2024-07-07 18:47:55 +03:00
Yuri Kuznetsov
fd092a3eb1 group index -100 2024-07-07 15:26:08 +03:00
Yuri Kuznetsov
fb3bdde2c3 link create with name 2024-07-07 13:21:59 +03:00
Yuri Kuznetsov
66623c02e2 rerender row actions on model change 2024-07-07 13:16:20 +03:00
Yuri Kuznetsov
22fded93fb docs 2024-07-07 11:54:57 +03:00
Yuri Kuznetsov
6da20c6f86 modal view related entityType option 2024-07-07 09:50:23 +03:00
Yuri Kuznetsov
34834b2d5c css class panel-actions-container-left 2024-07-06 19:21:50 +03:00
Yuri Kuznetsov
c971304b03 array keepItems param 2024-07-06 16:13:11 +03:00
Yuri Kuznetsov
6c7d424349 removeMassAction public 2024-07-06 10:51:36 +03:00
Yuri Kuznetsov
f8e43a3694 fix accounts field 2024-07-06 10:07:09 +03:00
Yuri Kuznetsov
17dddbc248 ref 2024-07-06 09:57:43 +03:00
Yuri Kuznetsov
2246ab0cfd select primary id 2024-07-06 09:57:34 +03:00
Yuri Kuznetsov
a42f2cbe3a dynamic logic schema fix 2024-07-06 09:45:00 +03:00
Yuri Kuznetsov
ad4c039b4a link multiple html fix 2024-07-06 09:38:45 +03:00
Yuri Kuznetsov
2638fdb884 link-multiple-with-columns list text color 2024-07-06 09:23:46 +03:00
Yuri Kuznetsov
c0cfe8a36f cs 2024-07-06 09:21:12 +03:00
Yuri Kuznetsov
b9ab872ccd autocomplete user-select none 2024-07-06 09:16:58 +03:00
Yuri Kuznetsov
018b0b46aa fix label manager options 2024-07-06 09:13:39 +03:00
Yuri Kuznetsov
7bcd347f2d link multiple id autocomple conflict fix 2024-07-05 19:32:13 +03:00
Yuri Kuznetsov
a6db71957a input-text-block 2024-07-05 19:05:50 +03:00
Yuri Kuznetsov
1a5df659cf date field after/before in params 2024-07-05 18:43:58 +03:00
Yuri Kuznetsov
2219f52140 id field view 2024-07-05 12:58:55 +03:00
Yuri Kuznetsov
78dde9c7a1 record helper in modal record 2024-07-04 17:50:31 +03:00
Yuri Kuznetsov
3f0e2a242d Merge branch 'fix' 2024-07-04 09:21:04 +03:00
Yuri Kuznetsov
64aebdde6b icon color fix 2024-07-04 09:20:54 +03:00
Arkadiy Asuratov
24f6db674f suggest toTimestamp over getTimestamp 2024-07-03 18:45:55 +03:00
Arkadiy Asuratov
bfc4fb7ca8 suggest toTimestamp over getTimestamp 2024-07-03 18:45:55 +03:00
Yuri Kuznetsov
d46945dd2e change mode 2024-07-03 17:00:00 +03:00
Yuri Kuznetsov
51130d1aef fix 2024-07-03 16:33:43 +03:00
Yuri Kuznetsov
6eb71a789e foreign check 2024-07-03 12:06:26 +03:00
Yuri Kuznetsov
9c77169e6c foreign allow delete for admin if no record 2024-07-03 11:43:38 +03:00
Yuri Kuznetsov
26f1218240 duplicate source ID in save options 2024-07-02 19:14:46 +03:00
Yuri Kuznetsov
8b09a81237 Merge branch 'fix' 2024-07-01 16:45:19 +03:00
Yuri Kuznetsov
f3ee5c654b 8.3.4 2024-07-01 16:35:35 +03:00
Yuri Kuznetsov
19c8fe9ac5 ref 2024-07-01 16:33:15 +03:00
Yuri Kuznetsov
81f45d5679 merge 2024-07-01 16:27:08 +03:00
Yuri Kuznetsov
1a413cb54e check failed code attempts in a separate hook 2024-07-01 16:23:37 +03:00
Yuri Kuznetsov
3ab2ffee3c back to edit after duplicate cancel 2024-06-30 10:56:42 +03:00
Yuri Kuznetsov
53a481622a address country duplicate check 2024-06-30 09:58:49 +03:00
Yuri Kuznetsov
7d72a7ff71 ref 2024-06-29 19:45:32 +03:00
Yuri Kuznetsov
314cc7f4c2 load empty link multiple names 2024-06-29 19:31:51 +03:00
Yuri Kuznetsov
df1228f720 ref 2024-06-29 13:04:52 +03:00
Yuri Kuznetsov
bbd071221a fix 2024-06-29 13:04:48 +03:00
Yuri Kuznetsov
9235a3cf79 unset data 2024-06-29 12:12:42 +03:00
Yuri Kuznetsov
75c966e4bb external account disable 2024-06-29 11:52:29 +03:00
Yuri Kuznetsov
c298f5ec9a cleanup 2024-06-29 10:43:58 +03:00
Yuri Kuznetsov
94986d8835 ui fix 2024-06-29 10:28:42 +03:00
Yuri Kuznetsov
ece8405b33 campaign unsubscribe change 2024-06-29 10:14:17 +03:00
Yuri Kuznetsov
0d2d708ba8 mass email post unsubscribe 2024-06-28 20:13:10 +03:00
Yuri Kuznetsov
ef31f7ba08 Merge branch 'fix' 2024-06-28 19:21:07 +03:00
Yuri Kuznetsov
41bcaf50c4 fix attachment-multiple render 2024-06-28 18:55:21 +03:00
Yuri Kuznetsov
dcd3fa0fc8 ref 2024-06-28 16:52:05 +03:00
Yuri Kuznetsov
b48b9683ab comments 2024-06-28 11:15:59 +03:00
Yuri Kuznetsov
2548f396ef ref 2024-06-28 11:09:53 +03:00
Yuri Kuznetsov
0122b99a4b ouath timeout 2024-06-28 10:16:09 +03:00
Yuri Kuznetsov
fb747d3f65 ref 2024-06-28 09:55:36 +03:00
Yuri Kuznetsov
41cb0de44e ref 2024-06-28 09:35:59 +03:00
Yuri Kuznetsov
f7442be97b cs 2024-06-28 09:19:10 +03:00
Yuri Kuznetsov
20ccacddf3 ref 2024-06-28 09:18:07 +03:00
Yuri Kuznetsov
1ce56bb522 event date start colors 2024-06-27 17:15:02 +03:00
Yuri Kuznetsov
f2330b9a51 date field style 2024-06-27 16:29:02 +03:00
Yuri Kuznetsov
0346a0023e Merge branch 'fix' 2024-06-27 16:04:54 +03:00
Yuri Kuznetsov
bc7d9443b1 8.3.3 2024-06-27 15:54:59 +03:00
Yuri Kuznetsov
8188dc065b fix row actions returning undefined 2024-06-27 15:29:30 +03:00
Yuri Kuznetsov
a2025d0a89 fix props not initiated 2024-06-27 15:17:04 +03:00
Yuri Kuznetsov
c46933f427 Merge branch 'fix' 2024-06-27 11:53:23 +03:00
Yuri Kuznetsov
1d31637c2e migrate: rebuild before each script 2024-06-27 11:53:13 +03:00
Yuri Kuznetsov
e26026c9ad migrate: rebuild before each script 2024-06-27 11:49:36 +03:00
Yuri Kuznetsov
f9e4d1a953 cleanup 2024-06-27 10:43:50 +03:00
Yuri Kuznetsov
6f00a6b2e7 2fa code attempts period separate param 2024-06-27 10:34:55 +03:00
Yuri Kuznetsov
a89860dc52 ref 2024-06-27 10:24:12 +03:00
Yuri Kuznetsov
626c23b1b5 ref 2024-06-27 10:01:45 +03:00
Yuri Kuznetsov
7513d05451 hide menu items in edit mode 2024-06-27 08:52:45 +03:00
Yuri Kuznetsov
5e7aabc46c ref, convert lead button improvement 2024-06-27 08:34:38 +03:00
Yuri Kuznetsov
740baffeb4 convert lead use record service 2024-06-27 08:26:56 +03:00
Yuri Kuznetsov
2ba808c371 auth limit denial reason check 2024-06-26 21:54:15 +03:00
Yuri Kuznetsov
8c7f9f43e4 convert lead require acl create 2024-06-26 16:42:16 +03:00
Yuri Kuznetsov
2df98585f9 lead convert: ability to select account 2024-06-26 16:38:27 +03:00
Yuri Kuznetsov
9d4266bed0 ref 2024-06-26 16:26:39 +03:00
Yuri Kuznetsov
cb313cd7ef ref 2024-06-26 14:29:10 +03:00
Yuri Kuznetsov
ce54d516e9 Merge branch 'fix' 2024-06-26 13:51:29 +03:00
Yuri Kuznetsov
6bce395daf fix dropdown empty 2024-06-26 13:51:19 +03:00
Yuri Kuznetsov
b634daca6e cs 2024-06-26 13:33:59 +03:00
Yuri Kuznetsov
338e0bb9d4 deleteId param 2024-06-26 13:27:20 +03:00
Yuri Kuznetsov
cc576a6af8 add user utility fields 2024-06-26 12:33:15 +03:00
Yuri Kuznetsov
cbb0159d27 userNameRegularExpression readonly 2024-06-26 12:22:04 +03:00
Yuri Kuznetsov
515b43614b sanitizer lower case 2024-06-26 12:20:29 +03:00
Yuri Kuznetsov
736d23fa6d inline edit shortcut do not reset 2024-06-26 08:40:53 +03:00
Yuri Kuznetsov
7a096cdde4 Merge branch 'fix' 2024-06-26 08:05:49 +03:00
Yuri Kuznetsov
ee37960259 mass action show/hide 2024-06-25 16:22:33 +03:00
Yuri Kuznetsov
3ce8fae228 cs ref 2024-06-25 15:37:00 +03:00
Yuri Kuznetsov
13ebc558e9 email dd item group index change 2024-06-25 15:03:53 +03:00
Yuri Kuznetsov
3ae342c275 mass actions groups 2024-06-25 15:00:25 +03:00
Yuri Kuznetsov
a283305c7b fix test 2024-06-25 14:05:29 +03:00
Yuri Kuznetsov
a6c698588c update bullbone 2024-06-24 18:59:23 +03:00
Yuri Kuznetsov
3007976506 fix test 2024-06-24 13:49:54 +03:00
Yuri Kuznetsov
8189f0eb2f wysiwyg: don't call getValueForDisplay 2024-06-24 13:27:42 +03:00
Yuri Kuznetsov
248063f327 activities, prepare for output 2024-06-24 13:23:07 +03:00
Yuri Kuznetsov
3a76807a2b parent field in activities and other dashlets 2024-06-24 13:12:29 +03:00
Yuri Kuznetsov
6e6d3e15ea emails archive 2024-06-24 12:44:35 +03:00
Yuri Kuznetsov
eee086ef52 Merge branch 'fix' 2024-06-24 11:24:47 +03:00
Yuri Kuznetsov
92695c0c39 email search cc 2024-06-24 10:56:51 +03:00
Yuri Kuznetsov
44346e962c Merge branch 'fix' 2024-06-24 10:54:58 +03:00
Yuri Kuznetsov
83a415cb33 Merge branch 'fix' 2024-06-24 10:35:56 +03:00
Yuri Kuznetsov
02efdf11f5 Merge branch 'fix' 2024-06-23 17:53:09 +03:00
Yuri Kuznetsov
26a0c4b108 ref 2024-06-23 11:55:00 +03:00
Yuri Kuznetsov
caeaf46403 email show quote part 2024-06-23 11:05:47 +03:00
Yuri Kuznetsov
c254f5cc0d Merge branch 'fix' 2024-06-23 10:52:27 +03:00
Yuri Kuznetsov
53c3f4b4c3 fix 2024-06-23 09:26:55 +03:00
Yuri Kuznetsov
53a12e17f8 Merge branch 'master' of https://github.com/espocrm/espocrm 2024-06-22 19:43:21 +03:00
Yuri Kuznetsov
2ab5489bec eml duplicate check 2024-06-22 19:43:11 +03:00
Yurii Kuznietsov
a5c87272b1 Update CONTRIBUTING.md 2024-06-22 19:15:42 +03:00
Yurii Kuznietsov
130bae6a88 Update CONTRIBUTING.md 2024-06-22 15:36:57 +03:00
Yurii Kuznietsov
c520a02ca5 Update CONTRIBUTING.md 2024-06-22 15:36:16 +03:00
Yuri Kuznetsov
b8d5612d32 ref 2024-06-22 11:19:54 +03:00
Yuri Kuznetsov
6ecf5fcdd0 note avatar style fix 2024-06-22 11:13:23 +03:00
Yuri Kuznetsov
5a93722232 import EML 2024-06-22 09:56:29 +03:00
Yuri Kuznetsov
d6035523e2 Merge branch 'fix' 2024-06-22 09:26:49 +03:00
Yuri Kuznetsov
8b49e72f2b Merge branch 'fix' 2024-06-21 22:12:19 +03:00
Yuri Kuznetsov
a1292cf933 navbar order change 2024-06-21 16:55:25 +03:00
Yuri Kuznetsov
5ccfd77669 ref 2024-06-21 16:52:40 +03:00
Yuri Kuznetsov
67a27ec21b Merge branch 'fix' 2024-06-21 16:27:43 +03:00
Yuri Kuznetsov
b1de15339a metadata navbar menu 2024-06-21 15:42:48 +03:00
Yuri Kuznetsov
3f819500d3 navbar item order, gaps 2024-06-21 13:56:06 +03:00
Yuri Kuznetsov
435be717c3 cleanup 2024-06-21 13:41:45 +03:00
Yuri Kuznetsov
d2f4fbc59d Merge branch 'fix' 2024-06-21 13:14:47 +03:00
Yuri Kuznetsov
9e0a77588d move navbar items to metadata 2024-06-21 13:06:29 +03:00
Yuri Kuznetsov
d033b26e57 Merge branch 'fix' 2024-06-21 09:40:43 +03:00
Yuri Kuznetsov
cbced73f6e history layout change 2024-06-20 22:03:43 +03:00
Yuri Kuznetsov
9168dd69f2 orm: mapper ref, fix wildcard in expression 2024-06-20 17:45:44 +03:00
Yuri Kuznetsov
c69fc7f2c9 orm mapper: deleted check 2024-06-20 16:39:10 +03:00
Yuri Kuznetsov
faebc13757 Merge branch 'fix' 2024-06-20 15:42:11 +03:00
Yuri Kuznetsov
bd6e0023c3 kanban column show/hide 2024-06-20 13:01:50 +03:00
Yuri Kuznetsov
5f00c85882 cs 2024-06-20 11:48:37 +03:00
Yuri Kuznetsov
6c8cffeb2a nowrap 2024-06-20 08:38:21 +03:00
Yuri Kuznetsov
683eb5a491 Merge branch 'master' into version/8.4 2024-06-20 08:25:23 +03:00
Yuri Kuznetsov
669701c6fd Merge branch 'master' into version/8.4 2024-06-19 12:29:07 +03:00
Yuri Kuznetsov
7221963990 stream assign avatar 2024-06-19 12:27:33 +03:00
Yuri Kuznetsov
062043d5e1 Merge branch 'master' into version/8.4 2024-06-19 12:23:58 +03:00
Yuri Kuznetsov
adc3df5144 Merge branch 'master' into version/8.4 2024-06-18 11:34:06 +03:00
Yuri Kuznetsov
890649e46b detail modes 2024-06-10 11:02:29 +03:00
Yuri Kuznetsov
33d710d265 Merge branch 'master' into version/8.4 2024-06-10 09:49:37 +03:00
Yuri Kuznetsov
b3f3226f55 metadata additional builders 2024-06-06 19:32:15 +03:00
653 changed files with 19989 additions and 11766 deletions

View File

@@ -1,14 +1,18 @@
## Issues
When reporting a possible bug, provide detail steps so that we will be able
to reproduce the issue. Try not to use phrases like "very big bug",
to reproduce the issue. Steps to reproduce should be clear and unambiguous. Try not to use phrases like "very big bug",
"huge issue", "useless feature", etc. No need to use exclamation marks as well.
Steps to reproduce should be clear and unambiguous.
Note that we don't provide developer help or any kind of support on GitHub.
For this, please use our [forum](https://forum.espocrm.com).
If you are very new to EspoCRM, it's probable that an issue you ran into is not a bug.
Consider creating a topic on our [forum](https://forum.espocrm.com/forum/general) instead.
The issue tracker is for the benefit of the EspoCRM project. The project maintainers are going to handle issues in the project's best interest.
The maintainers have right to close issues without explanation.
## Pull Requests
We are open for contributions that are bug fixes and small improvements. If you would like to contribute something that is not a small fix, please reach out to maintainers before submitting your PR (by creating a GitHub issue).

View File

@@ -1,6 +1,6 @@
---
name: Feature request
about: For high-level features, consider creating feature requests on the forum. For low-level (framework) here on GitHub.
about: For high-level features, create feature requests on our forum. For low-level (framework) here on GitHub.
title: ''
labels: ''
assignees: ''

View File

@@ -17,8 +17,6 @@
<codeStyleSettings language="PHP">
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
<option name="CATCH_ON_NEW_LINE" value="true" />
<option name="FINALLY_ON_NEW_LINE" value="true" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />

View File

@@ -3,6 +3,7 @@
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="ES6ConvertLetToConst" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
<inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="JSIgnoredPromiseFromCall" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PhpDocMissingThrowsInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PhpDocSignatureIsNotCompleteInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />

View File

@@ -134,9 +134,11 @@ module.exports = grunt => {
src: [
'build/tmp/custom/Espo/Custom/*',
'!build/tmp/custom/Espo/Custom/.htaccess',
'!build/tmp/custom/Espo/Modules',
'build/tmp/custom/Espo/Modules/*',
'!build/tmp/custom/Espo/Modules/.htaccess',
'build/tmp/install/config.php',
'build/tmp/vendor/*/*/.git',
'build/tmp/custom/Espo/Custom/*',
'build/tmp/client/custom/*',
'!build/tmp/client/custom/modules',
'build/tmp/client/custom/modules/*',

View File

@@ -27,35 +27,21 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Hooks\User;
namespace Espo\Classes\Acl\ImportEml;
use Espo\Core\Hook\Hook\BeforeRemove;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Core\Utils\Util;
use Espo\Core\Acl\AccessCreateChecker;
use Espo\Core\Acl\ScopeData;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\RemoveOptions;
use Espo\ORM\Repository\Option\SaveOptions;
/**
* @implements BeforeRemove<User>
* @implements BeforeSave<User>
*/
class DeleteId implements BeforeRemove, BeforeSave
class AccessChecker implements AccessCreateChecker
{
public function beforeRemove(Entity $entity, RemoveOptions $options): void
public function check(User $user, ScopeData $data): bool
{
$entity->set('deleteId', Util::generateId());
return $data->isTrue();
}
public function beforeSave(Entity $entity, SaveOptions $options): void
public function checkCreate(User $user, ScopeData $data): bool
{
if (!$entity->isAttributeChanged('deleted')) {
return;
}
$deleteId = $entity->get('deleted') ? Util::generateId() : '0';
$entity->set('deleteId', $deleteId);
return $data->isTrue();
}
}

View File

@@ -0,0 +1,66 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\Email;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Entities\Email;
use Espo\Entities\EmailFolder;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @implements Loader<Email>
*/
class FolderDataLoader implements Loader
{
public function __construct(private EntityManager $entityManager) {}
public function process(Entity $entity, Params $params): void
{
$folderId = $entity->get(Email::USERS_COLUMN_FOLDER_ID);
if (!$folderId) {
return;
}
$folder = $this->entityManager
->getRDBRepositoryByClass(EmailFolder::class)
->select(['id', 'name'])
->where(['id' => $folderId])
->findOne();
if (!$folder) {
return;
}
$entity->set('folderName', $folder->getName());
}
}

View File

@@ -41,14 +41,10 @@ use Espo\Entities\User;
*/
class UserColumnsLoader implements Loader
{
private EntityManager $entityManager;
private User $user;
public function __construct(EntityManager $entityManager, User $user)
{
$this->entityManager = $entityManager;
$this->user = $user;
}
public function __construct(
private EntityManager $entityManager,
private User $user
) {}
public function process(Entity $entity, Params $params): void
{
@@ -58,6 +54,7 @@ class UserColumnsLoader implements Loader
Email::USERS_COLUMN_IS_READ,
Email::USERS_COLUMN_IS_IMPORTANT,
Email::USERS_COLUMN_IN_TRASH,
Email::USERS_COLUMN_IN_ARCHIVE,
])
->where([
'deleted' => false,
@@ -70,6 +67,7 @@ class UserColumnsLoader implements Loader
$entity->set(Email::USERS_COLUMN_IS_READ, null);
$entity->clear(Email::USERS_COLUMN_IS_IMPORTANT);
$entity->clear(Email::USERS_COLUMN_IN_TRASH);
$entity->clear(Email::USERS_COLUMN_IN_ARCHIVE);
return;
}
@@ -78,6 +76,8 @@ class UserColumnsLoader implements Loader
Email::USERS_COLUMN_IS_READ => $emailUser->get(Email::USERS_COLUMN_IS_READ),
Email::USERS_COLUMN_IS_IMPORTANT => $emailUser->get(Email::USERS_COLUMN_IS_IMPORTANT),
Email::USERS_COLUMN_IN_TRASH => $emailUser->get(Email::USERS_COLUMN_IN_TRASH),
Email::USERS_COLUMN_IN_ARCHIVE => $emailUser->get(Email::USERS_COLUMN_IN_ARCHIVE),
'isUsersSent' => $entity->getSentBy()?->getId() === $this->user->getId(),
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
/**LICENSE**/
namespace Espo\Classes\FieldProcessing\InboundEmail;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\Utils\Config;
use Espo\Entities\InboundEmail;
use Espo\ORM\Entity;
/**
* @implements Loader<InboundEmail>
*/
class IsSystemLoader implements Loader
{
public function __construct(
private Config $config,
) {}
public function process(Entity $entity, Params $params): void
{
$isSystem = $entity->getEmailAddress() === $this->config->get('outboundEmailFromAddress');
$entity->set('isSystem', $isSystem);
}
}

View File

@@ -1,3 +1,4 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
@@ -26,27 +27,30 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/campaign/subscribe-again', ['view'], function (Dep) {
namespace Espo\Classes\FieldSanitizers;
return Dep.extend({
use Espo\Core\FieldSanitize\Sanitizer;
use Espo\Core\FieldSanitize\Sanitizer\Data;
template: 'crm:campaign/subscribe-again',
/**
* @noinspection PhpUnused
*/
class StringLowerCase implements Sanitizer
{
public function sanitize(Data $data, string $field): void
{
if (!$data->has($field)) {
return;
}
data: function () {
var revertUrl;
$value = $data->get($field);
var actionData = this.options.actionData;
if (!is_string($value)) {
return;
}
if (actionData.hash && actionData.emailAddress) {
revertUrl = '?entryPoint=unsubscribe&emailAddress=' + actionData.emailAddress +
'&hash=' + actionData.hash;
} else {
revertUrl = '?entryPoint=unsubscribe&id=' + actionData.queueItemId;
}
$value = mb_strtolower($value);
return {
revertUrl: revertUrl,
};
},
});
});
$data->set($field, $value);
}
}

View File

@@ -29,16 +29,54 @@
namespace Espo\Classes\FieldValidators;
use Doctrine\DBAL\Types\Types;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use stdClass;
class IntType
{
public function __construct(
private Defs $defs,
) {}
public function checkRequired(Entity $entity, string $field): bool
{
return $this->isNotEmpty($entity, $field);
}
/** @noinspection PhpUnused */
public function checkRangeInternal(Entity $entity, string $field): bool
{
$value = $entity->get($field);
if ($value === null) {
return true;
}
$dbType = $this->defs
->getEntity($entity->getEntityType())
->tryGetAttribute($field)
?->getParam('dbType') ?? Types::INTEGER;
$ranges = [
Types::INTEGER => [-2147483648, 2147483647],
Types::SMALLINT => [-32768, 32767],
];
$range = $ranges[$dbType] ?? null;
if (!$range) {
return true;
}
if ($value < $range[0] || $value > $range[1]) {
return false;
}
return true;
}
/**
* @param mixed $validationValue
* @noinspection PhpUnused

View File

@@ -51,8 +51,8 @@ class AuthTokenControl implements JobDataLess
public function run(): void
{
$lifetime = (int) $this->config->get('authTokenLifetime', 0) * 60;
$maxIdleTime = (int) $this->config->get('authTokenMaxIdleTime', 0) * 60;
$lifetime = (int) ($this->config->get('authTokenLifetime', 0) * 60);
$maxIdleTime = (int) ($this->config->get('authTokenMaxIdleTime', 0) * 60);
$portalIds = [];
@@ -69,11 +69,11 @@ class AuthTokenControl implements JobDataLess
foreach ($portals as $portal) {
$itemLifetime = $portal->get('authTokenLifetime') !== null ?
(int) $portal->get('authTokenLifetime') * 60 :
(int) ($portal->get('authTokenLifetime') * 60) :
$lifetime;
$itemMaxIdleTime = $portal->get('authTokenMaxIdleTime') !== null ?
(int) $portal->get('authTokenMaxIdleTime') * 60 :
(int) ($portal->get('authTokenMaxIdleTime') * 60) :
$maxIdleTime;
$this->process($portal->getId(), $itemLifetime, $itemMaxIdleTime);

View File

@@ -30,6 +30,7 @@
namespace Espo\Classes\MassAction\Email;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\MassAction\Data;
use Espo\Core\MassAction\MassAction;
@@ -44,11 +45,10 @@ use Espo\ORM\EntityManager;
use Espo\Tools\Email\Folder;
use Espo\Tools\Email\InboxService as EmailService;
use Exception;
use RuntimeException;
class MoveToFolder implements MassAction
{
private const FOLDER_INBOX = Folder::INBOX;
public function __construct(
private QueryBuilder $queryBuilder,
private EntityManager $entityManager,
@@ -68,7 +68,11 @@ class MoveToFolder implements MassAction
throw new BadRequest("No folder ID.");
}
if ($folderId !== self::FOLDER_INBOX && !str_starts_with($folderId, 'group:')) {
if (
$folderId !== Folder::INBOX &&
$folderId !== Folder::ARCHIVE &&
!str_starts_with($folderId, 'group:')
) {
$folder = $this->entityManager
->getRDBRepositoryByClass(EmailFolder::class)
->where([
@@ -93,7 +97,12 @@ class MoveToFolder implements MassAction
}
}
$query = $this->queryBuilder->build($params);
try {
$query = $this->queryBuilder->build($params);
}
catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
$collection = $this->entityManager
->getRDBRepositoryByClass(Email::class)

View File

@@ -31,6 +31,7 @@ namespace Espo\Classes\MassAction\User;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\MassAction\Actions\MassDelete as MassDeleteOriginal;
use Espo\Core\MassAction\Data;
@@ -59,6 +60,7 @@ class MassDelete implements MassAction
/**
* @throws Forbidden
* @throws BadRequest
* @throws Error
*/
public function process(Params $params, Data $data): Result
{

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\RecordHooks\AddressCountry;
use Espo\Core\Exceptions\ConflictSilent;
use Espo\Core\Exceptions\Error\Body;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Entities\AddressCountry;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @implements SaveHook<AddressCountry>
*/
class BeforeSave implements SaveHook
{
public function __construct(
private EntityManager $entityManager,
) {}
public function process(Entity $entity): void
{
$where = ['name' => $entity->getName()];
if (!$entity->isNew()) {
$where['id!='] = $entity->getId();
}
$one = $this->entityManager
->getRDBRepositoryByClass(AddressCountry::class)
->where($where)
->findOne();
if (!$one) {
return;
}
throw ConflictSilent::createWithBody(
'duplicateError',
Body::create()->withMessageTranslation('duplicateConflict')
);
}
}

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\RecordHooks\Email;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Mail\Account\SendingAccountProvider;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Core\Utils\Config;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\ORM\Entity;
/**
* @implements SaveHook<Email>
*/
class CheckFromAddress implements SaveHook
{
public function __construct(
private User $user,
private SendingAccountProvider $sendingAccountProvider,
private Config $config,
private Acl $acl,
) {}
public function process(Entity $entity): void
{
if ($this->user->isAdmin()) {
return;
}
$fromAddress = $entity->getFromAddress();
// Should be after 'getFromAddress'.
if (!$entity->isAttributeChanged('from')) {
return;
}
if (!$fromAddress) {
throw new BadRequest("No 'from' address");
}
if ($this->acl->checkScope('Import')) {
return;
}
$fromAddress = strtolower($fromAddress);
foreach ($this->user->getEmailAddressGroup()->getAddressList() as $address) {
if ($fromAddress === strtolower($address)) {
return;
}
}
if ($this->sendingAccountProvider->getShared($this->user, $fromAddress)) {
return;
}
$system = $this->sendingAccountProvider->getSystem();
if (
$system &&
$this->config->get('outboundEmailIsShared') &&
$system->getEmailAddress()
) {
if ($fromAddress === strtolower($system->getEmailAddress())) {
return;
}
}
throw new Forbidden("Not allowed 'from' address.");
}
}

View File

@@ -76,9 +76,9 @@ class Main implements AdditionalApplier
return;
}
if ($this->checkApplyDateSentIndex($queryBuilder, $searchParams)) {
/*if ($this->checkApplyDateSentIndex($queryBuilder, $searchParams)) {
$queryBuilder->useIndex('dateSent');
}
}*/
}
private function joinEmailUser(SelectBuilder $queryBuilder): void
@@ -93,6 +93,7 @@ class Main implements AdditionalApplier
Email::USERS_COLUMN_IS_READ,
Email::USERS_COLUMN_IS_IMPORTANT,
Email::USERS_COLUMN_IN_TRASH,
Email::USERS_COLUMN_IN_ARCHIVE,
Email::USERS_COLUMN_FOLDER_ID,
];
@@ -116,7 +117,7 @@ class Main implements AdditionalApplier
return null;
}
private function checkApplyDateSentIndex(SelectBuilder $queryBuilder, SearchParams $searchParams): bool
/*private function checkApplyDateSentIndex(SelectBuilder $queryBuilder, SearchParams $searchParams): bool
{
if ($searchParams->getTextFilter()) {
return false;
@@ -149,5 +150,5 @@ class Main implements AdditionalApplier
}
return true;
}
}*/
}

View File

@@ -0,0 +1,83 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Select\Email\Where\ItemConverters;
use Espo\Core\Select\Helpers\RandomStringGenerator;
use Espo\Core\Select\Where\Item;
use Espo\Core\Select\Where\ItemConverter;
use Espo\Classes\Select\Email\Helpers\EmailAddressHelper;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class CcEquals implements ItemConverter
{
public function __construct(
private EmailAddressHelper $emailAddressHelper,
private RandomStringGenerator $randomStringGenerator
) {}
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
{
$value = $item->getValue();
if (!$value) {
return WhereClause::fromRaw([
'id' => null,
]);
}
$emailAddressId = $this->emailAddressHelper->getEmailAddressIdByValue($value);
if (!$emailAddressId) {
return WhereClause::fromRaw([
'id' => null,
]);
}
$queryBuilder->distinct();
$alias = 'emailEmailAddress' . $this->randomStringGenerator->generate();
$queryBuilder->leftJoin(
'EmailEmailAddress',
$alias,
[
'emailId:' => 'id',
'deleted' => false,
]
);
return WhereClause::fromRaw([
$alias . '.emailAddressId' => $emailAddressId,
$alias . '.addressType' => 'cc',
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Select\Email\Where\ItemConverters;
use Espo\Core\Select\Where\Item;
use Espo\Core\Select\Where\ItemConverter;
use Espo\Classes\Select\Email\Helpers\JoinHelper;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class InArchiveIsFalse implements ItemConverter
{
public function __construct(private User $user, private JoinHelper $joinHelper)
{}
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
{
$this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId());
return WhereClause::fromRaw([
Email::ALIAS_INBOX . '.inArchive' => false,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Select\Email\Where\ItemConverters;
use Espo\Core\Select\Where\Item;
use Espo\Core\Select\Where\ItemConverter;
use Espo\Classes\Select\Email\Helpers\JoinHelper;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class InArchiveIsTrue implements ItemConverter
{
public function __construct(private User $user, private JoinHelper $joinHelper)
{}
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
{
$this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId());
return WhereClause::fromRaw([
Email::ALIAS_INBOX . '.inArchive' => true,
]);
}
}

View File

@@ -41,6 +41,9 @@ use Espo\Entities\User;
use Espo\Classes\Select\Email\Helpers\JoinHelper;
use Espo\Tools\Email\Folder;
/**
* @noinspection PhpUnused
*/
class InFolder implements ItemConverter
{
public function __construct(
@@ -59,17 +62,19 @@ class InFolder implements ItemConverter
Folder::IMPORTANT => $this->convertImportant($queryBuilder),
Folder::SENT => $this->convertSent($queryBuilder),
Folder::TRASH => $this->convertTrash($queryBuilder),
Folder::DRAFTS => $this->convertDraft($queryBuilder),
Folder::ARCHIVE => $this->convertArchive($queryBuilder),
Folder::DRAFTS => $this->convertDraft(),
default => $this->convertFolderId($queryBuilder, $folderId),
};
}
protected function convertInbox(QueryBuilder $queryBuilder): WhereClauseItem
private function convertInbox(QueryBuilder $queryBuilder): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
$whereClause = [
Email::ALIAS_INBOX . '.inTrash' => false,
Email::ALIAS_INBOX . '.inArchive' => false,
Email::ALIAS_INBOX . '.folderId' => null,
Email::ALIAS_INBOX . '.userId' => $this->user->getId(),
[
@@ -83,7 +88,7 @@ class InFolder implements ItemConverter
$emailAddressIdList = $this->getEmailAddressIdList();
if (!empty($emailAddressIdList)) {
if ($emailAddressIdList !== []) {
$whereClause['fromEmailAddressId!='] = $emailAddressIdList;
$whereClause[] = [
@@ -92,8 +97,7 @@ class InFolder implements ItemConverter
'createdById!=' => $this->user->getId(),
],
];
}
else {
} else {
$whereClause[] = [
'status' => Email::STATUS_ARCHIVED,
'createdById!=' => $this->user->getId(),
@@ -103,7 +107,7 @@ class InFolder implements ItemConverter
return WhereClause::fromRaw($whereClause);
}
protected function convertSent(QueryBuilder $queryBuilder): WhereClauseItem
private function convertSent(QueryBuilder $queryBuilder): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
@@ -122,7 +126,7 @@ class InFolder implements ItemConverter
]);
}
protected function convertImportant(QueryBuilder $queryBuilder): WhereClauseItem
private function convertImportant(QueryBuilder $queryBuilder): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
@@ -132,7 +136,7 @@ class InFolder implements ItemConverter
]);
}
protected function convertTrash(QueryBuilder $queryBuilder): WhereClauseItem
private function convertTrash(QueryBuilder $queryBuilder): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
@@ -142,7 +146,17 @@ class InFolder implements ItemConverter
]);
}
protected function convertDraft(QueryBuilder $queryBuilder): WhereClauseItem
private function convertArchive(QueryBuilder $queryBuilder): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
return WhereClause::fromRaw([
Email::ALIAS_INBOX . '.userId' => $this->user->getId(),
Email::ALIAS_INBOX . '.inArchive' => true,
]);
}
private function convertDraft(): WhereClauseItem
{
return WhereClause::fromRaw([
'status' => Email::STATUS_DRAFT,
@@ -150,7 +164,7 @@ class InFolder implements ItemConverter
]);
}
protected function convertFolderId(QueryBuilder $queryBuilder, string $folderId): WhereClauseItem
private function convertFolderId(QueryBuilder $queryBuilder, string $folderId): WhereClauseItem
{
$this->joinEmailUser($queryBuilder);
@@ -166,12 +180,14 @@ class InFolder implements ItemConverter
'OR' => [
Email::ALIAS_INBOX . '.id' => null,
Email::ALIAS_INBOX . '.inTrash' => false,
Email::ALIAS_INBOX . '.inArchive' => false,
]
]);
}
return WhereClause::fromRaw([
Email::ALIAS_INBOX . '.inTrash' => false,
Email::ALIAS_INBOX . '.inArchive' => false,
Email::ALIAS_INBOX . '.folderId' => $folderId,
'groupFolderId' => null,
]);

View File

@@ -0,0 +1,47 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Select\User\BoolFilters;
use Espo\Core\Select\Bool\Filter;
use Espo\Entities\User;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class OnlyMe implements Filter
{
public function __construct(
private User $user
) {}
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
{
$queryBuilder->where(['id' => $this->user->getId()]);
}
}

View File

@@ -30,11 +30,8 @@
namespace Espo\Core\Acl\AccessChecker\AccessCheckers;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\Core\Utils\Metadata;
use Espo\Core\Acl\DefaultAccessChecker;
use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency;
use Espo\Core\Acl\AccessEntityCreateChecker;
@@ -67,17 +64,12 @@ class Foreign implements
{
use DefaultAccessCheckerDependency;
private Metadata $metadata;
private EntityManager $entityManager;
public function __construct(
Metadata $metadata,
private Metadata $metadata,
DefaultAccessChecker $defaultAccessChecker,
EntityManager $entityManager
private EntityManager $entityManager
) {
$this->metadata = $metadata;
$this->defaultAccessChecker = $defaultAccessChecker;
$this->entityManager = $entityManager;
}
private function getForeignEntity(Entity $entity): ?Entity
@@ -121,7 +113,9 @@ class Foreign implements
return false;
}
return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data);
// @todo Check parent 'edit' access.
return $this->defaultAccessChecker->checkEntityCreate($user, $foreign, $data);
}
public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool
@@ -132,7 +126,7 @@ class Foreign implements
return false;
}
return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data);
return $this->defaultAccessChecker->checkEntityRead($user, $foreign, $data);
}
public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool
@@ -143,7 +137,7 @@ class Foreign implements
return false;
}
return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data);
return $this->defaultAccessChecker->checkEntityEdit($user, $foreign, $data);
}
public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool
@@ -151,10 +145,14 @@ class Foreign implements
$foreign = $this->getForeignEntity($entity);
if (!$foreign) {
if ($user->isAdmin()) {
return true;
}
return false;
}
return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data);
return $this->defaultAccessChecker->checkEntityDelete($user, $foreign, $data);
}
public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool
@@ -165,6 +163,6 @@ class Foreign implements
return false;
}
return $this->defaultAccessChecker->checkEntityStream($user, $entity, $data);
return $this->defaultAccessChecker->checkEntityStream($user, $foreign, $data);
}
}

View File

@@ -81,7 +81,6 @@ class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamCheck
return false;
}
/** @var string[] $userTeamIdList */
$userTeamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS);
if (

View File

@@ -52,9 +52,7 @@ class DefaultTable implements Table
protected string $type = 'acl';
protected string $defaultAclType = 'recordAllTeamOwnNo';
/**
* @var string[]
*/
/** @var string[] */
private $actionList = [
self::ACTION_READ,
self::ACTION_STREAM,
@@ -63,16 +61,12 @@ class DefaultTable implements Table
self::ACTION_CREATE,
];
/**
* @var string[]
*/
/** @var string[] */
private $booleanActionList = [
self::ACTION_CREATE,
];
/**
* @var string[]
*/
/** @var string[] */
protected $levelList = [
self::LEVEL_YES,
self::LEVEL_ALL,
@@ -81,30 +75,23 @@ class DefaultTable implements Table
self::LEVEL_NO,
];
/**
* @var string[]
*/
/** @var string[] */
private $fieldActionList = [
self::ACTION_READ,
self::ACTION_EDIT,
];
/**
* @var string[]
*/
/** @var string[] */
protected $fieldLevelList = [
self::LEVEL_YES,
self::LEVEL_NO,
];
private stdClass $data;
private string $cacheKey;
/**
* @var string[]
*/
/** @var string[] */
private $valuePermissionList = [];
private ScopeDataResolver $scopeDataResolver;
public function __construct(
private RoleListProvider $roleListProvider,
@@ -112,7 +99,7 @@ class DefaultTable implements Table
protected User $user,
Config $config,
protected Metadata $metadata,
DataCache $dataCache
DataCache $dataCache,
) {
$this->data = (object) [
@@ -135,14 +122,15 @@ class DefaultTable implements Table
$cachedData = $dataCache->get($this->cacheKey);
$this->data = $cachedData;
}
else {
} else {
$this->load();
if ($config->get('useCache')) {
$dataCache->store($this->cacheKey, $this->data);
}
}
$this->scopeDataResolver = new ScopeDataResolver($this);
}
/**
@@ -156,11 +144,7 @@ class DefaultTable implements Table
$data = $this->data->scopes->$scope;
if (is_string($data)) {
return $this->getScopeData($data);
}
return ScopeData::fromRaw($data);
return $this->scopeDataResolver->resolve($data);
}
/**

View File

@@ -0,0 +1,66 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Acl\Table;
use Espo\Core\Acl\ScopeData;
use Espo\Core\Acl\Table;
/**
* @internal
*/
class ScopeDataResolver
{
public function __construct(
private Table $table,
) {}
public function resolve(mixed $data): ScopeData
{
if (!is_string($data)) {
return ScopeData::fromRaw($data);
}
$foreignScope = $data;
$isBoolean = false;
if (str_starts_with($data, 'boolean:')) {
[, $foreignScope] = explode(':', $data, 2);
$isBoolean = true;
}
$scopeData = $this->table->getScopeData($foreignScope);
if ($isBoolean && !$scopeData->isBoolean()) {
return ScopeData::fromRaw(true);
}
return $scopeData;
}
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Api;
use Espo\Core\Authentication\HeaderKey;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\ServiceUnavailable;
use Espo\Core\Exceptions\Forbidden;
@@ -47,8 +48,6 @@ use Exception;
*/
class Auth
{
private const HEADER_ESPO_AUTHORIZATION = 'Espo-Authorization';
public function __construct(
private Log $log,
private Authentication $authentication,
@@ -275,7 +274,7 @@ class Auth
private function obtainAuthenticationMethodFromRequest(Request $request): ?string
{
if ($request->hasHeader(self::HEADER_ESPO_AUTHORIZATION)) {
if ($request->hasHeader(HeaderKey::AUTHORIZATION)) {
return null;
}
@@ -305,12 +304,10 @@ class Auth
*/
private function obtainUsernamePasswordFromRequest(Request $request): array
{
if ($request->hasHeader(self::HEADER_ESPO_AUTHORIZATION)) {
[$username, $password] = $this->decodeAuthorizationString(
$request->getHeader(self::HEADER_ESPO_AUTHORIZATION) ?? ''
);
if ($request->hasHeader(HeaderKey::AUTHORIZATION)) {
$headerValue = $request->getHeader(HeaderKey::AUTHORIZATION) ?? '';
return [$username, $password];
return $this->decodeAuthorizationString($headerValue);
}
if (

View File

@@ -67,9 +67,7 @@ class Authentication
{
private const LOGOUT_USERNAME = '**logout';
private const HEADER_ESPO_AUTHORIZATION = 'Espo-Authorization';
private const HEADER_CREATE_TOKEN_SECRET = 'Espo-Authorization-Create-Token-Secret';
private const HEADER_BY_TOKEN = 'Espo-Authorization-By-Token';
private const HEADER_ANOTHER_USER = 'X-Another-User';
private const HEADER_LOGOUT_REDIRECT_URL = 'X-Logout-Redirect-Url';
@@ -155,7 +153,7 @@ class Authentication
}
}
$byTokenAndUsername = $request->getHeader(self::HEADER_BY_TOKEN) === 'true';
$byTokenAndUsername = $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
if ($method && $byTokenAndUsername) {
return Result::fail(FailReason::DISCREPANT_DATA);
@@ -234,6 +232,7 @@ class Authentication
$this->applicationUser->setUser($loggedUser);
if (
!$result->bypassSecondStep() &&
!$result->isSecondStepRequired() &&
!$authToken &&
$this->configDataProvider->isTwoFactorEnabled()
@@ -253,7 +252,7 @@ class Authentication
if (
!$result->isSecondStepRequired() &&
$request->getHeader(self::HEADER_ESPO_AUTHORIZATION)
$request->getHeader(HeaderKey::AUTHORIZATION)
) {
$authToken = $this->processAuthTokenFinal(
$authToken,

View File

@@ -37,6 +37,7 @@ use Espo\Core\Utils\Metadata;
class ConfigDataProvider
{
private const FAILED_ATTEMPTS_PERIOD = '60 seconds';
private const FAILED_CODE_ATTEMPTS_PERIOD = '5 minutes';
private const MAX_FAILED_ATTEMPT_NUMBER = 10;
public function __construct(private Config $config, private Metadata $metadata)
@@ -50,6 +51,14 @@ class ConfigDataProvider
return $this->config->get('authFailedAttemptsPeriod', self::FAILED_ATTEMPTS_PERIOD);
}
/**
* A period for max failed 2FA code attempts checking.
*/
public function getFailedCodeAttemptsPeriod(): string
{
return $this->config->get('authFailedCodeAttemptsPeriod', self::FAILED_CODE_ATTEMPTS_PERIOD);
}
/**
* Max failed log in attempts.
*/

View File

@@ -1,3 +1,4 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
@@ -26,7 +27,11 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('views/email-filter/fields/action', ['views/fields/enum'], function (Dep) {
namespace Espo\Core\Authentication;
return Dep.extend({});
});
class HeaderKey
{
public const AUTHORIZATION_BY_TOKEN = 'Espo-Authorization-By-Token';
public const AUTHORIZATION_CODE = 'Espo-Authorization-Code';
public const AUTHORIZATION = 'Espo-Authorization';
}

View File

@@ -30,6 +30,7 @@
namespace Espo\Core\Authentication\Hook\Hooks;
use Espo\Core\Api\Util;
use Espo\Core\Authentication\HeaderKey;
use Espo\Core\Authentication\Hook\BeforeLogin;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Api\Request;
@@ -58,45 +59,22 @@ class FailedAttemptsLimit implements BeforeLogin
*/
public function process(AuthenticationData $data, Request $request): void
{
$isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true';
$isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true';
if ($isByTokenOnly) {
if ($isByTokenOnly || $this->configDataProvider->isAuthLogDisabled()) {
return;
}
if ($this->configDataProvider->isAuthLogDisabled()) {
return;
}
$isSecondStep = $request->getHeader('Espo-Authorization-Code') !== null;
$failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod();
$maxFailedAttempts = $this->configDataProvider->getMaxFailedAttemptNumber();
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
try {
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
}
catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$ipAddress = $this->util->obtainIpFromRequest($request);
$where = [
'requestTime>' => $requestTimeFrom->format('U'),
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'),
'isDenied' => true,
'ipAddress' => $ipAddress,
];
if ($isSecondStep) {
$where['username'] = $data->getUsername();
}
if (!$isSecondStep) {
$where['ipAddress'] = $ipAddress;
}
$wasFailed = (bool) $this->entityManager
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
->select(['id'])
@@ -112,16 +90,24 @@ class FailedAttemptsLimit implements BeforeLogin
->where($where)
->count();
if ($failAttemptCount <= $maxFailedAttempts) {
if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
return;
}
if ($isSecondStep) {
$username = $data->getUsername() ?? '';
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
}
throw new Forbidden("Max failed login attempts exceeded for IP address $ipAddress.");
}
private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime
{
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
try {
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
}
catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
return $requestTimeFrom;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Hook\Hooks;
use Espo\Core\Api\Request;
use Espo\Core\Authentication\AuthenticationData;
use Espo\Core\Authentication\ConfigDataProvider;
use Espo\Core\Authentication\Hook\BeforeLogin;
use Espo\Core\Exceptions\Forbidden;
use Espo\Entities\AuthLogRecord;
use Espo\ORM\EntityManager;
use DateTime;
use Exception;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class FailedCodeAttemptsLimit implements BeforeLogin
{
public function __construct(
private ConfigDataProvider $configDataProvider,
private EntityManager $entityManager,
) {}
/**
* @throws Forbidden
*/
public function process(AuthenticationData $data, Request $request): void
{
if (
$request->getHeader('Espo-Authorization-Code') === null ||
$this->configDataProvider->isAuthLogDisabled()
) {
return;
}
$isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true';
if ($isByTokenOnly) {
return;
}
$failedAttemptsPeriod = $this->configDataProvider->getFailedCodeAttemptsPeriod();
$where = [
'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'),
'isDenied' => true,
'username' => $data->getUsername(),
'denialReason' => AuthLogRecord::DENIAL_REASON_WRONG_CODE,
];
$wasFailed = (bool) $this->entityManager
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
->select(['id'])
->where($where)
->findOne();
if (!$wasFailed) {
return;
}
$failAttemptCount = $this->entityManager
->getRDBRepository(AuthLogRecord::ENTITY_TYPE)
->where($where)
->count();
if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) {
return;
}
$username = $data->getUsername() ?? '';
throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'.");
}
private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime
{
$requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT'));
try {
$requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod);
}
catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
return $requestTimeFrom;
}
}

View File

@@ -70,10 +70,16 @@ class Hmac implements Login
$string = $request->getMethod() . ' ' . $request->getResourcePath();
// To become a legacy.
if ($hash === ApiKey::hash($secretKey, $string)) {
return Result::success($user);
}
// As of v8.4.1.
if ($hash === hash_hmac('sha256', $string, $secretKey)) {
return Result::success($user);
}
return Result::fail(FailReason::HASH_NOT_MATCHED);
}
}

View File

@@ -152,7 +152,7 @@ class Login implements LoginInterface
return Result::fail(FailReason::USER_NOT_FOUND);
}
return Result::success($user);
return Result::success($user)->withBypassSecondStep();
}
private function loginFallback(Data $data, Request $request): Result

View File

@@ -51,6 +51,7 @@ class Result
private ?string $token = null;
private ?string $view = null;
private ?string $failReason = null;
private bool $bypassSecondStep = false;
private ?Data $data;
private function __construct(string $status, ?User $user = null, ?Data $data = null)
@@ -105,13 +106,23 @@ class Result
}
/**
* Second step is required. E.g. for 2FA.
* The second step is required.
*/
public function isSecondStepRequired(): bool
{
return $this->status === self::STATUS_SECOND_STEP_REQUIRED;
}
/**
* To bypass the second step.
*
* @since 8.4.0
*/
public function bypassSecondStep(): bool
{
return $this->bypassSecondStep;
}
/**
* Login is failed.
*/
@@ -183,4 +194,17 @@ class Result
{
return $this->failReason;
}
/**
* Clone with bypass second step.
*
* @since 8.4.0
*/
public function withBypassSecondStep(bool $bypassSecondStep = true): self
{
$obj = clone $this;
$obj->bypassSecondStep = $bypassSecondStep;
return $obj;
}
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Authentication\TwoFactor\Email;
use Espo\Core\Authentication\HeaderKey;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Utils\Log;
@@ -56,7 +57,7 @@ class EmailLogin implements Login
public function login(Result $result, Request $request): Result
{
$code = $request->getHeader('Espo-Authorization-Code');
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
$user = $result->getUser();

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Authentication\TwoFactor\Sms;
use Espo\Core\Authentication\HeaderKey;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
@@ -55,7 +56,7 @@ class SmsLogin implements Login
public function login(Result $result, Request $request): Result
{
$code = $request->getHeader('Espo-Authorization-Code');
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
$user = $result->getUser();

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Authentication\TwoFactor\Totp;
use Espo\Core\Authentication\HeaderKey;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Entities\UserData;
@@ -55,7 +56,7 @@ class TotpLogin implements Login
public function login(Result $result, Request $request): Result
{
$code = $request->getHeader('Espo-Authorization-Code');
$code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE);
$user = $result->getUser();

View File

@@ -30,60 +30,36 @@
namespace Espo\Core\ExternalAccount;
use Espo\Core\Exceptions\Error;
use Espo\Entities\Integration as IntegrationEntity;
use Espo\Entities\ExternalAccount as ExternalAccountEntity;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\Language;
use Espo\Entities\Integration;
use Espo\Entities\ExternalAccount;
use Espo\Entities\Notification;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Core\ExternalAccount\Clients\IClient;
use Espo\Core\ExternalAccount\OAuth2\Client as OAuth2Client;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use RuntimeException;
class ClientManager
{
/**
* @var EntityManager
*/
protected $entityManager;
private const REFRESH_TOKEN_ATTEMPTS_LIMIT = 20;
private const REFRESH_TOKEN_ATTEMPTS_PERIOD = '1 day';
/**
* @var Metadata
*/
protected $metadata;
/**
* @var Config
*/
protected $config;
/**
* @var InjectableFactory|null
*/
protected $injectableFactory = null;
/**
*
* @var array<string, array<string, mixed>>
*/
/** @var array<string, (array<string, mixed> & array{externalAccountEntity: ExternalAccount})> */
protected $clientMap = [];
public function __construct(
EntityManager $entityManager,
Metadata $metadata,
Config $config,
?InjectableFactory $injectableFactory = null
) {
$this->entityManager = $entityManager;
$this->metadata = $metadata;
$this->config = $config;
$this->injectableFactory = $injectableFactory;
}
protected EntityManager $entityManager,
protected Metadata $metadata,
protected Config $config,
protected ?InjectableFactory $injectableFactory = null,
private ?Language $language = null
) {}
/**
* @param array{
@@ -94,50 +70,61 @@ class ClientManager
* } $data
* @throws Error
*/
public function storeAccessToken(string $hash, array $data): void
public function storeAccessToken(object $client, array $data): void
{
if (empty($this->clientMap[$hash]) || empty($this->clientMap[$hash]['externalAccountEntity'])) {
try {
$account = $this->getClientRecord($client);
}
catch (Error) {
// @todo Revise.
return;
}
/** @var ExternalAccountEntity $externalAccountEntity */
$externalAccountEntity = $this->clientMap[$hash]['externalAccountEntity'];
$externalAccountEntity->set('accessToken', $data['accessToken']);
$externalAccountEntity->set('tokenType', $data['tokenType']);
$externalAccountEntity->set('expiresAt', $data['expiresAt'] ?? null);
$account->setAccessToken($data['accessToken']);
$account->setTokenType($data['tokenType']);
$account->setExpiresAt($data['expiresAt'] ?? null);
$account->setRefreshTokenAttempts(null);
if ($data['refreshToken'] ?? null) {
$externalAccountEntity->set('refreshToken', $data['refreshToken']);
$account->setRefreshToken($data['refreshToken']);
}
$copy = $this->entityManager->getEntity(ExternalAccountEntity::ENTITY_TYPE, $externalAccountEntity->getId());
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $account->getId());
if (!$copy) {
return;
if (!$account) {
throw new Error("External Account: Account removed.");
}
if (!$copy->get('enabled')) {
throw new Error("External Account Client Manager: Account got disabled.");
if (!$account->isEnabled()) {
throw new Error("External Account: Account disabled.");
}
$copy->set('accessToken', $data['accessToken']);
$copy->set('tokenType', $data['tokenType']);
$copy->set('expiresAt', $data['expiresAt'] ?? null);
$account->setAccessToken($data['accessToken']);
$account->setTokenType($data['tokenType']);
$account->setExpiresAt($data['expiresAt'] ?? null);
$account->setRefreshTokenAttempts(null);
if ($data['refreshToken'] ?? null) {
$copy->set('refreshToken', $data['refreshToken'] ?? null);
$account->setRefreshToken($data['refreshToken'] ?? null);
}
$this->entityManager->saveEntity($copy, [
$this->entityManager->saveEntity($account, [
'isTokenRenewal' => true,
SaveOption::SKIP_HOOKS => true,
]);
}
/**
* @throws Error
*/
public function create(string $integration, string $userId): ?object
{
$authMethod = $this->metadata->get("integrations.{$integration}.authMethod");
$authMethod = $this->metadata->get("integrations.$integration.authMethod");
if (ucfirst($authMethod) === 'OAuth2') {
return $this->createOAuth2($integration, $userId);
}
$methodName = 'create' . ucfirst($authMethod);
@@ -146,101 +133,88 @@ class ClientManager
}
if (!$this->injectableFactory) {
throw new Error();
throw new RuntimeException("No injectableFactory.");
}
/** @var IntegrationEntity|null $integrationEntity */
$integrationEntity = $this->entityManager->getEntity(IntegrationEntity::ENTITY_TYPE, $integration);
/** @var ?Integration $integrationEntity */
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
/** @var ExternalAccountEntity|null $externalAccountEntity */
$externalAccountEntity = $this->entityManager
->getEntity(ExternalAccountEntity::ENTITY_TYPE, $integration . '__' . $userId);
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
if (!$externalAccountEntity) {
throw new Error("External Account {$integration} not found for {$userId}.");
if (!$account) {
throw new Error("External Account $integration not found for $userId.");
}
if (!$integrationEntity) {
return null;
}
if (!$integrationEntity->get('enabled')) {
return null;
}
if (!$externalAccountEntity->get('enabled')) {
if (!$integrationEntity || !$integrationEntity->isEnabled() || !$account->isEnabled()) {
return null;
}
/** @var class-string $className */
$className = $this->metadata->get("integrations.{$integration}.clientClassName");
$className = $this->metadata->get("integrations.$integration.clientClassName");
$client = $this->injectableFactory->create($className);
if (!method_exists($client, 'setup')) {
throw new Error("{$className} does not have `setup` method.");
throw new RuntimeException("$className does not have `setup` method.");
}
$client->setup(
$userId,
$integrationEntity,
$externalAccountEntity,
$account,
$this
);
$this->addToClientMap($client, $integrationEntity, $externalAccountEntity, $userId);
$this->addToClientMap($client, $integrationEntity, $account, $userId);
return $client;
}
/**
* @throws Error
*/
protected function createOAuth2(string $integration, string $userId): ?object
{
/** @var IntegrationEntity|null $integrationEntity */
$integrationEntity = $this->entityManager->getEntity(IntegrationEntity::ENTITY_TYPE, $integration);
/** @var ?Integration $integrationEntity */
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
/** @var ExternalAccountEntity|null $externalAccountEntity */
$externalAccountEntity = $this->entityManager
->getEntity(ExternalAccountEntity::ENTITY_TYPE, $integration . '__' . $userId);
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
/** @var class-string $className */
$className = $this->metadata->get("integrations.{$integration}.clientClassName");
$className = $this->metadata->get("integrations.$integration.clientClassName");
$redirectUri = $this->config->get('siteUrl') . '?entryPoint=oauthCallback';
$redirectUriPath = $this->metadata->get(['integrations', $integration, 'params', 'redirectUriPath']);
if ($redirectUriPath) {
$redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/' . $redirectUriPath;
}
if (!$externalAccountEntity) {
throw new Error("External Account {$integration} not found for '{$userId}'.");
if (!$account) {
throw new Error("External Account $integration not found for '$userId'.");
}
if (!$integrationEntity) {
return null;
}
if (!$integrationEntity->get('enabled')) {
return null;
}
if (!$externalAccountEntity->get('enabled')) {
if (
!$integrationEntity ||
!$integrationEntity->isEnabled() ||
!$account->isEnabled()
) {
return null;
}
$oauth2Client = new OAuth2Client();
$params = [
'endpoint' => $this->metadata->get("integrations.{$integration}.params.endpoint"),
'tokenEndpoint' => $this->metadata->get("integrations.{$integration}.params.tokenEndpoint"),
'endpoint' => $this->metadata->get("integrations.$integration.params.endpoint"),
'tokenEndpoint' => $this->metadata->get("integrations.$integration.params.tokenEndpoint"),
'clientId' => $integrationEntity->get('clientId'),
'clientSecret' => $integrationEntity->get('clientSecret'),
'redirectUri' => $redirectUri,
'accessToken' => $externalAccountEntity->get('accessToken'),
'refreshToken' => $externalAccountEntity->get('refreshToken'),
'tokenType' => $externalAccountEntity->get('tokenType'),
'expiresAt' => $externalAccountEntity->get('expiresAt'),
'accessToken' => $account->getAccessToken(),
'refreshToken' => $account->getRefreshToken(),
'tokenType' => $account->getTokenType(),
'expiresAt' => $account->getExpiresAt() ? $account->getExpiresAt()->toString() : null,
];
foreach (get_object_vars($integrationEntity->getValueMap()) as $k => $v) {
@@ -263,13 +237,12 @@ class ClientManager
'params' => $params,
'manager' => $this,
]);
}
else {
} else {
// For backward compatibility.
$client = new $className($oauth2Client, $params, $this);
}
$this->addToClientMap($client, $integrationEntity, $externalAccountEntity, $userId);
$this->addToClientMap($client, $integrationEntity, $account, $userId);
return $client;
}
@@ -280,16 +253,16 @@ class ClientManager
*/
protected function addToClientMap(
$client,
IntegrationEntity $integrationEntity,
ExternalAccountEntity $externalAccountEntity,
Integration $integration,
ExternalAccount $account,
string $userId
) {
$this->clientMap[spl_object_hash($client)] = [
'client' => $client,
'userId' => $userId,
'integration' => $integrationEntity->getId(),
'integrationEntity' => $integrationEntity,
'externalAccountEntity' => $externalAccountEntity,
'integration' => $integration->getId(),
'integrationEntity' => $integration,
'externalAccountEntity' => $account,
];
}
@@ -297,12 +270,16 @@ class ClientManager
* @param object $client
* @throws Error
*/
protected function getClientRecord($client): Entity
protected function getClientRecord($client): ExternalAccount
{
$data = $this->clientMap[spl_object_hash($client)];
$data = $this->clientMap[spl_object_hash($client)] ?? null;
if (!$data) {
throw new Error("External Account Client Manager: Client not found in hash.");
throw new Error("External Account: Client not found in hash.");
}
if (!isset($data['externalAccountEntity'])) {
throw new Error("External Account: Account not found in hash.");
}
return $data['externalAccountEntity'];
@@ -314,91 +291,152 @@ class ClientManager
*/
public function isClientLocked($client): bool
{
$externalAccountEntity = $this->getClientRecord($client);
$accountSet = $this->getClientRecord($client);
$id = $externalAccountEntity->getId();
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
$e = $this->entityManager
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
->select(['id', 'isLocked'])
->where(['id' => $id])
->findOne();
if (!$e) {
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
}
return $e->get('isLocked');
return $account->isLocked();
}
/**
* @throws Error
*/
public function lockClient(object $client): void
{
$externalAccountEntity = $this->getClientRecord($client);
$accountSet = $this->getClientRecord($client);
$id = $externalAccountEntity->getId();
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
$account->setIsLocked(true);
$e = $this->entityManager
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
->select(['id', 'isLocked'])
->where(['id' => $id])
->findOne();
if (!$e) {
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
}
$e->set('isLocked', true);
$this->entityManager->saveEntity($e, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
}
public function unlockClient(object $client): void
{
$externalAccountEntity = $this->getClientRecord($client);
$id = $externalAccountEntity->getId();
$e = $this->entityManager
->getRDBRepository(ExternalAccountEntity::ENTITY_TYPE)
->select(['id', 'isLocked'])
->where(['id' => $id])
->findOne();
if (!$e) {
throw new Error("External Account Client Manager: Client '{$id}' not found in DB.");
}
$e->set('isLocked', false);
$this->entityManager->saveEntity($e, [
$this->entityManager->saveEntity($account, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
}
/**
* @throws Error
*/
public function unlockClient(object $client): void
{
$accountSet = $this->getClientRecord($client);
$accountSet = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
$accountSet->setIsLocked(false);
$this->entityManager->saveEntity($accountSet, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
}
/**
* @throws Error
*/
public function controlRefreshTokenAttempts(object $client): void
{
$accountSet = $this->getClientRecord($client);
$account = $this->entityManager
->getRDBRepositoryByClass(ExternalAccount::class)
->getById($accountSet->getId());
if (!$account) {
return;
}
$attempts = $account->getRefreshTokenAttempts();
$account->setRefreshTokenAttempts($attempts + 1);
if (
$attempts >= self::REFRESH_TOKEN_ATTEMPTS_LIMIT &&
$account->getExpiresAt() &&
$account->getExpiresAt()
->modify('+' . self::REFRESH_TOKEN_ATTEMPTS_PERIOD)
->isLessThan(DateTime::createNow())
) {
$account->setIsEnabled(false);
$account->unsetData();
}
$this->entityManager->saveEntity($account, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
if (!$account->isEnabled()) {
$this->createDisableNotification($account);
}
}
/**
* @param IClient $client
* @throws Error
*/
public function reFetchClient(object $client): void
public function reFetchClient($client): void
{
$externalAccountEntity = $this->getClientRecord($client);
$accountSet = $this->getClientRecord($client);
$id = $externalAccountEntity->getId();
$id = $accountSet->getId();
$e = $this->entityManager->getEntityById(ExternalAccountEntity::ENTITY_TYPE, $id);
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $id);
if (!$e) {
throw new Error("External Account Client Manager: Client {$id} not found in DB.");
if (!$account) {
throw new Error("External Account: Client $id not found in DB.");
}
$data = $e->getValueMap();
$data = $account->getValueMap();
$externalAccountEntity->set($data);
$accountSet->set($data);
$client->setParams(get_object_vars($data));
}
/**
* @throws Error
*/
private function fetchAccountOnlyWithIsLocked(string $id): ExternalAccount
{
$account = $this->entityManager
->getRDBRepository(ExternalAccount::ENTITY_TYPE)
->select(['id', 'isLocked'])
->where(['id' => $id])
->findOne();
if (!$account) {
throw new Error("External Account: Client '$id' not found in DB.");
}
return $account;
}
private function createDisableNotification(ExternalAccount $account): void
{
if (!str_contains($account->getId(), '__')) {
return;
}
[$integration, $userId] = explode('__', $account->getId());
if (!$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId)) {
return;
}
if (!$this->language) {
return;
}
$message = $this->language->translateLabel('externalAccountNoConnectDisabled', 'messages', 'ExternalAccount');
$message = str_replace('{integration}', $integration, $message);
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
$notification
->setType(Notification::TYPE_MESSAGE)
->setMessage($message)
->setUserId($userId);
$this->entityManager->saveEntity($notification);
}
}

View File

@@ -36,4 +36,3 @@ class Google extends OAuth2Abstract
return 'https://www.googleapis.com/calendar/v3/users/me/calendarList';
}
}

View File

@@ -30,6 +30,7 @@
namespace Espo\Core\ExternalAccount\Clients;
use Espo\Core\Exceptions\Error;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Json;
use Espo\Core\ExternalAccount\ClientManager;
use Espo\Core\ExternalAccount\OAuth2\Client;
@@ -164,9 +165,7 @@ abstract class OAuth2Abstract implements IClient
*/
protected function afterTokenRefreshed(array $data): void
{
if ($this->manager) {
$this->manager->storeAccessToken(spl_object_hash($this), $data);
}
$this->manager?->storeAccessToken($this, $data);
}
/**
@@ -184,7 +183,6 @@ abstract class OAuth2Abstract implements IClient
$data['accessToken'] = $result['access_token'] ?? null;
$data['tokenType'] = $result['token_type'] ?? null;
$data['expiresAt'] = null;
if (isset($result['refresh_token']) && $result['refresh_token'] !== $this->refreshToken) {
@@ -194,7 +192,7 @@ abstract class OAuth2Abstract implements IClient
if (isset($result['expires_in']) && is_numeric($result['expires_in'])) {
$data['expiresAt'] = (new DateTime())
->modify('+' . $result['expires_in'] . ' seconds')
->format('Y-m-d H:i:s');
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
}
/**
@@ -228,36 +226,34 @@ abstract class OAuth2Abstract implements IClient
]
);
if ($response['code'] == 200) {
if (!empty($response['result'])) {
/** @var array<string, mixed> $result */
$result = $response['result'];
$data = $this->getAccessTokenDataFromResponseResult($result);
$data['refreshToken'] = $result['refresh_token'] ?? null;
/**
* @var ?array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt: ?string,
* refreshToken: ?string,
* }
*/
return $data;
}
else {
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
return null;
}
}
else {
if ($response['code'] != 200) {
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
return null;
}
return null;
if (empty($response['result'])) {
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
return null;
}
/** @var array<string, mixed> $result */
$result = $response['result'];
$data = $this->getAccessTokenDataFromResponseResult($result);
$data['refreshToken'] = $result['refresh_token'] ?? null;
/**
* @var ?array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt: ?string,
* refreshToken: ?string,
* }
*/
return $data;
}
/**
@@ -281,7 +277,7 @@ abstract class OAuth2Abstract implements IClient
return true;
}
catch (Exception $e) {
catch (Exception) {
return false;
}
}
@@ -299,8 +295,8 @@ abstract class OAuth2Abstract implements IClient
try {
$dt = new DateTime($this->getParam('expiresAt'));
}
catch (Exception $e) {
$this->log->debug("Oauth: Bad expires-at parameter stored for client {$this->clientId}.");
catch (Exception) {
$this->log->debug("Oauth: Bad expires-at parameter stored for client $this->clientId.");
return;
}
@@ -311,9 +307,7 @@ abstract class OAuth2Abstract implements IClient
return;
}
$this->log->debug("Oauth: Refreshing expired token for client {$this->clientId}.");
$until = microtime(true) + $this::LOCK_TIMEOUT;
$this->log->debug("Oauth: Refreshing expired token for client $this->clientId.");
if (!$this->isLocked()) {
$this->refreshToken();
@@ -321,11 +315,13 @@ abstract class OAuth2Abstract implements IClient
return;
}
$until = microtime(true) + $this::LOCK_TIMEOUT;
while (true) {
usleep($this::LOCK_CHECK_STEP * 1000000);
if (!$this->isLocked()) { /** @phpstan-ignore-line */
$this->log->debug("Oauth: Waited until unlocked for client {$this->clientId}.");
if (!$this->isLocked()) {
$this->log->debug("Oauth: Waited until unlocked for client $this->clientId.");
$this->reFetch();
@@ -333,7 +329,7 @@ abstract class OAuth2Abstract implements IClient
}
if (microtime(true) > $until) {
$this->log->debug("Oauth: Waited until unlocked but timed out for client {$this->clientId}.");
$this->log->debug("Oauth: Waited until unlocked but timed out for client $this->clientId.");
$this->unlock();
@@ -344,6 +340,10 @@ abstract class OAuth2Abstract implements IClient
$this->refreshToken();
}
/**
* @throws Error
* @phpstan-impure
*/
protected function isLocked(): bool
{
if (!$this->manager) {
@@ -353,6 +353,9 @@ abstract class OAuth2Abstract implements IClient
return $this->manager->isClientLocked($this);
}
/**
* @throws Error
*/
protected function lock(): void
{
if (!$this->manager) {
@@ -362,6 +365,9 @@ abstract class OAuth2Abstract implements IClient
$this->manager->lockClient($this);
}
/**
* @throws Error
*/
protected function unlock(): void
{
if (!$this->manager) {
@@ -371,6 +377,21 @@ abstract class OAuth2Abstract implements IClient
$this->manager->unlockClient($this);
}
/**
* @throws Error
*/
private function controlRefreshTokenAttempts(): void
{
if (!$this->manager) {
return;
}
$this->manager->controlRefreshTokenAttempts($this);
}
/**
* @throws Error
*/
protected function reFetch(): void
{
if (!$this->manager) {
@@ -387,7 +408,6 @@ abstract class OAuth2Abstract implements IClient
* @param ?string $contentType
* @param bool $allowRenew
* @return mixed
*
* @throws Error
*/
public function request(
@@ -406,14 +426,8 @@ abstract class OAuth2Abstract implements IClient
$httpHeaders['Content-Type'] = $contentType;
switch ($contentType) {
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
if (is_string($params)) {
$httpHeaders['Content-Length'] = (string) strlen($params);
}
break;
case Client::CONTENT_TYPE_APPLICATION_JSON:
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
if (is_string($params)) {
$httpHeaders['Content-Length'] = (string) strlen($params);
}
@@ -422,7 +436,12 @@ abstract class OAuth2Abstract implements IClient
}
}
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
try {
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
}
catch (Exception $e) {
throw new Error($e->getMessage(), 0, $e);
}
$code = null;
@@ -443,8 +462,7 @@ abstract class OAuth2Abstract implements IClient
if ($this->refreshToken()) {
return $this->request($url, $params, $httpMethod, $contentType, false);
}
}
else if ($handledData['action'] === 'renew') {
} else if ($handledData['action'] === 'renew') {
return $this->request($url, $params, $httpMethod, $contentType, false);
}
}
@@ -453,14 +471,12 @@ abstract class OAuth2Abstract implements IClient
if (
is_array($result) &&
isset($result['error']) &&
is_array($result['error']) &&
isset($result['error']['message'])
) {
$reasonPart = '; Reason: ' . $result['error']['message'];
}
throw new Error("Oauth: Error after requesting {$httpMethod} {$url}{$reasonPart}.", (int) $code);
throw new Error("Oauth: Error after requesting $httpMethod $url$reasonPart.", (int) $code);
}
/**
@@ -471,14 +487,15 @@ abstract class OAuth2Abstract implements IClient
{
if (empty($this->refreshToken)) {
throw new Error(
"Oauth: Could not refresh token for client {$this->clientId}, because refreshToken is empty."
);
"Oauth: Could not refresh token for client $this->clientId, because refreshToken is empty.");
}
$this->lock();
assert(is_string($this->refreshToken));
try {
$r = $this->client->getAccessToken(
$response = $this->client->getAccessToken(
$this->getParam('tokenEndpoint'),
Client::GRANT_TYPE_REFRESH_TOKEN,
['refresh_token' => $this->refreshToken]
@@ -486,57 +503,50 @@ abstract class OAuth2Abstract implements IClient
}
catch (Exception $e) {
$this->unlock();
$this->controlRefreshTokenAttempts();
throw new Error("Oauth: Error while refreshing token: " . $e->getMessage());
}
if ($r['code'] == 200) {
if (is_array($r['result'])) {
if (!empty($r['result']['access_token'])) {
$data = $this->getAccessTokenDataFromResponseResult($r['result']);
if ($response['code'] == 200) {
if (is_array($response['result']) && !empty($response['result']['access_token'])) {
$data = $this->getAccessTokenDataFromResponseResult($response['result']);
$this->setParams($data);
$this->afterTokenRefreshed($data);
$this->setParams($data);
$this->afterTokenRefreshed($data);
$this->unlock();
$this->unlock();
return true;
}
return true;
}
}
$this->unlock();
$this->controlRefreshTokenAttempts();
$this->log->error("Oauth: Refreshing token failed for client {$this->clientId}: " . json_encode($r));
$this->log->error("Oauth: Refreshing token failed for client $this->clientId: " . json_encode($response));
return false;
}
/**
* @param array<string, mixed> $r
* @param array<string, mixed> $response
* @return ?array{
* action: string,
* }
*/
protected function handleErrorResponse($r)
protected function handleErrorResponse($response)
{
if ($r['code'] == 401 && !empty($r['result'])) {
if (strpos($r['header'], 'error=invalid_token') !== false) {
return [
'action' => 'refreshToken'
];
}
else {
return [
'action' => 'renew'
];
if ($response['code'] == 401 && !empty($response['result'])) {
if (str_contains($response['header'], 'error=invalid_token')) {
return ['action' => 'refreshToken'];
}
return ['action' => 'renew'];
}
else if ($r['code'] == 400 && !empty($r['result'])) {
if ($r['result']['error'] == 'invalid_token') {
return [
'action' => 'refreshToken'
];
if ($response['code'] == 400 && !empty($response['result'])) {
if ($response['result']['error'] == 'invalid_token') {
return ['action' => 'refreshToken'];
}
}

View File

@@ -43,6 +43,10 @@ class Client
const TOKEN_TYPE_BEARER = 'Bearer';
const TOKEN_TYPE_OAUTH = 'OAuth';
/**
* @noinspection PhpUnused
* @noinspection SpellCheckingInspection
*/
const CONTENT_TYPE_APPLICATION_X_WWW_FORM_URLENENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
const CONTENT_TYPE_APPLICATION_JSON = 'application/json';
@@ -57,57 +61,33 @@ class Client
const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
/** @noinspection PhpUnused */
const GRANT_TYPE_PASSWORD = 'password';
/** @noinspection PhpUnused */
const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
/**
* @var ?string
*/
private const REFRESH_TOKEN_TIMEOUT = 10;
private const DEFAULT_TIMEOUT = 3600 * 2;
/** @var ?string */
protected $clientId = null;
/**
* @var ?string
*/
/** @var ?string */
protected $clientSecret = null;
/**
* @var ?string
*/
/** @var ?string */
protected $accessToken = null;
/**
* @var ?string
*/
/** @var ?string */
protected $expiresAt = null;
/**
* @var int
*/
/** @var int */
protected $authType = self::AUTH_TYPE_URI;
/**
* @var string
*/
/** @var string */
protected $tokenType = self::TOKEN_TYPE_URI;
/**
* @var ?string
*/
/** @var ?string */
protected $accessTokenSecret = null;
/**
* @var string
*/
/** @var string */
protected $accessTokenParamName = 'access_token';
/**
* @var ?string
*/
/** @var ?string */
protected $certificateFile = null;
/**
* @var array<string, mixed>
*/
/** @var array<string, mixed> */
protected $curlOptions = [];
public function __construct()
@@ -120,6 +100,7 @@ class Client
/**
* @param string $clientId
* @return void
* @noinspection PhpUnused
*/
public function setClientId($clientId)
{
@@ -129,6 +110,7 @@ class Client
/**
* @param ?string $clientSecret
* @return void
* @noinspection PhpUnused
*/
public function setClientSecret($clientSecret)
{
@@ -138,6 +120,7 @@ class Client
/**
* @param ?string $accessToken
* @return void
* @noinspection PhpUnused
*/
public function setAccessToken($accessToken)
{
@@ -147,6 +130,7 @@ class Client
/**
* @param int $authType
* @return void
* @noinspection PhpUnused
*/
public function setAuthType($authType)
{
@@ -156,6 +140,7 @@ class Client
/**
* @param string $certificateFile
* @return void
* @noinspection PhpUnused
*/
public function setCertificateFile($certificateFile)
{
@@ -166,6 +151,7 @@ class Client
* @param string $option
* @param mixed $value
* @return void
* @noinspection PhpUnused
*/
public function setCurlOption($option, $value)
{
@@ -175,6 +161,7 @@ class Client
/**
* @param array<string, mixed> $options
* @return void
* @noinspection PhpUnused
*/
public function setCurlOptions($options)
{
@@ -184,6 +171,7 @@ class Client
/**
* @param string $tokenType
* @return void
* @noinspection PhpUnused
*/
public function setTokenType($tokenType)
{
@@ -193,6 +181,7 @@ class Client
/**
* @param ?string $value
* @return void
* @noinspection PhpUnused
*/
public function setExpiresAt($value)
{
@@ -202,6 +191,7 @@ class Client
/**
* @param ?string $accessTokenSecret
* @return void
* @noinspection PhpUnused
*/
public function setAccessTokenSecret($accessTokenSecret)
{
@@ -221,8 +211,13 @@ class Client
* }
* @throws Exception
*/
public function request($url, $params = null, $httpMethod = self::HTTP_METHOD_GET, array $httpHeaders = [])
{
public function request(
$url,
$params = null,
$httpMethod = self::HTTP_METHOD_GET,
array $httpHeaders = []
) {
if ($this->accessToken) {
switch ($this->tokenType) {
case self::TOKEN_TYPE_URI:
@@ -265,15 +260,23 @@ class Client
* }
* @throws Exception
*/
private function execute($url, $params, $httpMethod, array $httpHeaders = [])
{
private function execute(
$url,
$params,
$httpMethod,
array $httpHeaders = [],
?int $timeout = null
) {
$curlOptions = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_CUSTOMREQUEST => $httpMethod,
CURLOPT_TIMEOUT => $timeout ?: self::DEFAULT_TIMEOUT,
];
switch ($httpMethod) {
/** @noinspection PhpMissingBreakStatementInspection */
case self::HTTP_METHOD_POST:
$curlOptions[CURLOPT_POST] = true;
@@ -289,13 +292,14 @@ class Client
break;
/** @noinspection PhpMissingBreakStatementInspection */
case self::HTTP_METHOD_HEAD:
$curlOptions[CURLOPT_NOBODY] = true;
case self::HTTP_METHOD_DELETE:
case self::HTTP_METHOD_GET:
if (strpos($url, '?') === false) {
if (!str_contains($url, '?')) {
$url .= '?';
}
@@ -320,7 +324,7 @@ class Client
continue;
}
$curlOptHttpHeader[] = "{$key}: {$value}";
$curlOptHttpHeader[] = "$key: $value";
}
$curlOptions[CURLOPT_HTTPHEADER] = $curlOptHttpHeader;
@@ -330,7 +334,6 @@ class Client
curl_setopt_array($ch, $curlOptions);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
@@ -381,8 +384,11 @@ class Client
* @param string $url
* @param string $grantType
* @param array{
* client_id?: string,
* client_secret?: string,
* client_id?: string,
* client_secret?: string,
* redirect_uri?: string,
* code?: string,
* refresh_token?: string,
* } $params
* @return array{
* result: array<string, mixed>|string,
@@ -417,6 +423,6 @@ class Client
throw new LogicException("Bad auth type.");
}
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders);
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders, self::REFRESH_TOKEN_TIMEOUT);
}
}

View File

@@ -278,7 +278,7 @@ class Date implements DateTimeable
}
/**
* @deprecated As of v8.1. Use `getTimestamp` instead.
* @deprecated As of v8.1. Use `toTimestamp` instead.
* @todo Remove in v10.0.
*/
public function getTimestamp(): int

View File

@@ -385,7 +385,7 @@ class DateTime implements DateTimeable
}
/**
* @deprecated As of v8.1. Use `getTimestamp` instead.
* @deprecated As of v8.1. Use `toTimestamp` instead.
* @todo Remove in v10.0.
*/
public function getTimestamp(): int

View File

@@ -503,7 +503,7 @@ class DateTimeOptional implements DateTimeable
}
/**
* @deprecated As of v8.1. Use `getTimestamp` instead.
* @deprecated As of v8.1. Use `toTimestamp` instead.
* @todo Remove in v10.0.
*/
public function getTimestamp(): int

View File

@@ -81,8 +81,8 @@ class Link
/**
* Create from an ID.
*/
public static function create(string $id): self
public static function create(string $id, ?string $name = null): self
{
return new self($id);
return (new self($id))->withName($name);
}
}

View File

@@ -74,6 +74,10 @@ class ListLoader implements LoaderInterface
->getField($field)
->getParam('columns');
if ($entity->has($field . 'Ids') && $entity->has($field . 'Names')) {
continue;
}
$entity->loadLinkMultipleField($field, $columns);
}
}

View File

@@ -94,6 +94,18 @@ class LinkMultipleSaver
$columns = $defs->getField($name)->getParam('columns');
}
$allColumns = $columns;
if (is_array($columns)) {
$additionalColumns = $defs->getRelation($name)->getParam('additionalColumns') ?? [];
foreach ($columns as $column => $field) {
if (!array_key_exists($column, $additionalColumns)) {
unset($columns[$column]);
}
}
}
$columnData = !empty($columns) ?
$entity->get($columnsAttribute) :
null;
@@ -104,7 +116,7 @@ class LinkMultipleSaver
foreach ($foreignEntityList as $foreignEntity) {
$existingIdList[] = $foreignEntity->getId();
if (empty($columns)) {
if (empty($allColumns)) {
continue;
}
@@ -112,7 +124,7 @@ class LinkMultipleSaver
$foreignId = $foreignEntity->getId();
foreach ($columns as $columnName => $columnField) {
foreach ($allColumns as $columnName => $columnField) {
$data->$columnName = $foreignEntity->get($columnField);
}
@@ -129,7 +141,7 @@ class LinkMultipleSaver
$entity->setFetched($idListAttribute, $existingIdList);
}
if ($entity->has($columnsAttribute) && !empty($columns)) {
if ($entity->has($columnsAttribute) && !empty($allColumns)) {
$entity->setFetched($columnsAttribute, $existingColumnsData);
}
}
@@ -175,8 +187,14 @@ class LinkMultipleSaver
foreach ($toCreateIdList as $id) {
$data = null;
if (!empty($columns) && isset($columnData->$id)) {
if (is_array($columns) && isset($columnData->$id)) {
$data = (array) $columnData->$id;
foreach ($data as $column => $v) {
if (!array_key_exists($column, $columns)) {
unset($data[$column]);
}
}
}
$repository->getRelation($entity, $name)->relateById($id, $data, [
@@ -193,6 +211,14 @@ class LinkMultipleSaver
foreach ($toUpdateIdList as $id) {
$data = (array) $columnData->$id;
if (is_array($columns)) {
foreach ($data as $column => $v) {
if (!array_key_exists($column, $columns)) {
unset($data[$column]);
}
}
}
$repository->getRelation($entity, $name)->updateColumnsById($id, (array) $data);
}
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Formula;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Entities\EmailAddress;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Entity;
@@ -109,7 +110,7 @@ class AttributeFetcher
return;
}
if ($entity->getAttributeParam($attribute, 'isLinkMultipleIdList')) {
if ($entity->getAttributeParam($attribute, AttributeParam::IS_LINK_MULTIPLE_ID_LIST)) {
/** @var ?string $relationName */
$relationName = $entity->getAttributeParam($attribute, 'relation');

View File

@@ -177,12 +177,10 @@ class HookManager
private function createHookByClassName(string $className): object
{
if (!class_exists($className)) {
$this->log->error("Hook class '{$className}' does not exist.");
$this->log->error("Hook class '$className' does not exist.");
}
$obj = $this->injectableFactory->create($className);
return $obj;
return $this->injectableFactory->create($className);
}
/**

View File

@@ -34,8 +34,13 @@ use DOMDocument;
use DOMElement;
use DOMException;
use DOMXPath;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Entities\Attachment;
use Espo\Entities\User;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\Core\Utils\Json;
use Espo\Core\Acl;
@@ -43,7 +48,6 @@ use Espo\Core\InjectableFactory;
use Espo\Core\ServiceFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
@@ -71,17 +75,18 @@ class Htmlizer
private const LINK_LIMIT = 100;
public function __construct(
private FileManager $fileManager, /** @phpstan-ignore-line */
private DateTime $dateTime,
private NumberUtil $number,
private SelectBuilderFactory $selectBuilderFactory,
private User $user,
private EntityManager $entityManager,
private Metadata $metadata,
private Language $language,
private Config $config,
private Log $log,
private InjectableFactory $injectableFactory,
private ?Acl $acl = null,
private ?EntityManager $entityManager = null,
private ?Metadata $metadata = null,
private ?Language $language = null,
private ?Config $config = null,
private ?ServiceFactory $serviceFactory = null,
private ?Log $log = null,
private ?InjectableFactory $injectableFactory = null
) {}
/**
@@ -100,11 +105,13 @@ class Htmlizer
bool $skipInlineAttachmentHandling = false
): string {
$template = $this->prepare($template);
$helpers = $this->getHelpers();
$template = $this->prepare($template, array_keys($helpers));
$code = LightnCandy::compile($template, [
'flags' => Flags::FLAG_HANDLEBARSJS | Flags::FLAG_ERROR_EXCEPTION,
'helpers' => $this->getHelpers(),
'helpers' => $helpers,
]);
if ($code === false) {
@@ -154,10 +161,10 @@ class Htmlizer
$html = str_replace('?entryPoint=attachment&amp;', '?entryPoint=attachment&', $html);
}
if (!$skipInlineAttachmentHandling && $this->entityManager) {
if (!$skipInlineAttachmentHandling) {
/** @var string $html */
$html = preg_replace_callback(
'/\?entryPoint=attachment\&id=([A-Za-z0-9]*)/',
'/\?entryPoint=attachment&id=([A-Za-z0-9]*)/',
function ($matches) {
$id = $matches[1];
@@ -165,8 +172,6 @@ class Htmlizer
return '';
}
assert($this->entityManager !== null);
/** @var Attachment $attachment */
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
@@ -249,7 +254,6 @@ class Htmlizer
if (
!$skipLinks &&
$level === 0 &&
$this->entityManager &&
$entity->hasId()
) {
$this->loadRelatedCollections($entity, $template, $data);
@@ -360,8 +364,7 @@ class Htmlizer
}
if (
$fieldType === 'currency' &&
$this->metadata &&
$fieldType === FieldType::CURRENCY &&
$entity instanceof CoreEntity &&
$entity->getAttributeParam($attribute, 'attributeRole') === 'currency'
) {
@@ -382,21 +385,16 @@ class Htmlizer
$fieldType = $this->getFieldType($entity->getEntityType(), $attribute);
if ($fieldType === 'enum') {
if ($this->language) {
$data[$attribute] = $this->language->translateOption(
$data[$attribute], $attribute, $entity->getEntityType()
);
if ($fieldType === FieldType::ENUM) {
$data[$attribute] = $this->language->translateOption(
$data[$attribute], $attribute, $entity->getEntityType()
);
if ($this->metadata) {
$translationPath = $this->metadata->get(
['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']
);
$translationPath = $this->metadata
->get(['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']);
if ($translationPath) {
$data[$attribute] = $this->language->get($translationPath . '.' . $attribute, $data[$attribute]);
}
}
if ($translationPath) {
$data[$attribute] = $this->language->get($translationPath . '.' . $attribute, $data[$attribute]);
}
}
@@ -404,7 +402,7 @@ class Htmlizer
}
}
if (!$skipLinks && $this->entityManager) {
if (!$skipLinks) {
foreach ($entity->getRelationList() as $relation) {
if (in_array($relation, $forbiddenLinkList)) {
continue;
@@ -413,26 +411,26 @@ class Htmlizer
$relationType = $entity->getRelationType($relation);
if (
$relationType === Entity::BELONGS_TO ||
$relationType === Entity::BELONGS_TO_PARENT
$relationType !== Entity::BELONGS_TO &&
$relationType !== Entity::BELONGS_TO_PARENT
) {
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if (!$relatedEntity) {
continue;
}
if ($this->acl) {
if (!$this->acl->checkEntityRead($relatedEntity)) {
continue;
}
}
$data[$relation] = $this->getDataFromEntity($relatedEntity, true, $level + 1);
continue;
}
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if (!$relatedEntity) {
continue;
}
if ($this->acl && !$this->acl->checkEntityRead($relatedEntity)) {
continue;
}
$data[$relation] = $this->getDataFromEntity($relatedEntity, true, $level + 1);
}
}
@@ -458,11 +456,7 @@ class Htmlizer
*/
private function loadRelatedCollection(Entity $entity, string $relation, ?string $template): ?Collection
{
assert($this->entityManager !== null);
$limit = $this->config ?
$this->config->get('htmlizerLinkLimit', self::LINK_LIMIT) :
self::LINK_LIMIT;
$limit = $this->config->get('htmlizerLinkLimit', self::LINK_LIMIT);
$orderData = $this->getRelationOrder($entity->getEntityType(), $relation);
@@ -496,9 +490,33 @@ class Htmlizer
) &&
mb_stripos($template, '{{#each ' . $relation . '}}') !== false
) {
$foreignEntityType = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType())
->getRelation($relation)
->getForeignEntityType();
$selectBuilder = $this->selectBuilderFactory->create();
$selectBuilder->from($foreignEntityType);
if ($this->acl) {
$selectBuilder
->forUser($this->user)
->withAccessControlFilter();
}
try {
$query = $selectBuilder->build();
}
catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
return $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->clone($query)
->limit(0, $limit)
->order($orderData)
->find();
@@ -513,6 +531,63 @@ class Htmlizer
private function getHelpers(): array
{
$helpers = [
'and' => function () {
$args = func_get_args();
if (count($args) === 1) {
return false;
}
for ($i = 0; $i < count($args) - 1; $i++) {
$arg = $args[$i];
if (!$arg) {
return false;
}
}
return true;
},
'or' => function () {
$args = func_get_args();
for ($i = 0; $i < count($args) - 1; $i++) {
$arg = $args[$i];
if (!$arg) {
return true;
}
}
return false;
},
'not' => function () {
$args = func_get_args();
if (count($args) !== 2) {
return false;
}
$arg = $args[0];
return !$arg;
},
'equal' => function () {
$args = func_get_args();
$arg1 = $args[0] ?? null;
$arg2 = $args[1] ?? null;
return $arg1 === $arg2;
},
'notEqual' => function () {
$args = func_get_args();
$arg1 = $args[0] ?? null;
$arg2 = $args[1] ?? null;
return $arg1 !== $arg2;
},
'file' => function () {
$args = func_get_args();
@@ -522,10 +597,14 @@ class Htmlizer
return '';
}
/** @noinspection PhpUndefinedClassInspection */
/** @noinspection PhpUndefinedNamespaceInspection */
/** @phpstan-ignore-next-line */
return new LightnCandy\SafeString("?entryPoint=attachment&id=" . $id);
},
'pagebreak' => function () {
/** @noinspection PhpUndefinedClassInspection, HtmlUnknownAttribute */
/** @noinspection PhpUndefinedNamespaceInspection */
/** @phpstan-ignore-next-line */
return new LightnCandy\SafeString('<br pagebreak="true">');
},
@@ -555,15 +634,18 @@ class Htmlizer
$attributesPart = "";
if ($width) {
$attributesPart .= " width=\"" .strval($width) . "\"";
$attributesPart .= " width=\"$width\"";
}
if ($height) {
$attributesPart .= " height=\"" .strval($height) . "\"";
$attributesPart .= " height=\"$height\"";
}
$html = "<img src=\"?entryPoint=attachment&id={$id}\"{$attributesPart}>";
/** @noinspection HtmlRequiredAltAttribute */
$html = "<img src=\"?entryPoint=attachment&id=$id\"$attributesPart>";
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUndefinedClassInspection */
/** @phpstan-ignore-next-line */
return new LightnCandy\SafeString($html);
},
@@ -636,8 +718,10 @@ class Htmlizer
/** @phpstan-ignore-next-line */
$paramsString = urlencode(json_encode($params));
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUndefinedClassInspection */
/** @phpstan-ignore-next-line */
return new LightnCandy\SafeString("<barcodeimage data=\"{$paramsString}\"/>");
return new LightnCandy\SafeString("<barcodeimage data=\"$paramsString\"/>");
},
'ifEqual' => function () {
$args = func_get_args();
@@ -709,7 +793,7 @@ class Htmlizer
return null;
}
$css = "font-family: zapfdingbats; color: {$color}";
$css = "font-family: zapfdingbats; color: $color";
if (in_array($option, $list)) {
$html =
@@ -719,6 +803,8 @@ class Htmlizer
$html = '<input type="checkbox" name="1" readonly="true" value="1" style="color: '.$css.'">';
}
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUndefinedClassInspection */
/** @phpstan-ignore-next-line */
return new LightnCandy\SafeString($html);
},
@@ -758,6 +844,7 @@ class Htmlizer
$value = $result->getValue();
/** @noinspection PhpFullyQualifiedNameUsageInspection */
if ($value instanceof \Espo\Core\Htmlizer\Helper\SafeString) {
return $value->getWrappee();
}
@@ -765,28 +852,26 @@ class Htmlizer
return $value;
};
if ($this->metadata) {
$additionalHelpers = array_filter(
$additionalHelpers = array_filter(
$this->metadata->get(['app', 'templateHelpers']) ?? [],
function (string $item) {
return str_contains($item, '::');
}
);
$helpers = array_merge($helpers, $additionalHelpers);
$additionalHelper2NameList = array_keys(
array_filter(
$this->metadata->get(['app', 'templateHelpers']) ?? [],
function (string $item) {
return str_contains($item, '::');
return !str_contains($item, '::');
}
);
)
);
$helpers = array_merge($helpers, $additionalHelpers);
$additionalHelper2NameList = array_keys(
array_filter(
$this->metadata->get(['app', 'templateHelpers']) ?? [],
function (string $item) {
return !str_contains($item, '::');
}
)
);
foreach ($additionalHelper2NameList as $name) {
$helpers[$name] = $customHelper;
}
foreach ($additionalHelper2NameList as $name) {
$helpers[$name] = $customHelper;
}
return $helpers;
@@ -794,10 +879,6 @@ class Htmlizer
private function getFieldType(string $entityType, string $field): ?string
{
if (!$this->metadata) {
return null;
}
return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
}
@@ -806,10 +887,6 @@ class Htmlizer
*/
private function getRelationOrder(string $entityType, string $relation): array
{
if (!$this->entityManager) {
return [];
}
$relationDefs = $this->entityManager
->getDefs()
->getEntity($entityType)
@@ -840,14 +917,17 @@ class Htmlizer
return [[$orderBy, $order]];
}
private function handleIteration(string $template): string
/**
* @param string[] $helpers
*/
private function handleAttributeHelper(string $template, string $attribute, string $helper, array $helpers): string
{
if ($template === '') {
return $template;
}
if (!extension_loaded('dom')) {
$this->log?->warning("Extension 'dom' is not enabled. HTML templating functionality is restricted.");
$this->log->warning("Extension 'dom' is not enabled. HTML templating functionality is restricted.");
return $template;
}
@@ -857,7 +937,7 @@ class Htmlizer
$loadResult = $xml->loadHTML($template);
if ($loadResult === false) {
$this->log?->warning("HTML template parsing error.");
$this->log->warning("HTML template parsing error.");
return $template;
}
@@ -866,7 +946,7 @@ class Htmlizer
$found = false;
$elements = $xpath->query("//*[@iterate]");
$elements = $xpath->query("//*[@$attribute]");
if (!$elements) {
return $template;
@@ -878,13 +958,13 @@ class Htmlizer
}
try {
$wrapperElement = $xml->createElement('iteration-wrapper');
$wrapperElement = $xml->createElement("$attribute-wrapper");
if (!$wrapperElement) {
throw new LogicException();
}
$wrapperElement->setAttribute('v', $element->getAttribute('iterate'));
$wrapperElement->setAttribute('v', $element->getAttribute($attribute));
}
catch (DOMException $e) {
throw new LogicException($e->getMessage());
@@ -902,7 +982,7 @@ class Htmlizer
throw new LogicException();
}
$newElement->removeAttribute('iterate');
$newElement->removeAttribute($attribute);
$wrapperElement->appendChild($newElement);
$parentNode->replaceChild($wrapperElement, $element);
@@ -917,25 +997,54 @@ class Htmlizer
$newTemplate = $xml->saveXML();
if ($newTemplate === false || !is_string($newTemplate)) {
$this->log?->warning("DOM save error.");
$this->log->warning("DOM save error.");
return $template;
}
$newTemplate = str_replace('</iteration-wrapper>', '{{/each}}', $newTemplate);
$newTemplate = str_replace("</$attribute-wrapper>", "{{/$helper}}", $newTemplate);
$from = strpos($newTemplate,'<body>') + 6;
$to = strrpos($newTemplate, '</body>') - strlen($newTemplate);
$newTemplate = substr($newTemplate, $from, $to);
return preg_replace('/<iteration-wrapper v="{{(.*)}}">/', '{{#each $1}}', $newTemplate) ?? '';
$regExp = '/<' . $attribute . '-wrapper v="{{(.*?)}}">/';
$newTemplate = preg_replace_callback($regExp, function ($matches) use ($helpers, $helper) {
$expression = trim($matches[1]);
$isHelper = false;
foreach ($helpers as $it) {
if (str_starts_with($expression, $it . ' ')) {
$isHelper = true;
break;
}
}
if ($isHelper) {
$expression = "($expression)";
}
return "{{#$helper $expression}}";
}, $newTemplate);
return $newTemplate ?? '';
}
private function prepare(string $template): string
/**
* @param string[] $helpers
*/
private function prepare(string $template, array $helpers): string
{
$template = str_replace('<tcpdf ', '', $template);
return $this->handleIteration($template);
$template = $this->handleAttributeHelper($template, 'iterate', 'each', $helpers);
/** @noinspection PhpUnnecessaryLocalVariableInspection */
$template = $this->handleAttributeHelper($template, 'x-if', 'if', $helpers);
return $template;
}
}

View File

@@ -32,7 +32,6 @@ namespace Espo\Core\Htmlizer;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\DateTime\DateTimeFactory;
use Espo\Core\AclManager;
use Espo\Entities\User;
/**
@@ -41,21 +40,11 @@ use Espo\Entities\User;
*/
class HtmlizerFactory
{
private $injectableFactory;
private $dateTimeFactory;
private $aclManager;
public function __construct(
InjectableFactory $injectableFactory,
DateTimeFactory $dateTimeFactory,
AclManager $aclManager
) {
$this->injectableFactory = $injectableFactory;
$this->dateTimeFactory = $dateTimeFactory;
$this->aclManager = $aclManager;
}
private InjectableFactory $injectableFactory,
private DateTimeFactory $dateTimeFactory,
private AclManager $aclManager
) {}
public function create(bool $skipAcl = false, ?string $timeZone = null): Htmlizer
{
@@ -93,6 +82,7 @@ class HtmlizerFactory
if ($params->applyAcl) {
$deps['acl'] = $this->aclManager->createUserAcl($user);
$deps['user'] = $user;
}
return $this->injectableFactory->createWith(Htmlizer::class, $deps);

View File

@@ -38,6 +38,8 @@ use ReflectionClass;
use RuntimeException;
/**
* @internal
*
* @phpstan-type DefaultHandlerLoaderData array{
* className?: ?class-string<HandlerInterface>,
* params?: ?array<string, mixed>,
@@ -82,9 +84,10 @@ class DefaultHandlerLoader
}
/**
* @internal
* @param DefaultHandlerLoaderData $data
*/
private function loadFormatter(array $data): ?FormatterInterface
public function loadFormatter(array $data): ?FormatterInterface
{
$formatterData = $data['formatter'] ?? null;

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Log;
use Monolog\Handler\FormattableHandlerInterface;
use Monolog\Handler\HandlerInterface;
use Espo\Core\InjectableFactory;
@@ -71,7 +72,17 @@ class HandlerListLoader
if ($loaderClassName) {
$loader = $this->injectableFactory->create($loaderClassName);
return $loader->load($params);
$handler = $loader->load($params);
if ($handler instanceof FormattableHandlerInterface) {
$formatter = $this->defaultLoader->loadFormatter($data);
if ($formatter) {
$handler->setFormatter($formatter);
}
}
return $handler;
}
return $this->defaultLoader->load($data, $defaultLevel);

View File

@@ -155,7 +155,7 @@ class Service
'message' => $e->getMessage(),
]);
$message = $e instanceof ExceptionInterface ?
$message = $e instanceof ExceptionInterface || $e instanceof ImapError ?
$e->getMessage() : '';
throw new ErrorSilent($message);

View File

@@ -31,13 +31,13 @@ namespace Espo\Core\Mail\Message\MailMimeParser;
use Espo\Core\Mail\Message\Part as PartInterface;
use ZBateson\MailMimeParser\Message\Part\MessagePart;
use ZBateson\MailMimeParser\Message\IMessagePart;
class Part implements PartInterface
{
private MessagePart $part;
private IMessagePart $part;
public function __construct(MessagePart $part)
public function __construct(IMessagePart $part)
{
$this->part = $part;
}

View File

@@ -40,10 +40,10 @@ use Espo\Core\Mail\Message\MailMimeParser\Part as WrapperPart;
use Psr\Http\Message\StreamInterface;
use ZBateson\MailMimeParser\Header\AddressHeader;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser as WrappeeParser;
use ZBateson\MailMimeParser\Message\Part\MessagePart;
use ZBateson\MailMimeParser\Message\Part\MimePart;
use ZBateson\MailMimeParser\Message as ParserMessage;
use ZBateson\MailMimeParser\Message\MessagePart;
use ZBateson\MailMimeParser\Message\MimePart;
use stdClass;
@@ -68,7 +68,7 @@ class MailMimeParser implements Parser
private const DISPOSITION_INLINE = 'inline';
/** @var array<string, ParserMessage> */
/** @var array<string, IMessage> */
private array $messageHash = [];
public function __construct(private EntityManager $entityManager)
@@ -89,12 +89,11 @@ class MailMimeParser implements Parser
$key = spl_object_hash($message);
$this->messageHash[$key] = $this->getParser()->parse($raw);
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
}
/**
* @return ParserMessage
* @return IMessage
*/
private function getMessage(Message $message)
{
@@ -107,7 +106,7 @@ class MailMimeParser implements Parser
$raw = $message->getFullRawContent();
}
$this->messageHash[$key] = $this->getParser()->parse($raw);
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
}
return $this->messageHash[$key];
@@ -256,7 +255,7 @@ class MailMimeParser implements Parser
$inlinePart = $this->getMessage($message)->getHtmlPart($i);
$bodyHtml .= $inlinePart->getContent();
$bodyHtml .= $inlinePart?->getContent() ?? '';
}
for ($i = 0; $i < $textPartCount; $i++) {
@@ -266,7 +265,7 @@ class MailMimeParser implements Parser
$inlinePart = $this->getMessage($message)->getTextPart($i);
$bodyPlain .= $inlinePart->getContent();
$bodyPlain .= $inlinePart?->getContent() ?? '';
}
if ($bodyHtml) {

View File

@@ -30,7 +30,6 @@
namespace Espo\Core\MassAction\Actions;
use Espo\Core\Record\ActionHistory\Action;
use Espo\Entities\ActionHistoryRecord;
use Espo\Entities\User;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Forbidden;

View File

@@ -30,7 +30,6 @@
namespace Espo\Core\MassAction;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Query\Select;
use Espo\Core\Select\SelectBuilderFactory;
@@ -44,7 +43,6 @@ class QueryBuilder
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
*/
public function build(Params $params): Select
{

View File

@@ -34,6 +34,7 @@ use Espo\Core\Repositories\Database as DatabaseRepository;
use Espo\Core\Utils\ClassFinder;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity as Entity;
use Espo\ORM\EventDispatcher;
use Espo\ORM\Repository\Repository as Repository;
class ClassNameProvider
@@ -51,8 +52,16 @@ class ClassNameProvider
public function __construct(
private Metadata $metadata,
private ClassFinder $classFinder
) {}
private ClassFinder $classFinder,
EventDispatcher $eventDispatcher,
) {
$eventDispatcher->subscribeToMetadataUpdate(function () {
$this->entityCache = [];
$this->repositoryCache = [];
$this->classFinder->resetRuntimeCache();
});
}
/**
* @param string $entityType

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ORM\Defs;
class AttributeParam
{
/** @internal */
public const IS_LINK_MULTIPLE_NAME_MAP = 'isLinkMultipleNameMap';
/** @internal */
public const IS_LINK_MULTIPLE_ID_LIST = 'isLinkMultipleIdList';
/** @internal */
public const NOT_EXPORTABLE = 'notExportable';
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\ORM;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\ORM\BaseEntity;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Type\AttributeType;
@@ -49,7 +50,7 @@ class Entity extends BaseEntity
{
return
$this->hasRelation($field) &&
$this->getAttributeParam($field . 'Ids', 'isLinkMultipleIdList');
$this->getAttributeParam($field . 'Ids', AttributeParam::IS_LINK_MULTIPLE_ID_LIST);
}
/**

View File

@@ -66,12 +66,18 @@ class EntityManagerFactory
public function create(): EntityManager
{
$entityFactory = $this->injectableFactory->create(EntityFactory::class);
$entityFactory = $this->injectableFactory->createWithBinding(
EntityFactory::class,
BindingContainerBuilder::create()
->bindInstance(EventDispatcher::class, $this->eventDispatcher)
->build()
);
$repositoryFactory = $this->injectableFactory->createWithBinding(
RepositoryFactory::class,
BindingContainerBuilder::create()
->bindInstance(EntityFactoryInterface::class, $entityFactory)
->bindInstance(EventDispatcher::class, $this->eventDispatcher)
->build()
);

View File

@@ -81,4 +81,15 @@ class SaveOption
* Override modified-by. String.
*/
public const MODIFIED_BY_ID = 'modifiedById';
/**
* A duplicate source ID. A record that is being duplicated.
* @since 8.4.0
*/
public const DUPLICATE_SOURCE_ID = 'duplicateSourceId';
/**
* When saved in Mass-Update.
* @since 8.4.0
*/
public const MASS_UPDATE = 'massUpdate';
}

View File

@@ -82,7 +82,6 @@ class DefaultOwnershipChecker implements
public function checkAccount(User $user, Entity $entity): bool
{
/** @var string[] $accountIdList */
$accountIdList = $user->getLinkMultipleIdList(self::FIELD_ACCOUNTS);
if (!count($accountIdList)) {

View File

@@ -37,6 +37,7 @@ use Espo\Core\Exceptions\Error\Body as ErrorBody;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
@@ -125,6 +126,7 @@ class LinkCheck
}
$attribute = $name . 'Ids';
$namesAttribute = $name . 'Names';
if (
!$entityDefs->hasAttribute($attribute) ||
@@ -148,12 +150,6 @@ class LinkCheck
$this->processCheckLinkWithoutField($entityDefs, $name, $this->manyFieldTypeList);
if ($ids === []) {
continue;
}
$namesAttribute = $name . 'Names';
$names = $this->prepareNames($entity, $namesAttribute, $setIds);
foreach ($ids as $id) {
@@ -164,9 +160,7 @@ class LinkCheck
}
}
$namesAttributeDefs = $entityDefs->tryGetAttribute($namesAttribute);
if (!$namesAttributeDefs || !$namesAttributeDefs->getParam('isLinkMultipleNameMap')) {
if (!$entityDefs->tryGetAttribute($namesAttribute)?->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)) {
continue;
}

View File

@@ -40,6 +40,7 @@ use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Exceptions\NotFoundSilent;
use Espo\Core\FieldSanitize\SanitizeManager;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Record\Access\LinkCheck;
@@ -598,7 +599,7 @@ class Service implements Crud,
foreach ($entityDefs->getAttributeList() as $attributeDefs) {
if (
$attributeDefs->getType() !== AttributeType::FOREIGN &&
!$attributeDefs->getParam('isLinkMultipleNameMap')
!$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)
) {
continue;
}
@@ -862,6 +863,7 @@ class Service implements Crud,
$this->entityManager->saveEntity($entity, [
SaveOption::API => true,
SaveOption::KEEP_NEW => true,
SaveOption::DUPLICATE_SOURCE_ID => $params->getDuplicateSourceId(),
]);
$this->getRecordHookManager()->processAfterCreate($entity, $params);
@@ -1115,10 +1117,23 @@ class Service implements Crud,
}
if (!$entity->get('deleted')) {
throw new Forbidden();
throw new Forbidden("No 'deleted' attribute.");
}
$this->getRepository()->restoreDeleted($entity->getId());
$this->entityManager->getTransactionManager()
->run(function () use ($entity) {
$this->getRepository()->restoreDeleted($entity->getId());
if (
$entity->hasAttribute('deleteId') &&
$this->metadata->get("entityDefs.$this->entityType.deleteId")
) {
$this->entityManager->refreshEntity($entity);
$entity->set('deleteId', '0');
$this->getRepository()->save($entity, [SaveOption::SILENT => true]);
}
});
}
public function getMaxSelectTextAttributeLength(): ?int

View File

@@ -59,10 +59,10 @@ class OnlyOwn implements Filter
->select('id')
->from($this->entityType)
->leftJoin($middleEntityType, 'assignedUsersMiddle', [
"assignedUsersMiddle.{$key1}:" => 'id',
"assignedUsersMiddle.$key1:" => 'id',
'assignedUsersMiddle.deleted' => false,
])
->where(["assignedUsersMiddle.{$key2}" => $this->user->getId()])
->where(["assignedUsersMiddle.$key2" => $this->user->getId()])
->build();
$queryBuilder->where(['id=s' => $subQuery]);

View File

@@ -75,11 +75,11 @@ class OnlyTeam implements Filter
$key2 = $relationDefs->getForeignMidKey();
$subQueryBuilder->leftJoin($middleEntityType, 'assignedUsersMiddle', [
"assignedUsersMiddle.{$key1}:" => 'id',
"assignedUsersMiddle.$key1:" => 'id',
'assignedUsersMiddle.deleted' => false,
]);
$orGroup["assignedUsersMiddle.{$key2}"] = $this->user->getId();
$orGroup["assignedUsersMiddle.$key2"] = $this->user->getId();
}
else if ($this->fieldHelper->hasAssignedUserField()) {
$orGroup['assignedUserId'] = $this->user->getId();

View File

@@ -132,7 +132,7 @@ class DefaultDateTimeItemTransformer implements DateTimeItemTransformer
break;
case 'lastSevenDays':
case Type::LAST_SEVEN_DAYS:
$where['type'] = Type::BETWEEN;
$dtFrom = clone $dt;

View File

@@ -77,8 +77,8 @@ abstract class Base
];
private ZipArchive $zipUtil;
private ?DatabaseHelper $databaseHelper;
private ?Helper $helper;
private ?DatabaseHelper $databaseHelper = null;
private ?Helper $helper = null;
public function __construct(
private Container $container,

View File

@@ -51,6 +51,13 @@ class AfterUpgradeRunner
throw new RuntimeException("No after-upgrade script $step.");
}
try {
$this->dataManager->rebuild();
}
catch (Error $e) {
throw new RuntimeException("Error while rebuild: " . $e->getMessage());
}
/** @var Script $script */
$script = $this->injectableFactory->createWith($className, ['isUpgrade' => false]);
$script->run();

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Upgrades\Migrations\V8_4;
use Espo\Core\Upgrades\Migration\Script;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Type\RelationType;
class AfterUpgrade implements Script
{
public function __construct(
private Metadata $metadata,
) {}
public function run(): void
{
$this->updateMetadata();
}
private function updateMetadata(): void
{
$defs = $this->metadata->get(['entityDefs']);
$toSave = false;
foreach ($defs as $entityType => $item) {
if (!isset($item['links'])) {
continue;
}
foreach ($item['links'] as $link => $linkDefs) {
$type = $linkDefs['type'] ?? null;
$foreignEntityType = $linkDefs['entity'] ?? null;
$midKeys = $linkDefs['midKeys'] ?? null;
$isCustom = $linkDefs['isCustom'] ?? false;
if ($type !== RelationType::HAS_MANY) {
continue;
}
if ($foreignEntityType !== $entityType) {
continue;
}
if (!$midKeys) {
continue;
}
if (!$isCustom) {
continue;
}
if ($linkDefs['_keysSwappedAfterUpgrade'] ?? false) {
continue;
}
$this->metadata->set('entityDefs', $entityType, [
'links' => [
$link => [
'midKeys' => array_reverse($midKeys),
'_keysSwappedAfterUpgrade' => true,
]
]
]);
$toSave = true;
}
if ($toSave) {
$this->metadata->save();
}
}
}
}

View File

@@ -45,6 +45,17 @@ class ClassFinder
public function __construct(private ClassMap $classMap)
{}
/**
* Reset runtime cache.
*
* @internal
* @since 8.4.0
*/
public function resetRuntimeCache(): void
{
$this->dataHashMap = [];
}
/**
* Find class name by a category and name.
*

View File

@@ -31,6 +31,7 @@ namespace Espo\Core\Utils\Database\Orm;
use Doctrine\DBAL\Types\Types;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\Utils\Database\ConfigDataProvider;
use Espo\Core\Utils\Database\MetadataProvider;
use Espo\Core\Utils\Util;
@@ -70,7 +71,7 @@ class Converter
'maxLength' => 'len',
'len' => 'len',
'notNull' => 'notNull',
'exportDisabled' => 'notExportable',
'exportDisabled' => AttributeParam::NOT_EXPORTABLE,
'autoincrement' => 'autoincrement',
'entity' => 'entity',
'notStorable' => 'notStorable',
@@ -502,19 +503,19 @@ class Converter
$ormMetadata[$entityType]['attributes']['isFollowed'] = [
'type' => Entity::BOOL,
'notStorable' => true,
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
];
$ormMetadata[$entityType]['attributes']['followersIds'] = [
'type' => Entity::JSON_ARRAY,
'notStorable' => true,
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
];
$ormMetadata[$entityType]['attributes']['followersNames'] = [
'type' => Entity::JSON_OBJECT,
'notStorable' => true,
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
];
}
}
@@ -525,7 +526,7 @@ class Converter
$ormMetadata[$entityType]['attributes']['isStarred'] = [
'type' => Entity::BOOL,
'notStorable' => true,
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
'readOnly' => true,
];
}
@@ -536,7 +537,7 @@ class Converter
$ormMetadata[$entityType]['attributes']['versionNumber'] = [
'type' => Entity::INT,
'dbType' => Types::BIGINT,
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
];
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
@@ -52,7 +53,7 @@ class AttachmentMultiple implements FieldConverter
['createdAt', Order::ASC],
['name', Order::ASC],
],
'isLinkMultipleIdList' => true,
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
'relation' => $name,
])
)
@@ -61,7 +62,7 @@ class AttachmentMultiple implements FieldConverter
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParamsMerged([
'isLinkMultipleNameMap' => true,
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
])
);
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
@@ -61,7 +62,7 @@ class Email implements FieldConverter
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
'isEmailAddressData' => true,
'field' => $name,
]);

View File

@@ -29,6 +29,8 @@
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\FieldConverter;
@@ -49,7 +51,7 @@ class LinkMultiple implements FieldConverter
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
'isLinkMultipleIdList' => true,
AttributeParam::IS_LINK_MULTIPLE_ID_LIST => true,
'relation' => $name,
'isUnordered' => true,
'attributeRole' => 'idList',
@@ -67,9 +69,9 @@ class LinkMultiple implements FieldConverter
->withType(AttributeType::JSON_OBJECT)
->withNotStorable()
->withParamsMerged([
'isLinkMultipleNameMap' => true,
AttributeParam::IS_LINK_MULTIPLE_NAME_MAP => true,
'attributeRole' => 'nameMap',
'fieldType' => 'linkMultiple',
'fieldType' => FieldType::LINK_MULTIPLE,
]);
$orderBy = $fieldDefs->getParam('orderBy');

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
@@ -64,7 +65,7 @@ class Phone implements FieldConverter
->withType(AttributeType::JSON_ARRAY)
->withNotStorable()
->withParamsMerged([
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
'isPhoneNumberData' => true,
'field' => $name,
]);
@@ -389,7 +390,7 @@ class Phone implements FieldConverter
private function getNumericParams(string $entityType): array
{
return [
'notExportable' => true,
AttributeParam::NOT_EXPORTABLE => true,
'where' => [
'LIKE' => [
'whereClause' => [

View File

@@ -52,13 +52,18 @@ class ManyMany implements LinkConverter
$linkDefs->getRelationshipName() :
self::composeRelationshipName($entityType, $foreignEntityType);
$key1 = lcfirst($entityType) . 'Id';
$key2 = lcfirst($foreignEntityType) . 'Id';
if ($linkDefs->hasMidKey() && $linkDefs->hasForeignMidKey()) {
$key1 = $linkDefs->getMidKey();
$key2 = $linkDefs->getForeignMidKey();
} else {
$key1 = lcfirst($entityType) . 'Id';
$key2 = lcfirst($foreignEntityType) . 'Id';
if ($key1 === $key2) {
[$key1, $key2] = strcmp($name, $foreignRelationName) ?
['leftId', 'rightId'] :
['rightId', 'leftId'];
if ($key1 === $key2) {
[$key1, $key2] = strcmp($name, $foreignRelationName) > 0 ?
['leftId', 'rightId'] :
['rightId', 'leftId'];
}
}
$relationDefs = RelationDefs::create($name)

View File

@@ -50,11 +50,10 @@ class RelationConverter
private const DEFAULT_VARCHAR_LENGTH = 255;
/** @var string[] */
private $allowedParams = [
private $mergeParams = [
'relationName',
'conditions',
'additionalColumns',
'midKeys',
'noJoin',
'indexes',
];
@@ -111,7 +110,7 @@ class RelationConverter
$raw = $convertedEntityDefs->toAssoc();
if (isset($raw['relations'][$name])) {
$this->mergeAllowedParams($raw['relations'][$name], $params, $foreignParams ?? []);
$this->mergeParams($raw['relations'][$name], $params, $foreignParams ?? []);
$this->correct($raw['relations'][$name]);
}
@@ -171,10 +170,10 @@ class RelationConverter
* @param array<string, mixed> $params
* @param array<string, mixed> $foreignParams
*/
private function mergeAllowedParams(array &$relationDefs, array $params, array $foreignParams): void
private function mergeParams(array &$relationDefs, array $params, array $foreignParams): void
{
foreach ($this->allowedParams as $name) {
$additionalParam = $this->getAllowedParam($name, $params, $foreignParams);
foreach ($this->mergeParams as $name) {
$additionalParam = $this->getMergedParam($name, $params, $foreignParams);
if ($additionalParam === null) {
continue;
@@ -189,7 +188,7 @@ class RelationConverter
* @param array<string, mixed> $foreignParams
* @return array<string, mixed>|scalar|null
*/
private function getAllowedParam(string $name, array $params, array $foreignParams): mixed
private function getMergedParam(string $name, array $params, array $foreignParams): mixed
{
$value = $params[$name] ?? null;
$foreignValue = $foreignParams[$name] ?? null;

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Metadata;
use stdClass;
/**
* An additional metadata builder. Allows adding conditional metadata.
* Warning: Dependency injection is not available here. Instantiate
* needed classes explicitly.
*
* @since 8.4.0
*/
interface AdditionalBuilder
{
public function build(stdClass $data): void;
}

View File

@@ -1,3 +1,4 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
@@ -26,29 +27,35 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/call/fields/date-end', ['views/fields/datetime'], function (Dep) {
namespace Espo\Core\Utils\Metadata\AdditionalBuilder;
return Dep.extend({
use Espo\Core\Utils\Metadata\AdditionalBuilder;
use stdClass;
validateAfter: function () {
var field = this.model.getFieldParam(this.name, 'after');
class DeleteIdField implements AdditionalBuilder
{
public function build(stdClass $data): void
{
if (!isset($data->entityDefs)) {
return;
}
if (field) {
var value = this.model.get(this.name);
var otherValue = this.model.get(field);
if (value && otherValue) {
if (moment(value).unix() < moment(otherValue).unix()) {
var msg = this.translate('fieldShouldAfter', 'messages')
.replace('{field}', this.getLabelText())
.replace('{otherField}', this.translate(field, 'fields', this.entityType));
this.showValidationMessage(msg);
return true;
}
}
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
if (!($entityDefsItem->deleteId ?? false)) {
continue;
}
},
});
});
$entityDefsItem->fields ??= (object) [];
$data->entityDefs->$entityType->fields->deleteId = (object) [
"type" => "varchar",
"maxLength" => 17,
"readOnly" => true,
"notNull" => true,
"default" => "0",
"utility" => true,
"customizationDisabled" => true
];
}
}
}

View File

@@ -0,0 +1,108 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Metadata\AdditionalBuilder;
use Espo\Core\Utils\DataUtil;
use Espo\Core\Utils\Metadata\AdditionalBuilder;
use Espo\Core\Utils\Metadata\BuilderHelper;
use Espo\Core\Utils\Util;
use stdClass;
class Fields implements AdditionalBuilder
{
private BuilderHelper $builderHelper;
public function __construct()
{
$this->builderHelper = new BuilderHelper();
}
public function build(stdClass $data): void
{
if (!isset($data->entityDefs)) {
return;
}
$fieldDefinitionList = Util::objectToArray($data->fields);
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
if (isset($data->entityDefs->$entityType->collection)) {
/** @var stdClass $collectionItem */
$collectionItem = $data->entityDefs->$entityType->collection;
if (isset($collectionItem->orderBy)) {
$collectionItem->sortBy = $collectionItem->orderBy;
}
else if (isset($collectionItem->sortBy)) {
$collectionItem->orderBy = $collectionItem->sortBy;
}
if (isset($collectionItem->order)) {
$collectionItem->asc = $collectionItem->order === 'asc';
}
else if (isset($collectionItem->asc)) {
$collectionItem->order = $collectionItem->asc === true ? 'asc' : 'desc';
}
}
if (!isset($entityDefsItem->fields)) {
continue;
}
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
$additionalFields = $this->builderHelper->getAdditionalFieldList(
$field,
Util::objectToArray($fieldDefsItem),
$fieldDefinitionList
);
if (!$additionalFields) {
continue;
}
foreach ($additionalFields as $subFieldName => $subFieldParams) {
$item = Util::arrayToObject($subFieldParams);
if (isset($entityDefsItem->fields->$subFieldName)) {
$data->entityDefs->$entityType->fields->$subFieldName =
DataUtil::merge(
$item,
$entityDefsItem->fields->$subFieldName
);
continue;
}
$data->entityDefs->$entityType->fields->$subFieldName = $item;
}
}
}
}
}

View File

@@ -29,7 +29,6 @@
namespace Espo\Core\Utils\Metadata;
use Espo\Core\Utils\DataUtil;
use Espo\Core\Utils\Resource\Reader as ResourceReader;
use Espo\Core\Utils\Resource\Reader\Params as ResourceReaderParams;
use Espo\Core\Utils\Util;
@@ -39,6 +38,7 @@ class Builder
{
/** @var array<int, string[]> */
private $forceAppendPathList = [
['app', 'metadata', 'additionalBuilderClassNameList'],
['app', 'rebuild', 'actionClassNameList'],
['app', 'formula', 'functionList'],
['app', 'fieldProcessing', 'readLoaderClassNameList'],
@@ -76,10 +76,7 @@ class Builder
private const ANY_KEY = '__ANY__';
public function __construct(
private ResourceReader $resourceReader,
private BuilderHelper $builderHelper
) {}
public function __construct(private ResourceReader $resourceReader) {}
public function build(): stdClass
{
@@ -88,109 +85,24 @@ class Builder
$data = $this->resourceReader->read('metadata', $readerParams);
$this->addAdditionalField($data);
$this->applyAdditional($data);
return $data;
}
private function addAdditionalField(stdClass $data): void
private function applyAdditional(stdClass $data): void
{
if (!isset($data->entityDefs)) {
return;
}
/** @var class-string<AdditionalBuilder>[] $builderClassNameList */
$builderClassNameList = Util::getValueByKey($data, 'app.metadata.additionalBuilderClassNameList') ?? [];
$fieldDefinitionList = Util::objectToArray($data->fields);
/** @var AdditionalBuilder[] $builderList */
$builderList = array_map(
fn ($className) => new $className(),
$builderClassNameList
);
foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) {
if (isset($data->entityDefs->$entityType->collection)) {
/** @var stdClass $collectionItem */
$collectionItem = $data->entityDefs->$entityType->collection;
if (isset($collectionItem->orderBy)) {
$collectionItem->sortBy = $collectionItem->orderBy;
}
else if (isset($collectionItem->sortBy)) {
$collectionItem->orderBy = $collectionItem->sortBy;
}
if (isset($collectionItem->order)) {
$collectionItem->asc = $collectionItem->order === 'asc';
}
else if (isset($collectionItem->asc)) {
$collectionItem->order = $collectionItem->asc === true ? 'asc' : 'desc';
}
}
if (!isset($entityDefsItem->fields)) {
continue;
}
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
$additionalFields = $this->builderHelper->getAdditionalFieldList(
$field,
Util::objectToArray($fieldDefsItem),
$fieldDefinitionList
);
if (!$additionalFields) {
continue;
}
foreach ($additionalFields as $subFieldName => $subFieldParams) {
$item = Util::arrayToObject($subFieldParams);
if (isset($entityDefsItem->fields->$subFieldName)) {
$data->entityDefs->$entityType->fields->$subFieldName =
DataUtil::merge(
$item,
$entityDefsItem->fields->$subFieldName
);
continue;
}
$data->entityDefs->$entityType->fields->$subFieldName = $item;
}
}
foreach ($builderList as $builder) {
$builder->build($data);
}
}
/*private function setMissingFieldDefaults(stdClass $data): void
{
if (!isset($data->entityDefs) || !isset($data->fields)) {
return;
}
foreach (get_object_vars($data->entityDefs) as $entityDefsItem) {
if (!isset($entityDefsItem->fields)) {
continue;
}
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefs) {
$oFieldDefs = FieldDefs::fromRaw(Util::objectToArray($fieldDefs), $field);
$type = $oFieldDefs->getType();
$typeDefs = $data->fields->$type ?? null;
if (!$typeDefs) {
continue;
}
if (!property_exists($typeDefs, 'default')) {
continue;
}
if (
$oFieldDefs->getParam('utility') ||
$oFieldDefs->getParam('disabled') ||
$oFieldDefs->hasParam('default')
) {
continue;
}
$fieldDefs->default = $typeDefs->default;
}
}
}*/
}

View File

@@ -29,8 +29,12 @@
namespace Espo\Core\WebSocket;
use GuzzleHttp\Psr7\Query;
use Psr\Http\Message\RequestInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\Topic;
use Ratchet\Wamp\WampConnection;
use Ratchet\Wamp\WampServerInterface;
use Symfony\Component\Process\PhpExecutableFinder;
@@ -88,7 +92,7 @@ class Pusher implements WampServerInterface
* @param Topic<object> $topic
* @return void
*/
public function onSubscribe(ConnectionInterface $connection, $topic)
public function onSubscribe(ConnectionInterface $conn, $topic)
{
$topicId = $topic->getId();
@@ -101,10 +105,9 @@ class Pusher implements WampServerInterface
}
/** @var string $connectionId */
/** @phpstan-ignore-next-line */
$connectionId = $connection->resourceId;
$connectionId = $conn->resourceId ?? throw new RuntimeException();
$userId = $this->getUserIdByConnection($connection);
$userId = $this->getUserIdByConnection($conn);
if (!$userId) {
return;
@@ -114,27 +117,27 @@ class Pusher implements WampServerInterface
$this->connectionIdTopicIdListMap[$connectionId] = [];
}
$checkCommand = $this->getAccessCheckCommandForTopic($connection, $topic);
$checkCommand = $this->getAccessCheckCommandForTopic($conn, $topic);
if ($checkCommand) {
$checkResult = shell_exec($checkCommand);
if ($checkResult !== 'true') {
if ($this->isDebugMode) {
$this->log("{$connectionId}: check access failed for topic {$topicId} for user {$userId}");
$this->log("$connectionId: check access failed for topic $topicId for user $userId");
}
return;
}
if ($this->isDebugMode) {
$this->log("{$connectionId}: check access succeed for topic {$topicId} for user {$userId}");
$this->log("$connectionId: check access succeed for topic $topicId for user $userId");
}
}
if (!in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
if ($this->isDebugMode) {
$this->log("{$connectionId}: add topic {$topicId} for user {$userId}");
$this->log("$connectionId: add topic $topicId for user $userId");
}
$this->connectionIdTopicIdListMap[$connectionId][] = $topicId;
@@ -147,7 +150,7 @@ class Pusher implements WampServerInterface
* @param Topic<object> $topic
* @return void
*/
public function onUnSubscribe(ConnectionInterface $connection, $topic)
public function onUnSubscribe(ConnectionInterface $conn, $topic)
{
$topicId = $topic->getId();
@@ -160,36 +163,38 @@ class Pusher implements WampServerInterface
}
/** @var string $connectionId */
/** @phpstan-ignore-next-line */
$connectionId = $connection->resourceId;
$connectionId = $conn->resourceId ?? throw new RuntimeException();
$userId = $this->getUserIdByConnection($connection);
$userId = $this->getUserIdByConnection($conn);
if (!$userId) {
return;
}
if (isset($this->connectionIdTopicIdListMap[$connectionId])) {
$index = array_search($topicId, $this->connectionIdTopicIdListMap[$connectionId]);
if ($index !== false) {
if ($this->isDebugMode) {
$this->log("{$connectionId}: remove topic {$topicId} for user {$userId}");
}
unset($this->connectionIdTopicIdListMap[$connectionId][$index]);
$this->connectionIdTopicIdListMap[$connectionId] = array_values(
$this->connectionIdTopicIdListMap[$connectionId]
);
}
if (!isset($this->connectionIdTopicIdListMap[$connectionId])) {
return;
}
$index = array_search($topicId, $this->connectionIdTopicIdListMap[$connectionId]);
if ($index === false) {
return;
}
if ($this->isDebugMode) {
$this->log("$connectionId: remove topic $topicId for user $userId");
}
unset($this->connectionIdTopicIdListMap[$connectionId][$index]);
$this->connectionIdTopicIdListMap[$connectionId] =
array_values($this->connectionIdTopicIdListMap[$connectionId]);
}
/**
* @return array<string, mixed>
*/
protected function getCategoryData(string $topicId): array
private function getCategoryData(string $topicId): array
{
$arr = explode('.', $topicId);
@@ -197,11 +202,9 @@ class Pusher implements WampServerInterface
if (array_key_exists($category, $this->categoriesData)) {
$data = $this->categoriesData[$category];
}
else if (array_key_exists($topicId, $this->categoriesData)) {
} else if (array_key_exists($topicId, $this->categoriesData)) {
$data = $this->categoriesData[$topicId];
}
else {
} else {
$data = [];
}
@@ -211,7 +214,7 @@ class Pusher implements WampServerInterface
/**
* @return array<string, mixed>
*/
protected function getParamsFromTopicId(string $topicId): array
private function getParamsFromTopicId(string $topicId): array
{
$arr = explode('.', $topicId);
@@ -222,10 +225,10 @@ class Pusher implements WampServerInterface
if (array_key_exists('paramList', $data)) {
foreach ($data['paramList'] as $i => $item) {
/** @var string $item */
if (isset($arr[$i + 1])) {
$params[$item] = $arr[$i + 1];
}
else {
} else {
$params[$item] = '';
}
}
@@ -237,15 +240,15 @@ class Pusher implements WampServerInterface
/**
* @param Topic<object> $topic
*/
protected function getAccessCheckCommandForTopic(ConnectionInterface $connection, $topic): ?string
private function getAccessCheckCommandForTopic(ConnectionInterface $conn, $topic): ?string
{
$topicId = $topic->getId();
$params = $this->getParamsFromTopicId($topicId);
$params['userId'] = $this->getUserIdByConnection($connection);
$params['userId'] = $this->getUserIdByConnection($conn);
if (!$params['userId']) {
$connection->close();
$conn->close();
return null;
}
@@ -269,24 +272,13 @@ class Pusher implements WampServerInterface
return $command;
}
/**
* @param Topic<object> $topic
* @return string
*/
protected function getTopicCategory($topic)
{
list($category) = explode('.', $topic->getId());
return $category;
}
/**
* @param string $topicId
* @return bool
*/
protected function isTopicAllowed($topicId)
private function isTopicAllowed($topicId)
{
list($category) = explode('.', $topicId);
[$category] = explode('.', $topicId);
return in_array($topicId, $this->categoryList) || in_array($category, $this->categoryList);
}
@@ -295,7 +287,7 @@ class Pusher implements WampServerInterface
* @param string $userId
* @return string[]
*/
protected function getConnectionIdListByUserId($userId)
private function getConnectionIdListByUserId($userId)
{
if (!isset($this->userIdConnectionIdListMap[$userId])) {
return [];
@@ -307,26 +299,25 @@ class Pusher implements WampServerInterface
/**
* @return ?string
*/
protected function getUserIdByConnection(ConnectionInterface $connection)
private function getUserIdByConnection(ConnectionInterface $conn)
{
/** @phpstan-ignore-next-line */
if (!isset($this->connectionIdUserIdMap[$connection->resourceId])) {
$connectionId = $conn->resourceId ?? '';
if (!isset($this->connectionIdUserIdMap[$connectionId])) {
return null;
}
/** @phpstan-ignore-next-line */
return $this->connectionIdUserIdMap[$connection->resourceId];
return $this->connectionIdUserIdMap[$connectionId];
}
/**
* @param string $userId
* @return void
*/
protected function subscribeUser(ConnectionInterface $connection, $userId)
private function subscribeUser(ConnectionInterface $conn, $userId)
{
/** @var string $resourceId */
/** @phpstan-ignore-next-line */
$resourceId = $connection->resourceId;
$resourceId = $conn->resourceId ?? '';
$this->connectionIdUserIdMap[$resourceId] = $userId;
@@ -338,10 +329,10 @@ class Pusher implements WampServerInterface
$this->userIdConnectionIdListMap[$userId][] = $resourceId;
}
$this->connections[$resourceId] = $connection;
$this->connections[$resourceId] = $conn;
if ($this->isDebugMode) {
$this->log("{$resourceId}: user {$userId} subscribed");
$this->log("$resourceId: user $userId subscribed");
}
}
@@ -349,10 +340,9 @@ class Pusher implements WampServerInterface
* @param string $userId
* @return void
*/
protected function unsubscribeUser(ConnectionInterface $connection, $userId)
private function unsubscribeUser(ConnectionInterface $conn, $userId)
{
/** @phpstan-ignore-next-line */
$resourceId = $connection->resourceId;
$resourceId = $conn->resourceId ?? '';
unset($this->connectionIdUserIdMap[$resourceId]);
@@ -366,51 +356,52 @@ class Pusher implements WampServerInterface
}
if ($this->isDebugMode) {
$this->log("{$resourceId}: user {$userId} unsubscribed");
$this->log("$resourceId: user $userId unsubscribed");
}
}
/**
* @return void
*/
public function onOpen(ConnectionInterface $connection)
public function onOpen(ConnectionInterface $conn)
{
if ($this->isDebugMode) {
/** @phpstan-ignore-next-line */
$this->log("{$connection->resourceId}: open");
$resourceId = $conn->resourceId ?? '';
$this->log("$resourceId: open");
}
/** @phpstan-ignore-next-line */
$httpRequest = $connection->httpRequest;
/** @var RequestInterface $httpRequest */
$httpRequest = $conn->httpRequest ?? throw new RuntimeException();
$query = $httpRequest->getUri()->getQuery();
$params = \GuzzleHttp\Psr7\parse_query($query ?: '');
$params = Query::parse($query ?: '');
if (empty($params['userId']) || empty($params['authToken'])) {
$this->closeConnection($connection);
$this->closeConnection($conn);
return;
}
$authToken = preg_replace('/[^a-zA-Z0-9]+/', '', $params['authToken']);
$userId = preg_replace('/[^a-zA-Z0-9]+/', '', $params['userId']);
$authToken = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['authToken']);
$userId = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['userId']);
$result = $this->getUserIdByAuthToken($authToken);
if (empty($result)) {
$this->closeConnection($connection);
$this->closeConnection($conn);
return;
}
if ($result !== $userId) {
$this->closeConnection($connection);
$this->closeConnection($conn);
return;
}
$this->subscribeUser($connection, $userId);
$this->subscribeUser($conn, $userId);
}
/**
@@ -432,35 +423,35 @@ class Pusher implements WampServerInterface
/**
* @return void
*/
protected function closeConnection(ConnectionInterface $connection)
private function closeConnection(ConnectionInterface $conn)
{
$userId = $this->getUserIdByConnection($connection);
$userId = $this->getUserIdByConnection($conn);
if ($userId) {
$this->unsubscribeUser($connection, $userId);
$this->unsubscribeUser($conn, $userId);
}
$connection->close();
$conn->close();
}
/**
* @return void
*/
public function onClose(ConnectionInterface $connection)
public function onClose(ConnectionInterface $conn)
{
$connectionId = $conn->resourceId ?? '';
if ($this->isDebugMode) {
/** @phpstan-ignore-next-line */
$this->log("{$connection->resourceId}: close");
$this->log("$connectionId: close");
}
$userId = $this->getUserIdByConnection($connection);
$userId = $this->getUserIdByConnection($conn);
if ($userId) {
$this->unsubscribeUser($connection, $userId);
$this->unsubscribeUser($conn, $userId);
}
/** @phpstan-ignore-next-line */
unset($this->connections[$connection->resourceId]);
unset($this->connections[$connectionId]);
}
/**
@@ -469,25 +460,24 @@ class Pusher implements WampServerInterface
* @param array<string, mixed> $params
* @return void
*/
public function onCall(ConnectionInterface $connection, $id, $topic, array $params)
public function onCall(ConnectionInterface $conn, $id, $topic, array $params)
{
if (!method_exists($connection, 'callError')) {
if (!method_exists($conn, 'callError')) {
return;
}
$connection
->callError($id, $topic, 'You are not allowed to make calls')
$conn->callError($id, $topic, 'You are not allowed to make calls')
->close();
}
/**
* @param Topic<object> $topic
* @param string $event
* @param array<mixed, mixed> $exclude
* @param array<mixed, mixed> $eligible
* @param array<int|string, mixed> $exclude
* @param array<int|string, mixed> $eligible
* @return void
*/
public function onPublish(ConnectionInterface $connection, $topic, $event, array $exclude, array $eligible)
public function onPublish(ConnectionInterface $conn, $topic, $event, array $exclude, array $eligible)
{
$topicId = $topic->getId();
@@ -495,13 +485,13 @@ class Pusher implements WampServerInterface
return;
}
$connection->close();
$conn->close();
}
/**
* @return void
*/
public function onError(ConnectionInterface $connection, Exception $e)
public function onError(ConnectionInterface $conn, Exception $e)
{
}
@@ -534,12 +524,12 @@ class Pusher implements WampServerInterface
continue;
}
/** @var \Ratchet\Wamp\WampConnection $connection */
/** @var WampConnection $connection */
$connection = $this->connections[$connectionId];
if (in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
if ($this->isDebugMode) {
$this->log("send {$topicId} for connection {$connectionId}");
$this->log("send $topicId for connection $connectionId");
}
$connection->event($topicId, $data);
@@ -547,7 +537,7 @@ class Pusher implements WampServerInterface
}
if ($this->isDebugMode) {
$this->log("message {$topicId} for user {$userId}");
$this->log("message $topicId for user $userId");
}
return;
@@ -559,16 +549,16 @@ class Pusher implements WampServerInterface
$topic->broadcast($data);
if ($this->isDebugMode) {
$this->log("send {$topicId} to all");
$this->log("send $topicId to all");
}
}
if ($this->isDebugMode) {
$this->log("message {$topicId} for all");
$this->log("message $topicId for all");
}
}
protected function log(string $msg): void
private function log(string $msg): void
{
echo "[" . date('Y-m-d H:i:s') . "] " . $msg . "\n";
}

View File

@@ -35,6 +35,9 @@ use Espo\Core\Utils\Json;
use stdClass;
use Throwable;
/**
* @todo Introduce a wrapper class that will skip sending if WebSocket is not enabled.
*/
class Submission
{
public function __construct(

View File

@@ -29,6 +29,7 @@
namespace Espo\Core\Webhook;
use Espo\Core\Field\DateTime;
use Espo\Entities\User;
use Espo\Entities\Webhook;
use Espo\Entities\WebhookEventQueueItem;
@@ -41,7 +42,6 @@ use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Condition as Cond;
use Exception;
use DateTime;
use stdClass;
/**
@@ -74,11 +74,10 @@ class Queue
{
$portionSize = $this->config->get('webhookQueueEventPortionSize', self::EVENT_PORTION_SIZE);
/** @var iterable<WebhookEventQueueItem> $itemList */
$itemList = $this->entityManager
->getRDBRepository(WebhookEventQueueItem::ENTITY_TYPE)
->where([
'isProcessed' => false,
])
->where(['isProcessed' => false])
->order('number')
->limit(0, $portionSize)
->find();
@@ -86,10 +85,7 @@ class Queue
foreach ($itemList as $item) {
$this->createQueueFromEvent($item);
$item->set([
'isProcessed' => true,
]);
$item->setIsProcessed();
$this->entityManager->saveEntity($item);
}
}
@@ -186,8 +182,7 @@ class Queue
$user = null;
if ($webhook->getUserId()) {
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $webhook->getUserId());
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($webhook->getUserId());
if (!$user) {
foreach ($itemList as $item) {
@@ -352,11 +347,11 @@ class Queue
protected function succeedQueueItem(WebhookQueueItem $item): void
{
$item->set([
'attempts' => $item->getAttempts() + 1,
'status' => WebhookQueueItem::STATUS_SUCCESS,
'processedAt' => DateTimeUtil::getSystemNowString(),
]);
$item
->setAttempts($item->getAttempts() + 1)
->setStatus(WebhookQueueItem::STATUS_SUCCESS)
->setProcessedAt(DateTime::createNow());
$this->entityManager->saveEntity($item);
}
@@ -372,18 +367,14 @@ class Queue
$maxAttemptsNumber = 0;
}
$dt = new DateTime();
$dt->modify($period);
$processAt = DateTime::createNow()->modify($period);
$item->set([
'attempts' => $attempts,
'processAt' => $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
]);
$item->setAttempts($attempts);
$item->setProcessAt($processAt);
if ($attempts >= $maxAttemptsNumber) {
$item->set('status', WebhookQueueItem::STATUS_FAILED);
/** @noinspection PhpRedundantOptionalArgumentInspection */
$item->set('processAt', null);
$item->setStatus(WebhookQueueItem::STATUS_FAILED);
$item->setProcessAt(null);
}
$this->entityManager->saveEntity($item);

View File

@@ -55,6 +55,7 @@ class Email extends Entity
public const USERS_COLUMN_IS_READ = 'isRead';
public const USERS_COLUMN_IN_TRASH = 'inTrash';
public const USERS_COLUMN_IN_ARCHIVE = 'inArchive';
public const USERS_COLUMN_FOLDER_ID = 'folderId';
public const USERS_COLUMN_IS_IMPORTANT = 'isImportant';

View File

@@ -73,4 +73,11 @@ class EmailAddress extends Entity
{
return $this->get('invalid');
}
public function setOptedOut(bool $optedOut): self
{
$this->set('optOut', $optedOut);
return $this;
}
}

View File

@@ -29,8 +29,15 @@
namespace Espo\Entities;
class EmailFolder extends \Espo\Core\ORM\Entity
use Espo\Core\ORM\Entity;
class EmailFolder extends Entity
{
public const ENTITY_TYPE = 'EmailFolder';
public function getName(): string
{
return $this->get('name') ?? '';
}
}

View File

@@ -29,7 +29,113 @@
namespace Espo\Entities;
use Espo\Core\Field\DateTime;
use RuntimeException;
class ExternalAccount extends Integration
{
public const ENTITY_TYPE = 'ExternalAccount';
public function isEnabled(): bool
{
return (bool) $this->get('enabled');
}
public function setIsEnabled(bool $isEnabled): self
{
$this->set('enabled', $isEnabled);
return $this;
}
public function unsetData(): self
{
$this->set(['data' => null]);
return $this;
}
public function setIsLocked(bool $isLocked): self
{
$this->set('isLocked', $isLocked);
return $this;
}
public function isLocked(): bool
{
return (bool) $this->get('isLocked');
}
public function getRefreshTokenAttempts(): int
{
return (int) ($this->get('refreshTokenAttempts') ?? 0);
}
public function getAccessToken(): ?string
{
return $this->get('accessToken');
}
public function getRefreshToken(): ?string
{
return $this->get('refreshToken');
}
public function getTokenType(): ?string
{
return $this->get('tokenType');
}
public function getExpiresAt(): ?DateTime
{
$raw = $this->get('expiresAt');
if (!$raw) {
return null;
}
try {
return DateTime::fromString($raw);
}
catch (RuntimeException) {
return null;
}
}
public function setAccessToken(?string $accessToken): self
{
$this->set('accessToken', $accessToken);
return $this;
}
public function setTokenType(?string $tokenType): self
{
$this->set('tokenType', $tokenType);
return $this;
}
public function setRefreshToken(?string $refreshToken): self
{
$this->set('refreshToken', $refreshToken);
return $this;
}
public function setExpiresAt(?string $expiresAt): self
{
$this->set('expiresAt', $expiresAt);
return $this;
}
public function setRefreshTokenAttempts(?int $refreshTokenAttempts): self
{
$this->set('refreshTokenAttempts', $refreshTokenAttempts);
return $this;
}
}

View File

@@ -183,4 +183,9 @@ class Integration extends Entity
return (object) $arr;
}
public function isEnabled(): bool
{
return (bool) $this->get('enabled');
}
}

View File

@@ -31,9 +31,10 @@ namespace Espo\Entities;
use Espo\Core\Field\LinkParent;
use Espo\Core\ORM\Entity;
use stdClass;
class Notification extends \Espo\Core\ORM\Entity
class Notification extends Entity
{
public const ENTITY_TYPE = 'Notification';
@@ -109,4 +110,16 @@ class Notification extends \Espo\Core\ORM\Entity
return $this;
}
public function isRead(): bool
{
return $this->get('read');
}
public function setRead(bool $read = true): self
{
$this->set('read', $read);
return $this;
}
}

View File

@@ -42,4 +42,9 @@ class Preferences extends \Espo\Core\ORM\Entity
{
return null;
}
public function getTimeZone(): ?string
{
return $this->get('timeZone');
}
}

View File

@@ -49,4 +49,12 @@ class Team extends \Espo\Core\ORM\Entity
/** @var ?Link */
return $this->getValueObject('layoutSet');
}
/**
* @return string[]
*/
public function getPositionList(): array
{
return $this->get('positionList') ?? [];
}
}

View File

@@ -30,11 +30,13 @@
namespace Espo\Entities;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\LinkParent;
use Espo\Core\ORM\Entity;
use stdClass;
use LogicException;
class UniqueId extends \Espo\Core\ORM\Entity
class UniqueId extends Entity
{
public const ENTITY_TYPE = 'UniqueId';
@@ -65,4 +67,38 @@ class UniqueId extends \Espo\Core\ORM\Entity
return $value;
}
/**
* @param array<string, mixed>|stdClass $data
*/
public function setData(array|stdClass $data): self
{
$this->set('data', $data);
return $this;
}
public function setTarget(?LinkParent $target): self
{
if (!$target) {
$this->setMultiple([
'targetId' => null,
'targetType' => null,
]);
} else {
$this->setMultiple([
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
]);
}
return $this;
}
public function setTerminateAt(?DateTime $terminateAt): self
{
$this->setValueObject('terminateAt', $terminateAt);
return $this;
}
}

View File

@@ -29,7 +29,16 @@
namespace Espo\Entities;
class WebhookEventQueueItem extends \Espo\Core\ORM\Entity
use Espo\Core\ORM\Entity;
class WebhookEventQueueItem extends Entity
{
public const ENTITY_TYPE = 'WebhookEventQueueItem';
public function setIsProcessed(bool $isProcessed = true): self
{
$this->set('isProcessed', $isProcessed);
return $this;
}
}

View File

@@ -29,6 +29,7 @@
namespace Espo\Entities;
use Espo\Core\Field\DateTime;
use Espo\Core\ORM\Entity;
class WebhookQueueItem extends Entity
@@ -43,4 +44,44 @@ class WebhookQueueItem extends Entity
{
return $this->get('attempts') ?? 0;
}
public function setStatus(string $status): self
{
$this->set('status', $status);
return $this;
}
public function setAttempts(?int $attempts): self
{
$this->set('attempts', $attempts);
return $this;
}
public function setProcessAt(?DateTime $processAt): self
{
if (!$processAt) {
$this->set('processAt', $processAt);
return $this;
}
$this->set('processAt', $processAt->toString());
return $this;
}
public function setProcessedAt(?DateTime $processedAt): self
{
if (!$processedAt) {
$this->set('processedAt', $processedAt);
return $this;
}
$this->set('processedAt', $processedAt->toString());
return $this;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Hooks\Common;
use Espo\Core\Hook\Hook\BeforeRemove;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Util;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\RemoveOptions;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave;
/**
* Handles 'deleteId' on soft-deletes.
*
* @implements BeforeSave<Entity>
* @implements BeforeRemove<Entity>
*/
class DeleteId implements BeforeSave, BeforeRemove
{
private const ID_ATTR = 'deleteId';
private const DELETED_ATTR = 'deleted';
public function __construct(
private Metadata $metadata,
) {}
public function beforeRemove(Entity $entity, RemoveOptions $options): void
{
if (!$this->hasDeleteId($entity)) {
return;
}
$entity->set(self::ID_ATTR, Util::generateId());
}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
if (!$this->hasDeleteId($entity)) {
return;
}
if (!$entity->isAttributeChanged(self::DELETED_ATTR)) {
return;
}
$deleteId = $entity->get(self::DELETED_ATTR) ? Util::generateId() : '0';
$entity->set(self::ID_ATTR, $deleteId);
}
private function hasDeleteId(Entity $entity): bool
{
return $entity->hasAttribute(self::DELETED_ATTR) &&
$this->metadata->get("entityDefs.{$entity->getEntityType()}.deleteId");
}
}

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