Compare commits
730 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863ad60308 | ||
|
|
23b371c4f0 | ||
|
|
164303763d | ||
|
|
b1e9249cd2 | ||
|
|
17f060ee9a | ||
|
|
7fe68073f4 | ||
|
|
d772126165 | ||
|
|
4bf6a84223 | ||
|
|
67f23e88a2 | ||
|
|
cd450e819d | ||
|
|
b753c63924 | ||
|
|
773981de78 | ||
|
|
b4332f105f | ||
|
|
ffd758438d | ||
|
|
a6bb63c9fe | ||
|
|
bff838fa0e | ||
|
|
7ec40b691a | ||
|
|
041d7e09b2 | ||
|
|
a8290b421d | ||
|
|
8788b4a8e6 | ||
|
|
f14358c5fe | ||
|
|
f1b35d0d5f | ||
|
|
649d868c62 | ||
|
|
8fe96f83a7 | ||
|
|
cf951ea20b | ||
|
|
4de254f7cd | ||
|
|
a1e876f82d | ||
|
|
48c4fac6b4 | ||
|
|
eec09ff173 | ||
|
|
63546323af | ||
|
|
4a8bd527e3 | ||
|
|
4de16c62b8 | ||
|
|
917b4e0ce1 | ||
|
|
68fe730581 | ||
|
|
c2c2de734e | ||
|
|
7b5ab22381 | ||
|
|
7f0e612a15 | ||
|
|
65e963e163 | ||
|
|
ae038154db | ||
|
|
dcd178b3d5 | ||
|
|
f594564630 | ||
|
|
73e2d260a7 | ||
|
|
2eb76242a6 | ||
|
|
405c9de596 | ||
|
|
9921987a88 | ||
|
|
24f35e170e | ||
|
|
659e6e8813 | ||
|
|
c299a6e4b0 | ||
|
|
544829d7ea | ||
|
|
2f1739e817 | ||
|
|
1beaa0b60e | ||
|
|
f46f1d678b | ||
|
|
c5d8acb937 | ||
|
|
c96b24a7da | ||
|
|
78e987c920 | ||
|
|
dffbf0edf2 | ||
|
|
d661beeea2 | ||
|
|
929c575d28 | ||
|
|
42d753acbb | ||
|
|
56867a0b79 | ||
|
|
8d4eb9b899 | ||
|
|
be242eaa92 | ||
|
|
11663cf58d | ||
|
|
24e2fcaf20 | ||
|
|
0f87c3e50f | ||
|
|
a42a2a7134 | ||
|
|
c224ec4ef5 | ||
|
|
7ccaee0518 | ||
|
|
7e90331746 | ||
|
|
86a120da0c | ||
|
|
89bc9265b8 | ||
|
|
a287c272bf | ||
|
|
d709f731a8 | ||
|
|
57b8f79482 | ||
|
|
2dfa1fcb39 | ||
|
|
1a9c5b8d08 | ||
|
|
243320ddb9 | ||
|
|
2265016523 | ||
|
|
1a1dbcc5e0 | ||
|
|
003d2070c6 | ||
|
|
c422882ff8 | ||
|
|
3e194faa76 | ||
|
|
17037f219d | ||
|
|
8db5546bab | ||
|
|
95c4acdd3a | ||
|
|
1a49198844 | ||
|
|
8453429753 | ||
|
|
12adf2470c | ||
|
|
e3e8b521f1 | ||
|
|
46f1bc4658 | ||
|
|
24d0177cf8 | ||
|
|
96523cf6d9 | ||
|
|
2221777f3a | ||
|
|
05d45d3037 | ||
|
|
fd24aa6dbd | ||
|
|
2f6b583bf1 | ||
|
|
0489a6c29f | ||
|
|
7c64e53126 | ||
|
|
dbbfff831f | ||
|
|
01f5a69e6a | ||
|
|
e37596f1b6 | ||
|
|
c0248c2a8e | ||
|
|
6bc4005fa1 | ||
|
|
ef99594188 | ||
|
|
97a561e99e | ||
|
|
504a7f71dc | ||
|
|
bd7fa38500 | ||
|
|
29f057aa4c | ||
|
|
cd129aa634 | ||
|
|
198d839a4a | ||
|
|
945fb2f583 | ||
|
|
7f977959df | ||
|
|
e60e1cd3c3 | ||
|
|
b00c3f5f5d | ||
|
|
ab1c70d413 | ||
|
|
9c594f406b | ||
|
|
5be2d61755 | ||
|
|
8ee81b6050 | ||
|
|
e5d1065743 | ||
|
|
6fae3eecb1 | ||
|
|
4be0be3982 | ||
|
|
aada309876 | ||
|
|
3d5cffc50c | ||
|
|
f7a0b56db3 | ||
|
|
c61d5c97b7 | ||
|
|
12fb019cac | ||
|
|
f41709a6cf | ||
|
|
8597a4ca52 | ||
|
|
af49c663e0 | ||
|
|
e91f2e410c | ||
|
|
d9d02b5258 | ||
|
|
b386b9034f | ||
|
|
8667724e0b | ||
|
|
0567e3906b | ||
|
|
d3034ab4f3 | ||
|
|
8911f03f50 | ||
|
|
7c998efe8e | ||
|
|
5286397847 | ||
|
|
053fff2634 | ||
|
|
609d065b05 | ||
|
|
730401e757 | ||
|
|
3d01c23f0b | ||
|
|
6420feed47 | ||
|
|
c1bc7b90ef | ||
|
|
ce56346290 | ||
|
|
34537a1834 | ||
|
|
8293c064d9 | ||
|
|
1d115c6be8 | ||
|
|
2b71bbadbb | ||
|
|
ad8f305b13 | ||
|
|
09a18e6d0f | ||
|
|
a3cacca368 | ||
|
|
c1d63c7549 | ||
|
|
b792e25eec | ||
|
|
c5b8917747 | ||
|
|
891c971bb9 | ||
|
|
31a9856819 | ||
|
|
723598aa27 | ||
|
|
188a738528 | ||
|
|
5f140b117f | ||
|
|
9e1ca45169 | ||
|
|
5e32a629a3 | ||
|
|
7390179434 | ||
|
|
95513f1832 | ||
|
|
ed1a7ca9e3 | ||
|
|
fa86462665 | ||
|
|
d8f21f070b | ||
|
|
e52e641636 | ||
|
|
90209e8b54 | ||
|
|
c8f4e43aef | ||
|
|
721bc200aa | ||
|
|
46bd96f442 | ||
|
|
51543a4be5 | ||
|
|
d416863d20 | ||
|
|
9dc9053f66 | ||
|
|
89fced362e | ||
|
|
1e886cbff1 | ||
|
|
a381fe69ec | ||
|
|
78cf02d983 | ||
|
|
bce23ef2d3 | ||
|
|
868061500a | ||
|
|
69dd7a98dd | ||
|
|
bd54392ca4 | ||
|
|
4e8e4d80c7 | ||
|
|
06e0e85dcc | ||
|
|
ddbf13b0b5 | ||
|
|
1cc09c8a6e | ||
|
|
c0fde64b42 | ||
|
|
c8b20fbe4a | ||
|
|
adde156bc2 | ||
|
|
c1839cca8a | ||
|
|
1aa8ffa9cf | ||
|
|
27f752d23f | ||
|
|
33661df11a | ||
|
|
f87c43522d | ||
|
|
1040e0af3c | ||
|
|
fcfb029710 | ||
|
|
0e573cd6f9 | ||
|
|
6c2bf1d59b | ||
|
|
e42c59ab88 | ||
|
|
58a49aa806 | ||
|
|
0f47724cb7 | ||
|
|
38b9a236a8 | ||
|
|
c04444bfbf | ||
|
|
bbb8f94417 | ||
|
|
198a2afe75 | ||
|
|
002740b267 | ||
|
|
ef10136cb8 | ||
|
|
343a21e457 | ||
|
|
f706284996 | ||
|
|
b5c737cdf8 | ||
|
|
26ac7e2c62 | ||
|
|
34d2a019b0 | ||
|
|
661c000a0e | ||
|
|
e2a5930a5a | ||
|
|
7fad593186 | ||
|
|
752ce0f46b | ||
|
|
980c6533a8 | ||
|
|
9dc66919e1 | ||
|
|
68ba521efa | ||
|
|
84bf959276 | ||
|
|
3d14b81971 | ||
|
|
d3bb99dbc1 | ||
|
|
1407a1bfdd | ||
|
|
0f41f5cabb | ||
|
|
21768e1090 | ||
|
|
db4b4e4b4a | ||
|
|
2b3d120a49 | ||
|
|
c9347ee8a9 | ||
|
|
47c98e97a4 | ||
|
|
f9c8089ed5 | ||
|
|
9bd8a6527c | ||
|
|
a49b61d693 | ||
|
|
10e8d0c867 | ||
|
|
ae55bd7a36 | ||
|
|
3101b08d8b | ||
|
|
8d41a382f2 | ||
|
|
8a8dd09a0c | ||
|
|
d0ed4fda50 | ||
|
|
6e84ce7840 | ||
|
|
61c6fe0ec6 | ||
|
|
e67f09fe7e | ||
|
|
499fcfc6ca | ||
|
|
369125c24f | ||
|
|
14faec272c | ||
|
|
5c55597650 | ||
|
|
24e0a9a01b | ||
|
|
8ea4963b75 | ||
|
|
73832baf03 | ||
|
|
fab578bb5c | ||
|
|
ea2820d04a | ||
|
|
c333ea2780 | ||
|
|
ddadec3c2a | ||
|
|
f0533a5470 | ||
|
|
482b002a8e | ||
|
|
806f3d0228 | ||
|
|
2e83003030 | ||
|
|
756549c157 | ||
|
|
95fa038205 | ||
|
|
7c4539d445 | ||
|
|
90354deb6c | ||
|
|
2e7ad9a234 | ||
|
|
9ad3a8403a | ||
|
|
6141a718c4 | ||
|
|
7efcfd9809 | ||
|
|
de383fd9b6 | ||
|
|
bdef99d68e | ||
|
|
403f02f613 | ||
|
|
0f40c152e3 | ||
|
|
d9b96b7a28 | ||
|
|
f4a84f47e3 | ||
|
|
2c3d484271 | ||
|
|
0d9a508f2c | ||
|
|
441e46d65d | ||
|
|
582961ec86 | ||
|
|
31d0e4199f | ||
|
|
2429b49fdc | ||
|
|
fc89e7df5e | ||
|
|
d2e42a91a2 | ||
|
|
18b74a92f7 | ||
|
|
b21600e8f9 | ||
|
|
3eb73bf375 | ||
|
|
069b76adaf | ||
|
|
2a14a079b7 | ||
|
|
d74818bbf3 | ||
|
|
e6e13e20bd | ||
|
|
caafa618db | ||
|
|
12863e7ad2 | ||
|
|
85071db541 | ||
|
|
de967ed952 | ||
|
|
a7e85bfcc4 | ||
|
|
11f4ed76b3 | ||
|
|
1c6bd51ef4 | ||
|
|
16d392fb3c | ||
|
|
b3a47afa77 | ||
|
|
2ee52deaa1 | ||
|
|
acdc9ef5b5 | ||
|
|
ad8f668998 | ||
|
|
970ae6e2ba | ||
|
|
979e34dd5c | ||
|
|
e1f103568d | ||
|
|
e6721a094b | ||
|
|
5fe3c3838f | ||
|
|
200a2b38ff | ||
|
|
1e970caffa | ||
|
|
b12bb42d47 | ||
|
|
72bd06f6d2 | ||
|
|
9d16a0d4d3 | ||
|
|
e1b4a22383 | ||
|
|
059301b720 | ||
|
|
6e1b61fbb9 | ||
|
|
24fdc40dc9 | ||
|
|
95fcc75f90 | ||
|
|
646e8266d8 | ||
|
|
2fff439530 | ||
|
|
8f2a7f2703 | ||
|
|
83030464ee | ||
|
|
f0f8fd5625 | ||
|
|
609f7be8d2 | ||
|
|
1b50b24c62 | ||
|
|
41fd15bb68 | ||
|
|
6a45fff348 | ||
|
|
3f508d41a0 | ||
|
|
a4c587cf1f | ||
|
|
09799fd4bc | ||
|
|
154b513af8 | ||
|
|
2f6fbd76dd | ||
|
|
39bc5401ef | ||
|
|
c98b075fca | ||
|
|
5a34f2a312 | ||
|
|
f454d3f445 | ||
|
|
72a8137b03 | ||
|
|
b13ec05c2a | ||
|
|
9406e102ef | ||
|
|
41b4441ab5 | ||
|
|
85a5dbd38e | ||
|
|
1704473a77 | ||
|
|
f4dd553530 | ||
|
|
084cb6981b | ||
|
|
06fa6b2940 | ||
|
|
5f2925efd0 | ||
|
|
385a55b4b7 | ||
|
|
637fce7dbe | ||
|
|
e188765c21 | ||
|
|
5b5ce88a90 | ||
|
|
7337879a65 | ||
|
|
adec0de39a | ||
|
|
7c485c96be | ||
|
|
15c5aaaa15 | ||
|
|
29c6c2884a | ||
|
|
30ba029242 | ||
|
|
00f50ac6ca | ||
|
|
e7008fa267 | ||
|
|
460bc8f91b | ||
|
|
caa5aac1c3 | ||
|
|
4e197d8ce9 | ||
|
|
b7082a8867 | ||
|
|
e47a54a5af | ||
|
|
1f4deee636 | ||
|
|
656cb2349c | ||
|
|
38d49720e3 | ||
|
|
9ad0db4947 | ||
|
|
5fe3f44f09 | ||
|
|
73a7e1f9db | ||
|
|
193adb7e11 | ||
|
|
3d593022fe | ||
|
|
7f6a5042c1 | ||
|
|
315ad05f73 | ||
|
|
3193add63e | ||
|
|
4503d750cb | ||
|
|
2a804038ff | ||
|
|
2cd2e66b77 | ||
|
|
47da5838f7 | ||
|
|
e2b4151e78 | ||
|
|
cd9e8fea9c | ||
|
|
e903accecd | ||
|
|
8bb0594e95 | ||
|
|
8f7400492a | ||
|
|
135d2d6d0c | ||
|
|
5ec9169044 | ||
|
|
5e664f2e25 | ||
|
|
c7e21d9abe | ||
|
|
0aab1d715f | ||
|
|
d3c3148945 | ||
|
|
fed0ecb533 | ||
|
|
b7b4ce4069 | ||
|
|
beef6af9b5 | ||
|
|
afa0cb6ba5 | ||
|
|
26ff8c73ab | ||
|
|
30c0f68c97 | ||
|
|
875ab40739 | ||
|
|
e39fa1299f | ||
|
|
e2dcf6dc7e | ||
|
|
28621dbfcf | ||
|
|
6ba03d32a0 | ||
|
|
2fda21b54e | ||
|
|
fc8f50c973 | ||
|
|
15db137829 | ||
|
|
4a678b8309 | ||
|
|
1261b8b84c | ||
|
|
ae68e194b1 | ||
|
|
5dab098380 | ||
|
|
215241ee2e | ||
|
|
f5c566c2e8 | ||
|
|
3e216820ba | ||
|
|
4008a22ef5 | ||
|
|
fcea5716e5 | ||
|
|
b455972062 | ||
|
|
a958553b72 | ||
|
|
d902f19038 | ||
|
|
8e5e73b4ce | ||
|
|
48f05f34e6 | ||
|
|
ab8d681219 | ||
|
|
68746b8c17 | ||
|
|
369eb9217a | ||
|
|
5a5a48e32c | ||
|
|
79d5955ab1 | ||
|
|
d7fb065a5f | ||
|
|
956f631661 | ||
|
|
d5b63611ad | ||
|
|
296a4c82a1 | ||
|
|
285ca977fa | ||
|
|
a0b3888b18 | ||
|
|
f58b6d8bda | ||
|
|
a7a2fe91e6 | ||
|
|
b0bf40295b | ||
|
|
da9bbfde8f | ||
|
|
65219613b9 | ||
|
|
45356bfbdf | ||
|
|
c837ad77e5 | ||
|
|
164349549d | ||
|
|
0c75dfde32 | ||
|
|
87dba690ef | ||
|
|
61adcbd890 | ||
|
|
190cdea748 | ||
|
|
53ce4523a4 | ||
|
|
291ff9e306 | ||
|
|
855b573672 | ||
|
|
3dcdbb85d6 | ||
|
|
d8ec060a9d | ||
|
|
4fc258f77b | ||
|
|
25edcebd77 | ||
|
|
deb6788773 | ||
|
|
b729fcb2e1 | ||
|
|
1371340962 | ||
|
|
0dbf2e1665 | ||
|
|
2293911718 | ||
|
|
864ad08820 | ||
|
|
270bd58f61 | ||
|
|
60c01c3707 | ||
|
|
17d31b62ad | ||
|
|
66e5ac8e9d | ||
|
|
43f6e154a5 | ||
|
|
d61b74c306 | ||
|
|
95882ea8f1 | ||
|
|
a7d678ac21 | ||
|
|
4879a34b3d | ||
|
|
01fa0955d4 | ||
|
|
5f34e80a22 | ||
|
|
94485d86bb | ||
|
|
ce20dfa787 | ||
|
|
c703f38f6c | ||
|
|
8a887502e1 | ||
|
|
3368437e4e | ||
|
|
59cababa70 | ||
|
|
b4d2846a3a | ||
|
|
e45213e537 | ||
|
|
c35fc2a502 | ||
|
|
6583d6f5a5 | ||
|
|
25f92844c2 | ||
|
|
f4590b8279 | ||
|
|
cdadaf621b | ||
|
|
414878eb79 | ||
|
|
aaf334e9bb | ||
|
|
f35fddb0cc | ||
|
|
fb130de64e | ||
|
|
5f182818e2 | ||
|
|
eb397efae2 | ||
|
|
14f17a5cd2 | ||
|
|
d0713d5a67 | ||
|
|
b33d1e56a8 | ||
|
|
c779afdda8 | ||
|
|
83eea1ca47 | ||
|
|
d459a3b0cf | ||
|
|
b3d7611e6e | ||
|
|
2e2fe37111 | ||
|
|
c2680bd9ec | ||
|
|
88baece2a4 | ||
|
|
265379b3ae | ||
|
|
39182e106a | ||
|
|
6f6419aafd | ||
|
|
efe82e31b4 | ||
|
|
8babf94028 | ||
|
|
1d83e1cffb | ||
|
|
b2a1be6b3c | ||
|
|
f45b9105d4 | ||
|
|
97511c182b | ||
|
|
b1d6c2c601 | ||
|
|
af3f1e2889 | ||
|
|
a894ae1312 | ||
|
|
ef5fbe30f4 | ||
|
|
1eb0469bd0 | ||
|
|
269e5ad424 | ||
|
|
5aa8d63321 | ||
|
|
e009c4675b | ||
|
|
9131b16f80 | ||
|
|
c921033e8c | ||
|
|
dc886e7bb8 | ||
|
|
7c73d5f168 | ||
|
|
f35b4249c7 | ||
|
|
dbdf8f2260 | ||
|
|
1de4e9b37a | ||
|
|
726ef147eb | ||
|
|
b395a11791 | ||
|
|
2353c0bd1a | ||
|
|
b341dd86f7 | ||
|
|
7e29e2463f | ||
|
|
a8f3e558d7 | ||
|
|
55ff20a5d5 | ||
|
|
afac233d5a | ||
|
|
999cb49b58 | ||
|
|
2810916a88 | ||
|
|
023882f9d9 | ||
|
|
889d75c8e3 | ||
|
|
de69907c9d | ||
|
|
b9ee220984 | ||
|
|
4cea7689a4 | ||
|
|
ca6d5bb572 | ||
|
|
e489c2cc1d | ||
|
|
a09a0f901d | ||
|
|
729ee307e9 | ||
|
|
486a2b9c15 | ||
|
|
b0b4e46139 | ||
|
|
0565ba97b7 | ||
|
|
9ee5066922 | ||
|
|
ebe1913e12 | ||
|
|
d5c34fb3c7 | ||
|
|
2b2d7b1c29 | ||
|
|
a664ac58a7 | ||
|
|
3329379db3 | ||
|
|
c1d2a1cbe5 | ||
|
|
69d0504572 | ||
|
|
cb86188457 | ||
|
|
8bb8ea4140 | ||
|
|
8045aaae95 | ||
|
|
f5c3fe5610 | ||
|
|
2a13430265 | ||
|
|
1e319c91e1 | ||
|
|
cb6ea3284f | ||
|
|
44c4cd0da8 | ||
|
|
996a7f9633 | ||
|
|
16e78f5b3e | ||
|
|
b28d007210 | ||
|
|
f13f07b5fe | ||
|
|
e00480be02 | ||
|
|
5c2b2c7a51 | ||
|
|
f0104169dc | ||
|
|
411d8e7f91 | ||
|
|
5e08455cea | ||
|
|
08a6a53bdc | ||
|
|
c7798de0e2 | ||
|
|
bd498e0a24 | ||
|
|
7cbbd5d187 | ||
|
|
f691461318 | ||
|
|
a0d302dd0a | ||
|
|
b215d882d1 | ||
|
|
19b94fcbb1 | ||
|
|
e76b683d85 | ||
|
|
2919365ca0 | ||
|
|
c8621813b8 | ||
|
|
4b67a88227 | ||
|
|
594f5b93af | ||
|
|
be848b8f63 | ||
|
|
c77b77e774 | ||
|
|
5d1b5e7c87 | ||
|
|
a894d8c4f2 | ||
|
|
aaa876a6d5 | ||
|
|
6258a2d585 | ||
|
|
581d351ba6 | ||
|
|
e8dfd0fe68 | ||
|
|
6f3dd89f75 | ||
|
|
fae6417c40 | ||
|
|
f7bc16c56a | ||
|
|
7c9f9635c5 | ||
|
|
ee9cda1f0c | ||
|
|
5a235a96a5 | ||
|
|
451ef5cf27 | ||
|
|
017a958b0e | ||
|
|
a53818c147 | ||
|
|
026989d523 | ||
|
|
1d78708570 | ||
|
|
893c878c37 | ||
|
|
accd83b835 | ||
|
|
b20a586989 | ||
|
|
3cd905274a | ||
|
|
d5708283d5 | ||
|
|
b8232fad6a | ||
|
|
6aca3bdda2 | ||
|
|
619db43999 | ||
|
|
2101f72f4e | ||
|
|
417e25dd44 | ||
|
|
3ef7bf2af8 | ||
|
|
5ab9cace19 | ||
|
|
5710da0ccb | ||
|
|
b134ee8939 | ||
|
|
db902c7f04 | ||
|
|
9984071e6f | ||
|
|
00d45dc954 | ||
|
|
004a8231f6 | ||
|
|
1bfe3d5444 | ||
|
|
587ae175c1 | ||
|
|
e19633ce1a | ||
|
|
6e36e39187 | ||
|
|
3991b68a0e | ||
|
|
b76361f27d | ||
|
|
387c94333e | ||
|
|
9760bf620e | ||
|
|
931c93506a | ||
|
|
b01e0f7968 | ||
|
|
2bd7e35b57 | ||
|
|
47c085df03 | ||
|
|
84c96abdd4 | ||
|
|
3711f0d0a3 | ||
|
|
625f3b623d | ||
|
|
743c3241b5 | ||
|
|
4e8e852530 | ||
|
|
3a5cb77690 | ||
|
|
2338a3dddf | ||
|
|
06f6b4f6ac | ||
|
|
f79e56f7d1 | ||
|
|
e560512a03 | ||
|
|
4752adbf35 | ||
|
|
3454d28bd8 | ||
|
|
7152ee7902 | ||
|
|
cd56e5c3e0 | ||
|
|
bcee603c02 | ||
|
|
f3fb6ccb91 | ||
|
|
c4c4263772 | ||
|
|
2c781425c8 | ||
|
|
971a6915cc | ||
|
|
8c99abc8f4 | ||
|
|
e4e099d5ac | ||
|
|
7770ae10d2 | ||
|
|
c626c77411 | ||
|
|
19d306454e | ||
|
|
be4cd23e17 | ||
|
|
94ce31599a | ||
|
|
5883cbe9a4 | ||
|
|
c46e9a0acf | ||
|
|
d1087be216 | ||
|
|
6e9132526a | ||
|
|
7cf1dad4db | ||
|
|
43576f53ca | ||
|
|
061eabfadd | ||
|
|
16f97bc086 | ||
|
|
c7bc54cf15 | ||
|
|
e6cf128c3d | ||
|
|
d9186546f6 | ||
|
|
c632289e17 | ||
|
|
97006884b5 | ||
|
|
391a84ee06 | ||
|
|
22495f5847 | ||
|
|
b1d35954a6 | ||
|
|
2ecdad8114 | ||
|
|
c92e38a031 | ||
|
|
fc127d7dd4 | ||
|
|
6981620c15 | ||
|
|
4f1b0180c1 | ||
|
|
4e46be5470 | ||
|
|
69c58b4871 | ||
|
|
d4e1686ea0 | ||
|
|
d42a4dee4a | ||
|
|
62bd684642 | ||
|
|
ea8505e2e5 | ||
|
|
2855caeceb | ||
|
|
cc2bec702b | ||
|
|
7131be95fb | ||
|
|
229b156f7f | ||
|
|
49309ade2d | ||
|
|
3f813a0432 | ||
|
|
19e765f4b4 | ||
|
|
b1a7daa2d5 | ||
|
|
26527fa927 | ||
|
|
7c6d62b068 | ||
|
|
99e1f2a7ce | ||
|
|
3ac611f4b9 | ||
|
|
7d2b40c524 | ||
|
|
d0161321cf | ||
|
|
163a7f4745 | ||
|
|
ad06917b88 | ||
|
|
6c7db6c883 | ||
|
|
dfc6b002e0 | ||
|
|
3b721788f2 | ||
|
|
80a93ede92 | ||
|
|
037eec6bbf | ||
|
|
057c1d3e60 | ||
|
|
950859ccec | ||
|
|
a9ac255bc2 | ||
|
|
facbe21990 | ||
|
|
0ab988600d | ||
|
|
4e106cb0a7 | ||
|
|
44d7a28616 | ||
|
|
e1c596a729 | ||
|
|
6ae2b0fdbe | ||
|
|
6890cc38c9 | ||
|
|
b590251393 | ||
|
|
22ed3a775b | ||
|
|
45aa0faf1d | ||
|
|
701acfead9 | ||
|
|
3bf9bb9611 | ||
|
|
bc9bc6dffe | ||
|
|
27cef8055d | ||
|
|
76467c1771 | ||
|
|
c50226f4b3 | ||
|
|
f9e238bb2c | ||
|
|
ab7073f7fd | ||
|
|
68964b5f9c | ||
|
|
e53aece5b7 | ||
|
|
1e8483a886 | ||
|
|
45da3085db | ||
|
|
77e81c9598 | ||
|
|
324d8d861e | ||
|
|
19622ffd60 | ||
|
|
251187d463 | ||
|
|
762e50d68e | ||
|
|
c0cb3fa898 | ||
|
|
5b6e261c6a | ||
|
|
dcee75c499 | ||
|
|
baa8512d8e | ||
|
|
1e92ac8d7b |
10
.env.example
10
.env.example
@@ -12,8 +12,9 @@ APP_URL=https://example.com
|
||||
# If you are using HTTPS, feel free to uncomment this line to improve security
|
||||
#SESSION_SECURE_COOKIE=true
|
||||
|
||||
# Enter your proxy address here if freescout.net is not available from your server
|
||||
# (access to freescout.net is required to obtain official modules)
|
||||
# Enter your proxy address here. It will be used to connect to all external URLs via CURL.
|
||||
# You may need to do specify proxy for example if api.freescout.net is not available from your server
|
||||
# (access to api.freescout.net is required to obtain official modules).
|
||||
#APP_PROXY=
|
||||
|
||||
# Custom headers to add to all outgoing emails.
|
||||
@@ -31,6 +32,11 @@ APP_URL=https://example.com
|
||||
# To trust ALL proxies, including those that are in a chain of forwarding use double asterisk: **
|
||||
#APP_TRUSTED_PROXIES=192.168.1.1,192.168.1.2,192.168.1.3
|
||||
|
||||
# Set this parameter when receiving "Domain or IP address is not allowed".
|
||||
# https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide#-domain-or-ip-address-is-not-allowed
|
||||
# Example: "example.org,12.34.56.78"
|
||||
#APP_REMOTE_HOST_WHITE_LIST=""
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,3 +1,5 @@
|
||||
Keep in mind that pull requests should be sent to the `master` branch! See https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide#github-workflow
|
||||
Pull requests should be sent to the `master` branch: https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide#github-workflow
|
||||
|
||||
Also one commit is preferable to several commits - it's easier to read and analize single commit.
|
||||
|
||||
Now you can delete this text and type the description of your pull request...
|
||||
@@ -1,6 +1,9 @@
|
||||
name: PHP Code Sniffer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
2
.github/workflows/test-pgsql.yml
vendored
2
.github/workflows/test-pgsql.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
|
||||
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
|
||||
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,10 +28,12 @@ Thumbs.db
|
||||
/Modules/**/.git
|
||||
/public/modules
|
||||
/public/docs
|
||||
/public/.dh-diag
|
||||
/storage/.ignore_locales
|
||||
/storage/.installed
|
||||
/tools
|
||||
.well-known
|
||||
/resources/lang/module.*
|
||||
.phpunit.result.cache
|
||||
/.phpunit.cache
|
||||
/.phpunit.cache
|
||||
.env.testing
|
||||
3
DOCUMENTATION.md
Normal file
3
DOCUMENTATION.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Documentation
|
||||
|
||||
Documentation: https://github.com/freescout-help-desk/freescout/wiki
|
||||
71
README.md
71
README.md
@@ -3,13 +3,10 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/freescout-help-desk/freescout/master/public/img/logo-300.png" width="180" height="180" />
|
||||
<br/><br/>
|
||||
|
||||
[](https://github.com/freescout-help-desk/freescout#requirements)
|
||||
|
||||
</div>
|
||||
|
||||
**FreeScout** is the super lightweight and powerful free open source help desk and shared inbox built with PHP (Laravel framework). Now you can enjoy free Zendesk & Help Scout without giving up privacy or locking yourself into a service you don't control. FreeScout has been developed from scratch and is not using any copyrighted Help Scout or Zendesk materials.
|
||||
**FreeScout** is the super lightweight and powerful free open source help desk and shared inbox built with PHP (Laravel framework). Now you can enjoy free Zendesk & Help Scout without giving up privacy or locking yourself into a service you don't control. FreeScout has been developed from scratch and does not use any copyrighted Help Scout or Zendesk materials.
|
||||
|
||||
If you want to support the project feel free to **star this repository**. It helps to increase the visibility of the project and let people know that it is valuable. Thanks for your support!
|
||||
|
||||
@@ -21,7 +18,7 @@ If you want to support the project feel free to **star this repository**. It hel
|
||||
* [Mobile Apps](#mobile-apps)
|
||||
* [Requirements](#requirements)
|
||||
* [Installation](#installation)
|
||||
* [Cloud Hosted](#cloud-hosted)
|
||||
* [Images & One-Click Installs](#images--one-click-installs)
|
||||
* [Modules](#modules)
|
||||
* [Tools & Integrations](#tools--integrations)
|
||||
* [News & Updates](#news--updates)
|
||||
@@ -34,34 +31,36 @@ If you want to support the project feel free to **star this repository**. It hel
|
||||
|
||||
## Features
|
||||
|
||||
* No limitations on the number of users, tickets, mailboxes, etc.
|
||||
* 100% Mobile-friendly.
|
||||
* Multilingual: English, Chinese, Croatian, Czech, Danish, Dutch, Finnish, French, German, Hungarian, Italian, Japanese, Kazakh, Korean, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Spanish, Slovak, Swedish, Turkish.
|
||||
* Seamless email integration.
|
||||
* Supports modern Microsoft Exchange authentication.
|
||||
* Omnichannel — FreeScout centralizes in one place all customer conversations received by email, [WhatsApp](https://freescout.net/whatsapp/), Telegram, Facebook, Slack, Live Chat and more.
|
||||
* [AI-powered](https://github.com/freescout-help-desk/AiIntegration) and super efficient.
|
||||
* Unlimited support agents, tickets, mailboxes, etc.
|
||||
* [Secure](https://freescout.net/security) by design.
|
||||
* Mobile-friendly.
|
||||
* Multilingual (translated into 33 languages).
|
||||
* Seamless email integration (via SMTP, IMAP, POP3, etc).
|
||||
* Modern OAuth authentication for Microsoft Office 365 and Google Workspace.
|
||||
* [Progressive Web App](https://freescout.net/progressive-app/) for phones and desktops (installable from browser and sending Web Push notifications).
|
||||
* Native [S3 storage](https://github.com/freescout-help-desk/S3Storage) support.
|
||||
* Fully supports screen readers (for visually impaired).
|
||||
* Built with strong focus on [security](https://freescout.net/security).
|
||||
* Web installer & updater.
|
||||
* Starred conversations.
|
||||
* Forwarding conversations.
|
||||
* Merging conversations.
|
||||
* Moving conversations between mailboxes.
|
||||
* Push notifications.
|
||||
* Following conversations.
|
||||
* Auto replies.
|
||||
* Internal notes.
|
||||
* Forwarding / Merging / Moving conversations.
|
||||
* Phone conversations.
|
||||
* Sending new conversations to multiple recipients at once.
|
||||
* Collision detection – notice is shown when two agents open the same conversation.
|
||||
* Push notifications.
|
||||
* Following a conversation.
|
||||
* Auto reply.
|
||||
* Internal notes.
|
||||
* Automatic refreshing of the conversations list without the need to reload the page.
|
||||
* Pasting screenshots from the clipboard into the reply area.
|
||||
* Configuring notifications on a per user basis.
|
||||
* Flexible notifications for user agents.
|
||||
* Open tracking.
|
||||
* Editing threads.
|
||||
* Search.
|
||||
* And more…
|
||||
* Powerful search.
|
||||
|
||||
Need anything else? Suggest features [here](https://freescout.net/request-feature/).
|
||||
A full list of features is available [here](https://freescout.net/#custom_html-3).
|
||||
|
||||
Need anything else? [Suggest features](https://freescout.net/request-feature/).
|
||||
|
||||
## Mobile Apps
|
||||
|
||||
@@ -69,14 +68,14 @@ Mobile apps support the same functionality and modules as the web version of you
|
||||
|
||||
<a href="https://freescout.net/android-app/" target="_blank" rel="nofollow"><img alt="Android App" src="https://freescout-helpdesk.github.io/img/apps/android.png" width="200px" /></a> <a href="https://freescout.net/ios-app/" target="_blank" rel="nofollow"><img alt="iOS App" src="https://freescout-helpdesk.github.io/img/apps/ios.png?v=1" width="200px" /></a>
|
||||
|
||||
[MacOS Menu Bar App](https://github.com/jonalaniz/scouter)
|
||||
[Progressive Web App for phones and desktops](https://freescout.net/progressive-app/) | [MacOS Menu Bar App](https://github.com/jonalaniz/scouter)
|
||||
|
||||
## Requirements
|
||||
|
||||
FreeScout is a pure PHP/MySQL application, so it can be easily deployed even on a [shared hosting](https://github.com/freescout-help-desk/freescout/wiki/Choosing-a-Server).
|
||||
|
||||
* Nginx / Apache / IIS
|
||||
* PHP 7.1 - 8.x
|
||||
* PHP 7.4 - 8.x
|
||||
* MySQL 5.0+ / MariaDB 5.0+ / PostgreSQL
|
||||
|
||||
There are no minimum system requirements (CPU / RAM) – FreeScout will run on any system.
|
||||
@@ -85,17 +84,18 @@ There are no minimum system requirements (CPU / RAM) – FreeScout will run on a
|
||||
|
||||
[Installation Guide](https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide)
|
||||
|
||||
Images & one-click installs:
|
||||
## Images & One-Click Installs
|
||||
|
||||
* [Docker Image](http://freescout.net/docker/)
|
||||
* [Softaculous](http://www.softaculous.com/apps/customersupport/FreeScout) (cPanel, Plesk, ISPmanager, H-Sphere, DirectAdmin, InterWorx)
|
||||
* [Fantastico](http://ff3.netenberg.com/visitors/scripts/freescout/view) (cPanel, DirectAdmin, ISP Manager, ISP Config)
|
||||
* [Docker Image](https://freescout.net/docker/)
|
||||
* [Pikapods](https://freescout.net/pikapods/) — Open-source apps hosting (Fully managed)
|
||||
* [Zenith](https://freescout.net/zenith/) — Open-source apps hosting (Fully managed)
|
||||
* [Hostinger](https://freescout.net/hostinger/) — VPS with automatic backups included
|
||||
* [Softaculous](http://www.softaculous.com/apps/customersupport/FreeScout) — cPanel, Plesk, ISPmanager, H-Sphere, DirectAdmin, InterWorx
|
||||
* [Fantastico](http://ff3.netenberg.com/visitors/scripts/freescout/view) — cPanel, DirectAdmin, ISP Manager, ISP Config
|
||||
* [Cloudron](https://cloudron.io/store/net.freescout.cloudronapp.html)
|
||||
* [Ubuntu](https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide#interactive-installation-bash-script-ubuntu) (bash script)
|
||||
* [Ubuntu](https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide#interactive-installation-bash-script-ubuntu) — Bash script
|
||||
|
||||
## Cloud Hosted
|
||||
|
||||
[Cloud Hosted FreeScout](https://freescout.net/cloud-hosted/)
|
||||
[More one-click installs…](https://freescout.net/one-click-installs/)
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -132,10 +132,9 @@ Conversation:
|
||||
|
||||

|
||||
|
||||
Drafting reply with AI:
|
||||
|
||||
Mailbox connection settings page:
|
||||
|
||||

|
||||

|
||||
|
||||
Notifications:
|
||||
|
||||
|
||||
14
SECURITY.md
14
SECURITY.md
@@ -1,5 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
Read about **[FreeScout's Security](https://freescout.net/security/)**.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Email at support@freescout.net
|
||||
* Report a security vulnerability in the **Application Core**: https://github.com/freescout-help-desk/freescout/security/advisories
|
||||
* Report a security vulnerability in a **Module**: https://freescout.net/contact-us/
|
||||
|
||||
## Guidelines
|
||||
|
||||
* One issue per Advisory — do not put several issues into one Advisory, as GitHub will not assign a CVE to such an Advisory.
|
||||
* CVEs are requested after the fix for the vulnerability is released.
|
||||
* If you found an issue related to the "Manage » Settings » Customer Visibility" option (`APP_LIMIT_USER_CUSTOMER_VISIBILITY=true`), make sure to read the [FAQ](https://github.com/freescout-help-desk/freescout/wiki/FAQ#is-it-possible-to-have-separate-contactscustomers-per-mailbox).
|
||||
* Support agents are allowed to move conversations to any mailbox, even to ones they don't have access to.
|
||||
* Drafts are viewable/discardable by any member of the mailbox where they are created.
|
||||
* FreeScout loads external images in received emails, and there is a Module allowing to disable loading external images: https://freescout.net/module/block-external-images/.
|
||||
@@ -19,9 +19,17 @@ class Attachment extends Model
|
||||
|
||||
const DIRECTORY = 'attachment';
|
||||
|
||||
CONST DISK = 'private';
|
||||
const DISK = 'local_app';
|
||||
|
||||
CONST MIME_TYPE_MAX_LENGTH = 127;
|
||||
const MIME_TYPE_MAX_LENGTH = 127;
|
||||
|
||||
// This token type was used for backward compatibility for some time
|
||||
// and has been deprecated.
|
||||
//const TOKEN_TYPE_LEGACY = 1;
|
||||
// For backward compatibility.
|
||||
const TOKEN_TYPE_MD5 = 2;
|
||||
// Current way.
|
||||
const TOKEN_TYPE_SHA256 = 3;
|
||||
|
||||
// https://github.com/Webklex/laravel-imap/blob/master/src/IMAP/Attachment.php
|
||||
public static $types = [
|
||||
@@ -37,7 +45,7 @@ class Attachment extends Model
|
||||
];
|
||||
|
||||
public static $type_extensions = [
|
||||
self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv']
|
||||
self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv'],
|
||||
];
|
||||
|
||||
public $timestamps = false;
|
||||
@@ -53,7 +61,7 @@ class Attachment extends Model
|
||||
/**
|
||||
* Save attachment to file and database.
|
||||
*/
|
||||
public static function create($file_name, $mime_type, $type, $content, $uploaded_file, $embedded = false, $thread_id = null, $user_id = null)
|
||||
public static function create($file_name, $mime_type, $type, $content, $uploaded_file, $embedded = false, $thread_id = null, $user_id = null, $upload_mode = \Helper::UPLOAD_MODE_DEFAULT)
|
||||
{
|
||||
if (!$content && !$uploaded_file) {
|
||||
return false;
|
||||
@@ -70,7 +78,7 @@ class Attachment extends Model
|
||||
$orig_extension = pathinfo($file_name, PATHINFO_EXTENSION);
|
||||
|
||||
// Add underscore to the extension if file has restricted extension.
|
||||
$file_name = \Helper::sanitizeUploadedFileName($file_name, $uploaded_file, $content);
|
||||
$file_name = \Helper::sanitizeUploadedFileName($file_name, $uploaded_file, $content, $mime_type, $upload_mode);
|
||||
|
||||
// Replace some symbols in file name.
|
||||
// Gmail can not load image if it contains spaces.
|
||||
@@ -121,12 +129,13 @@ class Attachment extends Model
|
||||
$attachment->mime_type = $mime_type;
|
||||
$attachment->type = $type;
|
||||
$attachment->embedded = $embedded;
|
||||
$attachment->token_type = self::TOKEN_TYPE_SHA256;
|
||||
$attachment->save();
|
||||
|
||||
$file_info = self::saveFileToDisk($attachment, $file_name, $content, $uploaded_file);
|
||||
|
||||
$attachment->file_dir = $file_info['file_dir'];
|
||||
$attachment->size = Storage::disk(self::DISK)->size($file_info['file_path']);
|
||||
$attachment->size = self::getDisk()->size($file_info['file_path']);
|
||||
$attachment->save();
|
||||
|
||||
return $attachment;
|
||||
@@ -134,6 +143,7 @@ class Attachment extends Model
|
||||
|
||||
/**
|
||||
* Save file to the disk and return file_dir.
|
||||
* $content may be a resource pointing to the file in remote storage.
|
||||
*/
|
||||
public static function saveFileToDisk($attachment, $file_name, $content, $uploaded_file)
|
||||
{
|
||||
@@ -146,21 +156,24 @@ class Attachment extends Model
|
||||
do {
|
||||
$i++;
|
||||
$file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir.$i.DIRECTORY_SEPARATOR.$file_name;
|
||||
} while (Storage::disk(self::DISK)->exists($file_path));
|
||||
} while (self::getDisk()->exists($file_path));
|
||||
|
||||
$file_dir .= $i.DIRECTORY_SEPARATOR;
|
||||
|
||||
try {
|
||||
if ($uploaded_file) {
|
||||
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]);
|
||||
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, self::getDiskName());
|
||||
} else {
|
||||
Storage::disk(self::DISK)->put($file_path, $content);
|
||||
self::getDisk()->put($file_path, $content);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, '[Attachment::saveFileToDisk()]');
|
||||
}
|
||||
|
||||
\Helper::sanitizeUploadedFileData($file_path, \Helper::getPrivateStorage(), $content);
|
||||
// $content may be a stream resource rather than a string; the SVG sanitizer needs
|
||||
// actual string content, so let it re-read the just-written file in that case instead
|
||||
// of treating the resource as file content.
|
||||
\Helper::sanitizeUploadedFileData($file_path, is_resource($content) ? null : $content);
|
||||
|
||||
return [
|
||||
'file_dir' => $file_dir,
|
||||
@@ -257,7 +270,7 @@ class Attachment extends Model
|
||||
// URL must contain only forward slashes.
|
||||
$file_path = str_replace(DIRECTORY_SEPARATOR, '/', $file_path);
|
||||
|
||||
$file_url = Storage::url($file_path);
|
||||
$file_url = self::getDisk()->url($file_path);
|
||||
|
||||
// Fix percents.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3530
|
||||
@@ -271,18 +284,23 @@ class Attachment extends Model
|
||||
*/
|
||||
public function getToken()
|
||||
{
|
||||
// \Hash::make() may contain . and / symbols which may cause problems.
|
||||
return md5(config('app.key').$this->id.$this->size);
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-89ww-mfww-5vr6
|
||||
/*if ($this->token_type == self::TOKEN_TYPE_MD5) {
|
||||
// Backward compatibility.
|
||||
// \Hash::make() may contain . and / symbols which may cause problems.
|
||||
return md5(config('app.key').$this->id.$this->size);
|
||||
} else {*/
|
||||
|
||||
return hash_hmac('sha256', $this->id.$this->size.$this->file_name, config('app.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs the current Attachment as download
|
||||
*/
|
||||
public function download($view = false)
|
||||
public function download($view = false, $headers = [])
|
||||
{
|
||||
$headers = [];
|
||||
// #533
|
||||
//return $this->getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name));
|
||||
//return self::getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name));
|
||||
if ($view) {
|
||||
$headers['Content-Disposition'] = '';
|
||||
}
|
||||
@@ -292,11 +310,10 @@ class Attachment extends Model
|
||||
$file_name = $file_name.'.eml';
|
||||
}
|
||||
|
||||
return $this->getDisk()->download($this->getStorageFilePath(), $file_name, $headers);
|
||||
}
|
||||
// Cache attachments in browser - 1 month.
|
||||
$headers['Cache-Control'] = 'max-age=2592000';
|
||||
|
||||
private function getDisk() {
|
||||
return Storage::disk(self::DISK);
|
||||
return self::getDisk()->download($this->getStorageFilePath(), $file_name, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,7 +340,7 @@ class Attachment extends Model
|
||||
public function getLocalFilePath($full = true)
|
||||
{
|
||||
if ($full) {
|
||||
return $this->getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
|
||||
return self::getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
|
||||
} else {
|
||||
return DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name;
|
||||
}
|
||||
@@ -334,7 +351,7 @@ class Attachment extends Model
|
||||
*/
|
||||
public function fileExists()
|
||||
{
|
||||
return $this->getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
|
||||
return self::getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
|
||||
}
|
||||
|
||||
public static function formatBytes($size, $precision = 0)
|
||||
@@ -385,7 +402,7 @@ class Attachment extends Model
|
||||
{
|
||||
// Delete from disk
|
||||
foreach ($attachments as $attachment) {
|
||||
$attachment->getDisk()->delete($attachment->getStorageFilePath());
|
||||
self::getDisk()->delete($attachment->getStorageFilePath());
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
@@ -426,6 +443,10 @@ class Attachment extends Model
|
||||
*/
|
||||
public function duplicate($thread_id = null)
|
||||
{
|
||||
if (!$this->fileExists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$new_attachment = $this->replicate();
|
||||
|
||||
$new_attachment->thread_id = $thread_id;
|
||||
@@ -433,12 +454,20 @@ class Attachment extends Model
|
||||
$new_attachment->save();
|
||||
|
||||
try {
|
||||
$attachment_file = new \Illuminate\Http\UploadedFile(
|
||||
$this->getLocalFilePath(), $this->file_name,
|
||||
null, null, true
|
||||
);
|
||||
$content_stream = '';
|
||||
$attachment_file = null;
|
||||
|
||||
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
|
||||
if (\Helper::isLocalStorage()) {
|
||||
$attachment_file = new \Illuminate\Http\UploadedFile(
|
||||
$this->getLocalFilePath(), $this->file_name,
|
||||
null, null, true
|
||||
);
|
||||
} else {
|
||||
// File in remote storage.
|
||||
$content_stream = $this->getFileStream();
|
||||
}
|
||||
|
||||
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
|
||||
|
||||
if (!empty($file_info['file_dir'])) {
|
||||
$new_attachment->file_dir = $file_info['file_dir'];
|
||||
@@ -453,6 +482,41 @@ class Attachment extends Model
|
||||
|
||||
public function getFileContents()
|
||||
{
|
||||
return $this->getDisk()->get($this->getStorageFilePath());
|
||||
try {
|
||||
return self::getDisk()->get($this->getStorageFilePath());
|
||||
} catch (\Exception $e) {
|
||||
// File not found at path:...
|
||||
\Helper::logException($e, '[Attachment::getFileContents()]');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getFileContents(), but as a stream instead of loading the whole
|
||||
* file into memory - use this when the bytes are only being copied
|
||||
* elsewhere (e.g. duplicating/forwarding an attachment).
|
||||
*/
|
||||
public function getFileStream()
|
||||
{
|
||||
try {
|
||||
return self::getDisk()->readStream($this->getStorageFilePath());
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, '[Attachment::getFileStream()]');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDiskName()
|
||||
{
|
||||
if (\Helper::isLocalStorage()) {
|
||||
return self::DISK;
|
||||
} else {
|
||||
return config('filesystems.default');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDisk()
|
||||
{
|
||||
return Storage::disk(self::getDiskName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ class PolycastBroadcaster extends Broadcaster
|
||||
*/
|
||||
public function auth($request)
|
||||
{
|
||||
if (!$request->user()) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
// For connect request
|
||||
if (empty($request->channels)) {
|
||||
return true;
|
||||
@@ -63,12 +67,9 @@ class PolycastBroadcaster extends Broadcaster
|
||||
|
||||
// Check all channels
|
||||
foreach ($request->channels as $channel_name => $channel_info) {
|
||||
// Copied from Illuminate\Broadcasting\Broadcasters\PusherBroadcaster
|
||||
if (Str::startsWith($channel_name, ['private-', 'presence-']) &&
|
||||
!$request->user()) {
|
||||
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
// Copied from Illuminate\Broadcasting\Broadcasters\PusherBroadcaster
|
||||
//if (Str::startsWith($channel_name, ['private-', 'presence-']) &&
|
||||
|
||||
if (Str::startsWith($channel_name, ['private-', 'presence-'])) {
|
||||
$channelName = Str::startsWith($channel_name, 'private-')
|
||||
|
||||
@@ -40,5 +40,7 @@ class AfterAppUpdate extends Command
|
||||
$this->call('freescout:clear-cache');
|
||||
$this->call('migrate', ['--force' => true]);
|
||||
$this->call('queue:restart');
|
||||
|
||||
\Eventy::action('command.after_app_update');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class CleanTmp extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Remove from system temp folder FreeScout files older than 1 week to avoid "No space left on device"';
|
||||
protected $description = 'Remove from system temp folder old FreeScout tempt files to avoid "No space left on device"';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
@@ -39,8 +39,15 @@ class CleanTmp extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// General cleaning.
|
||||
// Remove 1 week old FreeScout files.
|
||||
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +7 -type f -name '.\Helper::getTempFilePrefix().'* -exec rm -r -f {} \;');
|
||||
|
||||
// Remove temporary 1 day old SwiftMailer files.
|
||||
// Example: /tmp/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6/body
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5558
|
||||
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +1 -type d -regextype posix-extended -regex ".*/[a-f0-9]{32}" -exec rm -r -f {} \;');
|
||||
|
||||
$this->comment("Done");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,16 @@ class ClearCache extends Command
|
||||
// // Do nothing.
|
||||
// }
|
||||
|
||||
// Clear JS and CSS builds (generated in prod environment).
|
||||
\Helper::shellExec('find '.public_path('js/builds').' -type f -name "*.js" ! -name "vars.js" -exec rm -r -f {} \;');
|
||||
\Helper::shellExec('find '.public_path('css/builds').' -type f -name "*.css" -exec rm -r -f {} \;');
|
||||
$this->info("Cleared: JS and CSS builds");
|
||||
|
||||
// Regenerate vars to get new data from .env
|
||||
if (!$this->option('doNotGenerateVars')) {
|
||||
$this->call('freescout:generate-vars');
|
||||
}
|
||||
|
||||
// This should not be done during installation.
|
||||
if (\Helper::isInstalled()) {
|
||||
\Helper::queueWorkerRestart();
|
||||
|
||||
@@ -48,8 +48,8 @@ class CreateUser extends Command
|
||||
$user = new $class();
|
||||
|
||||
$user->role = $this->option('role');
|
||||
if ($user->role ) {
|
||||
if (!in_array($user->role , User::$roles)) {
|
||||
if ($user->role) {
|
||||
if (!in_array($user->role, User::$roles)) {
|
||||
$this->error('Invalid role');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Events\ConversationCustomerChanged;
|
||||
use App\Events\CustomerCreatedConversation;
|
||||
use App\Events\CustomerReplied;
|
||||
use App\Events\UserReplied;
|
||||
use App\Jobs\SendEmailReplyError;
|
||||
use App\Mailbox;
|
||||
use App\Misc\Mail;
|
||||
use App\Option;
|
||||
@@ -114,7 +115,7 @@ class FetchEmails extends Command
|
||||
);
|
||||
|
||||
foreach ($this->mailboxes as $mailbox) {
|
||||
if (!$mailbox->isInActive()) {
|
||||
if (!$mailbox->isInActive() || !$mailbox->isActive()) {
|
||||
continue;
|
||||
}
|
||||
if ($mailboxIds !== [] && !in_array($mailbox->id, $mailboxIds, true)) {
|
||||
@@ -140,7 +141,7 @@ class FetchEmails extends Command
|
||||
// (when there are many mailboxes for example),
|
||||
// we increase connection sleep time and retry after sleep.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4227
|
||||
if (trim($e->getMessage()) == 'connection setup failed') {
|
||||
if (\Str::startsWith($e->getMessage(), 'connection setup failed')) {
|
||||
$sleep += 500000;
|
||||
|
||||
usleep(self::MAX_SLEEP);
|
||||
@@ -225,11 +226,22 @@ class FetchEmails extends Command
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
|
||||
// POP3 uses LegacyProtocol.php
|
||||
// For POP3. Before it used LegacyProtocol.php.
|
||||
// Keeping it in case PopProtocol.php also throws such error.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/4060
|
||||
if ($error && \Str::startsWith($error, 'Mailbox is empty')) {
|
||||
$this->line('['.date('Y-m-d H:i:s').'] Fetched: 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry one transient TLS handshake failure.
|
||||
// https://github.com/freescout-help-desk/freescout/pull/5626
|
||||
if ($error
|
||||
&& \Str::startsWith($error, 'connection failed - stream_socket_client(): SSL operation failed')
|
||||
&& stripos($error, 'SSL routines::wrong version number') !== false
|
||||
) {
|
||||
usleep(self::MAX_SLEEP);
|
||||
$client->connect();
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
@@ -273,8 +285,43 @@ class FetchEmails extends Command
|
||||
|
||||
// Requesting emails by bunches allows to fetch large amounts of emails
|
||||
// without problems with memory.
|
||||
$page = 0;
|
||||
do {
|
||||
//
|
||||
// Compute how many bunches to fetch from a plain IMAP SEARCH count up
|
||||
// front, instead of driving the loop off the per-bunch materialized count.
|
||||
// That avoids two bugs:
|
||||
// (1) Silent loss of the NEWEST emails (#4624): a single message that fails
|
||||
// to materialize in a batch fetch makes a bunch come back short; the old
|
||||
// `while (count($messages) == $page_size)` then stopped before fetching
|
||||
// the later bunches. With fetch_order='asc' those later bunches hold the
|
||||
// newest emails, so they were dropped silently.
|
||||
// (2) Looping "until an empty page" instead would fetch one page past the
|
||||
// end, and the IMAP layer throws "Array to string conversion" when asked
|
||||
// to fetch an empty UID list.
|
||||
// Webklex Query::limit()/forPage() are 1-indexed, so pages start at 1.
|
||||
//
|
||||
// NOTE: this assumes a stable search set across pages (fetch_unseen=0). With
|
||||
// fetch_unseen=1, processMessage() marks messages \Seen mid-loop, shrinking
|
||||
// the unseen set between pages and shifting forPage() — a separate,
|
||||
// pre-existing drift (#4047), not addressed here.
|
||||
try {
|
||||
$count_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread();
|
||||
if ($unseen) {
|
||||
$count_query->unseen();
|
||||
}
|
||||
if ($no_charset) {
|
||||
$count_query->setCharset(null);
|
||||
}
|
||||
$total_messages = $count_query->count();
|
||||
} catch (\Exception $e) {
|
||||
// If the SEARCH count fails (e.g. charset issues on some servers), fall
|
||||
// back to a single bunch so we never silently fetch nothing. The
|
||||
// per-bunch charset retry below still recovers the rest on such servers.
|
||||
$total_messages = $page_size;
|
||||
$this->logError('Folder: '.$folder->name.'; Count error: '.$e->getMessage());
|
||||
}
|
||||
$total_pages = ($page_size > 0) ? (int) ceil($total_messages / $page_size) : 1;
|
||||
|
||||
for ($page = 1; $page <= $total_pages; $page++) {
|
||||
// Get messages.
|
||||
$last_error = '';
|
||||
$messages = collect([]);
|
||||
@@ -291,9 +338,7 @@ class FetchEmails extends Command
|
||||
|
||||
$messages = $messages_query->get();
|
||||
|
||||
if (method_exists($client, 'getLastError')) {
|
||||
$last_error = $client->getLastError();
|
||||
}
|
||||
$last_error = $client->getLastError();
|
||||
} catch (\Exception $e) {
|
||||
$last_error = $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')'.')';
|
||||
}
|
||||
@@ -309,11 +354,7 @@ class FetchEmails extends Command
|
||||
$messages = $messages_query->get();
|
||||
|
||||
$no_charset = true;
|
||||
if (count($client->getErrors()) > $errors_count) {
|
||||
$last_error = $client->getLastError();
|
||||
} else {
|
||||
$last_error = null;
|
||||
}
|
||||
$last_error = $client->getLastError();
|
||||
}
|
||||
|
||||
if ($last_error && !\Str::startsWith($last_error, 'Mailbox is empty')) {
|
||||
@@ -339,8 +380,7 @@ class FetchEmails extends Command
|
||||
$dest_mailbox = \Eventy::filter('fetch_emails.mailbox_to_save_message', $mailbox, $folder);
|
||||
$this->processMessage($message, $message_id, $dest_mailbox, $this->mailboxes);
|
||||
}
|
||||
$page++;
|
||||
} while (count($messages) == $page_size);
|
||||
}
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
@@ -372,6 +412,7 @@ class FetchEmails extends Command
|
||||
}*/
|
||||
|
||||
if ($from) {
|
||||
// Also sanitizes email address.
|
||||
$from = $this->formatEmailList($from);
|
||||
}
|
||||
|
||||
@@ -413,7 +454,7 @@ class FetchEmails extends Command
|
||||
$duplicate_message_id = Thread::where('message_id', $message_id)->first();
|
||||
}
|
||||
|
||||
// Mailbox has been mentioned in Bcc.
|
||||
// Mailbox has been mentioned in Bcc or email originates from within FreeScout.
|
||||
if (!$extra && $duplicate_message_id) {
|
||||
|
||||
$recipients = array_merge(
|
||||
@@ -421,7 +462,11 @@ class FetchEmails extends Command
|
||||
$this->formatEmailList($message->getCc())
|
||||
);
|
||||
|
||||
if (!in_array(Email::sanitizeEmail($mailbox->email), $recipients)
|
||||
// $is_outbound_from_fs is needed to fetch messages sent from one FreeScout mailbox to another.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5368#issuecomment-4309140388
|
||||
$is_outbound_from_fs = \MailHelper::isFsMessageId($duplicate_message_id->message_id);
|
||||
|
||||
if ((!in_array(Email::sanitizeEmail($mailbox->email), $recipients) || $is_outbound_from_fs)
|
||||
// Make sure that previous email has been imported into other mailbox.
|
||||
&& $duplicate_message_id->conversation
|
||||
&& $duplicate_message_id->conversation->mailbox_id != $mailbox->id
|
||||
@@ -431,11 +476,26 @@ class FetchEmails extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Gnerate artificial Message-ID if importing same email into several mailboxes.
|
||||
if ($extra) {
|
||||
// Is this a new email having the same Message-ID.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5591
|
||||
$new_email_with_same_message_id = false;
|
||||
if (!$extra && $duplicate_message_id) {
|
||||
// Compare headers
|
||||
if ($duplicate_message_id->headers != $this->headerToStr($message->getHeader())) {
|
||||
$new_email_with_same_message_id = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Gnerate artificial Message-ID if importing same email into several mailboxes
|
||||
// or this is a new email having the same Message-ID.
|
||||
if ($extra || $new_email_with_same_message_id) {
|
||||
// Generate artificial Message-ID.
|
||||
$message_id = \MailHelper::generateMessageId(strstr($message_id, '@') ? $message_id : $from, $mailbox->id.$message_id);
|
||||
|
||||
$this->line('['.date('Y-m-d H:i:s').'] Generated artificial Message-ID: '.$message_id);
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5434
|
||||
$duplicate_message_id = Thread::where('message_id', $message_id)->first();
|
||||
}
|
||||
|
||||
// Check if message already fetched.
|
||||
@@ -482,11 +542,11 @@ class FetchEmails extends Command
|
||||
|
||||
// Some mail service providers change Message-ID of the outgoing email,
|
||||
// so we are passing Message-ID in marker in body.
|
||||
$reply_prefixes = [
|
||||
\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION,
|
||||
\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||||
\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||||
];
|
||||
// $reply_prefixes = [
|
||||
// \MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION,
|
||||
// \MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||||
// \MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||||
// ];
|
||||
|
||||
// Try to get previous message ID from marker in body.
|
||||
$html_body = $message->getHTMLBody(false);
|
||||
@@ -551,8 +611,12 @@ class FetchEmails extends Command
|
||||
|
||||
if ($is_bounce && !$bounced_message_id) {
|
||||
foreach ($attachments as $attachment_msg) {
|
||||
// 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message
|
||||
if ($attachment_msg->content_type == 'message/rfc822') {
|
||||
// message/rfc822: 7.3.1 The Message/rfc822 (primary) subtype.
|
||||
// A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message,
|
||||
// with the syntax of an RFC 822 message.
|
||||
//
|
||||
// text/rfc822-headers: https://github.com/freescout-help-desk/freescout/issues/5583
|
||||
if (in_array($attachment_msg->content_type, ['message/rfc822', 'text/rfc822-headers'])) {
|
||||
$bounced_message_id = \MailHelper::getHeader($attachment_msg->getContent(), 'message_id');
|
||||
if ($bounced_message_id) {
|
||||
break;
|
||||
@@ -561,14 +625,58 @@ class FetchEmails extends Command
|
||||
}
|
||||
}
|
||||
|
||||
# Try to get the thread traversing the possible prev_message_ids
|
||||
// Webklex/php-imap returns object instead of a string.
|
||||
$subject = $message->getSubject()."";
|
||||
|
||||
// Convert subject encoding
|
||||
if (preg_match('/=\?[a-z\d-]+\?[BQ]\?.*\?=/i', $subject)) {
|
||||
$subject = \Helper::iconvMimeDecode($subject);
|
||||
}
|
||||
|
||||
// Add to prev messaged IDs auto-generated Message-IDs.
|
||||
foreach ($prev_message_ids as $prev_message_id) {
|
||||
$new_prev_message_id = \MailHelper::generateMessageId($prev_message_id, $mailbox->id.$prev_message_id);
|
||||
if (!in_array($new_prev_message_id, $prev_message_ids)) {
|
||||
$prev_message_ids[] = $new_prev_message_id;
|
||||
}
|
||||
}
|
||||
|
||||
$prev_message_ids = array_values(array_unique($prev_message_ids));
|
||||
|
||||
# Try to get the thread traversing the possible prev_message_ids
|
||||
foreach ($prev_message_ids as $i => $prev_message_id) {
|
||||
|
||||
$is_reply = false;
|
||||
if (!$prev_message_id) {
|
||||
continue;
|
||||
}
|
||||
// Is it a message from Customer or User replied to the notification
|
||||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION."\-(\d+)\-(\d+)\-/", $prev_message_id, $m);
|
||||
preg_match('/^'.$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION)."\-(\d+)\-(\d+)\-([a-z0-9]+)/", $prev_message_id, $m);
|
||||
|
||||
if (!$is_bounce && !empty($m[1]) && !empty($m[2])) {
|
||||
// Reply from User to the notification
|
||||
// Reply from User to the notification.
|
||||
$prev_thread_id = '';
|
||||
// Check hash.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6r38-6mcf-2ww3
|
||||
if (!empty($m[1]) && !empty($m[3])) {
|
||||
$message_id_hash = $m[3];
|
||||
if (strlen($message_id_hash) == 16) {
|
||||
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
|
||||
$prev_thread_id = $m[1];
|
||||
}
|
||||
} else {
|
||||
// No backward compatibility for security reasons.
|
||||
//$prev_thread_id = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$prev_thread_id) {
|
||||
$this->logError('Invalid hash in the Message-ID: '.$prev_message_id);
|
||||
$this->setSeen($message, $mailbox);
|
||||
return;
|
||||
}
|
||||
$prev_thread = Thread::find($m[1]);
|
||||
|
||||
$user_id = $m[2];
|
||||
$user = User::find($user_id);
|
||||
$message_from_customer = false;
|
||||
@@ -592,14 +700,29 @@ class FetchEmails extends Command
|
||||
$this->line('['.date('Y-m-d H:i:s').'] Message from: Customer');
|
||||
|
||||
if (!$is_bounce) {
|
||||
if ($prev_message_id) {
|
||||
$prev_thread_id = '';
|
||||
|
||||
$prev_thread_id = '';
|
||||
|
||||
// Customer replied to the email from user
|
||||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||||
// Simply checking thread_id from message_id was causing an issue when
|
||||
// customer was sending a message from FreeScout - the message was
|
||||
// connected to the wrong conversation.
|
||||
// Customer replied to the email from user
|
||||
preg_match('/^'.$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER)."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||||
// Simply checking thread_id from message_id was causing an issue when
|
||||
// customer was sending a message from FreeScout - the message was
|
||||
// connected to the wrong conversation.
|
||||
if (!empty($m[1]) && !empty($m[2])) {
|
||||
$message_id_hash = $m[2];
|
||||
if (strlen($message_id_hash) == 16) {
|
||||
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
|
||||
$prev_thread_id = $m[1];
|
||||
}
|
||||
} else {
|
||||
// Backward compatibility (deprecated).
|
||||
//$prev_thread_id = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Customer replied to the auto reply
|
||||
if (!$prev_thread_id) {
|
||||
preg_match('/^'.$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY)."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||||
if (!empty($m[1]) && !empty($m[2])) {
|
||||
$message_id_hash = $m[2];
|
||||
if (strlen($message_id_hash) == 16) {
|
||||
@@ -607,79 +730,157 @@ class FetchEmails extends Command
|
||||
$prev_thread_id = $m[1];
|
||||
}
|
||||
} else {
|
||||
// Backward compatibility.
|
||||
$prev_thread_id = $m[1];
|
||||
// Backward compatibility (deprecated).
|
||||
//$prev_thread_id = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customer replied to the auto reply
|
||||
if (!$prev_thread_id) {
|
||||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||||
if (!empty($m[1]) && !empty($m[2])) {
|
||||
$message_id_hash = $m[2];
|
||||
if (strlen($message_id_hash) == 16) {
|
||||
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
|
||||
$prev_thread_id = $m[1];
|
||||
}
|
||||
} else {
|
||||
// Backward compatibility.
|
||||
$prev_thread_id = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($prev_thread_id) {
|
||||
$prev_thread = Thread::find($prev_thread_id);
|
||||
} else {
|
||||
// Customer replied to his own message
|
||||
$prev_thread = Thread::where('message_id', $prev_message_id)->first();
|
||||
}
|
||||
|
||||
if ($prev_thread_id) {
|
||||
$prev_thread = Thread::find($prev_thread_id);
|
||||
} else {
|
||||
// Customer replied to his own message
|
||||
$prev_thread = Thread::where('message_id', $prev_message_id)->first();
|
||||
}
|
||||
|
||||
// Reply from user to his reply to the notification
|
||||
if (!$prev_thread
|
||||
&& ($prev_thread = Thread::where('message_id', $prev_message_id)->first())
|
||||
&& $prev_thread->created_by_user_id
|
||||
&& $prev_thread->created_by_user->hasEmail($from)
|
||||
) {
|
||||
$user_id = $user->id;
|
||||
$message_from_customer = false;
|
||||
$is_reply = true;
|
||||
}
|
||||
// Reply from user to his reply to the notification
|
||||
if (!$prev_thread
|
||||
&& ($prev_thread = Thread::where('message_id', $prev_message_id)->first())
|
||||
&& $prev_thread->created_by_user_id
|
||||
&& $prev_thread->created_by_user->hasEmail($from)
|
||||
) {
|
||||
$user_id = $user->id;
|
||||
$message_from_customer = false;
|
||||
$is_reply = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If a thread is found, we keep it and break
|
||||
# If a thread is found, we keep it and check
|
||||
if (!empty($prev_thread)) {
|
||||
$is_reply = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that prev_thread belongs to the current mailbox.
|
||||
// Problems may arise when forwarding conversation for example.
|
||||
//
|
||||
// For replies to email notifications it's allowed to have prev_thread in
|
||||
// another mailbox as conversation can be moved.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3455
|
||||
if ($prev_thread && $message_from_customer) {
|
||||
if ($prev_thread->conversation->mailbox_id != $mailbox->id) {
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2807
|
||||
// Behaviour of email sent to multiple mailboxes:
|
||||
// If a user from either mailbox replies, then a new conversation is created
|
||||
// in the other mailbox with another new conversation ID.
|
||||
// Reply from customer.
|
||||
//
|
||||
// Make sure that prev_thread belongs to the current mailbox.
|
||||
// Problems may arise when forwarding conversation for example.
|
||||
if ($prev_thread && $message_from_customer) {
|
||||
|
||||
if ($prev_thread->conversation->mailbox_id != $mailbox->id) {
|
||||
|
||||
// Try to get thread by artificial message ID.
|
||||
if ($in_reply_to) {
|
||||
|
||||
// If customer sends an email to multiple mailboxes:
|
||||
// - A conversation is created for each mailbox.
|
||||
// - An agent responds from each mailbox and includes other mailboxes in CC.
|
||||
// - The customer replies to each email.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5308
|
||||
|
||||
// Is this a reply to the message with artificial Message-ID.
|
||||
$message_ids = [\MailHelper::generateMessageId($in_reply_to, $mailbox->id.$in_reply_to), $in_reply_to];
|
||||
$prev_thread_tmp = Thread::whereIn('message_id', $message_ids)->whereHas('conversation', function ($q) use ($mailbox) {
|
||||
$q->where('mailbox_id', $mailbox->id);
|
||||
})->first();
|
||||
|
||||
if ($prev_thread_tmp) {
|
||||
$prev_thread = $prev_thread_tmp;
|
||||
} elseif (\MailHelper::isGeneratedMessageId($prev_thread->message_id)) {
|
||||
// Try to extract the original Message-ID from the mismatched thread's raw headers
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5308#issuecomment-4218652527
|
||||
$original_id = trim(\MailHelper::getHeader($prev_thread->headers, 'Message-ID'));
|
||||
$correct_thread = null;
|
||||
|
||||
// Search the current mailbox for BOTH the original ID and this mailbox's generated hash of the original ID
|
||||
if ($original_id) {
|
||||
$search_ids = [
|
||||
$original_id,
|
||||
\MailHelper::generateMessageId($original_id, $mailbox->id.$original_id),
|
||||
];
|
||||
|
||||
$correct_thread = Thread::whereIn('message_id', $search_ids)
|
||||
->whereHas('conversation', function ($q) use ($mailbox) {
|
||||
$q->where('mailbox_id', $mailbox->id);
|
||||
})->first();
|
||||
}
|
||||
if ($correct_thread) {
|
||||
$prev_thread = $correct_thread;
|
||||
}
|
||||
if (!$correct_thread) {
|
||||
$prev_thread = null;
|
||||
}
|
||||
} else {
|
||||
// Prev Thread SHOULD BE PRESERVED in the following case:
|
||||
// - Existing conversation is moved from Mailbox A to Mailbox B.
|
||||
// - Customer replies to some earlier received reply from an agent.
|
||||
// - The email is fetched into Mailbox A (as To in in the email points to Mailbox A).
|
||||
// - The reply creates a new conversation in Mailbox A instead of
|
||||
// being linked to the existing conversation in Mailbox B.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5590
|
||||
//
|
||||
// Search for LINE_ITEM threads and check if this conversations
|
||||
// was in Mailbox A at some point (action_data contains prev mailbox IDs).
|
||||
$prev_mailbox_ids = Thread::where('conversation_id', $prev_thread->conversation_id)
|
||||
//->where('state', Thread::STATE_PUBLISHED)
|
||||
->where('action_type', Thread::ACTION_TYPE_MOVED_FROM_MAILBOX)
|
||||
->pluck('action_data')
|
||||
->toArray();
|
||||
|
||||
if (!in_array($mailbox->id, $prev_mailbox_ids)) {
|
||||
// Prev Thread has to be reset in the following case:
|
||||
// - Customer sends an email to Mailbox A and B.
|
||||
// - Support agent replies in Mailbox A and CCs Mailbox B.
|
||||
// - Customer replies to this email keeping Mailbox B in copy.
|
||||
// - Mailbox B fetches customer reply and sees that In-Reply-To is linked to a thread from Mailbox A
|
||||
// (as Message-ID of agent email contains -thread_id- in it and thread_id belongs to Mailbox A).
|
||||
// - FreeScout links this agent's reply to the conversation in Mailbox A instead of creating a new
|
||||
// conversation in Mailbox B.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5350
|
||||
$prev_thread = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$prev_thread) {
|
||||
$prev_thread = null;
|
||||
$is_reply = false;
|
||||
}
|
||||
} else {
|
||||
$prev_thread = null;
|
||||
$is_reply = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent's reply to the email notification.
|
||||
//
|
||||
// Try to get thread by generated message ID.
|
||||
if ($in_reply_to) {
|
||||
$prev_thread = Thread::where('message_id', \MailHelper::generateMessageId($in_reply_to, $mailbox->id.$in_reply_to))->first();
|
||||
|
||||
if (!$prev_thread) {
|
||||
// If agent forwards email notification to another mailbox, create
|
||||
// a new conversation instead of adding agent's reply to the existing conversation.
|
||||
// https://github.com/freescout-help-desk/freescout/pull/5517
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4515
|
||||
//
|
||||
// For user replies to email notifications it's allowed to have prev_thread in
|
||||
// another mailbox as conversation can be moved.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3455
|
||||
if ($prev_thread && !$message_from_customer) {
|
||||
if (// Subject looks like FWD:
|
||||
preg_match("/^[[:alpha:]]{1,3}\s*:(.*)/i", trim($subject ?? ''))
|
||||
// And reply sent to another mailbox.
|
||||
&& $prev_thread->conversation->mailbox_id != $mailbox->id
|
||||
) {
|
||||
$prev_thread = null;
|
||||
$is_reply = false;
|
||||
$message_from_customer = true;
|
||||
|
||||
$this->line('['.date('Y-m-d H:i:s').'] Forwarded email notification detected. Creating a new conversation.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$prev_thread && $i < (count($prev_message_ids)-1)) {
|
||||
// Try another prev_message_id.
|
||||
continue;
|
||||
} else {
|
||||
$prev_thread = null;
|
||||
$is_reply = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -699,6 +900,7 @@ class FetchEmails extends Command
|
||||
$body = $message->getTextBody() ?? '';
|
||||
$body = htmlspecialchars($body);
|
||||
}
|
||||
$body = \Helper::utf8Encode($body);
|
||||
|
||||
// We have to fetch absolutely all emails, even with empty body.
|
||||
// if (!$body) {
|
||||
@@ -707,14 +909,6 @@ class FetchEmails extends Command
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// Webklex/php-imap returns object instead of a string.
|
||||
$subject = $message->getSubject()."";
|
||||
|
||||
// Convert subject encoding
|
||||
if (preg_match('/=\?[a-z\d-]+\?[BQ]\?.*\?=/i', $subject)) {
|
||||
$subject = iconv_mime_decode($subject, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||||
}
|
||||
|
||||
$to = $this->formatEmailList($message->getTo());
|
||||
|
||||
$cc = $this->formatEmailList($message->getCc());
|
||||
@@ -748,6 +942,11 @@ class FetchEmails extends Command
|
||||
if ($original_sender) {
|
||||
// Check if sender is the existing user.
|
||||
$sender_is_user = User::nonDeleted()->where('email', $from)->exists();
|
||||
// Check Alternate emails.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5047
|
||||
if (!$sender_is_user) {
|
||||
$sender_is_user = User::findByAlternateEmail($from);
|
||||
}
|
||||
|
||||
if ($sender_is_user) {
|
||||
// Substitute sender.
|
||||
@@ -811,6 +1010,7 @@ class FetchEmails extends Command
|
||||
|
||||
$new_thread = null;
|
||||
if ($message_from_customer) {
|
||||
// SAVE CUSTOMER MESSAGE.
|
||||
|
||||
// We should import the message into other mailboxes even if previous thread is set.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3473
|
||||
@@ -857,6 +1057,8 @@ class FetchEmails extends Command
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// SAVE SUPPORT AGENT MESSAGE.
|
||||
|
||||
// Check if From is the same as user's email.
|
||||
// If not we send an email with information to the sender.
|
||||
if (!$user->hasEmail($from)) {
|
||||
@@ -864,17 +1066,25 @@ class FetchEmails extends Command
|
||||
$this->setSeen($message, $mailbox);
|
||||
|
||||
// Send "Unable to process your update email" to user
|
||||
\App\Jobs\SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails');
|
||||
SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save user thread only if there prev_thread is set.
|
||||
// Save user thread only if there is prev_thread set.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3455
|
||||
if (!$prev_thread) {
|
||||
$this->logError("Support agent's reply to the email notification could not be processed as previous thread could not be determined.");
|
||||
$this->setSeen($message, $mailbox);
|
||||
SendEmailReplyError::dispatch($from, $user, $mailbox, __("The conversation you replied to could not be found."))->onQueue('emails');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check user access to the mailbox and conversation.
|
||||
if (!$user->can('view', $prev_thread->conversation)) {
|
||||
$this->logError("Support agent (ID: ".$user->id.") has no accesss to the conversation #".$prev_thread->conversation->number." anymore and can not reply to the conversation.");
|
||||
$this->setSeen($message, $mailbox);
|
||||
SendEmailReplyError::dispatch($from, $user, $mailbox, __("You no longer have access to the conversation you replied to. Please contact your administrator."))->onQueue('emails');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -904,6 +1114,12 @@ class FetchEmails extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Get prefix as regex (for backward compatibility).
|
||||
public function formatMessageIdPrefix($prefix)
|
||||
{
|
||||
return str_replace('FS_', '(?:FS_)?', $prefix);
|
||||
}
|
||||
|
||||
// Try to get "From:" from body.
|
||||
public function getOriginalSenderFromFwd($body)
|
||||
{
|
||||
@@ -912,12 +1128,19 @@ class FetchEmails extends Command
|
||||
// Cut out the command, otherwise it will be recognized as an email.
|
||||
$body = preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/isu", '$1', $body);
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5480
|
||||
// Strip mailto: prefix to avoid false positive match
|
||||
$body = preg_replace("/mailto:/i", "", $body);
|
||||
// Decode HTML entities so <email> becomes <email>
|
||||
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, "UTF-8");
|
||||
// Strip \@ prefix used in CSS CJK font names (e.g. \@DengXian) to avoid false positive match
|
||||
$body = preg_replace("/\\\@/", "", $body);
|
||||
|
||||
// Looks like email texts may appear in attributes:
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/276
|
||||
// - :test@example.org
|
||||
// - <test@example.org>
|
||||
// - <test@example.org>
|
||||
|
||||
preg_match("/[\"'<:;]([^\"'<:;!@\s]+@[^\"'>:&@\s]+)[\"'>:&]/", $body, $b);
|
||||
|
||||
$email = $b[1] ?? '';
|
||||
@@ -933,8 +1156,8 @@ class FetchEmails extends Command
|
||||
$bounced_thread = null;
|
||||
if ($bounced_message_id) {
|
||||
$prefixes = [
|
||||
\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||||
\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||||
$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER),
|
||||
$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY),
|
||||
];
|
||||
preg_match('/^('.implode('|', $prefixes).')\-(\d+)\-/', $bounced_message_id, $matches);
|
||||
if (!empty($matches[2])) {
|
||||
@@ -1008,8 +1231,8 @@ class FetchEmails extends Command
|
||||
$conversation = null;
|
||||
$prev_customer_id = null;
|
||||
if ($use_mail_date_on_fetching) {
|
||||
$now = $date;
|
||||
}else{
|
||||
$now = $date->format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
}
|
||||
$conv_cc = $cc;
|
||||
@@ -1067,11 +1290,14 @@ class FetchEmails extends Command
|
||||
$conversation->setBcc($bcc);
|
||||
}
|
||||
$conversation->customer_email = $from;
|
||||
// Reply from customer makes conversation active
|
||||
if ($conversation->status != Conversation::STATUS_ACTIVE) {
|
||||
// Reply from customer makes conversation active.
|
||||
// If conversation is marked as Spam the status does not change.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5005
|
||||
if (!$conversation->isActive() && !$conversation->isSpam()) {
|
||||
$conversation->status = \Eventy::filter('conversation.status_changing', Conversation::STATUS_ACTIVE, $conversation);
|
||||
}
|
||||
$conversation->last_reply_at = $now;
|
||||
|
||||
$conversation->setLastReplyAt($now, Conversation::PERSON_CUSTOMER);
|
||||
$conversation->last_reply_from = Conversation::PERSON_CUSTOMER;
|
||||
// Reply from customer to deleted conversation should undelete it.
|
||||
if ($conversation->state == Conversation::STATE_DELETED) {
|
||||
@@ -1161,17 +1387,45 @@ class FetchEmails extends Command
|
||||
\Eventy::action('conversation.customer_replied', $conversation, $thread, $customer);
|
||||
}
|
||||
|
||||
// Conversation customer changed
|
||||
// if ($prev_customer_id) {
|
||||
// event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer));
|
||||
// }
|
||||
// Simply returning original customer is disabled for better security:
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-j49f-9g94-3wh4
|
||||
|
||||
// Return original customer back.
|
||||
// Change customer when reply to the conversation from a completely new email is received.
|
||||
// Check From, To, Cc and Bcc in all previous threads.
|
||||
if ($prev_customer_id) {
|
||||
$conversation->customer_id = $prev_customer_id;
|
||||
$conversation->customer_email = $prev_customer_email;
|
||||
$conversation->setCc(array_merge($prev_conv_cc, array_diff($to, $mailbox->getEmails())));
|
||||
$conversation->save();
|
||||
$change_customer = true;
|
||||
|
||||
$prev_threads = Thread::select(['id', 'from', 'to', 'cc', 'bcc'])
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE])
|
||||
->get();
|
||||
|
||||
foreach ($prev_threads as $prev_thread) {
|
||||
if ($prev_thread->id == $thread->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender has been already mentioned in the conversation -
|
||||
// no need to change the customer.
|
||||
if ($prev_thread->from == $from
|
||||
|| in_array($from, $prev_thread->getTo())
|
||||
|| in_array($from, $prev_thread->getCc())
|
||||
|| in_array($from, $prev_thread->getBcc())
|
||||
) {
|
||||
$change_customer = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($change_customer) {
|
||||
event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer));
|
||||
} else {
|
||||
// Return original customer back.
|
||||
$conversation->customer_id = $prev_customer_id;
|
||||
$conversation->customer_email = $prev_customer_email;
|
||||
$conversation->setCc(array_merge($prev_conv_cc, array_diff($to, $mailbox->getEmails())));
|
||||
$conversation->save();
|
||||
}
|
||||
}
|
||||
|
||||
return $thread;
|
||||
@@ -1188,7 +1442,7 @@ class FetchEmails extends Command
|
||||
$conversation = null;
|
||||
if ($use_mail_date_on_fetching) {
|
||||
$now = $date;
|
||||
}else{
|
||||
} else {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
}
|
||||
$user_id = $user->id;
|
||||
@@ -1225,8 +1479,9 @@ class FetchEmails extends Command
|
||||
|
||||
// Respect mailbox settings for "Status After Replying
|
||||
$prev_status = $conversation->status;
|
||||
$conversation->status = ($mailbox->ticket_status == Mailbox::TICKET_STATUS_KEEP_CURRENT ? $conversation->status : $mailbox->ticket_status);
|
||||
if ($conversation->status != $mailbox->ticket_status) {
|
||||
$new_status = ($mailbox->ticket_status == Mailbox::TICKET_STATUS_KEEP_CURRENT ? $conversation->status : $mailbox->ticket_status);
|
||||
if ($new_status != $prev_status) {
|
||||
$conversation->setStatus($new_status, $user, $update_folder = false);
|
||||
\Eventy::action('conversation.status_changed', $conversation, $user, true, $prev_status);
|
||||
}
|
||||
$conversation->last_reply_at = $now;
|
||||
@@ -1365,11 +1620,15 @@ class FetchEmails extends Command
|
||||
$result = '';
|
||||
|
||||
// Can fix broken HTML, remove sensitive parts and etc.
|
||||
$body = \Eventy::filter('fetch_emails.separate_reply.preprocess_body', $body);
|
||||
$body = \Eventy::filter('fetch_emails.separate_reply.preprocess_body', $body ?? '');
|
||||
|
||||
if ($is_html) {
|
||||
// Remove Outlook "downlevel-revealed" conditional comments (<![if !vml]>...<![endif]>),
|
||||
// preserving the data inside. Otherwise DOMDocument turns them into escaped text.
|
||||
$body = preg_replace('/<!\[(?:if\s[^\]]*|endif)\]>/i', '', $body) ?: $body;
|
||||
|
||||
// Extract body content from HTML
|
||||
|
||||
|
||||
// Proton has it's own unique way of placing replies:
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4537#issuecomment-2629836738
|
||||
if ($is_reply
|
||||
@@ -1384,6 +1643,21 @@ class FetchEmails extends Command
|
||||
$htmls = [];
|
||||
preg_match_all("/<html[^>]*>(.*?)<\/html>/is", $body, $htmls);
|
||||
|
||||
// Some clients (e.g. Yahoo Android) top-post the customer's new reply as plain
|
||||
// HTML fragments before the first <html> block, which wraps only the quoted
|
||||
// prior message. Here we prepend that pre-<html> content so it enters $result and the
|
||||
// reply-separator logic can extract it correctly.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5409
|
||||
if (!empty($htmls[0])) {
|
||||
$first_html_pos = mb_strpos($body, '<html');
|
||||
if ($first_html_pos > 0) {
|
||||
$pre_html = mb_substr($body, 0, $first_html_pos);
|
||||
if (trim(strip_tags($pre_html))) {
|
||||
$result .= $pre_html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($htmls[0])) {
|
||||
$htmls[0] = [$body];
|
||||
}
|
||||
@@ -1393,7 +1667,8 @@ class FetchEmails extends Command
|
||||
libxml_use_internal_errors(true);
|
||||
//$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
//$dom->loadHTML(\Helper::mbConvertEncodingHtmlEntities($html));
|
||||
$dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') ?: $html);
|
||||
// LIBXML_PARSEHUGE - https://github.com/freescout-help-desk/freescout/issues/5304
|
||||
$dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') ?: $html, LIBXML_PARSEHUGE);
|
||||
libxml_use_internal_errors(false);
|
||||
$bodies = $dom->getElementsByTagName('body');
|
||||
if ($bodies->length == 1) {
|
||||
@@ -1430,6 +1705,7 @@ class FetchEmails extends Command
|
||||
}
|
||||
|
||||
// Try to separate customer reply using hashed reply separator.
|
||||
// No other reply separators are used in this case.
|
||||
// In this case some extra text may appear below customer's reply:
|
||||
// On Thu, Jan 4, 2024 at 8:41 AM John Doe | Demo <test@example.org> wrote:
|
||||
if (config('app.alternative_reply_separation')) {
|
||||
@@ -1449,6 +1725,14 @@ class FetchEmails extends Command
|
||||
$parts = explode($reply_separator, $result);
|
||||
}
|
||||
if (count($parts) > 1) {
|
||||
// When replying to an email notification Outlook places its reply header
|
||||
// (<hr> + div#divRplyFwdMsg containing From/Sent/To/Subject) above the
|
||||
// quoted notification, i.e. above the separator, so it has to be removed
|
||||
// from the extracted reply separately.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5545
|
||||
if ($user_reply_to_notification) {
|
||||
$parts[0] = preg_replace('/<hr[^>]*>\s*<div[^>]+id="(?:x_)?divRplyFwdMsg".*$/is', '', $parts[0]) ?: $parts[0];
|
||||
}
|
||||
// Check if part contains any real text.
|
||||
$text = \Helper::htmlToText($parts[0]);
|
||||
$text = trim($text);
|
||||
@@ -1585,7 +1869,7 @@ class FetchEmails extends Command
|
||||
if (!is_string($header)) {
|
||||
$header = $header->raw;
|
||||
}
|
||||
return $header;
|
||||
return \Helper::utf8Encode($header);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1598,12 +1882,12 @@ class FetchEmails extends Command
|
||||
public function sortMessage($messages)
|
||||
{
|
||||
$messages = $messages->sortBy(function ($message, $key) {
|
||||
$date = $message->getDate();
|
||||
$date = $this->attrToDate($message->getDate());
|
||||
if ($date) {
|
||||
if (isset($message->getDate()->timestamp)) {
|
||||
return $message->getDate()->timestamp;
|
||||
if (isset($date->timestamp)) {
|
||||
return $date->timestamp;
|
||||
} else {
|
||||
return (string)$message->getDate();
|
||||
return (string)$date;
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
@@ -1627,10 +1911,12 @@ class FetchEmails extends Command
|
||||
// }
|
||||
$data = [];
|
||||
if (!empty($item->personal)) {
|
||||
$name_parts = explode(' ', $item->personal, 2);
|
||||
$data['first_name'] = $name_parts[0];
|
||||
if (!empty($name_parts[1])) {
|
||||
$data['last_name'] = $name_parts[1];
|
||||
$name_parts = Customer::parseName($item->personal);
|
||||
if (!empty($name_parts['first_name'])) {
|
||||
$data['first_name'] = $name_parts['first_name'];
|
||||
}
|
||||
if (!empty($name_parts['last_name'])) {
|
||||
$data['last_name'] = $name_parts['last_name'];
|
||||
}
|
||||
}
|
||||
Customer::create($item->mail, $data);
|
||||
|
||||
@@ -49,7 +49,9 @@ class GenerateVars extends Command
|
||||
//$filesystem = new Filesystem();
|
||||
|
||||
//$file_path = public_path('js/vars.js');
|
||||
$file_path = storage_path('app/public/js/vars.js');
|
||||
//$file_path = '/public/js/builds/vars.js';
|
||||
$relative_path = 'js/builds/vars.js';
|
||||
$file_path = public_path($relative_path);
|
||||
|
||||
$content = view('js/vars', $params)->render();
|
||||
|
||||
@@ -57,25 +59,34 @@ class GenerateVars extends Command
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4369
|
||||
$content = preg_replace_callback(
|
||||
"#(:[ ]*\")(.*)(\"[,\r\n])#",
|
||||
function($v) {
|
||||
function ($v) {
|
||||
return $v[1].str_replace('"', '\"', $v[2]).$v[3];
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// Save vars only if content has changed.
|
||||
try {
|
||||
if (\Storage::exists('js/vars.js')) {
|
||||
$old_content = \Storage::get('js/vars.js');
|
||||
if (!\File::exists(dirname($file_path))) {
|
||||
\File::makeDirectory(dirname($file_path), \Helper::DIR_PERMISSIONS);
|
||||
}
|
||||
|
||||
// Save vars only if content has changed.
|
||||
if (\File::exists($file_path)) {
|
||||
$old_content = \File::get($file_path);
|
||||
if ($content != $old_content) {
|
||||
\Storage::put('js/vars.js', $content);
|
||||
\File::put($file_path, $content);
|
||||
}
|
||||
} else {
|
||||
\Storage::put('js/vars.js', $content);
|
||||
\File::put($file_path, $content);
|
||||
}
|
||||
$this->info("Created: ".substr($file_path, strlen(base_path())+1));
|
||||
|
||||
// Backward compatibility.
|
||||
// Before vars.js was stored in /storage/app/public/js/vars.js
|
||||
\Storage::put('js/vars.js', $content);
|
||||
|
||||
$this->info("Created: /".$relative_path);
|
||||
} catch (\Exception $e) {
|
||||
$msg = "Error occurred saving /storage/app/public/js/vars.js. ".\Helper::formatException($e);
|
||||
$msg = "Error occurred saving $file_path. ".\Helper::formatException($e);
|
||||
\Log::error($msg);
|
||||
$this->error($msg);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,35 @@ class LogsMonitor extends Command
|
||||
*/
|
||||
protected $description = 'Send new log records by email';
|
||||
|
||||
/**
|
||||
* Default minimum identical-error occurrences required for
|
||||
* `fetch_errors` entries to be included in the digest. Used when
|
||||
* the `alert_logs_fetch_min_occurrences` option is not set. The
|
||||
* default is 1, which matches upstream behaviour (no filtering);
|
||||
* raise it via the alerts settings page to suppress transient
|
||||
* single-shot fetch errors.
|
||||
*/
|
||||
const FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT = 1;
|
||||
|
||||
/**
|
||||
* Substrings that mark a `fetch_errors` row as transient. Rows
|
||||
* matching any of these are dropped regardless of count, unless
|
||||
* they appear at least the configured min-occurrences times.
|
||||
*/
|
||||
const FETCH_ERRORS_TRANSIENT_PATTERNS = [
|
||||
'connection setup failed',
|
||||
'failed to fetch messages',
|
||||
'failed to authenticate',
|
||||
'Connection closed',
|
||||
'Connection reset',
|
||||
'Connection timed out',
|
||||
'Connection refused',
|
||||
'stream_socket_client',
|
||||
'SSL operation failed',
|
||||
'Temporary failure',
|
||||
'no response',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
@@ -62,6 +91,8 @@ class LogsMonitor extends Command
|
||||
->where('created_at', '<', $now->toDateTimeString())
|
||||
->get();
|
||||
|
||||
$logs = $this->filterTransientFetchErrors($logs);
|
||||
|
||||
if (!count($logs)) {
|
||||
$this->line('['.date('Y-m-d H:i:s').'] No new log records found for the last '.$options['alert_logs_period']);
|
||||
|
||||
@@ -72,18 +103,18 @@ class LogsMonitor extends Command
|
||||
$text = 'Logs having new records for the last '.$options['alert_logs_period'].':<ul>';
|
||||
foreach ($names as $name) {
|
||||
$text .= '<li>';
|
||||
$text .= '<strong>'.\App\ActivityLog::getLogTitle($name).'</strong>';
|
||||
$text .= '<strong>'.htmlspecialchars(\App\ActivityLog::getLogTitle($name)).'</strong>';
|
||||
$text .= '</li>';
|
||||
}
|
||||
$text .= '</ul>';
|
||||
|
||||
foreach ($names as $name) {
|
||||
$text .= '<br/><strong>'.\App\ActivityLog::getLogTitle($name).'</strong><br/>';
|
||||
$text .= '<br/><strong>'.htmlspecialchars(\App\ActivityLog::getLogTitle($name)).'</strong><br/>';
|
||||
foreach ($logs as $log) {
|
||||
if ($log->log_name != $name) {
|
||||
continue;
|
||||
}
|
||||
$text .= '● ['.$log->created_at.'] '.$log->getEventDescription().' <code>'.$log->properties.'</code><br/>';
|
||||
$text .= '● ['.$log->created_at.'] '.htmlspecialchars($log->getEventDescription()).' <code>'.htmlspecialchars($log->properties).'</code><br/>';
|
||||
}
|
||||
}
|
||||
// Send alert.
|
||||
@@ -93,4 +124,87 @@ class LogsMonitor extends Command
|
||||
|
||||
$this->info('['.date('Y-m-d H:i:s').'] Monitoring finished');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop transient single-shot fetch errors. A fetch_errors row is
|
||||
* considered transient when its error message matches one of the
|
||||
* known transient patterns AND the same message appears fewer
|
||||
* than the configured min-occurrences threshold within the window.
|
||||
*/
|
||||
protected function filterTransientFetchErrors($logs)
|
||||
{
|
||||
$threshold = (int) \Option::get(
|
||||
'alert_logs_fetch_min_occurrences',
|
||||
self::FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT
|
||||
);
|
||||
|
||||
if ($threshold <= 1) {
|
||||
return $logs;
|
||||
}
|
||||
|
||||
$fetchLogName = \App\ActivityLog::NAME_EMAILS_FETCHING;
|
||||
|
||||
$counts = [];
|
||||
foreach ($logs as $log) {
|
||||
if ($log->log_name !== $fetchLogName) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->fetchErrorFingerprint($log);
|
||||
$counts[$key] = ($counts[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
$dropped = 0;
|
||||
foreach ($logs as $log) {
|
||||
if ($log->log_name === $fetchLogName) {
|
||||
$message = $this->fetchErrorMessage($log);
|
||||
if ($this->isTransientFetchError($message)) {
|
||||
$key = $this->fetchErrorFingerprint($log);
|
||||
if (($counts[$key] ?? 0) < $threshold) {
|
||||
$dropped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
$kept[] = $log;
|
||||
}
|
||||
|
||||
if ($dropped > 0) {
|
||||
$this->line('['.date('Y-m-d H:i:s').'] Suppressed '.$dropped.' transient fetch_errors entries (threshold '.$threshold.')');
|
||||
}
|
||||
|
||||
return collect($kept);
|
||||
}
|
||||
|
||||
protected function fetchErrorMessage($log): string
|
||||
{
|
||||
$props = $log->properties;
|
||||
if (is_object($props) && method_exists($props, 'toArray')) {
|
||||
$props = $props->toArray();
|
||||
} elseif (is_string($props)) {
|
||||
$decoded = json_decode($props, true);
|
||||
$props = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return (string) ($props['error'] ?? '');
|
||||
}
|
||||
|
||||
protected function fetchErrorFingerprint($log): string
|
||||
{
|
||||
$message = $this->fetchErrorMessage($log);
|
||||
// Strip the trailing "; File: /path (line)" so the same error
|
||||
// from different stack frames still groups together.
|
||||
$message = preg_replace('/;\s*File:.*$/s', '', $message);
|
||||
return trim($message);
|
||||
}
|
||||
|
||||
protected function isTransientFetchError(string $message): bool
|
||||
{
|
||||
$lower = strtolower($message);
|
||||
foreach (self::FETCH_ERRORS_TRANSIENT_PATTERNS as $needle) {
|
||||
if (strpos($lower, strtolower($needle)) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
96
app/Console/Commands/ModuleMigrate.php
Normal file
96
app/Console/Commands/ModuleMigrate.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* php artisan module:migrate modulename.
|
||||
*
|
||||
* Replaces the command from nwidart/laravel-modules, which passes the module's
|
||||
* migrations folder to the migrate command after removing base_path() from it.
|
||||
* Module::getPath() is a realpath(), so on installations where the Modules
|
||||
* folder is a symlink leading outside of the application folder (a common
|
||||
* container layout: /var/www/html/Modules -> /data/Modules) base_path() does
|
||||
* not occur in that path. The value stays absolute, migrate prefixes
|
||||
* base_path() to it anyway (Illuminate\Database\Console\Migrations\
|
||||
* BaseCommand::getMigrationPaths()), the resulting folder does not exist and
|
||||
* the migration reports "Nothing to migrate" with a zero exit code.
|
||||
*
|
||||
* https://github.com/freescout-help-desk/freescout/issues/5569
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Nwidart\Modules\Commands\MigrateCommand;
|
||||
use Nwidart\Modules\Migrations\Migrator;
|
||||
use Nwidart\Modules\Module;
|
||||
|
||||
class ModuleMigrate extends MigrateCommand
|
||||
{
|
||||
/**
|
||||
* Run the migrations of the specified module.
|
||||
*
|
||||
* @param Module $module
|
||||
*/
|
||||
protected function migrate(Module $module)
|
||||
{
|
||||
$path = $this->getMigrationPath($module);
|
||||
|
||||
if ($this->option('subpath')) {
|
||||
$path = $path.'/'.$this->option('subpath');
|
||||
}
|
||||
|
||||
$this->call('migrate', [
|
||||
'--path' => $path,
|
||||
'--database' => $this->option('database'),
|
||||
'--pretend' => $this->option('pretend'),
|
||||
'--force' => $this->option('force'),
|
||||
]);
|
||||
|
||||
if ($this->option('seed')) {
|
||||
$this->call('module:seed', ['module' => $module->getName()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module's migrations folder relative to base_path(), as the
|
||||
* migrate command prepends base_path() to whatever --path receives.
|
||||
*
|
||||
* @param Module $module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getMigrationPath(Module $module)
|
||||
{
|
||||
$absolute_path = (new Migrator($module))->getPath();
|
||||
|
||||
$path = str_replace(base_path(), '', $absolute_path);
|
||||
|
||||
if ($path != $absolute_path) {
|
||||
// The module is located inside of the application folder.
|
||||
return $path;
|
||||
}
|
||||
|
||||
// The module path is a realpath() leading outside of the application
|
||||
// folder. Rebuild the path from the folder the module has been scanned
|
||||
// from, which still contains the symlink and is therefore located
|
||||
// inside of the application folder.
|
||||
$module_path = $module->getPath();
|
||||
|
||||
if (method_exists($module, 'getScannedPath')
|
||||
&& $module_path
|
||||
&& strpos($absolute_path, $module_path) === 0
|
||||
) {
|
||||
$scanned_path = str_replace(base_path(), '', $module->getScannedPath());
|
||||
$scanned_path .= substr($absolute_path, strlen($module_path));
|
||||
|
||||
if (is_dir(base_path().DIRECTORY_SEPARATOR.$scanned_path)) {
|
||||
return $scanned_path;
|
||||
}
|
||||
}
|
||||
|
||||
// Neither path is located inside of the application folder, so the
|
||||
// migrate command can not resolve it. Tell the user instead of
|
||||
// reporting "Nothing to migrate".
|
||||
$this->error('Module migrations folder is located outside of the application folder: '.$absolute_path);
|
||||
$this->error('Run "php artisan migrate" to apply the module migrations.');
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class ParseEml extends Command
|
||||
{
|
||||
$email = file_get_contents(storage_path('logs/email.eml'));
|
||||
|
||||
if (!str_contains($email, "\r\n")){
|
||||
if (!str_contains($email, "\r\n")) {
|
||||
$email = str_replace("\n", "\r\n", $email);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ use App\Option;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
const FETCH_MAX_EXECUTION_TIME = 30; // minutes
|
||||
|
||||
/**
|
||||
* The Artisan commands provided by your application.
|
||||
*
|
||||
@@ -127,8 +125,8 @@ class Kernel extends ConsoleKernel
|
||||
$mutex_name = \Cache::get('fetch_mutex_name') ?? '';
|
||||
|
||||
// If there is no cache mutext but there are running fetch commands
|
||||
// it means the mutex had expired after self::FETCH_MAX_EXECUTION_TIME
|
||||
// and the existing command(s) is running longer than self::FETCH_MAX_EXECUTION_TIME.
|
||||
// it means the mutex had expired after 'fetch_max_execution_time'
|
||||
// and the existing command(s) is running longer than 'fetch_max_execution_time'.
|
||||
if (count($fetch_command_pids) > 0 && !\Cache::get($mutex_name)) {
|
||||
// Kill freescout:fetch-emails commands running for too long
|
||||
shell_exec('kill '.implode(' | kill ', $fetch_command_pids));
|
||||
@@ -141,6 +139,11 @@ class Kernel extends ConsoleKernel
|
||||
// So here we are forcefully removing the mutex. Otherwise mutex will live for 24 hours.
|
||||
if (\Cache::has($mutex_name)) {
|
||||
\Cache::forget($mutex_name);
|
||||
|
||||
// We could not remove the mutex from cache - something is wrong with permissions.
|
||||
if (\Cache::has($mutex_name)) {
|
||||
\Log::error('[schedule:run] Could not remove fetch mutex from cache (cache key: '.$mutex_name.'). Check file permissions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +156,7 @@ class Kernel extends ConsoleKernel
|
||||
// So we are passing an 'expiresAt' parameter to withoutOverlapping() to
|
||||
// prevent fetching from not being executed when fetching command by some reason
|
||||
// does not remove the mutex from the cache.
|
||||
->withoutOverlapping($expiresAt = self::FETCH_MAX_EXECUTION_TIME /* minutes */)
|
||||
->withoutOverlapping($expiresAt = (int)config('app.fetch_max_execution_time') /* minutes */)
|
||||
->sendOutputTo(storage_path().'/logs/fetch-emails.log');
|
||||
|
||||
switch (config('app.fetch_schedule')) {
|
||||
@@ -182,7 +185,7 @@ class Kernel extends ConsoleKernel
|
||||
$fetch_command->everyMinute();
|
||||
break;
|
||||
}
|
||||
\Cache::put('fetch_mutex_name', $fetch_command->mutexName(), self::FETCH_MAX_EXECUTION_TIME);
|
||||
\Cache::put('fetch_mutex_name', $fetch_command->mutexName(), (int)config('app.fetch_max_execution_time'));
|
||||
|
||||
$schedule = \Eventy::filter('schedule', $schedule);
|
||||
|
||||
@@ -243,8 +246,14 @@ class Kernel extends ConsoleKernel
|
||||
return true;
|
||||
})
|
||||
->mutexName();
|
||||
if (\Cache::get($mutex_name)) {
|
||||
|
||||
if (\Cache::has($mutex_name)) {
|
||||
\Cache::forget($mutex_name);
|
||||
|
||||
// We could not remove the mutex from cache - something is wrong with permissions.
|
||||
if (\Cache::has($mutex_name)) {
|
||||
\Log::error('[schedule:run] Could not remove queue:work mutex from cache (cache key: '.$mutex_name.'). Check file permissions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Events\UserReplied;
|
||||
use App\Events\ConversationStatusChanged;
|
||||
use App\Events\ConversationUserChanged;
|
||||
use App\Events\ConversationCustomerChanged;
|
||||
use App\Events\UserMovedConversation;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Input;
|
||||
use Watson\Rememberable\Rememberable;
|
||||
@@ -34,6 +35,11 @@ class Conversation extends Model
|
||||
*/
|
||||
const SUBJECT_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* Subject max length.
|
||||
*/
|
||||
const SUBJECT_MAXLENGTH = 998;
|
||||
|
||||
/**
|
||||
* Conversation reply undo timeout in seconds.
|
||||
* Value has to be larger than close_after in fsFloatingAlertsInit.
|
||||
@@ -73,7 +79,8 @@ class Conversation extends Model
|
||||
const STATUS_PENDING = 2;
|
||||
const STATUS_CLOSED = 3;
|
||||
const STATUS_SPAM = 4;
|
||||
// Not used
|
||||
// Not used in conversations.
|
||||
const STATUS_NOCHANGE = 6;
|
||||
//const STATUS_OPEN = 5;
|
||||
|
||||
public static $statuses = [
|
||||
@@ -83,6 +90,13 @@ class Conversation extends Model
|
||||
self::STATUS_SPAM => 'spam',
|
||||
//self::STATUS_OPEN => 'open',
|
||||
];
|
||||
|
||||
public static $standard_statuses = [];
|
||||
|
||||
// Just to improve performance.
|
||||
public static $status_names = [];
|
||||
|
||||
public static $get_status_callbacks = [];
|
||||
|
||||
/**
|
||||
* https://glyphicons.bootstrapcheatsheets.com/.
|
||||
@@ -284,6 +298,7 @@ class Conversation extends Model
|
||||
|
||||
/**
|
||||
* Cached mailbox.
|
||||
*
|
||||
* @return [type] [description]
|
||||
*/
|
||||
public function mailbox_cached()
|
||||
@@ -390,12 +405,17 @@ class Conversation extends Model
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getThreads($skip = null, $take = null, $types = [])
|
||||
public function getThreads($skip = null, $take = null, $types = [], $states = [Thread::STATE_PUBLISHED])
|
||||
{
|
||||
$query = $this->threads()
|
||||
->where('state', Thread::STATE_PUBLISHED)
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (count($states) == 1 && !empty($states[0])) {
|
||||
$query->where('state', $states[0]);
|
||||
} else {
|
||||
$query->whereIn('state', $states);
|
||||
}
|
||||
|
||||
if (!is_null($skip)) {
|
||||
$query->skip($skip);
|
||||
}
|
||||
@@ -457,6 +477,50 @@ class Conversation extends Model
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* When "waiting_since_as_first_unanswered_customer_message" mode is enabled,
|
||||
* the $last_reply_at field does not store time of each customer reply,
|
||||
* But "last_customer_reply_at" always stores the time of the last customer reply.
|
||||
*/
|
||||
public function getLastCustomerReplyAt()
|
||||
{
|
||||
if ($this->last_customer_reply_at) {
|
||||
return $this->last_customer_reply_at;
|
||||
}
|
||||
|
||||
if ($this->last_reply_from == self::PERSON_CUSTOMER) {
|
||||
return $this->last_reply_at;
|
||||
} else {
|
||||
// Undefined.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Takes into account "app.waiting_since_as_first_unanswered_customer_message".
|
||||
// And also also sets "last_customer_reply_at".
|
||||
// $this->last_reply_from MUST store previous value.
|
||||
public function setLastReplyAt($value, $new_last_reply_from)
|
||||
{
|
||||
if (\Helper::isCarbon($value)) {
|
||||
$value = $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
if (config('app.waiting_since_as_first_unanswered_customer_message')) {
|
||||
// Treat Waiting Since column as "Time of the first unanswered customer message"
|
||||
// instead of "Time of the last customer activity".
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5225
|
||||
if ($this->last_reply_from != Conversation::PERSON_CUSTOMER) {
|
||||
$this->last_reply_at = $value;
|
||||
}
|
||||
} else {
|
||||
$this->last_reply_at = $value;
|
||||
}
|
||||
|
||||
// "last_customer_reply_at" always stores the time of the last customer reply.
|
||||
if ($new_last_reply_from == Conversation::PERSON_CUSTOMER) {
|
||||
$this->last_customer_reply_at = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preview text.
|
||||
*
|
||||
@@ -490,7 +554,7 @@ class Conversation extends Model
|
||||
$title .= '<br/>'.User::dateFormat($this->created_at, 'M j, Y H:i');
|
||||
} else {
|
||||
$person = '';
|
||||
if (!empty(self::$persons[$this->last_reply_from])) {
|
||||
if (!empty(self::$persons[$this->last_reply_from ?? ''])) {
|
||||
$person = __(ucfirst(self::$persons[$this->last_reply_from]));
|
||||
}
|
||||
$title = __('Last reply by :person', ['person' => $person]);
|
||||
@@ -506,22 +570,22 @@ class Conversation extends Model
|
||||
|
||||
public function isActive()
|
||||
{
|
||||
return $this->status == self::STATUS_ACTIVE;
|
||||
return $this->getMainStatus() == self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isPending()
|
||||
{
|
||||
return $this->status == self::STATUS_PENDING;
|
||||
return $this->getMainStatus() == self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isSpam()
|
||||
{
|
||||
return $this->status == self::STATUS_SPAM;
|
||||
return $this->getMainStatus() == self::STATUS_SPAM;
|
||||
}
|
||||
|
||||
public function isClosed()
|
||||
{
|
||||
return $this->status == self::STATUS_CLOSED;
|
||||
return $this->getMainStatus() == self::STATUS_CLOSED;
|
||||
}
|
||||
|
||||
public function isPublished()
|
||||
@@ -533,7 +597,7 @@ class Conversation extends Model
|
||||
{
|
||||
return $this->state == self::STATE_DRAFT;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get status name.
|
||||
*
|
||||
@@ -545,7 +609,7 @@ class Conversation extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert status code to name.
|
||||
* Convert status ID to name.
|
||||
*
|
||||
* @param int $status
|
||||
*
|
||||
@@ -553,31 +617,47 @@ class Conversation extends Model
|
||||
*/
|
||||
public static function statusCodeToName($status)
|
||||
{
|
||||
$name = '';
|
||||
|
||||
if (count(self::$get_status_callbacks)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if (!empty(self::$status_names[$locale][$status])) {
|
||||
return self::$status_names[$locale][$status];
|
||||
}
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case self::STATUS_ACTIVE:
|
||||
return __('Active');
|
||||
$name = __('Active');
|
||||
break;
|
||||
|
||||
case self::STATUS_PENDING:
|
||||
return __('Pending');
|
||||
$name = __('Pending');
|
||||
break;
|
||||
|
||||
case self::STATUS_CLOSED:
|
||||
return __('Closed');
|
||||
$name = __('Closed');
|
||||
break;
|
||||
|
||||
case self::STATUS_SPAM:
|
||||
return __('Spam');
|
||||
$name = __('Spam');
|
||||
break;
|
||||
|
||||
// case self::STATUS_OPEN:
|
||||
// return __('Open');
|
||||
// $name = __('Open');
|
||||
// break;
|
||||
|
||||
default:
|
||||
return '';
|
||||
break;
|
||||
//default:
|
||||
//break;
|
||||
}
|
||||
|
||||
if (count(self::$get_status_callbacks)) {
|
||||
$name = \Eventy::filter('conversation.status_name', $name, $status);
|
||||
self::$status_names[$locale][$status] = $name;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,13 +688,55 @@ class Conversation extends Model
|
||||
}
|
||||
}
|
||||
|
||||
public function getStatus()
|
||||
public function getStatusAttribute($value)
|
||||
{
|
||||
if (array_key_exists($this->status, self::$statuses)) {
|
||||
return $this->status;
|
||||
} else {
|
||||
return self::STATUS_ACTIVE;
|
||||
// Use custom callback for better performance.
|
||||
//return \Eventy::filter('conversation.get_status', $value, $this);
|
||||
if (count(self::$get_status_callbacks)) {
|
||||
foreach (self::$get_status_callbacks as $callback) {
|
||||
if (is_callable($callback)) {
|
||||
$value = $callback($value, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function setStatusAttribute($status)
|
||||
{
|
||||
$status = \Eventy::filter('conversation.set_status', $status, $this);
|
||||
|
||||
$this->attributes['status'] = $status;
|
||||
}
|
||||
|
||||
// Get normalized status.
|
||||
public function getMainStatus()
|
||||
{
|
||||
return self::toMainStatus($this->status);
|
||||
}
|
||||
|
||||
public static function isStandardStatus($status)
|
||||
{
|
||||
return is_numeric($status) && (int)$status < 10;
|
||||
}
|
||||
|
||||
public static function toMainStatus($status)
|
||||
{
|
||||
return (int)substr($status.'', 0, 1);
|
||||
}
|
||||
|
||||
public static function compareStatus($status, $status_list)
|
||||
{
|
||||
if (is_array($status_list)) {
|
||||
return in_array(self::toMainStatus($status), $status_list);
|
||||
} else {
|
||||
return self::toMainStatus($status) == (int)$status_list;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasStatus($list)
|
||||
{
|
||||
return in_array($this->getMainStatus(), $list);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -622,20 +744,83 @@ class Conversation extends Model
|
||||
*
|
||||
* @param int $status
|
||||
*/
|
||||
public function setStatus($status, $user = null)
|
||||
public function setStatus($status, $user = null, $update_folder = true)
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$this->status = $status;
|
||||
$this->updateFolder();
|
||||
|
||||
if ($update_folder) {
|
||||
$this->updateFolder();
|
||||
}
|
||||
$this->user_updated_at = $now;
|
||||
|
||||
if ($user && $status == self::STATUS_CLOSED) {
|
||||
if ($user && self::toMainStatus($status) == self::STATUS_CLOSED) {
|
||||
$this->closed_by_user_id = $user->id;
|
||||
$this->closed_at = $now;
|
||||
}
|
||||
}
|
||||
|
||||
// Override self::$statuses.
|
||||
public static function setStatuses($statuses)
|
||||
{
|
||||
self::$statuses = $statuses;
|
||||
}
|
||||
|
||||
public static function getStatuses($exclude_ids = [])
|
||||
{
|
||||
$statuses = self::$statuses;
|
||||
if (count($exclude_ids)) {
|
||||
$statuses = array_diff_key($statuses, array_flip($exclude_ids));
|
||||
}
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
public static function getStandardStatuses()
|
||||
{
|
||||
return self::$standard_statuses ?: self::$statuses;
|
||||
}
|
||||
|
||||
// Returns array of status ids as keys and names as values.
|
||||
public static function getStatusesWithNames($exclude_ids = [])
|
||||
{
|
||||
$statuses = self::$statuses;
|
||||
if (count($exclude_ids)) {
|
||||
$statuses = array_diff_key($statuses, array_flip($exclude_ids));
|
||||
}
|
||||
foreach ($statuses as $status_id => $status_code) {
|
||||
$statuses[$status_id] = self::statusCodeToName($status_id);
|
||||
}
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
public static function statusCode($status_id)
|
||||
{
|
||||
return self::$statuses[$status_id] ?? '';
|
||||
}
|
||||
|
||||
public function getStatusIcon()
|
||||
{
|
||||
return self::$status_icons[$this->getMainStatus()].'';
|
||||
}
|
||||
|
||||
public static function statusIcon($status)
|
||||
{
|
||||
//return \Eventy::filter('conversation.get_status_icon', self::$status_icons[$this->getStatus()], $this).'';
|
||||
return self::$status_icons[self::toMainStatus($status)].'';
|
||||
}
|
||||
|
||||
public function getStatusClass()
|
||||
{
|
||||
//return \Eventy::filter('conversation.get_status_class', self::$status_classes[$this->getStatus()], $this).'';
|
||||
return self::$status_classes[$this->getMainStatus()].'';
|
||||
}
|
||||
|
||||
public static function statusClass($status)
|
||||
{
|
||||
return self::$status_classes[self::toMainStatus($status)].'';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set conversation user and all related fields.
|
||||
*
|
||||
@@ -664,6 +849,22 @@ class Conversation extends Model
|
||||
}
|
||||
}
|
||||
|
||||
public static function addStatusCondition($query, $status_list, $table_name = 'conversations')
|
||||
{
|
||||
$query_changed = \Eventy::filter('conversations.query_status_in', false, $query, $status_list, $table_name);
|
||||
if (!$query_changed) {
|
||||
if (is_array($status_list)) {
|
||||
if (count($status_list) == 1) {
|
||||
$query->where('conversations.status', $status_list[0]);
|
||||
} else {
|
||||
$query->whereIn('conversations.status', $status_list);
|
||||
}
|
||||
} else {
|
||||
$query->where('conversations.status', $status_list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next active conversation.
|
||||
*
|
||||
@@ -787,9 +988,9 @@ class Conversation extends Model
|
||||
$folder_type = Folder::TYPE_DRAFTS;
|
||||
} elseif ($this->state == self::STATE_DELETED) {
|
||||
$folder_type = Folder::TYPE_DELETED;
|
||||
} elseif ($this->status == self::STATUS_SPAM) {
|
||||
} elseif ($this->isSpam()) {
|
||||
$folder_type = Folder::TYPE_SPAM;
|
||||
} elseif ($this->status == self::STATUS_CLOSED) {
|
||||
} elseif ($this->isClosed()) {
|
||||
$folder_type = Folder::TYPE_CLOSED;
|
||||
} elseif ($this->user_id) {
|
||||
$folder_type = Folder::TYPE_ASSIGNED;
|
||||
@@ -944,7 +1145,17 @@ class Conversation extends Model
|
||||
*/
|
||||
public function getStatusColor()
|
||||
{
|
||||
return self::$status_colors[$this->status];
|
||||
return self::$status_colors[$this->getMainStatus()].'';
|
||||
}
|
||||
|
||||
public function statusColor($status)
|
||||
{
|
||||
return self::$status_colors[self::toMainStatus($status)].'';
|
||||
}
|
||||
|
||||
public static function statusName($status)
|
||||
{
|
||||
return self::statusCodeToName($status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1171,9 +1382,12 @@ class Conversation extends Model
|
||||
* Replace vars in signature.
|
||||
* `data` contains extra info which can be used to build signature.
|
||||
*/
|
||||
public function getSignatureProcessed($data = [], $escape = false)
|
||||
public function getSignatureProcessed($data = [], $escape = false, $mailbox = null)
|
||||
{
|
||||
$replaced_text = $this->replaceTextVars($this->mailbox->signature, $data, $escape);
|
||||
if (!$mailbox) {
|
||||
$mailbox = $this->mailbox;
|
||||
}
|
||||
$replaced_text = $this->replaceTextVars($mailbox->signature, $data, $escape);
|
||||
|
||||
// https://github.com/freescout-helpdesk/freescout/security/advisories/GHSA-fffc-phh8-5h4v
|
||||
$replaced_text = \Helper::stripDangerousTags($replaced_text);
|
||||
@@ -1266,7 +1480,14 @@ class Conversation extends Model
|
||||
*/
|
||||
public function moveToMailbox($mailbox, $user)
|
||||
{
|
||||
$prev_mailbox = $this->mailbox;
|
||||
$prev_mailbox = clone $this->mailbox;
|
||||
|
||||
// Make conversation Unassigned if current assignee does not have
|
||||
// access to the target mailbox.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5333
|
||||
if ($this->user_id && $this->user && !$this->user->can('view', $mailbox)) {
|
||||
$this->changeUser(self::USER_UNASSIGNED, $user, $create_thread = true);
|
||||
}
|
||||
|
||||
foreach ($this->folders as $folder) {
|
||||
// Process indirect folders.
|
||||
@@ -1327,6 +1548,8 @@ class Conversation extends Model
|
||||
'user_id' => $this->user_id,
|
||||
'state' => Thread::STATE_PUBLISHED,
|
||||
'action_type' => Thread::ACTION_TYPE_MOVED_FROM_MAILBOX,
|
||||
// Store previous mailbox ID in action_data.
|
||||
'action_data' => $prev_mailbox->id,
|
||||
'source_via' => Thread::PERSON_USER,
|
||||
'source_type' => Thread::SOURCE_TYPE_WEB,
|
||||
'customer_id' => $this->customer_id,
|
||||
@@ -1336,6 +1559,10 @@ class Conversation extends Model
|
||||
$prev_mailbox->updateFoldersCounters();
|
||||
$mailbox->updateFoldersCounters();
|
||||
|
||||
$this->load('mailbox');
|
||||
|
||||
event(new UserMovedConversation($this, /*$this->getLastReply(true),*/ $user->id, $prev_mailbox));
|
||||
|
||||
\Eventy::action('conversation.moved', $this, $user, $prev_mailbox);
|
||||
|
||||
return true;
|
||||
@@ -1345,6 +1572,11 @@ class Conversation extends Model
|
||||
*/
|
||||
public function mergeConversations($second_conversation, $user)
|
||||
{
|
||||
// Do not allow to merge with self.
|
||||
if ($second_conversation->id == $this->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move all threads from old to new conversation.
|
||||
foreach ($second_conversation->threads as $thread) {
|
||||
$thread->conversation_id = $this->id;
|
||||
@@ -1723,52 +1955,74 @@ class Conversation extends Model
|
||||
|
||||
/**
|
||||
* Get information on viewers for conversation table.
|
||||
* Returns users in "users" key with the current user excluded: replying users first, then viewing users.
|
||||
*/
|
||||
public static function getViewersInfo($conversations, $fields = ['id', 'first_name', 'last_name'], $exclude_user_ids = [])
|
||||
{
|
||||
$viewers_cache = \Cache::get('conv_view');
|
||||
$viewers = [];
|
||||
$first_user_id = null;
|
||||
$user_ids = [];
|
||||
// Skip stale records not yet removed by freescout:check-conv-viewers.
|
||||
// Dates have 'Y-m-d H:i:s' format, so they can be compared as strings.
|
||||
// https://github.com/freescout-help-desk/freescout/pull/5670
|
||||
$min_date = \Carbon\Carbon::now()->subSeconds(30)->toDateTimeString();
|
||||
foreach ($conversations as $conversation) {
|
||||
if (!empty($viewers_cache[$conversation->id])) {
|
||||
// Get replying viewers
|
||||
foreach ($viewers_cache[$conversation->id] as $user_id => $viewer) {
|
||||
if (!$first_user_id) {
|
||||
$first_user_id = $user_id;
|
||||
}
|
||||
if (!empty($viewer['r']) && !in_array($user_id, $exclude_user_ids)) {
|
||||
$viewers[$conversation->id] = [
|
||||
'user' => null,
|
||||
'user_id' => $user_id,
|
||||
'replying' => true
|
||||
];
|
||||
$user_ids[] = $user_id;
|
||||
break;
|
||||
}
|
||||
if (empty($viewers_cache[$conversation->id]) || !is_array($viewers_cache[$conversation->id])) {
|
||||
continue;
|
||||
}
|
||||
$replying = [];
|
||||
$viewing = [];
|
||||
foreach ($viewers_cache[$conversation->id] as $user_id => $viewer) {
|
||||
if (in_array($user_id, $exclude_user_ids)
|
||||
|| empty($viewer['t'])
|
||||
|| $viewer['t'] < $min_date
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Get first non-replying viewer
|
||||
if (empty($viewers[$conversation->id]) && !in_array($user_id, $exclude_user_ids)) {
|
||||
$viewers[$conversation->id] = [
|
||||
'user' => null,
|
||||
'user_id' => $first_user_id,
|
||||
'replying' => false
|
||||
];
|
||||
$user_ids[] = $first_user_id;
|
||||
$item = [
|
||||
'user' => null,
|
||||
'user_id' => $user_id,
|
||||
'replying' => !empty($viewer['r']),
|
||||
];
|
||||
if ($item['replying']) {
|
||||
$replying[] = $item;
|
||||
} else {
|
||||
$viewing[] = $item;
|
||||
}
|
||||
$user_ids[] = $user_id;
|
||||
}
|
||||
// Replying viewers go first.
|
||||
$list = array_merge($replying, $viewing);
|
||||
if ($list) {
|
||||
// First viewer is kept on the top level for backward compatibility.
|
||||
$viewers[$conversation->id] = $list[0];
|
||||
$viewers[$conversation->id]['users'] = $list;
|
||||
}
|
||||
}
|
||||
// Get all viewing users in one query
|
||||
if ($user_ids) {
|
||||
$user_ids = array_unique($user_ids);
|
||||
$users = User::select($fields)->whereIn('id', $user_ids)->get();
|
||||
$users = User::select($fields)->whereIn('id', $user_ids)->get()->keyBy('id');
|
||||
|
||||
foreach ($viewers as $i => $viewer) {
|
||||
foreach ($users as $user) {
|
||||
if ($user->id == $viewer['user_id']) {
|
||||
$viewers[$i]['user'] = $user;
|
||||
foreach ($viewers as $conversation_id => $viewer) {
|
||||
foreach ($viewer['users'] as $i => $item) {
|
||||
if (!empty($users[$item['user_id']])) {
|
||||
$viewers[$conversation_id]['users'][$i]['user'] = $users[$item['user_id']];
|
||||
} else {
|
||||
// User not found in DB.
|
||||
unset($viewers[$conversation_id]['users'][$i]);
|
||||
}
|
||||
}
|
||||
// Remove not found users.
|
||||
$viewers[$conversation_id]['users'] = array_values($viewers[$conversation_id]['users']);
|
||||
if (!$viewers[$conversation_id]['users']) {
|
||||
unset($viewers[$conversation_id]);
|
||||
continue;
|
||||
}
|
||||
$first_user = $viewers[$conversation_id]['users'][0];
|
||||
$viewers[$conversation_id]['user'] = $first_user['user'];
|
||||
$viewers[$conversation_id]['user_id'] = $first_user['user_id'];
|
||||
$viewers[$conversation_id]['replying'] = $first_user['replying'];
|
||||
}
|
||||
}
|
||||
return $viewers;
|
||||
@@ -1798,11 +2052,15 @@ class Conversation extends Model
|
||||
\Eventy::action('conversation.state_changed', $this, $user, $prev_state);
|
||||
}
|
||||
|
||||
public function changeStatus($new_status, $user, $create_thread = true)
|
||||
public function changeStatus($new_status, $user = null, $create_thread = true)
|
||||
{
|
||||
if (!array_key_exists($new_status, self::$statuses)) {
|
||||
if (!array_key_exists((int)$new_status, self::$statuses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
$user = new User();
|
||||
}
|
||||
|
||||
$prev_status = $this->status;
|
||||
|
||||
@@ -1822,12 +2080,12 @@ class Conversation extends Model
|
||||
// todo: this need to be changed for API
|
||||
$thread->source_type = Thread::SOURCE_TYPE_WEB;
|
||||
$thread->customer_id = $this->customer_id;
|
||||
$thread->created_by_user_id = $user->id;
|
||||
$thread->created_by_user_id = $user ? $user->id : null;
|
||||
$thread->save();
|
||||
}
|
||||
|
||||
event(new ConversationStatusChanged($this));
|
||||
\Eventy::action('conversation.status_changed', $this, $user, $changed_on_reply = false, $prev_status);
|
||||
\Eventy::action('conversation.status_changed', $this, $user ?? null, $changed_on_reply = false, $prev_status);
|
||||
}
|
||||
|
||||
public function changeUser($new_user_id, $user, $create_thread = true)
|
||||
@@ -1904,6 +2162,8 @@ class Conversation extends Model
|
||||
{
|
||||
\Eventy::action('conversations.before_delete_forever', $conversation_ids);
|
||||
|
||||
$folder_ids = [];
|
||||
|
||||
//$conversation_ids = $conversations->pluck('id')->toArray();
|
||||
for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) {
|
||||
|
||||
@@ -1913,6 +2173,20 @@ class Conversation extends Model
|
||||
$thread_ids = Thread::whereIn('conversation_id', $ids)->pluck('id')->toArray();
|
||||
Attachment::deleteByThreadIds($thread_ids);
|
||||
|
||||
// Collect folders IDs.
|
||||
$folder_ids = array_merge($folder_ids, ConversationFolder::whereIn('conversation_id', $ids)
|
||||
->distinct()
|
||||
->pluck('id')
|
||||
->toArray()
|
||||
);
|
||||
$folder_ids = array_unique($folder_ids);
|
||||
$folder_ids = array_merge($folder_ids, Conversation::whereIn('id', $ids)
|
||||
->distinct()
|
||||
->pluck('folder_id')
|
||||
->toArray()
|
||||
);
|
||||
$folder_ids = array_unique($folder_ids);
|
||||
|
||||
// Observers do not react on this kind of deleting.
|
||||
|
||||
// Delete threads.
|
||||
@@ -1923,8 +2197,22 @@ class Conversation extends Model
|
||||
|
||||
// Delete conversations.
|
||||
Conversation::whereIn('id', $ids)->delete();
|
||||
|
||||
// Delete links to folders.
|
||||
ConversationFolder::whereIn('conversation_id', $ids)->delete();
|
||||
}
|
||||
|
||||
// Update folders counters.
|
||||
for ($i=0; $i < ceil(count($folder_ids) / \Helper::IN_LIMIT); $i++) {
|
||||
|
||||
$ids = array_slice($folder_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT);
|
||||
|
||||
// Update counters.
|
||||
$folders = Folder::whereIn('id', $ids)->get();
|
||||
foreach ($folders as $folder) {
|
||||
$folder->updateCounters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2035,12 +2323,20 @@ class Conversation extends Model
|
||||
$new_attachment->save();
|
||||
|
||||
try {
|
||||
$attachment_file = new \Illuminate\Http\UploadedFile(
|
||||
$attachment->getLocalFilePath(), $attachment->file_name,
|
||||
null, null, true
|
||||
);
|
||||
$content_stream = '';
|
||||
$attachment_file = null;
|
||||
|
||||
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
|
||||
if (\Helper::isLocalStorage()) {
|
||||
$attachment_file = new \Illuminate\Http\UploadedFile(
|
||||
$attachment->getLocalFilePath(), $attachment->file_name,
|
||||
null, null, true
|
||||
);
|
||||
} else {
|
||||
// File in remote storage.
|
||||
$content_stream = $attachment->getFileStream();
|
||||
}
|
||||
|
||||
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
|
||||
|
||||
if (!empty($file_info['file_dir'])) {
|
||||
$new_attachment->file_dir = $file_info['file_dir'];
|
||||
@@ -2079,7 +2375,8 @@ class Conversation extends Model
|
||||
// return self::$email_history_codes[(int)$this->email_history] ?? 'global';
|
||||
// }
|
||||
|
||||
public static function getEmailHistoryName($code) {
|
||||
public static function getEmailHistoryName($code)
|
||||
{
|
||||
$label = '';
|
||||
|
||||
switch ($code) {
|
||||
@@ -2213,8 +2510,8 @@ class Conversation extends Model
|
||||
public static function refreshConversations($conversation, $thread)
|
||||
{
|
||||
\App\Events\RealtimeConvNewThread::dispatchSelf($thread);
|
||||
\App\Events\RealtimeMailboxNewThread::dispatchSelf($conversation->mailbox_id);
|
||||
\App\Events\RealtimeChat::dispatchSelf($conversation->mailbox_id);
|
||||
\App\Events\RealtimeMailboxNewThread::dispatchSelf($conversation->mailbox_id, $thread->id, (int)$conversation->isChat());
|
||||
\App\Events\RealtimeChat::dispatchSelf($conversation->mailbox_id, $thread->id, (int)$conversation->isChat());
|
||||
}
|
||||
|
||||
public static function getConvTableSorting($request = null)
|
||||
@@ -2230,8 +2527,7 @@ class Conversation extends Model
|
||||
|
||||
$result = \Eventy::filter('conversations.table_sorting', $result);
|
||||
|
||||
if (
|
||||
!empty($request->sorting['sort_by']) && !empty($request->sorting['order']) &&
|
||||
if (!empty($request->sorting['sort_by']) && !empty($request->sorting['order']) &&
|
||||
in_array($request->sorting['sort_by'], ['subject', 'number', 'date']) &&
|
||||
in_array($request->sorting['order'], ['asc', 'desc'])
|
||||
) {
|
||||
@@ -2287,10 +2583,11 @@ class Conversation extends Model
|
||||
$query->where('conversations.subject', $like_op, $like)
|
||||
->orWhere('conversations.customer_email', $like_op, $like)
|
||||
->orWhere('conversations.'.self::numberFieldName(), $q_int)
|
||||
->orWhere('conversations.id', $q_int)
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5298
|
||||
//->orWhere('conversations.id', $q_int)
|
||||
->orWhere('customers.first_name', $like_op, $like)
|
||||
->orWhere('customers.last_name', $like_op, $like)
|
||||
->orWhere(\Helper::isPgSql() ? \DB::raw('(customers.first_name || \' \' || customers.last_name)') : \DB::raw('CONCAT(customers.first_name, " ", customers.last_name)'), $like_op, $like)
|
||||
->orWhere(\Helper::isPgSql() ? \DB::raw('('.\DB::getTablePrefix().'customers.first_name || \' \' || '.\DB::getTablePrefix().'customers.last_name)') : \DB::raw('CONCAT('.\DB::getTablePrefix().'customers.first_name, " ", '.\DB::getTablePrefix().'customers.last_name)'), $like_op, $like)
|
||||
->orWhere('threads.body', $like_op, $like)
|
||||
->orWhere('threads.from', $like_op, $like)
|
||||
->orWhere('threads.to', $like_op, $like)
|
||||
@@ -2315,15 +2612,10 @@ class Conversation extends Model
|
||||
->orWhere('threads.created_by_customer_id', '=', $customer_id);
|
||||
});
|
||||
}
|
||||
if (!empty($filters['status'])) {
|
||||
if (count($filters['status']) == 1) {
|
||||
// = is faster than IN.
|
||||
$query_conversations->where('conversations.status', '=', $filters['status'][0]);
|
||||
} else {
|
||||
$query_conversations->whereIn('conversations.status', $filters['status']);
|
||||
}
|
||||
if (!empty($filters['status']) && is_array($filters['status'])) {
|
||||
self::addStatusCondition($query_conversations, $filters['status']);
|
||||
}
|
||||
if (!empty($filters['state'])) {
|
||||
if (!empty($filters['state']) && is_array($filters['state'])) {
|
||||
if (count($filters['state']) == 1) {
|
||||
// = is faster than IN.
|
||||
$query_conversations->where('conversations.state', '=', $filters['state'][0]);
|
||||
@@ -2374,7 +2666,7 @@ class Conversation extends Model
|
||||
}
|
||||
|
||||
if (!self::queryContainsStr($query_sql, '`customers`.`id`')) {
|
||||
$query_conversations->leftJoin('customers', 'conversations.customer_id', '=' ,'customers.id');
|
||||
$query_conversations->leftJoin('customers', 'conversations.customer_id', '=', 'customers.id');
|
||||
}
|
||||
|
||||
$query_conversations = \Eventy::filter('search.conversations.apply_filters', $query_conversations, $filters, $q);
|
||||
@@ -2464,14 +2756,20 @@ class Conversation extends Model
|
||||
|
||||
public static function getChats($mailbox_id, $offset = 0, $limit = self::CHATS_LIST_SIZE+1)
|
||||
{
|
||||
$chats = Conversation::where('type', self::TYPE_CHAT)
|
||||
$query = Conversation::where('type', self::TYPE_CHAT)
|
||||
->where('mailbox_id', $mailbox_id)
|
||||
->where('state', self::STATE_PUBLISHED)
|
||||
->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_PENDING])
|
||||
->orderBy('last_reply_at', 'desc')
|
||||
->offset($offset)
|
||||
->limit($limit)
|
||||
->get();
|
||||
->limit($limit);
|
||||
|
||||
// Check access.
|
||||
$user = auth()->user();
|
||||
if ($user->canSeeOnlyAssignedConversations()) {
|
||||
$query->where('conversations.user_id', $user->id);
|
||||
}
|
||||
$chats = $query->get();
|
||||
|
||||
// Preload customers.
|
||||
if (count($chats)) {
|
||||
@@ -2491,9 +2789,18 @@ class Conversation extends Model
|
||||
|
||||
public function userHasAccessToMailbox($user_id)
|
||||
{
|
||||
return MailboxUser::where('mailbox_id', $this->mailbox_id)
|
||||
$has_access = \Helper::memoryCacheGet('mailbox_access.'.$this->mailbox_id.'_'.$user_id);
|
||||
if (!is_null($has_access)) {
|
||||
return $has_access;
|
||||
}
|
||||
|
||||
$has_access = MailboxUser::where('mailbox_id', $this->mailbox_id)
|
||||
->where('user_id', $user_id)
|
||||
->exists();
|
||||
|
||||
\Helper::memoryCachePut('mailbox_access.'.$this->mailbox_id.'_'.$user_id, $has_access);
|
||||
|
||||
return $has_access;
|
||||
}
|
||||
|
||||
public function chatShouldStartNew($mailbox = null)
|
||||
@@ -2502,12 +2809,31 @@ class Conversation extends Model
|
||||
$mailbox = $this->mailbox;
|
||||
}
|
||||
if (!empty($mailbox->meta['chat_start_new'])
|
||||
&& ($this->status == Conversation::STATUS_CLOSED
|
||||
|| $this->state == Conversation::STATE_DELETED)
|
||||
&& ($this->isClosed() || $this->state == Conversation::STATE_DELETED)
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAssignedToUser($user)
|
||||
{
|
||||
return \Eventy::filter('conversation.is_user_assignee', $this->user_id == $user->id, $this, $user->id);
|
||||
}
|
||||
|
||||
// Check if conversation mailbox is archived using caching.
|
||||
public function isMailboxArchived()
|
||||
{
|
||||
$is_archived = \Helper::memoryCacheGet('is_archived.'.$this->mailbox_id);
|
||||
if (!is_null($is_archived)) {
|
||||
return $is_archived;
|
||||
}
|
||||
|
||||
$is_archived = $this->mailbox->isArchived();
|
||||
|
||||
\Helper::memoryCachePut('is_archived.'.$this->mailbox_id, $is_archived);
|
||||
|
||||
return $is_archived;
|
||||
}
|
||||
}
|
||||
|
||||
126
app/Customer.php
126
app/Customer.php
@@ -4,6 +4,7 @@ namespace App;
|
||||
|
||||
use App\Email;
|
||||
use App\CustomerChannel;
|
||||
use App\Thread;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -494,11 +495,19 @@ class Customer extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get main email.
|
||||
* Get emails as string.
|
||||
*/
|
||||
public static function getMainEmailStatic($customer_id)
|
||||
public function getEmailsAsArray()
|
||||
{
|
||||
return Email::select('email')->where('customer_id', $customer_id)->pluck('email');
|
||||
return self::getCustomerEmailsAsArray($this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emails as string.
|
||||
*/
|
||||
public static function getCustomerEmailsAsArray($customer_id)
|
||||
{
|
||||
return Email::select('email')->where('customer_id', $customer_id)->pluck('email')->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,7 +661,7 @@ class Customer extends Model
|
||||
return [[
|
||||
'value' => '',
|
||||
'type' => self::PHONE_TYPE_WORK,
|
||||
]];
|
||||
],];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -776,7 +785,7 @@ class Customer extends Model
|
||||
return [[
|
||||
'type' => '',
|
||||
'value' => '',
|
||||
]];
|
||||
],];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -822,7 +831,7 @@ class Customer extends Model
|
||||
if (!$value || preg_match("/^http(s)?:?\/?\/?$/i", $value)) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match("/http(s)?:\/\//i", $value)) {
|
||||
if (!preg_match("/^http(s)?:\/\//i", $value)) {
|
||||
$value = 'http://'.$value;
|
||||
}
|
||||
$websites[] = (string) $value;
|
||||
@@ -932,6 +941,9 @@ class Customer extends Model
|
||||
if (!$email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = Email::sanitizeLength($email);
|
||||
|
||||
$email_obj = Email::where('email', $email)->first();
|
||||
if ($email_obj) {
|
||||
$customer = $email_obj->customer;
|
||||
@@ -953,7 +965,7 @@ class Customer extends Model
|
||||
} else {
|
||||
$customer = new self();
|
||||
$email_obj = new Email();
|
||||
$email_obj->email = $email;
|
||||
$email_obj->email = Email::sanitizeLength($email);
|
||||
|
||||
$new = true;
|
||||
}
|
||||
@@ -1009,6 +1021,9 @@ class Customer extends Model
|
||||
$data['notes'] = $data['background'];
|
||||
}
|
||||
|
||||
// Strip tags.
|
||||
$data = \Helper::stripTagsFromArray($data, $this->fillable);
|
||||
|
||||
if ($replace_data) {
|
||||
// Replace data.
|
||||
$data_prepared = $data;
|
||||
@@ -1142,7 +1157,7 @@ class Customer extends Model
|
||||
*/
|
||||
public function url()
|
||||
{
|
||||
return route('customers.update', ['id'=>$this->id]);
|
||||
return route('customers.update', ['id' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1152,7 +1167,7 @@ class Customer extends Model
|
||||
*/
|
||||
public function urlView()
|
||||
{
|
||||
return route('customers.conversations', ['id'=>$this->id]);
|
||||
return route('customers.conversations', ['id' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1292,6 +1307,10 @@ class Customer extends Model
|
||||
*/
|
||||
public function mergeWith(Customer $customer2)
|
||||
{
|
||||
if ($this->id == $customer2->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$customer2->conversations()->update(['customer_id' => $this->id]);
|
||||
@@ -1306,6 +1325,11 @@ class Customer extends Model
|
||||
// Move emails.
|
||||
$customer2->emails()->update(['customer_id' => $this->id]);
|
||||
|
||||
// Update customer in threads.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5505
|
||||
Thread::where('customer_id', $customer2->id)->update(['customer_id' => $this->id]);
|
||||
Thread::where('created_by_customer_id', $customer2->id)->update(['created_by_customer_id' => $this->id]);
|
||||
|
||||
// Merge attributes
|
||||
// if (in_array('phone', $keepAttributes) && !$this->phone) {
|
||||
// $this->phone = $customerToMerge->phone;
|
||||
@@ -1341,6 +1365,8 @@ class Customer extends Model
|
||||
\Eventy::action('customer.merged', $this, $customer2, $user);
|
||||
|
||||
$customer2->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function mergeTypeValueLists($list1, $list2)
|
||||
@@ -1378,7 +1404,8 @@ class Customer extends Model
|
||||
}
|
||||
|
||||
$file_name = md5(Hash::make($this->id)).'.jpg';
|
||||
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
|
||||
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
|
||||
$dest_path = Storage::disk('local')->path($storage_path);
|
||||
|
||||
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
|
||||
if (!file_exists($dest_dir)) {
|
||||
@@ -1392,6 +1419,8 @@ class Customer extends Model
|
||||
|
||||
imagejpeg($resized_image, $dest_path, self::PHOTO_QUALITY);
|
||||
|
||||
$file_name = \Eventy::filter('customer.save_photo', $file_name, $this, $dest_path, $storage_path);
|
||||
|
||||
return $file_name;
|
||||
}
|
||||
|
||||
@@ -1426,10 +1455,23 @@ class Customer extends Model
|
||||
return $data;
|
||||
}
|
||||
|
||||
$name_parts = explode(' ', $name, 2);
|
||||
$data['first_name'] = $name_parts[0];
|
||||
if (!empty($name_parts[1])) {
|
||||
$data['last_name'] = $name_parts[1];
|
||||
if (strstr($name, ',')) {
|
||||
// Smith, John.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5074
|
||||
$name_parts = explode(',', $name, 2);
|
||||
if (!empty($name_parts[1]) && trim($name_parts[1])) {
|
||||
$data['first_name'] = trim($name_parts[1]);
|
||||
$data['last_name'] = trim($name_parts[0]);
|
||||
} else {
|
||||
$data['first_name'] = trim($name_parts[0]);
|
||||
}
|
||||
} else {
|
||||
// Normal format.
|
||||
$name_parts = explode(' ', $name, 2);
|
||||
$data['first_name'] = $name_parts[0];
|
||||
if (!empty($name_parts[1])) {
|
||||
$data['last_name'] = $name_parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -1465,13 +1507,31 @@ class Customer extends Model
|
||||
|
||||
public function setPhotoFromRemoteFile($url)
|
||||
{
|
||||
$headers = get_headers($url);
|
||||
// Sanitize URL.
|
||||
/*try {
|
||||
\Helper::sanitizeRemoteUrl($url, true);
|
||||
} catch (\Exception $e) {
|
||||
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
|
||||
\Helper::logException($e, 'Customer::setPhotoFromRemoteFile()');
|
||||
return false;
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}*/
|
||||
|
||||
// Quick check for response code 200.
|
||||
try {
|
||||
$headers = get_headers($url);
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, 'Customer::setPhotoFromRemoteFile()');
|
||||
return false;
|
||||
}
|
||||
if (!preg_match("/200/", $headers[0])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$image_data = \Helper::getRemoteFileContents($url);
|
||||
// URL sanitizing takes place in getRemoteFileContents().
|
||||
$image_data = \Helper::getRemoteFileContents($url, $follow_redirects = false);
|
||||
|
||||
if (!$image_data) {
|
||||
return false;
|
||||
@@ -1563,8 +1623,40 @@ class Customer extends Model
|
||||
$this->channel_id = $channel_id;
|
||||
$this->save();
|
||||
}
|
||||
$customer_channel = CustomerChannel::where('customer_id', $this->id)
|
||||
->where('channel', $channel)
|
||||
->first();
|
||||
|
||||
return CustomerChannel::create($this->id, $channel, $channel_id);
|
||||
if ($customer_channel) {
|
||||
// Update channel_id.
|
||||
if ($customer_channel->channel_id != $channel_id) {
|
||||
try {
|
||||
$customer_channel->channel_id = $channel_id;
|
||||
$customer_channel->save();
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
return $customer_channel;
|
||||
}
|
||||
} else {
|
||||
// Create.
|
||||
return CustomerChannel::create($this->id, $channel, $channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Each customer can have only one record for specific CHANNEL in CustomerChannel table.
|
||||
public function updateChannelId($channel, $new_channel_id)
|
||||
{
|
||||
if (!$channel || !$new_channel_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CustomerChannel::where('customer_id', $this->id)
|
||||
->where('channel', $channel)
|
||||
->update(['channel_id' => $new_channel_id]);
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCustomerByChannel($channel, $channel_id)
|
||||
|
||||
@@ -18,6 +18,8 @@ class Email extends Model
|
||||
const TYPE_HOME = 2;
|
||||
const TYPE_OTHER = 3;
|
||||
|
||||
const MAX_LENGTH = 191;
|
||||
|
||||
public static $types = [
|
||||
self::TYPE_WORK => 'work',
|
||||
self::TYPE_HOME => 'home',
|
||||
@@ -31,6 +33,14 @@ class Email extends Model
|
||||
*/
|
||||
protected $guarded = ['id', 'customer_id'];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
if (!empty($attributes['email'])) {
|
||||
$attributes['email'] = self::sanitizeLength($attributes['email']);
|
||||
}
|
||||
parent::__construct($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email's customer.
|
||||
*/
|
||||
@@ -84,4 +94,9 @@ class Email extends Model
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function sanitizeLength($email_address)
|
||||
{
|
||||
return substr($email_address, Email::MAX_LENGTH*(-1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class CustomerCreatedConversation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Conversation $conversation, Thread $last_thread)
|
||||
public function __construct(Conversation $conversation, $last_thread)
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->last_thread = $last_thread;
|
||||
|
||||
@@ -69,13 +69,15 @@ class RealtimeChat implements ShouldBroadcastNow
|
||||
/**
|
||||
* Helper funciton.
|
||||
*/
|
||||
public static function dispatchSelf($mailbox_id)
|
||||
public static function dispatchSelf($mailbox_id, $thread_id, $is_chat)
|
||||
{
|
||||
if (!\Helper::isChatModeAvailable()) {
|
||||
return;
|
||||
}
|
||||
$notification_data = [
|
||||
'mailbox_id' => $mailbox_id
|
||||
'mailbox_id' => $mailbox_id,
|
||||
'thread_id' => $thread_id,
|
||||
'is_chat' => (int)$is_chat,
|
||||
];
|
||||
event(new \App\Events\RealtimeChat($notification_data));
|
||||
}
|
||||
@@ -97,6 +99,13 @@ class RealtimeChat implements ShouldBroadcastNow
|
||||
|
||||
$payload->chats_html = \View::make('mailboxes/partials/chat_list')->with($template_data)->render();
|
||||
|
||||
// Audio notification for chats.
|
||||
if ((int)$payload->is_chat && $payload->thread_id) {
|
||||
$payload->audio = [
|
||||
'thread_id' => $payload->thread_id,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,30 +88,48 @@ class RealtimeConvNewThread implements ShouldBroadcastNow
|
||||
public static function processPayload($payload)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$mailbox = Mailbox::rememberForever()->find($payload->mailbox_id);
|
||||
|
||||
// Check if user can listen to this event.
|
||||
if (!$user || !$mailbox || !$user->can('viewCached', $mailbox)) {
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$thread = Thread::find($payload->thread_id);
|
||||
|
||||
// Check if user can listen to this event.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-w668-wq26-6c94
|
||||
if (!$thread) {
|
||||
return $payload;
|
||||
return [];
|
||||
}
|
||||
|
||||
$conversation = $thread->conversation;
|
||||
|
||||
if (!$conversation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$user->can('view', $conversation)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add thread html to the payload.
|
||||
$template_data = [
|
||||
'conversation' => $thread->conversation,
|
||||
'mailbox' => $thread->conversation->mailbox,
|
||||
'conversation' => $conversation,
|
||||
'mailbox' => $conversation->mailbox,
|
||||
'threads' => [$thread],
|
||||
];
|
||||
|
||||
$payload->thread_html = \View::make('conversations/partials/threads')->with($template_data)->render();
|
||||
$payload->conversation_user_id = $thread->conversation->user_id;
|
||||
$payload->conversation_status = $thread->conversation->status;
|
||||
$payload->conversation_status_class = Conversation::$status_classes[$thread->conversation->status];
|
||||
$payload->conversation_status_icon = Conversation::$status_icons[$thread->conversation->status];
|
||||
$payload->conversation_user_id = $conversation->user_id;
|
||||
$payload->conversation_status = $conversation->status;
|
||||
$payload->conversation_status_class = Conversation::$status_classes[$conversation->status];
|
||||
$payload->conversation_status_icon = Conversation::$status_icons[$conversation->status];
|
||||
|
||||
// Audio notification for chats.
|
||||
if ($conversation->isChat()) {
|
||||
$payload->audio = [
|
||||
'thread_id' => $thread->id,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Conversation;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
//use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
@@ -110,4 +111,18 @@ class RealtimeConvView implements ShouldBroadcastNow
|
||||
];
|
||||
event(new \App\Events\RealtimeConvView($notification_data));
|
||||
}
|
||||
|
||||
// Check access.
|
||||
public static function processPayload($payload)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$conversation = Conversation::find($payload->conversation_id);
|
||||
|
||||
// Check if user can listen to this event.
|
||||
if (!$user || !$conversation || !$user->can('view', $conversation)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
namespace App\Events;
|
||||
|
||||
use App\Conversation;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
@@ -59,4 +60,22 @@ class RealtimeConvViewFinish implements ShouldBroadcastNow
|
||||
{
|
||||
return 'conv';
|
||||
}
|
||||
|
||||
// Check access.
|
||||
// $notification_data = [
|
||||
// 'conversation_id' => $conversation_id,
|
||||
// 'user_id' => $user_id,
|
||||
// ];
|
||||
public static function processPayload($payload)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$conversation = Conversation::find($payload->conversation_id);
|
||||
|
||||
// Check if user can listen to this event.
|
||||
if (!$user || !$conversation || !$user->can('view', $conversation)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,12 @@ class RealtimeMailboxNewThread implements ShouldBroadcastNow
|
||||
/**
|
||||
* Helper funciton.
|
||||
*/
|
||||
public static function dispatchSelf($mailbox_id)
|
||||
public static function dispatchSelf($mailbox_id, $thread_id, $is_chat)
|
||||
{
|
||||
$notification_data = [
|
||||
'mailbox_id' => $mailbox_id
|
||||
'mailbox_id' => $mailbox_id,
|
||||
'thread_id' => $thread_id,
|
||||
'is_chat' => (int)$is_chat,
|
||||
];
|
||||
event(new \App\Events\RealtimeMailboxNewThread($notification_data));
|
||||
}
|
||||
@@ -105,6 +107,13 @@ class RealtimeMailboxNewThread implements ShouldBroadcastNow
|
||||
|
||||
$payload->folders_html = \View::make('mailboxes/partials/folders')->with($template_data)->render();
|
||||
|
||||
// Audio notification for chats.
|
||||
if ((int)$payload->is_chat && $payload->thread_id) {
|
||||
$payload->audio = [
|
||||
'thread_id' => $payload->thread_id,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class UserCreatedConversation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Conversation $conversation, Thread $last_thread)
|
||||
public function __construct(Conversation $conversation, $last_thread)
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->last_thread = $last_thread;
|
||||
|
||||
28
app/Events/UserMovedConversation.php
Normal file
28
app/Events/UserMovedConversation.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Conversation;
|
||||
use App\Mailbox;
|
||||
use App\Thread;
|
||||
|
||||
class UserMovedConversation
|
||||
{
|
||||
public $conversation;
|
||||
//public $last_thread;
|
||||
public $caused_by_user_id;
|
||||
public $from_mailbox;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Conversation $conversation, /*$last_thread,*/ $caused_by_user_id, Mailbox $from_mailbox)
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
//$this->last_thread = $last_thread;
|
||||
$this->caused_by_user_id = $caused_by_user_id;
|
||||
$this->from_mailbox = $from_mailbox;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
@@ -26,6 +28,26 @@ class Handler extends ExceptionHandler
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* True while an exception is being reported. Protects against
|
||||
* recursive reporting (an exception thrown while reporting another one).
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $is_reporting = false;
|
||||
|
||||
/**
|
||||
* Classes/namespaces whose stack frame arguments may contain DB credentials
|
||||
* (host, username and password are passed as arguments when connecting).
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $sensitive_frame_prefixes = [
|
||||
'Illuminate\Database\Connectors\\',
|
||||
'Doctrine\DBAL\Driver\\',
|
||||
'PDO',
|
||||
];
|
||||
|
||||
/**
|
||||
* Report or log an exception.
|
||||
*
|
||||
@@ -37,7 +59,123 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function report(Exception $exception)
|
||||
{
|
||||
parent::report($exception);
|
||||
// If an exception occurs while another one is being reported
|
||||
// (for example the database is down and something in the reporting
|
||||
// chain queries it), do not report it again to avoid an infinite
|
||||
// loop and gigantic log files.
|
||||
if ($this->is_reporting) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->is_reporting = true;
|
||||
|
||||
try {
|
||||
// Do not write DB credentials from the stack trace to the logs.
|
||||
$this->maskSensitiveTraceArgs($exception);
|
||||
|
||||
parent::report($exception);
|
||||
} finally {
|
||||
$this->is_reporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace string arguments (which may contain DB host, username and
|
||||
* password) of the sensitive stack frames with asterisks (the first
|
||||
* 2 symbols are left untouched), so that they
|
||||
* are not written to the logs. Applies to the whole chain of previous
|
||||
* exceptions.
|
||||
*
|
||||
* @param \Exception $exception
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function maskSensitiveTraceArgs($exception)
|
||||
{
|
||||
try {
|
||||
$property = new \ReflectionProperty(\Exception::class, 'trace');
|
||||
// Has no effect since PHP 8.1 and is deprecated since PHP 8.5.
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
$property->setAccessible(true);
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
while ($exception && $depth < 20) {
|
||||
$trace = $property->getValue($exception);
|
||||
|
||||
if (is_array($trace)) {
|
||||
$changed = false;
|
||||
foreach ($trace as $i => $frame) {
|
||||
if (empty($frame['args']) || !is_array($frame['args'])) {
|
||||
continue;
|
||||
}
|
||||
$class = isset($frame['class']) ? $frame['class'] : '';
|
||||
foreach (self::$sensitive_frame_prefixes as $prefix) {
|
||||
if ($class !== '' && strpos($class, $prefix) === 0) {
|
||||
foreach ($frame['args'] as $arg_index => $arg) {
|
||||
if (is_string($arg)) {
|
||||
$trace[$i]['args'][$arg_index] = $this->maskString($arg);
|
||||
}
|
||||
}
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$property->setValue($exception, $trace);
|
||||
}
|
||||
}
|
||||
|
||||
$exception = $exception->getPrevious();
|
||||
$depth++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a string: keep the first 2 symbols and replace the rest with asterisks.
|
||||
* Strings of 2 symbols or less are masked completely.
|
||||
*
|
||||
* @param string $string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function maskString($string)
|
||||
{
|
||||
if (mb_strlen($string) <= 2) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return mb_substr($string, 0, 2).'***';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default context variables for logging.
|
||||
*
|
||||
* Same as in Laravel 5.8+: only the user ID is added (no email).
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function context()
|
||||
{
|
||||
// Try to avoid reading user from DB.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5707
|
||||
$guard = Auth::guard();
|
||||
$user_id = $guard->getSession()->get($guard->getName());
|
||||
/*if (!$user_id) {
|
||||
$user_id = Auth::id();
|
||||
}*/
|
||||
|
||||
try {
|
||||
return array_filter([
|
||||
'userId' => $user_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,7 +179,7 @@ class Folder extends Model
|
||||
$sorting = Conversation::getConvTableSorting();
|
||||
if ($sorting['sort_by'] == 'date') {
|
||||
if ($sorting['order'] != 'desc') {
|
||||
foreach ($order_by as $block_i => $block) {
|
||||
foreach ($order_by as $block_i => $block) {
|
||||
foreach ($block as $field => $order) {
|
||||
if ($field == 'status') {
|
||||
unset($order_by[$block_i][$field]);
|
||||
@@ -224,7 +224,7 @@ class Folder extends Model
|
||||
public function updateCounters()
|
||||
{
|
||||
if (config('app.update_folder_counters_in_background')) {
|
||||
if(!\Illuminate\Support\Facades\Cache::has("folder_update_lock_{$this->id}")) {
|
||||
if (!\Illuminate\Support\Facades\Cache::has("folder_update_lock_{$this->id}")) {
|
||||
\App\Jobs\UpdateFolderCounters::dispatch($this);
|
||||
}
|
||||
} else {
|
||||
@@ -382,13 +382,13 @@ class Folder extends Model
|
||||
} elseif ($this->type == \App\Folder::TYPE_DELETED) {
|
||||
return 'user_updated_at';
|
||||
} else {
|
||||
return'last_reply_at';
|
||||
return 'last_reply_at';
|
||||
}
|
||||
}
|
||||
|
||||
public function url($mailbox_id)
|
||||
{
|
||||
return \Eventy::filter('folder.url', route('mailboxes.view.folder', ['id'=>$mailbox_id, 'folder_id'=>$this->id]), $mailbox_id, $this);
|
||||
return \Eventy::filter('folder.url', route('mailboxes.view.folder', ['id' => $mailbox_id, 'folder_id' => $this->id]), $mailbox_id, $this);
|
||||
}
|
||||
|
||||
public static function create($data, $unique_per_user = true, $save = true)
|
||||
@@ -396,7 +396,7 @@ class Folder extends Model
|
||||
if (!isset($data['mailbox_id']) || !isset($data['type'])) {
|
||||
return null;
|
||||
}
|
||||
$folder = new Folder ();
|
||||
$folder = new Folder();
|
||||
$folder->mailbox_id = $data['mailbox_id'];
|
||||
$folder->type = $data['type'];
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
@@ -20,6 +25,12 @@ class ForgotPasswordController extends Controller
|
||||
|
||||
use SendsPasswordResetEmails;
|
||||
|
||||
// Max reset password attempts per period.
|
||||
const THROTTLE_ATTEMPTS = 5;
|
||||
|
||||
// Minutes
|
||||
const THROTTLE_PERIOD = 1;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
@@ -29,4 +40,73 @@ class ForgotPasswordController extends Controller
|
||||
{
|
||||
$this->middleware('guest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
{
|
||||
$this->validateEmail($request);
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jvmv-2qcp-7855
|
||||
if ($this->hasTooManyResetEmailAttempts($request)) {
|
||||
event(new Lockout($request));
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$response = $this->broker()->sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
if ($response == Password::RESET_LINK_SENT) {
|
||||
$this->clearResetEmailAttempts($request);
|
||||
} else {
|
||||
$this->incrementResetEmailAttempts($request);
|
||||
}
|
||||
|
||||
// For security purposes always return an identical response regardless of whether the email exists.
|
||||
// return $response == Password::RESET_LINK_SENT
|
||||
// ? $this->sendResetLinkResponse($response)
|
||||
// : $this->sendResetLinkFailedResponse($request, $response);
|
||||
return $this->sendResetLinkResponse(__('If an account exists for this email, you will receive a password reset link.'));
|
||||
}
|
||||
|
||||
protected function hasTooManyResetEmailAttempts(Request $request)
|
||||
{
|
||||
return app(RateLimiter::class)->tooManyAttempts(
|
||||
$this->throttleKey($request),
|
||||
self::THROTTLE_ATTEMPTS
|
||||
);
|
||||
}
|
||||
|
||||
protected function incrementResetEmailAttempts(Request $request)
|
||||
{
|
||||
app(RateLimiter::class)->hit(
|
||||
$this->throttleKey($request),
|
||||
self::THROTTLE_PERIOD
|
||||
);
|
||||
}
|
||||
|
||||
protected function clearResetEmailAttempts(Request $request)
|
||||
{
|
||||
app(RateLimiter::class)->clear($this->throttleKey($request));
|
||||
}
|
||||
|
||||
protected function throttleKey(Request $request)
|
||||
{
|
||||
return strtolower($request->ip()) . '|reset_email_password';
|
||||
}
|
||||
|
||||
protected function sendLockoutResponse(Request $request)
|
||||
{
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [trans('auth.throttle', ['seconds' => self::THROTTLE_PERIOD*60])],
|
||||
])->status(423);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
@@ -23,6 +28,12 @@ class ResetPasswordController extends Controller
|
||||
|
||||
use ResetsPasswords;
|
||||
|
||||
// Max reset password attempts per period.
|
||||
const THROTTLE_ATTEMPTS = 5;
|
||||
|
||||
// Minutes
|
||||
const THROTTLE_PERIOD = 1;
|
||||
|
||||
/**
|
||||
* Where to redirect users after resetting their password.
|
||||
*
|
||||
@@ -40,6 +51,46 @@ class ResetPasswordController extends Controller
|
||||
$this->middleware('guest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$this->validate($request, $this->rules(), $this->validationErrorMessages());
|
||||
|
||||
if ($this->hasTooManyResetAttempts($request)) {
|
||||
event(new Lockout($request));
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$response = $this->broker()->reset(
|
||||
$this->credentials($request), function ($user, $password) {
|
||||
$this->resetPassword($user, $password);
|
||||
}
|
||||
);
|
||||
|
||||
if ($response == Password::PASSWORD_RESET) {
|
||||
$this->clearResetAttempts($request);
|
||||
} else {
|
||||
$this->incrementResetAttempts($request);
|
||||
// When user not found and token is invalid return same error for security reasons.
|
||||
$response = Password::INVALID_USER;
|
||||
}
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response == Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse($response)
|
||||
: $this->sendResetFailedResponse($request, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*
|
||||
@@ -59,4 +110,37 @@ class ResetPasswordController extends Controller
|
||||
|
||||
//$this->guard()->login($user);
|
||||
}
|
||||
|
||||
protected function hasTooManyResetAttempts(Request $request)
|
||||
{
|
||||
return app(RateLimiter::class)->tooManyAttempts(
|
||||
$this->throttleKey($request),
|
||||
self::THROTTLE_ATTEMPTS
|
||||
);
|
||||
}
|
||||
|
||||
protected function incrementResetAttempts(Request $request)
|
||||
{
|
||||
app(RateLimiter::class)->hit(
|
||||
$this->throttleKey($request),
|
||||
self::THROTTLE_PERIOD
|
||||
);
|
||||
}
|
||||
|
||||
protected function clearResetAttempts(Request $request)
|
||||
{
|
||||
app(RateLimiter::class)->clear($this->throttleKey($request));
|
||||
}
|
||||
|
||||
protected function throttleKey(Request $request)
|
||||
{
|
||||
return strtolower($request->ip()) . '|reset_password';
|
||||
}
|
||||
|
||||
protected function sendLockoutResponse(Request $request)
|
||||
{
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [trans('auth.throttle', ['seconds' => self::THROTTLE_PERIOD*60])],
|
||||
])->status(423);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +61,18 @@ class ConversationsController extends Controller
|
||||
|
||||
// Mark notifications as read
|
||||
if (!empty($request->mark_as_read)) {
|
||||
$mark_read_result = $user->unreadNotifications()->where('id', $request->mark_as_read)->update(['read_at' => now()]);
|
||||
$user->unreadNotifications()->where('id', $request->mark_as_read)->update(['read_at' => now()]);
|
||||
$user->clearWebsiteNotificationsCache();
|
||||
} else {
|
||||
$mark_read_result = $user->unreadNotifications()->where('data', 'like', '%"conversation_id":'.$conversation->id.'%')->update(['read_at' => now()]);
|
||||
}
|
||||
if ($mark_read_result) {
|
||||
$user->clearWebsiteNotificationsCache();
|
||||
// Build quiery manually instad of unreadNotifications() in order to use indexes.
|
||||
//$mark_read_result = $user->unreadNotifications()->where('data', 'like', '%"conversation_id":'.$conversation->id.'%')->update(['read_at' => now()]);
|
||||
$mark_read_result = $user->morphMany(\Illuminate\Notifications\DatabaseNotification::class, 'notifiable')
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => now()]);
|
||||
if ($mark_read_result) {
|
||||
$user->clearWebsiteNotificationsCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Detect folder and redirect if needed
|
||||
@@ -99,7 +104,10 @@ class ConversationsController extends Controller
|
||||
|
||||
\Session::reflash();
|
||||
|
||||
return redirect()->away($conversation->url($folder->id, null, $params));
|
||||
// A user may have no Mine folder in this mailbox (e.g. admins / non-members),
|
||||
// so the lookup above can return null. Fall back to the conversation's own
|
||||
// folder instead of throwing "Attempt to read property id on null".
|
||||
return redirect()->away($conversation->url($folder ? $folder->id : $conversation->folder_id, null, $params));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +125,8 @@ class ConversationsController extends Controller
|
||||
|
||||
\Session::reflash();
|
||||
|
||||
return redirect()->away($conversation->url($folder->id));
|
||||
// The Mine-folder lookup (or $conversation->folder) can be null; fall back to folder_id.
|
||||
return redirect()->away($conversation->url($folder ? $folder->id : $conversation->folder_id));
|
||||
}
|
||||
|
||||
//$after_send = $conversation->mailbox->getUserSettings($user->id)->after_send;
|
||||
@@ -304,12 +313,12 @@ class ConversationsController extends Controller
|
||||
if (isset($conv_view[$conversation->id][$viewer->id]['r']) && $viewer->id != $user->id) {
|
||||
$viewers[] = [
|
||||
'user' => $viewer,
|
||||
'replying' => (int)$conv_view[$conversation->id][$viewer->id]['r']
|
||||
'replying' => (int)$conv_view[$conversation->id][$viewer->id]['r'],
|
||||
];
|
||||
}
|
||||
}
|
||||
// Show replying first.
|
||||
usort($viewers, function($a, $b) {
|
||||
usort($viewers, function ($a, $b) {
|
||||
return $b['replying'] <=> $a['replying'];
|
||||
});
|
||||
}
|
||||
@@ -450,6 +459,11 @@ class ConversationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->get('body') && !$thread) {
|
||||
$thread = new Thread();
|
||||
$thread->body = \Helper::stripDangerousTags($request->get('body'));
|
||||
}
|
||||
|
||||
$conversation->subject = $subject;
|
||||
|
||||
return view('conversations/create', [
|
||||
@@ -468,7 +482,7 @@ class ConversationsController extends Controller
|
||||
/**
|
||||
* Clone conversation.
|
||||
*/
|
||||
public function cloneConversation(Request $request, $mailbox_id, $from_thread_id)
|
||||
public function cloneConversation(Request $request, $mailbox_id, $from_thread_id, $token)
|
||||
{
|
||||
$mailbox = Mailbox::findOrFail($mailbox_id);
|
||||
$this->authorize('view', $mailbox);
|
||||
@@ -480,6 +494,10 @@ class ConversationsController extends Controller
|
||||
$orign_conv = $orig_thread->conversation;
|
||||
$this->authorize('view', $orign_conv);
|
||||
|
||||
if (csrf_token() != $token) {
|
||||
\Session::flash('flash_error_floating', __('Not enough permissions'));
|
||||
return redirect()->away($orign_conv->url($orign_conv->folder_id));
|
||||
}
|
||||
|
||||
// $thread = $orig_thread->replicate();
|
||||
// $thread->id = '';
|
||||
@@ -797,7 +815,7 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->getMessages()as $errors) {
|
||||
foreach ($validator->errors()->getMessages() as $errors) {
|
||||
foreach ($errors as $field => $message) {
|
||||
$response['msg'] .= $message.' ';
|
||||
}
|
||||
@@ -827,7 +845,7 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
// Check max. message size.
|
||||
if (!$response['msg']) {
|
||||
if (!$response['msg'] && !$is_note) {
|
||||
|
||||
$max_message_size = (int)config('app.max_message_size');
|
||||
if ($max_message_size) {
|
||||
@@ -839,13 +857,17 @@ class ConversationsController extends Controller
|
||||
$attachments_ids = $this->decodeAttachmentsIds($attachments_ids);
|
||||
|
||||
if (count($attachments_ids)) {
|
||||
$attachments_to_check = Attachment::select('size')->whereIn('id', $attachments_ids)->get();
|
||||
foreach ($attachments_to_check as $attachment) {
|
||||
$attachments_query = Attachment::select('size')->whereIn('id', $attachments_ids);
|
||||
// Skip embedded images.
|
||||
if (!\Eventy::filter('attachments.embedded.check_size', false)) {
|
||||
$attachments_query->where('embedded', false);
|
||||
}
|
||||
foreach ($attachments_query->get() as $attachment) {
|
||||
$message_size += (int)$attachment->size;
|
||||
}
|
||||
}
|
||||
|
||||
if ($message_size > $max_message_size*1024*1024) {
|
||||
if ($message_size*1.37 > $max_message_size*1024*1024) {
|
||||
$response['msg'] = __('Message is too large — :info. Please shorten your message or remove some attachments.', ['info' => __('Max. Message Size').': '.$max_message_size.' MB']);
|
||||
}
|
||||
}
|
||||
@@ -855,7 +877,7 @@ class ConversationsController extends Controller
|
||||
|
||||
// Get attachments info
|
||||
// Delete removed attachments.
|
||||
$attachments_info = $this->processReplyAttachments($request);
|
||||
$attachments_info = $this->processReplyAttachments($request, $thread->id ?? null);
|
||||
|
||||
// Determine redirect.
|
||||
// Must be done before updating current conversation's status or assignee.
|
||||
@@ -919,7 +941,12 @@ class ConversationsController extends Controller
|
||||
|
||||
if ($is_phone && $is_create) {
|
||||
// Phone.
|
||||
$phone_customer_data = $this->processPhoneCustomer($request);
|
||||
$phone_customer_data = $this->processPhoneCustomer($request, $user);
|
||||
|
||||
if (!empty($phone_customer_data['msg'])) {
|
||||
$response['msg'] = $phone_customer_data['msg'];
|
||||
break;
|
||||
}
|
||||
|
||||
$customer_email = $phone_customer_data['customer_email'];
|
||||
$customer = $phone_customer_data['customer'];
|
||||
@@ -957,7 +984,7 @@ class ConversationsController extends Controller
|
||||
$conversation->status = $request_status ?: $conversation->status;
|
||||
|
||||
if (($prev_status != $conversation->status || $is_create)
|
||||
&& $conversation->status == Conversation::STATUS_CLOSED
|
||||
&& $conversation->isClosed()
|
||||
) {
|
||||
$conversation->closed_by_user_id = $user->id;
|
||||
$conversation->closed_at = date('Y-m-d H:i:s');
|
||||
@@ -1137,8 +1164,8 @@ class ConversationsController extends Controller
|
||||
}
|
||||
$forwarded_conversation->updateFolder();
|
||||
$forwarded_conversation->save();
|
||||
|
||||
$forwarded_thread = $thread->replicate();
|
||||
$forwarded_thread->setTo($recipient_email);
|
||||
|
||||
$forwarded_conversations[] = $forwarded_conversation;
|
||||
$forwarded_threads[] = $forwarded_thread;
|
||||
@@ -1147,8 +1174,8 @@ class ConversationsController extends Controller
|
||||
// Set forwarding meta data.
|
||||
// todo: store array of numbers and IDs.
|
||||
$thread->subtype = Thread::SUBTYPE_FORWARD;
|
||||
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number);
|
||||
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id);
|
||||
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversations[0]->number);
|
||||
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversations[0]->id);
|
||||
}
|
||||
|
||||
// Conversation history.
|
||||
@@ -1172,8 +1199,8 @@ class ConversationsController extends Controller
|
||||
\Eventy::action('thread.before_save_from_request', $thread, $request);
|
||||
$thread->save();
|
||||
|
||||
// Save forwarded thread.
|
||||
if ($is_forward) {
|
||||
// Save forwarded threads.
|
||||
foreach ($forwarded_conversations as $i => $forwarded_conversation) {
|
||||
$forwarded_thread = $forwarded_threads[$i];
|
||||
|
||||
@@ -1188,6 +1215,17 @@ class ConversationsController extends Controller
|
||||
$forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_THREAD_ID, $thread->id);
|
||||
\Eventy::action('send_reply.before_save_forwarded_thread', $forwarded_thread, $request);
|
||||
$forwarded_thread->save();
|
||||
|
||||
// In the current conversation create Forward-notes corresponding to each recipient.
|
||||
// Forward-note for the first recipient is already created.
|
||||
if ($i != 0) {
|
||||
// $thread contains note created in the original conversation.
|
||||
$forward_note = $thread->replicate();
|
||||
$forward_note->setTo($forwarded_conversation->customer_email);
|
||||
$forward_note->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number);
|
||||
$forward_note->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id);
|
||||
$forward_note->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1344,7 +1382,7 @@ class ConversationsController extends Controller
|
||||
$show_view_link = false;
|
||||
}
|
||||
|
||||
$flash_vars = ['%tag_start%' => '<strong>', '%tag_end%' => '</strong>', '%view_start%' => ' <a href="'.$conversation->url().'">', '%a_end%' => '</a> ', '%undo_start%' => ' <a href="'.route('conversations.undo', ['thread_id' => $thread->id]).'" class="text-danger">'];
|
||||
$flash_vars = ['%tag_start%' => '<strong>', '%tag_end%' => '</strong>', '%view_start%' => ' <a href="'.$conversation->url().'">', '%a_end%' => '</a> ', '%undo_start%' => ' <a href="'.route('conversations.undo', ['thread_id' => $thread->id, 'token' => csrf_token()]).'" class="text-danger">'];
|
||||
|
||||
if ($is_phone) {
|
||||
$flash_type = 'warning';
|
||||
@@ -1359,7 +1397,7 @@ class ConversationsController extends Controller
|
||||
if ($show_view_link) {
|
||||
$flash_text = __(':%tag_start%' . $identifier . ' added:%tag_end% :%view_start%View:%a_end%', $flash_vars);
|
||||
} else {
|
||||
$flash_text = '<strong>'.__('%identifier% added',['%identifier%'=>$identifier]).'</strong>';
|
||||
$flash_text = '<strong>'.__('%identifier% added', ['%identifier%' => $identifier]).'</strong>';
|
||||
}
|
||||
} elseif ($is_note) {
|
||||
$flash_type = 'warning';
|
||||
@@ -1393,16 +1431,18 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
$conversation = null;
|
||||
// Conversation does not exist yet.
|
||||
$new = true;
|
||||
if (!$response['msg'] && !empty($request->conversation_id)) {
|
||||
$conversation = Conversation::find($request->conversation_id);
|
||||
if ($conversation && !$user->can('view', $conversation) && !$user->hasManageMailboxPermission($request->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED)) {
|
||||
if ($conversation && !$user->can('view', $conversation) /*&& !$user->hasManageMailboxPermission($request->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED)*/) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
} else {
|
||||
$new = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Draft saved from "New Conversation" page.
|
||||
$is_create = false;
|
||||
if (!empty($request->is_create)) {
|
||||
$is_create = true;
|
||||
@@ -1446,12 +1486,23 @@ class ConversationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that conversation is in Draft state.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6ff4-3w2c-mjj8
|
||||
if (!$response['msg']
|
||||
&& $conversation
|
||||
&& !$conversation->isDraft()
|
||||
&& ($new || $is_create)
|
||||
) {
|
||||
$response['msg'] = __('Message has been already sent. Please discard this draft.');
|
||||
}
|
||||
|
||||
// Validation is not needed on draft create, fields can be empty
|
||||
|
||||
// Update conversation data.
|
||||
if (!$response['msg']) {
|
||||
|
||||
// Get attachments info
|
||||
$attachments_info = $this->processReplyAttachments($request);
|
||||
$attachments_info = $this->processReplyAttachments($request, $thread->id ?? null);
|
||||
|
||||
// Conversation
|
||||
$now = date('Y-m-d H:i:s');
|
||||
@@ -1475,7 +1526,12 @@ class ConversationsController extends Controller
|
||||
|
||||
if ($type == Conversation::TYPE_PHONE) {
|
||||
// Phone.
|
||||
$phone_customer_data = $this->processPhoneCustomer($request);
|
||||
$phone_customer_data = $this->processPhoneCustomer($request, $user);
|
||||
|
||||
if (!empty($phone_customer_data['msg'])) {
|
||||
$response['msg'] = $phone_customer_data['msg'];
|
||||
break;
|
||||
}
|
||||
|
||||
$customer_email = $phone_customer_data['customer_email'];
|
||||
$customer = $phone_customer_data['customer'];
|
||||
@@ -1765,12 +1821,14 @@ class ConversationsController extends Controller
|
||||
$attachment_copy = $attachment;
|
||||
}
|
||||
|
||||
$attachments[] = [
|
||||
'id' => encrypt($attachment_copy->id),
|
||||
'name' => $attachment_copy->file_name,
|
||||
'size' => $attachment_copy->size,
|
||||
'url' => $attachment_copy->url(),
|
||||
];
|
||||
if ($attachment_copy) {
|
||||
$attachments[] = [
|
||||
'id' => encrypt($attachment_copy->id),
|
||||
'name' => $attachment_copy->file_name,
|
||||
'size' => $attachment_copy->size,
|
||||
'url' => $attachment_copy->url(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1829,6 +1887,7 @@ class ConversationsController extends Controller
|
||||
case 'conversation_change_customer':
|
||||
$conversation = Conversation::find($request->conversation_id);
|
||||
$customer_email = $request->customer_email;
|
||||
$target_customer = Customer::getByEmail($request->customer_email);
|
||||
|
||||
if (!$conversation) {
|
||||
$response['msg'] = __('Conversation not found');
|
||||
@@ -1840,10 +1899,25 @@ class ConversationsController extends Controller
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
$conversation->changeCustomer($customer_email, null, $user);
|
||||
// Allow to change customer when user creates a customer
|
||||
// while changing conversation's customer
|
||||
// and APP_LIMIT_USER_CUSTOMER_VISIBILITY is enabled.
|
||||
if (session()->get('user_created_customer') == $target_customer->id) {
|
||||
session()->forget('user_created_customer');
|
||||
} else {
|
||||
if (!$response['msg'] && $target_customer && !$user->can('view', $target_customer)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
}
|
||||
|
||||
$response['status'] = 'success';
|
||||
\Session::flash('flash_success_floating', __('Customer changed'));
|
||||
if (!$response['msg']) {
|
||||
$result = $conversation->changeCustomer($customer_email, null, $user);
|
||||
|
||||
if ($result) {
|
||||
$response['status'] = 'success';
|
||||
\Session::flash('flash_success_floating', __('Customer changed'));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -1876,11 +1950,11 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$folder_id = $conversation->getCurrentFolder();
|
||||
$redirect_url = $this->getRedirectUrlAfterDelete($conversation, $user);
|
||||
|
||||
$conversation->deleteToFolder($user);
|
||||
|
||||
$response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]);
|
||||
$response['redirect_url'] = $redirect_url;
|
||||
|
||||
$response['status'] = 'success';
|
||||
|
||||
@@ -1898,7 +1972,7 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$folder_id = $conversation->getCurrentFolder();
|
||||
$redirect_url = $this->getRedirectUrlAfterDelete($conversation, $user);
|
||||
$mailbox = $conversation->mailbox;
|
||||
|
||||
$conversation->deleteForever();
|
||||
@@ -1906,7 +1980,7 @@ class ConversationsController extends Controller
|
||||
// Recalculate only old and new folders
|
||||
$mailbox->updateFoldersCounters();
|
||||
|
||||
$response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]);
|
||||
$response['redirect_url'] = $redirect_url;
|
||||
|
||||
$response['status'] = 'success';
|
||||
|
||||
@@ -1972,7 +2046,7 @@ class ConversationsController extends Controller
|
||||
$thread->body = \Helper::stripDangerousTags($thread->body);
|
||||
|
||||
$data = [
|
||||
'thread' => $thread
|
||||
'thread' => $thread,
|
||||
];
|
||||
$response['html'] = \View::make('conversations/partials/edit_thread')->with($data)->render();
|
||||
|
||||
@@ -2128,7 +2202,23 @@ class ConversationsController extends Controller
|
||||
// Delete converations in a specific folder.
|
||||
case 'empty_folder':
|
||||
// At first, check if this user is able to delete conversations
|
||||
if (!auth()->user()->isAdmin() && !auth()->user()->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) {
|
||||
if (!$user->isAdmin() && !$user->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
return \Response::json($response);
|
||||
}
|
||||
|
||||
// Check access to the mailbox.
|
||||
$folder = Folder::find($request->folder_id ?? '');
|
||||
|
||||
if (!$folder) {
|
||||
$response['msg'] = __('Folder not found');
|
||||
return \Response::json($response);
|
||||
}
|
||||
if (!in_array($folder->type, [Folder::TYPE_SPAM, Folder::TYPE_DELETED])) {
|
||||
$response['msg'] = __('Folder not found');
|
||||
return \Response::json($response);
|
||||
}
|
||||
if (!$folder->mailbox->userHasAccess($user->id)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
return \Response::json($response);
|
||||
}
|
||||
@@ -2139,14 +2229,28 @@ class ConversationsController extends Controller
|
||||
);
|
||||
|
||||
if (empty($response['processed'])) {
|
||||
$folder = Folder::find($request->folder_id ?? '');
|
||||
|
||||
if (!$folder) {
|
||||
$response['msg'] = __('Folder not found');
|
||||
if (!$user->isAdmin() && $folder->mailbox && !$folder->mailbox->userHasAccess($user->id)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
|
||||
// Do not allow users who can see only assigned conversations
|
||||
// delete any conversations in the folder.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6mhr-m8m9-6q6h
|
||||
if (!$user->isAdmin() && $user->canSeeOnlyAssignedConversations()) {
|
||||
// User can see (and selete) only assigned conversations.
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)
|
||||
->get()
|
||||
->filter(function ($conversation) use ($user) {
|
||||
return $conversation->isAssignedToUser($user);
|
||||
})
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
} else {
|
||||
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
Conversation::deleteConversationsForever($conversation_ids);
|
||||
if ($folder->mailbox) {
|
||||
Conversation::clearStarredByUserCache($user->id, $folder->mailbox_id);
|
||||
@@ -2221,7 +2325,7 @@ class ConversationsController extends Controller
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
if (!empty($request->merge_conversation_id) && is_array($request->merge_conversation_id)) {
|
||||
if (!$response['msg'] && !empty($request->merge_conversation_id) && is_array($request->merge_conversation_id)) {
|
||||
|
||||
$sigle_conv = count($request->merge_conversation_id) == 1;
|
||||
|
||||
@@ -2268,14 +2372,16 @@ class ConversationsController extends Controller
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
if ($request->action == 'follow') {
|
||||
$user->followConversation($request->conversation_id);
|
||||
} else {
|
||||
$follower = Follower::where('conversation_id', $request->conversation_id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
if ($follower) {
|
||||
$follower->delete();
|
||||
if (!$response['msg']) {
|
||||
if ($request->action == 'follow') {
|
||||
$user->followConversation($request->conversation_id);
|
||||
} else {
|
||||
$follower = Follower::where('conversation_id', $request->conversation_id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
if ($follower) {
|
||||
$follower->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,7 +2420,7 @@ class ConversationsController extends Controller
|
||||
case 'merge_search':
|
||||
$conversation = Conversation::where(Conversation::numberFieldName(), $request->number)->first();
|
||||
|
||||
if (!$conversation) {
|
||||
if (!$conversation || $conversation->id == ($request->cur_conv_id ?? '')) {
|
||||
$response['msg'] = __('Conversation not found');
|
||||
}
|
||||
if (!$response['msg'] && !$user->can('view', $conversation)) {
|
||||
@@ -2323,7 +2429,7 @@ class ConversationsController extends Controller
|
||||
|
||||
if (!$response['msg']) {
|
||||
$response['html'] = \View::make('conversations/partials/merge_search_result')->with([
|
||||
'conversation' => $conversation
|
||||
'conversation' => $conversation,
|
||||
])->render();
|
||||
$response['status'] = 'success';
|
||||
}
|
||||
@@ -2376,11 +2482,22 @@ class ConversationsController extends Controller
|
||||
$customer = Customer::getByEmail($request->customer_email);
|
||||
|
||||
if ($customer) {
|
||||
// Previous conversations
|
||||
$prev_conversations = [];
|
||||
|
||||
if (!$user->can('view', $customer)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
break;
|
||||
}
|
||||
|
||||
$mailbox = Mailbox::find($request->mailbox_id);
|
||||
|
||||
if (!$mailbox || !$mailbox->userHasAccess($user->id)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
break;
|
||||
}
|
||||
|
||||
// Previous conversations
|
||||
$prev_conversations = [];
|
||||
|
||||
if ($mailbox && $mailbox->userHasAccess($user->id)) {
|
||||
$conversation_id = (int)$request->conversation_id ?? 0;
|
||||
|
||||
@@ -2572,7 +2689,7 @@ class ConversationsController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$mailboxes = \Eventy::filter( 'conversations.move_conv.mailboxes', $user->mailboxesCanView() );
|
||||
$mailboxes = \Eventy::filter('conversations.move_conv.mailboxes', $user->mailboxesCanView());
|
||||
|
||||
return view('conversations/ajax_html/move_conv', [
|
||||
'conversation' => $conversation,
|
||||
@@ -2737,6 +2854,20 @@ class ConversationsController extends Controller
|
||||
return $redirect_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to go after deleting a conversation.
|
||||
* Follows the user's "after send" setting, as changing the status does: "Next active conversation" opens it,
|
||||
* anything else shows the folder. Must be called before the conversation is deleted.
|
||||
*/
|
||||
public function getRedirectUrlAfterDelete($conversation, $user)
|
||||
{
|
||||
if ($conversation->mailbox->getUserSettings($user->id)->after_send == MailboxUser::AFTER_SEND_NEXT) {
|
||||
return $conversation->urlNext(Conversation::getFolderParam(), Conversation::STATUS_ACTIVE, true);
|
||||
}
|
||||
|
||||
return route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $conversation->getCurrentFolder()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files and images.
|
||||
*/
|
||||
@@ -2855,6 +2986,9 @@ class ConversationsController extends Controller
|
||||
$filters_data['customer'] = Customer::find($filters['customer']);
|
||||
}
|
||||
//$filters = \Eventy::filter('search.filters', $filters, $filters_data, $mode, $q);
|
||||
if ($user->canSeeOnlyAssignedConversations()) {
|
||||
$filters['assigned'] = $user->id;
|
||||
}
|
||||
|
||||
// Remember recent query.
|
||||
$recent_search_queries = session('recent_search_queries') ?? [];
|
||||
@@ -2865,13 +2999,27 @@ class ConversationsController extends Controller
|
||||
}
|
||||
|
||||
$conversations = [];
|
||||
|
||||
if (\Eventy::filter('search.is_needed', true, 'conversations')) {
|
||||
$conversations = $this->searchQuery($user, $q, $filters);
|
||||
// If search string starts with # - try to find conversation by number.
|
||||
if (\Str::startsWith($q, '#')) {
|
||||
$conv_number = ltrim($q, '#');
|
||||
if (is_numeric($conv_number)) {
|
||||
$conversation = Conversation::where(Conversation::numberFieldName(), $conv_number)->first();
|
||||
if ($conversation && $user->can('view', $conversation)) {
|
||||
$conversations[] = $conversation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!count($conversations)) {
|
||||
$conversations = $this->searchQuery($user, $q, $filters);
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to the conversation if searching by conversation number.
|
||||
if (count($conversations) == 1
|
||||
&& $conversations[0]->number == $q
|
||||
&& ($conversations[0]->number == $q || $conversations[0]->number == ltrim($q, '#'))
|
||||
&& empty($filters)
|
||||
&& !$request->x_embed
|
||||
) {
|
||||
@@ -2949,6 +3097,11 @@ class ConversationsController extends Controller
|
||||
if ($conversations !== '') {
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
if ($user->canSeeOnlyAssignedConversations()) {
|
||||
$filters['assigned'] = $user->id;
|
||||
}
|
||||
|
||||
$query_conversations = Conversation::search($q, $filters, $user);
|
||||
return $query_conversations->paginate(Conversation::DEFAULT_LIST_SIZE);
|
||||
}
|
||||
@@ -2989,6 +3142,12 @@ class ConversationsController extends Controller
|
||||
$filters[$filter] = date('Y-m-d', strtotime($value));
|
||||
}
|
||||
break;
|
||||
case 'status':
|
||||
case 'state':
|
||||
if (!is_array($value)) {
|
||||
unset($filters[$filter]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3008,7 +3167,7 @@ class ConversationsController extends Controller
|
||||
$mailbox_ids = $user->mailboxesIdsCanView();
|
||||
|
||||
// Filters
|
||||
$filters = $this->getSearchFilters($request);;
|
||||
$filters = $this->getSearchFilters($request);
|
||||
|
||||
// Search query
|
||||
$q = $this->getSearchQuery($request);
|
||||
@@ -3017,7 +3176,7 @@ class ConversationsController extends Controller
|
||||
$like = '%'.mb_strtolower($q).'%';
|
||||
|
||||
// We need to use aggregate function for email to avoid "Grouping error" error in PostgreSQL.
|
||||
$query_customers = Customer::select(['customers.*', \DB::raw('MAX(emails.email)')])
|
||||
$query_customers = Customer::select(['customers.*', \DB::raw('MAX('.\DB::getTablePrefix().'emails.email)')])
|
||||
->groupby('customers.id')
|
||||
->leftJoin('emails', function ($join) {
|
||||
$join->on('customers.id', '=', 'emails.customer_id');
|
||||
@@ -3030,7 +3189,7 @@ class ConversationsController extends Controller
|
||||
|
||||
$query->where('customers.first_name', $like_op, $like)
|
||||
->orWhere('customers.last_name', $like_op, $like)
|
||||
->orWhere(\Helper::isPgSql() ? \DB::raw('(customers.first_name || \' \' || customers.last_name)') : \DB::raw('CONCAT(customers.first_name, " ", customers.last_name)'), $like_op, $like)
|
||||
->orWhere(\Helper::isPgSql() ? \DB::raw('('.\DB::getTablePrefix().'customers.first_name || \' \' || '.\DB::getTablePrefix().'customers.last_name)') : \DB::raw('CONCAT('.\DB::getTablePrefix().'customers.first_name, " ", '.\DB::getTablePrefix().'customers.last_name)'), $like_op, $like)
|
||||
->orWhere('customers.company', $like_op, $like)
|
||||
->orWhere('customers.job_title', $like_op, $like)
|
||||
->orWhere('customers.websites', $like_op, $like)
|
||||
@@ -3123,6 +3282,10 @@ class ConversationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($user->canSeeOnlyAssignedConversations()) {
|
||||
$query_conversations->where('conversations.user_id', $user->id);
|
||||
}
|
||||
|
||||
if (!empty($request->params['user_id'])) {
|
||||
$query_conversations->where('conversations.user_id', (int)$request->params['user_id']);
|
||||
}
|
||||
@@ -3133,7 +3296,7 @@ class ConversationsController extends Controller
|
||||
/**
|
||||
* Process attachments on reply, new conversation, saving draft and forwarding.
|
||||
*/
|
||||
public function processReplyAttachments($request)
|
||||
public function processReplyAttachments($request, $thread_id = null)
|
||||
{
|
||||
$has_attachments = false;
|
||||
$attachments = [];
|
||||
@@ -3153,7 +3316,18 @@ class ConversationsController extends Controller
|
||||
) {
|
||||
$has_attachments = true;
|
||||
}
|
||||
Attachment::deleteByIds($attachments_to_remove);
|
||||
// Sanitize $attachments_to_remove list.
|
||||
if (count($attachments_to_remove)) {
|
||||
$attachments_check = Attachment::select('id', 'thread_id')
|
||||
->whereIn('id', $attachments_to_remove)
|
||||
->get();
|
||||
foreach ($attachments_check as $attachment) {
|
||||
if ($attachment->thread_id && $attachment->thread_id != $thread_id) {
|
||||
$attachments_to_remove = array_diff($attachments_to_remove, [$attachment->id]);
|
||||
}
|
||||
}
|
||||
Attachment::deleteByIds($attachments_to_remove);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -3179,7 +3353,7 @@ class ConversationsController extends Controller
|
||||
/**
|
||||
* Undo reply.
|
||||
*/
|
||||
public function undoReply(Request $request, $thread_id)
|
||||
public function undoReply(Request $request, $thread_id, $token)
|
||||
{
|
||||
$thread = Thread::findOrFail($thread_id);
|
||||
|
||||
@@ -3187,13 +3361,23 @@ class ConversationsController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($thread->created_by_user_id != \Auth::id()) {
|
||||
\Session::flash('flash_error_floating', __('Sending can not be undone'));
|
||||
return redirect()->away($conversation->url($conversation->folder_id));
|
||||
}
|
||||
|
||||
$conversation = $thread->conversation;
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
if (csrf_token() != $token) {
|
||||
//return throw new \Illuminate\Session\TokenMismatchException;
|
||||
\Session::flash('flash_error_floating', __('Sending can not be undone'));
|
||||
return redirect()->away($conversation->url($conversation->folder_id));
|
||||
}
|
||||
|
||||
// Check undo timeout
|
||||
if ($thread->created_at->diffInSeconds(now()) > Conversation::UNDO_TIMOUT) {
|
||||
\Session::flash('flash_error_floating', __('Sending can not be undone'));
|
||||
|
||||
return redirect()->away($conversation->url($conversation->folder_id));
|
||||
}
|
||||
|
||||
@@ -3225,8 +3409,8 @@ class ConversationsController extends Controller
|
||||
|
||||
// Restore conversation data from penultimate thread
|
||||
if ($last_thread) {
|
||||
$conversation->setCc($last_thread->cc);
|
||||
$conversation->setBcc($last_thread->bcc);
|
||||
$conversation->setCc($last_thread->getCcArray());
|
||||
$conversation->setBcc($last_thread->getBccArray());
|
||||
$conversation->last_reply_at = $last_thread->created_at;
|
||||
$conversation->last_reply_from = $last_thread->source_via;
|
||||
$conversation->user_updated_at = date('Y-m-d H:i:s');
|
||||
@@ -3263,7 +3447,7 @@ class ConversationsController extends Controller
|
||||
/**
|
||||
* Find or create customer when creating a Phone conversation.
|
||||
*/
|
||||
public function processPhoneCustomer($request)
|
||||
public function processPhoneCustomer($request, $user)
|
||||
{
|
||||
$customer_data = [];
|
||||
$customer_email = '';
|
||||
@@ -3290,6 +3474,14 @@ class ConversationsController extends Controller
|
||||
if (!$request->customer_id && is_numeric($request_name)) {
|
||||
// Try to find customer by ID.
|
||||
$customer = Customer::find($request_name);
|
||||
if ($customer) {
|
||||
if (!$user->can('view', $customer)) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'msg' => __('Inaccessible customer'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$customer && $request->to_email) {
|
||||
@@ -3317,6 +3509,12 @@ class ConversationsController extends Controller
|
||||
if ($request->customer_id) {
|
||||
$customer = Customer::find($request->customer_id);
|
||||
if ($customer) {
|
||||
if (!$user->can('view', $customer)) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'msg' => __('Inaccessible customer'),
|
||||
];
|
||||
}
|
||||
// Add email to customer.
|
||||
$customer->addEmail($customer_email, true);
|
||||
} else {
|
||||
@@ -3329,6 +3527,12 @@ class ConversationsController extends Controller
|
||||
if ($request->customer_id) {
|
||||
$customer = Customer::find($request->customer_id);
|
||||
if ($customer) {
|
||||
if (!$user->can('view', $customer)) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'msg' => __('Inaccessible customer'),
|
||||
];
|
||||
}
|
||||
$customer->setData($customer_data, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,17 @@ class CustomersController extends Controller
|
||||
foreach ($new_emails as $new_email) {
|
||||
$email = Email::where('email', $new_email)->first();
|
||||
if ($email && $email->customer) {
|
||||
// Prevent pulling in an out-of-scope customer by attaching one
|
||||
// of that customer's emails to the current profile.
|
||||
if ($email->customer_id !== $customer->id) {
|
||||
if (!$this->checkLimitVisibility($email->customer, true)) {
|
||||
$validator->errors()->add('email', __('The specified email belongs to a customer from an inaccessible mailbox.'));
|
||||
return redirect()->route('customers.update', ['id' => $id])
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// If customer whose email is removed does not have first name and other emails
|
||||
// we have to create first name for this customer
|
||||
if (!$email->customer->first_name && count($email->customer->emails) == 1) {
|
||||
@@ -131,7 +142,7 @@ class CustomersController extends Controller
|
||||
'tag_email_begin' => '<strong>',
|
||||
'tag_email_end' => '</strong>',
|
||||
'customer' => htmlspecialchars($email->customer->getFullName()),
|
||||
'a_begin' => '<strong><a href="'.$email->customer->url().'" target="_blank">',
|
||||
'a_begin' => '<strong><a href="'.$email->customer->url().'">',
|
||||
'a_end' => '</a></strong>',
|
||||
]).' ';
|
||||
|
||||
@@ -206,53 +217,19 @@ class CustomersController extends Controller
|
||||
return redirect()->route('customers.update', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function checkLimitVisibility($customer)
|
||||
public function checkLimitVisibility($customer, $return_result = false)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
|
||||
|
||||
if ($limited_visibility) {
|
||||
$mailbox_ids = $user->mailboxesIdsCanView();
|
||||
|
||||
$accesible = Conversation::where('customer_id', $customer->id)
|
||||
->whereIn('conversations.mailbox_id', $mailbox_ids)
|
||||
->exists();
|
||||
|
||||
if (!$accesible) {
|
||||
|
||||
if (!$user->can('view', $customer)) {
|
||||
if (!$return_result) {
|
||||
\Helper::denyAccess();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User mailboxes.
|
||||
*/
|
||||
public function permissions($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$mailboxes = Mailbox::all();
|
||||
|
||||
return view('users/permissions', ['user' => $user, 'mailboxes' => $mailboxes, 'user_mailboxes' => $user->mailboxes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user permissions.
|
||||
*
|
||||
* @param int $id
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*/
|
||||
public function permissionsSave($id, Request $request)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$user->mailboxes()->sync($request->mailboxes ?: []);
|
||||
|
||||
\Session::flash('flash_success', __('Permissions saved successfully'));
|
||||
|
||||
return redirect()->route('users.permissions', ['id' => $id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +241,8 @@ class CustomersController extends Controller
|
||||
{
|
||||
$customer = Customer::findOrFail($id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
$query = $customer->conversations()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('mailbox_id', auth()->user()->mailboxesIdsCanView())
|
||||
@@ -305,7 +284,12 @@ class CustomersController extends Controller
|
||||
|
||||
$select_list = ['customers.id', 'first_name', 'last_name'];
|
||||
if ($join_emails) {
|
||||
$select_list[] = 'emails.email';
|
||||
if ($limited_visibility) {
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5032
|
||||
$select_list[] = \DB::raw('MAX('.\DB::getTablePrefix().'emails.email)');
|
||||
} else {
|
||||
$select_list[] = 'emails.email';
|
||||
}
|
||||
}
|
||||
if ($request->show_fields == 'phone') {
|
||||
$select_list[] = 'phones';
|
||||
@@ -439,17 +423,29 @@ class CustomersController extends Controller
|
||||
$validator = Validator::make($request->all(), $validator_config);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->getMessages()as $errors) {
|
||||
foreach ($validator->errors()->getMessages() as $errors) {
|
||||
foreach ($errors as $field => $message) {
|
||||
$response['msg'] .= $message.' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response['msg'] && $limited_visibility) {
|
||||
$existing_email = Email::where('email', $request->email)->first();
|
||||
|
||||
if ($existing_email
|
||||
&& $existing_email->customer
|
||||
&& !$user->can('view', $existing_email->customer)
|
||||
) {
|
||||
$response['msg'] .= __('The specified email belongs to a customer from an inaccessible mailbox.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
$customer = Customer::create($request->email, $request->all());
|
||||
if ($customer) {
|
||||
session()->put('user_created_customer', $customer->id);
|
||||
|
||||
$response['email'] = $request->email;
|
||||
$response['status'] = 'success';
|
||||
}
|
||||
@@ -484,6 +480,8 @@ class CustomersController extends Controller
|
||||
{
|
||||
$customer = Customer::findOrFail($id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
// $customers = Customer::where('id', '!=', $id)
|
||||
// ->orderBy('first_name')
|
||||
// ->orderBy('last_name')
|
||||
@@ -498,12 +496,17 @@ class CustomersController extends Controller
|
||||
public function mergeSave(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'customer2_id' => 'required|exists:customers,id',
|
||||
'customer2_id' => 'required|exists:customers,id|not_in:'.$id,
|
||||
//'keep_attributes' => 'array'
|
||||
], [], [
|
||||
'customer2_id' => __('Merge With'),
|
||||
]);
|
||||
|
||||
$customer = Customer::findOrFail($id);
|
||||
$customer2 = Customer::find($request->customer2_id);
|
||||
$customer2 = Customer::findOrFail($request->customer2_id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
$this->checkLimitVisibility($customer2);
|
||||
|
||||
// Ensure customers are different
|
||||
if ($id === $customer2->id) {
|
||||
|
||||
@@ -36,7 +36,7 @@ class MailboxesController extends Controller
|
||||
|
||||
if (!\Eventy::filter('user.can_view_mailbox_menu', false, $user)) {
|
||||
foreach ($mailboxes as $i => $mailbox) {
|
||||
if (!$user->canManageMailbox($mailbox->id)) {
|
||||
if (!$user->canManageMailbox($mailbox)) {
|
||||
$mailboxes->forget($i);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ class MailboxesController extends Controller
|
||||
|
||||
$allowed_fields = [];
|
||||
|
||||
if ($user->can('updateSettings', $mailbox)) {
|
||||
if ($can_update_settings) {
|
||||
|
||||
// Checkboxes
|
||||
$request->merge([
|
||||
@@ -186,10 +186,10 @@ class MailboxesController extends Controller
|
||||
]);
|
||||
|
||||
// if not admin, the text only fields don't pass so spike them into the request.
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
if (!$user->isAdmin()) {
|
||||
$request->merge([
|
||||
'name' => $mailbox->name,
|
||||
'email' => $mailbox->email
|
||||
'email' => $mailbox->email,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -257,14 +257,27 @@ class MailboxesController extends Controller
|
||||
|
||||
$mailbox->fill($fields);
|
||||
|
||||
// Chat: Start a new conversation when receiving a reply to the closed / deleted Chat conversation.
|
||||
if (!empty($request->chat_start_new)) {
|
||||
$mailbox->setMetaParam('chat_start_new', true);
|
||||
} else {
|
||||
$mailbox->removeMetaParam('chat_start_new');
|
||||
// Save state.
|
||||
if ($user->isAdmin()) {
|
||||
if ($request->filled('state')) {
|
||||
$mailbox->state = Mailbox::STATE_ARCHIVED;
|
||||
} else {
|
||||
$mailbox->state = Mailbox::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
$mailbox->signature = \Helper::stripDangerousTags($mailbox->signature);
|
||||
// Chat: Start a new conversation when receiving a reply to the closed / deleted Chat conversation.
|
||||
if ($can_update_settings) {
|
||||
if (!empty($request->chat_start_new)) {
|
||||
$mailbox->setMetaParam('chat_start_new', true);
|
||||
} else {
|
||||
$mailbox->removeMetaParam('chat_start_new');
|
||||
}
|
||||
}
|
||||
|
||||
// Purify signature HTML to avoid sending unsafe HTML to customers by email.
|
||||
//$mailbox->signature = \Helper::stripDangerousTags($mailbox->signature);
|
||||
$mailbox->signature = Mailbox::sanitizeSignature($mailbox->signature);
|
||||
|
||||
$mailbox->save();
|
||||
|
||||
@@ -402,9 +415,9 @@ class MailboxesController extends Controller
|
||||
|
||||
if ($request->out_method == Mailbox::OUT_METHOD_SMTP) {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'out_server' => 'required|string|max:255',
|
||||
'out_server' => 'required|string|max:255|safehost',
|
||||
'out_port' => 'required|integer',
|
||||
'out_username' => 'nullable|string|max:100',
|
||||
'out_username' => 'nullable|string|max:255',
|
||||
'out_password' => 'nullable|string|max:255',
|
||||
'out_encryption' => 'required|integer',
|
||||
]);
|
||||
@@ -422,6 +435,18 @@ class MailboxesController extends Controller
|
||||
} else {
|
||||
$params = $request->all();
|
||||
}
|
||||
|
||||
// Leave only allowed fields.
|
||||
$params = \Helper::filterArrayByKeys($params, [
|
||||
'out_method',
|
||||
'out_server',
|
||||
'out_port',
|
||||
'out_username',
|
||||
'out_password',
|
||||
'out_encryption',
|
||||
'send_test_to',
|
||||
]);
|
||||
|
||||
$mailbox->fill($params);
|
||||
$mailbox->save();
|
||||
|
||||
@@ -453,13 +478,22 @@ class MailboxesController extends Controller
|
||||
];
|
||||
|
||||
$validator = Validator::make($fields, [
|
||||
'in_server' => 'required',
|
||||
//'in_server' => 'required',
|
||||
'in_port' => 'required',
|
||||
'in_username' => 'required',
|
||||
'in_password' => 'required',
|
||||
]);
|
||||
|
||||
return view('mailboxes/connection_incoming', ['mailbox' => $mailbox, 'flashes' => $this->mailboxActiveWarning($mailbox)])->withErrors($validator);
|
||||
$response = view('mailboxes/connection_incoming', ['mailbox' => $mailbox, 'flashes' => $this->mailboxActiveWarning($mailbox)]);
|
||||
|
||||
if (empty($request->session()->get('errors'))) {
|
||||
if (empty($mailbox->in_server)) {
|
||||
$validator->errors()->add('in_server', 'dummy');
|
||||
}
|
||||
$response->withErrors($validator);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,18 +504,18 @@ class MailboxesController extends Controller
|
||||
$mailbox = Mailbox::findOrFail($id);
|
||||
$this->authorize('admin', $mailbox);
|
||||
|
||||
// $validator = Validator::make($request->all(), [
|
||||
// 'in_server' => 'nullable|string|max:255',
|
||||
// 'in_port' => 'nullable|integer',
|
||||
// 'in_username' => 'nullable|string|max:100',
|
||||
// 'in_password' => 'nullable|string|max:255',
|
||||
// ]);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'in_server' => 'required|string|max:255|safehost',
|
||||
'in_port' => 'required|integer',
|
||||
'in_username' => 'required|string|max:100',
|
||||
'in_password' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
// if ($validator->fails()) {
|
||||
// return redirect()->route('mailboxes.connection.incoming', ['id' => $id])
|
||||
// ->withErrors($validator)
|
||||
// ->withInput();
|
||||
// }
|
||||
if ($validator->fails()) {
|
||||
return redirect()->route('mailboxes.connection.incoming', ['id' => $id])
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
|
||||
// Checkboxes
|
||||
$request->merge([
|
||||
@@ -497,6 +531,19 @@ class MailboxesController extends Controller
|
||||
|
||||
\Eventy::action('mailbox.incoming_settings_before_save', $mailbox, $request);
|
||||
|
||||
// Leave only allowed fields.
|
||||
$params = \Helper::filterArrayByKeys($params, [
|
||||
'in_protocol',
|
||||
'in_server',
|
||||
'in_port',
|
||||
'in_username',
|
||||
'in_password',
|
||||
'in_encryption',
|
||||
'in_imap_folders',
|
||||
'in_validate_cert',
|
||||
'imap_sent_folder',
|
||||
]);
|
||||
|
||||
$mailbox->fill($params);
|
||||
|
||||
// Save IMAP Folders.
|
||||
@@ -564,14 +611,14 @@ class MailboxesController extends Controller
|
||||
if (Route::currentRouteName() != 'mailboxes.connection' && !$mailbox->isOutActive()) {
|
||||
$flashes[] = [
|
||||
'type' => 'warning',
|
||||
'text' => __('Sending emails need to be configured for the mailbox in order to send emails to customers and support agents').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection', ['id' => $mailbox->id]).'">'.__('Sending Emails').'</a>)',
|
||||
'text' => '<i class="glyphicon glyphicon-flash"></i> '.__('Sending emails need to be configured for the mailbox in order to send emails to customers and support agents').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection', ['id' => $mailbox->id]).'">'.__('Sending Emails').'</a>)',
|
||||
'unescaped' => true,
|
||||
];
|
||||
}
|
||||
if (Route::currentRouteName() != 'mailboxes.connection.incoming' && !$mailbox->isInActive()) {
|
||||
$flashes[] = [
|
||||
'type' => 'warning',
|
||||
'text' => __('Receiving emails need to be configured for the mailbox in order to fetch emails from your support email address').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection.incoming', ['id' => $mailbox->id]).'">'.__('Receiving Emails').'</a>)',
|
||||
'text' => '<i class="glyphicon glyphicon-flash"></i> '.__('Receiving emails need to be configured for the mailbox in order to fetch emails from your support email address').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection.incoming', ['id' => $mailbox->id]).'">'.__('Fetching Emails').'</a>)',
|
||||
'unescaped' => true,
|
||||
];
|
||||
}
|
||||
@@ -637,6 +684,8 @@ class MailboxesController extends Controller
|
||||
];
|
||||
|
||||
$mailbox->fill($data);
|
||||
$mailbox->auto_reply_message = \Helper::purifyHtml($mailbox->auto_reply_message);
|
||||
// To be 100% sure.
|
||||
$mailbox->auto_reply_message = \Helper::stripDangerousTags($mailbox->auto_reply_message);
|
||||
|
||||
$mailbox->save();
|
||||
@@ -692,15 +741,23 @@ class MailboxesController extends Controller
|
||||
|
||||
// Check if outgoing port is open.
|
||||
if (!$response['msg'] && $mailbox->out_method == Mailbox::OUT_METHOD_SMTP) {
|
||||
$test_result = \Helper::checkPort($mailbox->out_server, $mailbox->out_port);
|
||||
if (!$test_result) {
|
||||
$response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => '<strong>'.$mailbox->out_server.'</strong>', 'port' => '<strong>'.$mailbox->out_port.'</strong>']);
|
||||
$test_result = false;
|
||||
try {
|
||||
$test_result = \Helper::checkPort($mailbox->out_server, $mailbox->out_port);
|
||||
} catch (\Exception $e) {
|
||||
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($response['msg']) && !$test_result) {
|
||||
$response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => $mailbox->out_server, 'port' => $mailbox->out_port]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$test_result = [
|
||||
'status' => 'error'
|
||||
'status' => 'error',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -743,9 +800,16 @@ class MailboxesController extends Controller
|
||||
|
||||
// Check if outgoing port is open.
|
||||
if (!$response['msg'] && !$tested) {
|
||||
$test_result = \Helper::checkPort($mailbox->in_server, $mailbox->in_port);
|
||||
if (!$test_result) {
|
||||
$response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => '<strong>'.$mailbox->in_server.'</strong>', 'port' => '<strong>'.$mailbox->in_port.'</strong>']);
|
||||
$test_result = false;
|
||||
try {
|
||||
$test_result = \Helper::checkPort($mailbox->in_server, $mailbox->in_port);
|
||||
} catch (\Exception $e) {
|
||||
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (empty($response['msg']) && !$test_result) {
|
||||
$response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => $mailbox->in_server, 'port' => $mailbox->in_port]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,20 +909,7 @@ class MailboxesController extends Controller
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
// Remove threads and conversations.
|
||||
$conversation_ids = $mailbox->conversations()->pluck('id')->toArray();
|
||||
|
||||
for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) {
|
||||
$slice_ids = array_slice($conversation_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT);
|
||||
Thread::whereIn('conversation_id', $slice_ids)->delete();
|
||||
}
|
||||
|
||||
$mailbox->conversations()->delete();
|
||||
$mailbox->users()->sync([]);
|
||||
$mailbox->folders()->delete();
|
||||
// Maybe remove notifications on events in this mailbox?
|
||||
|
||||
$mailbox->delete();
|
||||
$mailbox->deleteMailbox();
|
||||
|
||||
\Session::flash('flash_success_floating', __('Mailbox deleted'));
|
||||
|
||||
@@ -908,7 +959,8 @@ class MailboxesController extends Controller
|
||||
}
|
||||
|
||||
// Recursively interate over folders.
|
||||
public function interateFolders($response, $imap_folders, $subfolder = false) {
|
||||
public function interateFolders($response, $imap_folders, $subfolder = false)
|
||||
{
|
||||
foreach ($imap_folders as $imap_folder) {
|
||||
if (!empty($imap_folder->name) && !$subfolder) {
|
||||
$response['folders'][] = $imap_folder->name;
|
||||
@@ -967,10 +1019,10 @@ class MailboxesController extends Controller
|
||||
return __('Mailbox not found').': '.$mailbox_id;
|
||||
}
|
||||
if ($in_out == 'in') {
|
||||
$username = $mailbox->in_username;
|
||||
$username = $mailbox->getInOauthClientId();
|
||||
$password = $mailbox->in_password;
|
||||
} else {
|
||||
$username = $mailbox->out_username;
|
||||
$username = $mailbox->getOutOauthClientId();
|
||||
$password = $mailbox->out_password;
|
||||
}
|
||||
if (empty($username)) {
|
||||
@@ -993,7 +1045,7 @@ class MailboxesController extends Controller
|
||||
'in_out' => $in_out,
|
||||
'state' => crc32($username.$password),
|
||||
];
|
||||
$url = \MailHelper::oauthGetAuthorizationUrl(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
|
||||
$url = \MailHelper::oauthGetAuthorizationUrl($provider, [
|
||||
'state' => json_encode($state),
|
||||
'client_id' => $username,
|
||||
]);
|
||||
@@ -1018,7 +1070,7 @@ class MailboxesController extends Controller
|
||||
} else {
|
||||
// state is set.
|
||||
// Try to get an access token (using the authorization code grant)
|
||||
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
|
||||
$token_data = \MailHelper::oauthGetAccessToken($provider, [
|
||||
'client_id' => $username,
|
||||
'client_secret' => $password,
|
||||
'code' => $request->code,
|
||||
@@ -1028,7 +1080,7 @@ class MailboxesController extends Controller
|
||||
// Set username and password for the oppozite in_out.
|
||||
if ($in_out == 'in') {
|
||||
if (empty($mailbox->out_server)
|
||||
|| (trim($mailbox->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP
|
||||
|| ($mailbox->isOutServerOauth()
|
||||
&& (!$mailbox->out_username || $mailbox->out_username == $username))
|
||||
) {
|
||||
$mailbox->out_username = $username;
|
||||
@@ -1062,6 +1114,10 @@ class MailboxesController extends Controller
|
||||
$mailbox = Mailbox::findOrFail($mailbox_id);
|
||||
$this->authorize('admin', $mailbox);
|
||||
|
||||
if (!\Helper::hashEquals(csrf_token(), $request->token)) {
|
||||
throw new \Illuminate\Session\TokenMismatchException;
|
||||
}
|
||||
|
||||
// oAuth Disconnect.
|
||||
$mailbox->removeMetaParam('oauth', true);
|
||||
|
||||
@@ -1071,6 +1127,6 @@ class MailboxesController extends Controller
|
||||
$route = 'mailboxes.connection';
|
||||
}
|
||||
|
||||
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]));
|
||||
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]), $mailbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,17 +96,24 @@ class ModulesController extends Controller
|
||||
if (is_array($modules_directory)) {
|
||||
foreach ($modules_directory as $i_dir => $dir_module) {
|
||||
|
||||
$modules_directory[$i_dir] = \App\Module::formatModuleData($dir_module);
|
||||
$dir_module = \App\Module::formatModuleData($dir_module);
|
||||
$modules_directory[$i_dir] = $dir_module;
|
||||
|
||||
// Remove modules without aliases
|
||||
if (empty($dir_module['alias'])) {
|
||||
unset($modules_directory[$i_dir]);
|
||||
}
|
||||
|
||||
$all_modules[$dir_module['alias']] = $dir_module['name'];
|
||||
|
||||
foreach ($installed_modules as $i_installed => $module) {
|
||||
|
||||
if ($dir_module['alias'] == $module['alias']) {
|
||||
// Set image from director
|
||||
$installed_modules[$i_installed]['img'] = $dir_module['img'];
|
||||
|
||||
$installed_modules[$i_installed] = \App\Module::formatModuleData($installed_modules[$i_installed]);
|
||||
|
||||
// Remove installed modules from modules directory.
|
||||
unset($modules_directory[$i_dir]);
|
||||
|
||||
@@ -120,11 +127,6 @@ class ModulesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dir_module['authorUrl']) || !\App\Module::isOfficial($dir_module['authorUrl'])) {
|
||||
unset($modules_directory[$i_dir]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($dir_module['requiredPhpExtensions'])) {
|
||||
$modules_directory[$i_dir]['requiredPhpExtensionsMissing'] = \App\Module::getMissingExtensions($dir_module['requiredPhpExtensions']);
|
||||
}
|
||||
@@ -135,6 +137,9 @@ class ModulesController extends Controller
|
||||
if (\App\Module::isThirdParty($dir_module)) {
|
||||
$third_party_modules[] = $modules_directory[$i_dir];
|
||||
unset($modules_directory[$i_dir]);
|
||||
} elseif (empty($dir_module['authorUrl']) || !\App\Module::isOfficial($dir_module['authorUrl'])) {
|
||||
unset($modules_directory[$i_dir]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -198,6 +203,10 @@ class ModulesController extends Controller
|
||||
// Sort all modules.
|
||||
asort($all_modules);
|
||||
|
||||
usort($third_party_modules, function ($a, $b) {
|
||||
return strcasecmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
return view('modules/modules', [
|
||||
'installed_modules' => $installed_modules,
|
||||
'modules_directory' => $modules_directory,
|
||||
@@ -221,10 +230,11 @@ class ModulesController extends Controller
|
||||
|
||||
switch ($request->action) {
|
||||
|
||||
// Install module or activate license.
|
||||
case 'install':
|
||||
case 'activate_license':
|
||||
$license = $request->license;
|
||||
$alias = $request->alias;
|
||||
$alias = preg_replace("#[^a-zA-Z0-9_\-]#", '', $request->alias);
|
||||
|
||||
if (!$license) {
|
||||
$response['msg'] = __('Empty license key');
|
||||
@@ -317,8 +327,11 @@ class ModulesController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// If everything is fine try to activate the Module.
|
||||
if (!empty($response['msg'])) {
|
||||
break;
|
||||
}
|
||||
// Continue to activation.
|
||||
case 'activate':
|
||||
$alias = $request->alias;
|
||||
$module = \Module::findByAlias($alias);
|
||||
@@ -331,7 +344,7 @@ class ModulesController extends Controller
|
||||
if (!$response['msg']) {
|
||||
if (!empty($module->get('authorUrl')) && $module->isOfficial()) {
|
||||
$params = [
|
||||
'license' => $module->getLicense(),
|
||||
'license' => $request->license ?: $module->getLicense(),
|
||||
'module_alias' => $alias,
|
||||
'url' => \App\Module::getAppUrl(),
|
||||
];
|
||||
@@ -355,6 +368,7 @@ class ModulesController extends Controller
|
||||
break;
|
||||
case 'inactive':
|
||||
$response['msg'] = __('License key has not been activated yet');
|
||||
break;
|
||||
case 'site_inactive':
|
||||
$response['msg'] = __('No activations left for this license key').' ('.__("Use 'Deactivate License' link above to transfer license key from another domain").')';
|
||||
break;
|
||||
@@ -376,6 +390,53 @@ class ModulesController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check folder names for custom modules o avoid errors after activation:
|
||||
// Class "Modules\CustomModule\Providers\CustomModuleServiceProvider" not found.
|
||||
try {
|
||||
$old_path = '';
|
||||
$correct_path = '';
|
||||
foreach (\Module::getScanPaths() as $key => $path) {
|
||||
$manifests = \Module::getFiles()->glob("{$path}/module.json");
|
||||
|
||||
is_array($manifests) || $manifests = [];
|
||||
|
||||
// Determine correct module folder name from providers in module.json.
|
||||
// Each module must have at least on "provider" specified.
|
||||
foreach ($manifests as $manifest) {
|
||||
$manifest_json = \Nwidart\Modules\Json::make($manifest);
|
||||
if ($manifest_json->get('alias') != $alias) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$providers = $manifest_json->get('providers');
|
||||
$correct_folder_name = $providers[0] ?? '';
|
||||
$correct_folder_name = preg_replace('#^Modules\\\\([^\\\\]+)\\\\.*#', '$1', $correct_folder_name);
|
||||
if (!$correct_folder_name) {
|
||||
break;
|
||||
}
|
||||
// Rename module's folder into correct name.
|
||||
$old_path = str_replace('/module.json', '', $manifest);
|
||||
$correct_path = \Module::getPath().'/'.$correct_folder_name;
|
||||
|
||||
if (\File::exists($old_path) && !\File::exists($correct_path)) {
|
||||
\File::move($old_path, $correct_path);
|
||||
// Re-scan and re-cache modules.
|
||||
\Module::scan();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($old_path && $correct_path) {
|
||||
$response['msg'] = __('Rename ":old_path" into ":new_path"', [
|
||||
'old_path' => str_replace(\Module::getPath(), '/Modules', $old_path),
|
||||
'new_path' => str_replace(\Module::getPath(), '/Modules', $correct_path),
|
||||
]);
|
||||
} else {
|
||||
\Helper::logException($e, '[Modules] Error occured checking module folder name on activation');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,6 +583,8 @@ class ModulesController extends Controller
|
||||
$response['status'] = 'success';
|
||||
} elseif (!empty($result['error'])) {
|
||||
$response['msg'] = \App\Module::getErrorMessage($result['error'], $result);
|
||||
} elseif (!empty($result['status']) && $result['status'] == 'error') {
|
||||
$response['msg'] = __('License key does not exist');
|
||||
} else {
|
||||
$response['msg'] = __('Error occurred. Please try again later.');
|
||||
}
|
||||
|
||||
@@ -27,34 +27,56 @@ class OpenController extends Controller
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function userSetup($hash)
|
||||
public function userSetup($hash, $invite_sent_at)
|
||||
{
|
||||
if (auth()->user()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$user = User::where('invite_hash', $hash)->first();
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jqj5-r72v-v29g
|
||||
$hash = trim($hash);
|
||||
|
||||
$user = null;
|
||||
if (strlen($hash) == User::INVITE_HASH_LENGTH) {
|
||||
$user = User::where('invite_hash', $hash)->first();
|
||||
}
|
||||
|
||||
if ($user && $user->locale) {
|
||||
\Helper::setLocale($user->locale);
|
||||
}
|
||||
|
||||
if ($user && !$this->isInviteExpirationValid($invite_sent_at, $user)) {
|
||||
$user = null;
|
||||
}
|
||||
|
||||
return view('open/user_setup', ['user' => $user]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user from invitation.
|
||||
*/
|
||||
public function userSetupSave($hash, Request $request)
|
||||
public function userSetupSave($hash, $invite_sent_at, Request $request)
|
||||
{
|
||||
if (auth()->user()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$user = User::where('invite_hash', $hash)->first();
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jqj5-r72v-v29g
|
||||
$hash = trim($hash);
|
||||
|
||||
$user = null;
|
||||
if (strlen($hash) == User::INVITE_HASH_LENGTH) {
|
||||
$user = User::where('invite_hash', $hash)->first();
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (!$this->isInviteExpirationValid($invite_sent_at, $user)) {
|
||||
\Helper::denyAccess();
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|string|email|max:100|unique:users,email,'.$user->id,
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
@@ -82,7 +104,7 @@ class OpenController extends Controller
|
||||
});
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect()->route('user_setup', ['hash' => $hash])
|
||||
return redirect()->route('user_setup', ['hash' => $hash, 'invite_sent_at' => $invite_sent_at])
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
@@ -114,21 +136,48 @@ class OpenController extends Controller
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
public function isInviteExpirationValid($invite_sent_at_encrypted, $user)
|
||||
{
|
||||
$invite_sent_at = \Helper::decrypt($invite_sent_at_encrypted, $user->password);
|
||||
|
||||
if ($invite_sent_at == $invite_sent_at_encrypted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$invite_sent_at
|
||||
|| !is_numeric($invite_sent_at)
|
||||
|| (int)$invite_sent_at < time() - User::INVITE_TTL_DAYS*86400
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Set a thread as read by customer
|
||||
*/
|
||||
public function setThreadAsRead($conversation_id, $thread_id)
|
||||
public function setThreadAsRead($conversation_id, $thread_id, $hash)
|
||||
{
|
||||
$mark_as_read = true;
|
||||
|
||||
$conversation = Conversation::findOrFail($conversation_id);
|
||||
$thread = Thread::findOrFail($thread_id);
|
||||
|
||||
if ((int)$thread->conversation_id !== (int)$conversation_id) {
|
||||
$mark_as_read = false;
|
||||
} elseif (!\Helper::hashEquals($hash, Thread::getOpenTrackingHash($thread, $conversation, $conversation->mailbox))) {
|
||||
$mark_as_read = false;
|
||||
}
|
||||
|
||||
// We only track the first opening
|
||||
if (empty($thread->opened_at)) {
|
||||
if ($mark_as_read && empty($thread->opened_at)) {
|
||||
$thread->opened_at = date('Y-m-d H:i:s');
|
||||
$thread->save();
|
||||
\Eventy::action('thread.opened', $thread, $conversation);
|
||||
}
|
||||
|
||||
// We return always the same result for security purposes.
|
||||
// Create a 1x1 ttransparent pixel and return it
|
||||
$pixel = sprintf('%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c', 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 255, 0, 192, 192, 192, 0, 0, 0, 33, 249, 4, 1, 0, 0, 0, 0, 44, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 68, 1, 0, 59);
|
||||
$response = \Response::make($pixel, 200);
|
||||
@@ -167,8 +216,15 @@ class OpenController extends Controller
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// Only allow download if the attachment is public or if the token matches the hash of the contents
|
||||
if ($token != $attachment->getToken() && (bool)$attachment->public !== true) {
|
||||
// Check attachment name.
|
||||
if (mb_strtolower(trim($attachment->file_name)) != mb_strtolower(trim($file_name))) {
|
||||
return \Helper::denyAccess();
|
||||
}
|
||||
|
||||
// Links to atachments without token have been disabled.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-wg74-ww4w-2qpc
|
||||
//if ($token != $attachment->getToken() && $attachment->token_type != Attachment::TOKEN_TYPE_LEGACY) {
|
||||
if (!\Helper::hashEquals($token, $attachment->getToken())) {
|
||||
return \Helper::denyAccess();
|
||||
}
|
||||
|
||||
@@ -184,36 +240,93 @@ class OpenController extends Controller
|
||||
$allowed_mime_type = false;
|
||||
|
||||
foreach (config('app.viewable_mime_types') as $mime_type) {
|
||||
if (preg_match('#'.$mime_type.'#', $attachment->mime_type)) {
|
||||
if (preg_match('#^'.$mime_type.'$#', $attachment->mime_type)) {
|
||||
$allowed_mime_type = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($allowed_mime_type) {
|
||||
foreach (config('app.non_viewable_mime_types') as $mime_type) {
|
||||
if (preg_match('#^'.$mime_type.'$#', $attachment->mime_type)) {
|
||||
$allowed_mime_type = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$allowed_mime_type) {
|
||||
$view_attachment = false;
|
||||
}
|
||||
}
|
||||
|
||||
// CSP header for extra security.
|
||||
$csp_header_value = "script-src 'none'; frame-src 'none'; object-src 'none'; font-src 'none'; connect-src 'none'; media-src 'self'; form-action 'none'; base-uri 'none'; sandbox";
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5281
|
||||
//
|
||||
// The bare sandbox directive gives the attachment document an opaque
|
||||
// ("null") origin. The media element the browser's built-in player creates
|
||||
// then requests the file cross-origin and is blocked by CORS, so the player
|
||||
// appears but never plays. allow-same-origin keeps the real origin.
|
||||
// nosniff replaces the protection sandbox was providing here: the browser
|
||||
// is not allowed to reinterpret an audio/* response as HTML.
|
||||
// https://github.com/freescout-help-desk/freescout/pull/5597
|
||||
if (preg_match('#^audio/.*$#', $attachment->mime_type)) {
|
||||
//$csp_header_value .= ' allow-scripts';
|
||||
$csp_header_value .= ' allow-same-origin';
|
||||
}
|
||||
|
||||
// Send file.
|
||||
if (config('app.download_attachments_via') == 'apache') {
|
||||
// Send using Apache mod_xsendfile.
|
||||
$response = response(null)
|
||||
->header('Content-Type' , $attachment->mime_type)
|
||||
->header('Content-Type', $attachment->mime_type)
|
||||
// Laravel adds 'nosniff' by itself.
|
||||
//->header('X-Content-Type-Options', 'nosniff')
|
||||
->header('X-Sendfile', $attachment->getLocalFilePath());
|
||||
|
||||
if (!$view_attachment) {
|
||||
$response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
|
||||
} else {
|
||||
$response->header('Content-Security-Policy', $csp_header_value);
|
||||
}
|
||||
} elseif (config('app.download_attachments_via') == 'nginx') {
|
||||
// Send using Nginx.
|
||||
$response = response(null)
|
||||
->header('Content-Type' , $attachment->mime_type)
|
||||
->header('Content-Type', $attachment->mime_type)
|
||||
// Laravel adds 'nosniff' by itself.
|
||||
//->header('X-Content-Type-Options', 'nosniff')
|
||||
->header('X-Accel-Redirect', $attachment->getLocalFilePath(false));
|
||||
|
||||
if (!$view_attachment) {
|
||||
$response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
|
||||
} else {
|
||||
$response->header('Content-Security-Policy', $csp_header_value);
|
||||
}
|
||||
} else {
|
||||
$response = $attachment->download($view_attachment);
|
||||
// Send via PHP.
|
||||
// In this case whether the file is viewed or downloaded is controlled
|
||||
// via the web server config or /storage/app/public/.htaccess.
|
||||
$headers = [
|
||||
'Content-Type' => $attachment->mime_type,
|
||||
// Laravel adds 'nosniff' by itself.
|
||||
//'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
if ($view_attachment) {
|
||||
$headers['Content-Security-Policy'] = $csp_header_value;
|
||||
}
|
||||
// Storage::download() returns a StreamedResponse, which does not
|
||||
// implement HTTP Range requests: no Accept-Ranges, no 206 responses.
|
||||
// Browsers disable seeking without it, so audio and video attachments
|
||||
// can not be rewound or fast forwarded. BinaryFileResponse handles
|
||||
// Range requests, so use it whenever the file is shown in the browser.
|
||||
if ($view_attachment && \Helper::isLocalStorage() && $attachment->fileExists()) {
|
||||
$response = response()->file(
|
||||
$attachment->getLocalFilePath(),
|
||||
$headers
|
||||
);
|
||||
} else {
|
||||
$response = $attachment->download($view_attachment, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -29,11 +29,8 @@ class SecureController extends Controller
|
||||
public function dashboard()
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (!$user->isAdmin()) {
|
||||
$mailboxes = $user->mailboxesCanView();
|
||||
} else {
|
||||
$mailboxes = $user->mailboxesCanViewWithSettings();
|
||||
}
|
||||
|
||||
$mailboxes = $user->mailboxesCanViewWithSettings();
|
||||
|
||||
// Sort by name.
|
||||
$mailboxes = \Eventy::filter('dashboard.mailboxes', $mailboxes->sortBy('name'));
|
||||
@@ -200,7 +197,7 @@ class SecureController extends Controller
|
||||
/**
|
||||
* Upload files and images.
|
||||
*/
|
||||
public function upload(Request $request, $allowed_exts = [])
|
||||
public function upload(Request $request)
|
||||
{
|
||||
// 'jpg','gif','png'
|
||||
$response = [
|
||||
@@ -220,14 +217,24 @@ class SecureController extends Controller
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
$upload = Helper::uploadFile($request->file, $allowed_exts);
|
||||
$filename = basename($upload);
|
||||
$uploaded_file_path = '';
|
||||
try {
|
||||
$uploaded_file_path = Helper::uploadFile($request->file);
|
||||
} catch (\Exception $e) {
|
||||
if ($e->getCode() == \Helper::EXCEPTION_NOT_ALLOWED_FILE_EXTENSION) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($upload) {
|
||||
$response['status'] = 'success';
|
||||
$response['url'] = Helper::uploadedFileUrl($filename);
|
||||
} else {
|
||||
$response['msg'] = __('Error occurred uploading file');
|
||||
if (!$response['msg']) {
|
||||
$filename = basename($uploaded_file_path);
|
||||
|
||||
if ($uploaded_file_path) {
|
||||
$response['status'] = 'success';
|
||||
$response['url'] = Helper::uploadedFileUrl($filename);
|
||||
} else {
|
||||
$response['msg'] = __('Error occurred uploading file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class SettingsController extends Controller
|
||||
],
|
||||
],
|
||||
'validator_rules' => [
|
||||
'settings.mail_host' => 'safehost',
|
||||
'settings.mail_from' => 'required|email',
|
||||
],
|
||||
'settings' => [
|
||||
@@ -135,6 +136,9 @@ class SettingsController extends Controller
|
||||
'env' => 'APP_USER_PERMISSIONS',
|
||||
'env_encode' => true,
|
||||
],
|
||||
'limit_user_customer_visibility' => [
|
||||
'env' => 'APP_LIMIT_USER_CUSTOMER_VISIBILITY',
|
||||
],
|
||||
],
|
||||
];
|
||||
break;
|
||||
@@ -200,6 +204,7 @@ class SettingsController extends Controller
|
||||
'next_ticket' => (Option::get('next_ticket') >= Conversation::max('number') + 1) ? Option::get('next_ticket') : Conversation::max('number') + 1,
|
||||
'custom_number' => (int)config('app.custom_number'),
|
||||
'user_permissions' => User::getGlobalUserPermissions(),
|
||||
'limit_user_customer_visibility' => (int)config('app.limit_user_customer_visibility'),
|
||||
'email_branding' => Option::get('email_branding'),
|
||||
'open_tracking' => Option::get('open_tracking'),
|
||||
'email_conv_history' => config('app.email_conv_history'),
|
||||
@@ -232,11 +237,13 @@ class SettingsController extends Controller
|
||||
'alert_logs',
|
||||
'alert_logs_names',
|
||||
'alert_logs_period',
|
||||
'alert_logs_fetch_min_occurrences',
|
||||
'subscription_defaults',
|
||||
], [
|
||||
'alert_logs_names' => [],
|
||||
'alert_logs' => config('app.alert_logs'),
|
||||
'alert_logs_period' => config('app.alert_logs_period'),
|
||||
'alert_logs_names' => [],
|
||||
'alert_logs' => config('app.alert_logs'),
|
||||
'alert_logs_period' => config('app.alert_logs_period'),
|
||||
'alert_logs_fetch_min_occurrences' => \App\Console\Commands\LogsMonitor::FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
@@ -313,7 +320,7 @@ class SettingsController extends Controller
|
||||
$env_value = json_encode($env_value);
|
||||
}
|
||||
|
||||
if (!empty($settings_params[$option_name]['encrypt'])) {
|
||||
if ($env_value !== '' && !empty($settings_params[$option_name]['encrypt'])) {
|
||||
$env_value = encrypt($env_value);
|
||||
}
|
||||
|
||||
@@ -395,7 +402,7 @@ class SettingsController extends Controller
|
||||
|
||||
if (!$response['msg']) {
|
||||
$test_result = [
|
||||
'status' => 'error'
|
||||
'status' => 'error',
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
@@ -54,11 +54,15 @@ class SystemController extends Controller
|
||||
$non_writable_cache_file = '';
|
||||
if (function_exists('shell_exec')) {
|
||||
$non_writable_cache_file = \Helper::shellExec('find '.base_path('storage/framework/cache/data/').' -type f | xargs -I {} sh -c \'[ ! -w "{}" ] && echo {}\' 2>&1 | head -n 1');
|
||||
$non_writable_cache_file = trim($non_writable_cache_file ?? '');
|
||||
// Leave only one line (in case head -n 1 does not work)
|
||||
$non_writable_cache_file = preg_replace("#[\r\n].+#m", '', $non_writable_cache_file);
|
||||
if (!strstr($non_writable_cache_file, base_path('storage/framework/cache/data/'))) {
|
||||
$non_writable_cache_file = '';
|
||||
if ($non_writable_cache_file === false) {
|
||||
$non_writable_cache_file = 'Could not execute command via shell_exec()';
|
||||
} else {
|
||||
$non_writable_cache_file = trim($non_writable_cache_file ?? '');
|
||||
// Leave only one line (in case head -n 1 does not work)
|
||||
$non_writable_cache_file = preg_replace("#[\r\n].+#m", '', $non_writable_cache_file);
|
||||
if (!strstr($non_writable_cache_file, base_path('storage/framework/cache/data/'))) {
|
||||
$non_writable_cache_file = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ class SystemController extends Controller
|
||||
// Commands
|
||||
$commands_list = [
|
||||
'freescout:fetch-emails' => 'freescout:fetch-emails',
|
||||
\Helper::getWorkerIdentifier() => 'queue:work'
|
||||
\Helper::getWorkerIdentifier() => 'queue:work',
|
||||
];
|
||||
foreach ($commands_list as $command_identifier => $command_name) {
|
||||
$status_texts = [];
|
||||
@@ -97,19 +101,29 @@ class SystemController extends Controller
|
||||
$running_commands = 0;
|
||||
|
||||
try {
|
||||
$processes = preg_split("/[\r\n]/", \Helper::shellExec("ps auxww | grep '{$command_identifier}'"));
|
||||
$pids = [];
|
||||
foreach ($processes as $process) {
|
||||
$process = trim($process);
|
||||
preg_match("/^[\S]+\s+([\d]+)\s+/", $process, $m);
|
||||
if (empty($m)) {
|
||||
// Another format (used in Docker image).
|
||||
// 1713 nginx 0:00 /usr/bin/php82...
|
||||
preg_match("/^([\d]+)\s+[\S]+\s+/", $process, $m);
|
||||
}
|
||||
if (!preg_match("/(sh \-c|grep )/", $process) && !empty($m[1])) {
|
||||
$running_commands++;
|
||||
$pids[] = $m[1];
|
||||
$ps_output = \Helper::shellExec("ps auxww | grep '{$command_identifier}'");
|
||||
|
||||
if ($ps_output === false) {
|
||||
$commands[] = [
|
||||
'name' => $command_name,
|
||||
'status' => 'error',
|
||||
'status_text' => __h('Could not execute command via shell_exec()'),
|
||||
];
|
||||
} else {
|
||||
$processes = preg_split("/[\r\n]/", $ps_output);
|
||||
$pids = [];
|
||||
foreach ($processes as $process) {
|
||||
$process = trim($process);
|
||||
preg_match("/^[\S]+\s+([\d]+)\s+/", $process, $m);
|
||||
if (empty($m)) {
|
||||
// Another format (used in Docker image).
|
||||
// 1713 nginx 0:00 /usr/bin/php82...
|
||||
preg_match("/^([\d]+)\s+[\S]+\s+/", $process, $m);
|
||||
}
|
||||
if (!preg_match("/(sh \-c|grep )/", $process) && !empty($m[1])) {
|
||||
$running_commands++;
|
||||
$pids[] = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
@@ -119,7 +133,7 @@ class SystemController extends Controller
|
||||
$commands[] = [
|
||||
'name' => $command_name,
|
||||
'status' => 'success',
|
||||
'status_text' => __('Running'),
|
||||
'status_text' => __h('Running'),
|
||||
];
|
||||
continue;
|
||||
} elseif ($running_commands > 1) {
|
||||
@@ -129,14 +143,14 @@ class SystemController extends Controller
|
||||
$commands[] = [
|
||||
'name' => $command_name,
|
||||
'status' => 'error',
|
||||
'status_text' => __(':number commands were running at the same time. Commands have been restarted', ['number' => $running_commands]),
|
||||
'status_text' => __h(':number commands were running at the same time. Commands have been restarted', ['number' => htmlspecialchars($running_commands)]),
|
||||
];
|
||||
} else {
|
||||
unset($pids[0]);
|
||||
$commands[] = [
|
||||
'name' => $command_name,
|
||||
'status' => 'error',
|
||||
'status_text' => __(':number commands are running at the same time. Please stop extra commands by executing the following console command:', ['number' => $running_commands]).' kill '.implode(' | kill ', $pids),
|
||||
'status_text' => __h(':number commands are running at the same time. Please stop extra commands by executing the following console command:', ['number' => htmlspecialchars($running_commands)]).' kill '.implode(' | kill ', $pids),
|
||||
];
|
||||
}
|
||||
continue;
|
||||
@@ -151,7 +165,7 @@ class SystemController extends Controller
|
||||
$date = Carbon::createFromTimestamp($last_run);
|
||||
$date_text = User::dateFormat($date);
|
||||
}
|
||||
$status_texts[] = __('Last run:').' '.$date_text;
|
||||
$status_texts[] = __h('Last run:').' '.htmlspecialchars($date_text);
|
||||
|
||||
$date_text = '?';
|
||||
$last_successful_run = Option::get($option_name.'_last_successful_run');
|
||||
@@ -159,7 +173,7 @@ class SystemController extends Controller
|
||||
$date_ = Carbon::createFromTimestamp($last_successful_run);
|
||||
$date_text = User::dateFormat($date);
|
||||
}
|
||||
$status_texts[] = __('Last successful run:').' '.$date_text;
|
||||
$status_texts[] = __h('Last successful run:').' '.htmlspecialchars($date_text);
|
||||
|
||||
$status = 'error';
|
||||
if ($last_successful_run && $last_run && (int) $last_successful_run >= (int) $last_run) {
|
||||
@@ -169,7 +183,7 @@ class SystemController extends Controller
|
||||
|
||||
// If queue:work is not running, clear cache to let it start if something is wrong with the mutex
|
||||
if ($command_name == 'queue:work' && !$last_successful_run) {
|
||||
$status_texts[] = __('Try to :%a_start%clear cache:%a_end% to force command to start.', ['%a_start%' => '<a href="'.route('system.tools').'" target="_blank">', '%a_end%' => '</a>']);
|
||||
$status_texts[] = __h('Try to :%a_start%clear cache:%a_end% to force command to start.', ['%a_start%' => '<a href="'.route('system.tools').'" target="_blank">', '%a_end%' => '</a>']);
|
||||
// This sometimes makes Status page open as non logged in user.
|
||||
//\Artisan::call('freescout:clear-cache', ['--doNotGenerateVars' => true]);
|
||||
}
|
||||
@@ -378,18 +392,24 @@ class SystemController extends Controller
|
||||
*/
|
||||
public function cron(Request $request)
|
||||
{
|
||||
if (empty($request->hash) || $request->hash != \Helper::getWebCronHash()) {
|
||||
if (empty($request->hash) || !\Helper::hashEquals($request->hash, \Helper::getWebCronHash())) {
|
||||
abort(404);
|
||||
}
|
||||
$outputLog = new BufferedOutput();
|
||||
\Artisan::call('schedule:run', [], $outputLog);
|
||||
$output = $outputLog->fetch();
|
||||
|
||||
return response($output, 200)->header('Content-Type', 'text/plain');
|
||||
preg_match_all("#'artisan'\s+([^\s>]+)#", $output ?? '', $m);
|
||||
|
||||
$commands = $m[1] ?? [];
|
||||
$result = count($commands)." commands executed:\r\n".(count($commands) ? '- ' : '').implode("\r\n- ", $commands);
|
||||
|
||||
return response($result, 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajax HTML.
|
||||
* Content Security Policy header is sent via ContentSecurityPolicy middleware.
|
||||
*/
|
||||
public function ajaxHtml(Request $request)
|
||||
{
|
||||
@@ -404,7 +424,7 @@ class SystemController extends Controller
|
||||
$payload = json_decode($job->payload, true);
|
||||
|
||||
if (!empty($payload['data']['command'])) {
|
||||
$html .= '<pre>'.\Helper::stripDangerousTags(print_r(unserialize($payload['data']['command']), 1)).'</pre>';
|
||||
$html .= '<pre>'.\Helper::stripDangerousTags(print_r(unserialize($payload['data']['command'], ['allowed_classes' => false]), 1)).'</pre>';
|
||||
}
|
||||
|
||||
$html .= '<pre>'.\Helper::stripDangerousTags($job->exception).'</pre>';
|
||||
|
||||
@@ -69,9 +69,13 @@ class TranslateController extends BaseController
|
||||
public function postDownload()
|
||||
{
|
||||
$this->manager->exportTranslations('*', false);
|
||||
$file_name = 'lang.zip';
|
||||
|
||||
// Archive langs folder
|
||||
$archive_path = \Helper::createZipArchive(base_path().DIRECTORY_SEPARATOR.'resources/lang', $file_name, 'lang');
|
||||
$archive_path = \Helper::createZipArchive(base_path().DIRECTORY_SEPARATOR.'resources/lang', 'lang.zip', 'lang');
|
||||
|
||||
$hash = substr(\Helper::hmacHash($archive_path), 0, 10);
|
||||
$file_name = 'lang_'.$hash.'.zip';
|
||||
|
||||
$public_path = storage_path('app/public/'.$file_name);
|
||||
|
||||
\File::copy($archive_path, $public_path);
|
||||
|
||||
@@ -32,7 +32,14 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('create', 'App\User');
|
||||
|
||||
$users = User::nonDeleted()->get();
|
||||
$users_query = User::nonDeleted();
|
||||
|
||||
// Exclude admins from list for non-admins.
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
$users_query->where('role', User::ROLE_USER);
|
||||
}
|
||||
|
||||
$users = $users_query->get();
|
||||
$users = User::sortUsers($users);
|
||||
|
||||
return view('users/users', ['users' => $users]);
|
||||
@@ -44,7 +51,7 @@ class UsersController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', 'App\User');
|
||||
$mailboxes = Mailbox::all();
|
||||
$mailboxes = auth()->user()->mailboxesCanView();
|
||||
|
||||
return view('users/create', ['mailboxes' => $mailboxes]);
|
||||
}
|
||||
@@ -62,7 +69,7 @@ class UsersController extends Controller
|
||||
|
||||
$rules = [
|
||||
'first_name' => 'required|string|max:20',
|
||||
'last_name' => 'required|string|max:30',
|
||||
'last_name' => 'max:30',
|
||||
'email' => 'required|string|email|max:100|unique:users',
|
||||
//'role' => ['required', Rule::in(array_keys(User::$roles))],
|
||||
];
|
||||
@@ -85,32 +92,44 @@ class UsersController extends Controller
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$password = '';
|
||||
if (empty($request->send_invite)) {
|
||||
// Set password from request.
|
||||
$password = $request->password;
|
||||
} else {
|
||||
// Set some random password before sending invite.
|
||||
$password = User::generateRandomPassword();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'first_name' => $request->first_name,
|
||||
'last_name' => $request->last_name,
|
||||
'last_name' => $request->last_name ?? '',
|
||||
'email' => $request->email,
|
||||
'password' => $password,
|
||||
];
|
||||
|
||||
$user = new User();
|
||||
|
||||
$user->fill($data);
|
||||
$user = User::create($data);
|
||||
|
||||
if (!$auth_user->can('changeRole', $user)) {
|
||||
$user->role = User::ROLE_USER;
|
||||
} elseif (array_key_exists($request->role, User::$roles)) {
|
||||
$user->role = $request->role;
|
||||
}
|
||||
if (empty($request->send_invite)) {
|
||||
// Set password from request
|
||||
$user->password = Hash::make($request->password);
|
||||
} else {
|
||||
// Set some random password before sending invite
|
||||
$user->password = Hash::make($user->generateRandomPassword());
|
||||
}
|
||||
|
||||
// Set system timezone.
|
||||
$user->timezone = config('app.timezone') ?: User::DEFAULT_TIMEZONE;
|
||||
$user = \Eventy::filter('user.create_save', $user, $request);
|
||||
$user->save();
|
||||
|
||||
$user->mailboxes()->sync($request->mailboxes ?: []);
|
||||
$mailbox_ids = $request->mailboxes ?: [];
|
||||
|
||||
if (!$auth_user->isAdmin()) {
|
||||
// Exlude mailboxes to which current user has no access.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-w36p-9vhj-x273
|
||||
$mailbox_ids = array_intersect($mailbox_ids, $auth_user->mailboxesIdsCanView());
|
||||
}
|
||||
|
||||
$user->mailboxes()->sync($mailbox_ids);
|
||||
$user->syncPersonalFolders($request->mailboxes);
|
||||
|
||||
// Send invite
|
||||
@@ -148,7 +167,7 @@ class UsersController extends Controller
|
||||
public function getUsersForSidebar($except_id)
|
||||
{
|
||||
if (auth()->user()->isAdmin()) {
|
||||
return User::sortUsers(User::nonDeleted()->get());/*->except($except_id)*/;
|
||||
return User::sortUsers(User::nonDeleted()->get()); // ->except($except_id);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -173,7 +192,7 @@ class UsersController extends Controller
|
||||
// This is also present in PublicController::userSetup
|
||||
$validator = Validator::make($request->all(), [
|
||||
'first_name' => 'required|string|max:20',
|
||||
'last_name' => 'required|string|max:30',
|
||||
'last_name' => 'max:30',
|
||||
'email' => 'required|string|email|max:100|unique:users,email,'.$id,
|
||||
//'emails' => 'max:100',
|
||||
'job_title' => 'max:100',
|
||||
@@ -259,6 +278,7 @@ class UsersController extends Controller
|
||||
'role',
|
||||
'status',
|
||||
'email',
|
||||
'only_assigned_tickets',
|
||||
];
|
||||
$nonfillable_fields = [
|
||||
'type',
|
||||
@@ -279,6 +299,14 @@ class UsersController extends Controller
|
||||
|
||||
$user->setData($request_data);
|
||||
|
||||
if ($auth_user->isAdmin() && !$user->isAdmin()) {
|
||||
if (!empty($request_data['only_assigned_tickets'])) {
|
||||
$user->addPermission(User::PERM_ONLY_ASSIGNED_TICKETS, false);
|
||||
} else {
|
||||
$user->removePermission(User::PERM_ONLY_ASSIGNED_TICKETS, false);
|
||||
}
|
||||
}
|
||||
|
||||
$user = \Eventy::filter('user.save_profile', $user, $request);
|
||||
|
||||
$user->save();
|
||||
@@ -304,7 +332,7 @@ class UsersController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mailboxes = Mailbox::all();
|
||||
$mailboxes = Mailbox::all()->sortBy('name');
|
||||
|
||||
$users = $this->getUsersForSidebar($id);
|
||||
|
||||
@@ -360,7 +388,7 @@ class UsersController extends Controller
|
||||
public function notifications($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
$this->authorize('updateNotifications', $user);
|
||||
|
||||
if ($user->isDeleted()) {
|
||||
abort(404);
|
||||
@@ -393,7 +421,7 @@ class UsersController extends Controller
|
||||
public function notificationsSave($id, Request $request)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
$this->authorize('updateNotifications', $user);
|
||||
|
||||
Subscription::saveFromArray($request->subscriptions, $user->id);
|
||||
|
||||
@@ -547,7 +575,7 @@ class UsersController extends Controller
|
||||
|
||||
$user->deleteUser($auth_user, $request->assign_user);
|
||||
|
||||
\Session::flash('flash_success_floating', __('User deleted').': '.$user->getFullName());
|
||||
\Session::flash('flash_success_floating', __('User deleted').': '.htmlspecialchars($user->getFullName()));
|
||||
|
||||
$response['status'] = 'success';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class Kernel extends HttpKernel
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
\App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\App\Http\Middleware\ResponseHeaders::class,
|
||||
\App\Http\Middleware\TerminateHandler::class,
|
||||
@@ -39,10 +40,19 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\App\Http\Middleware\HttpsRedirect::class,
|
||||
\App\Http\Middleware\CheckBrowser::class,
|
||||
\App\Http\Middleware\Localize::class,
|
||||
\App\Http\Middleware\LogoutIfDeleted::class,
|
||||
\App\Http\Middleware\FrameGuard::class,
|
||||
\App\Http\Middleware\CustomHandle::class,
|
||||
\App\Http\Middleware\ContentSecurityPolicy::class,
|
||||
],
|
||||
|
||||
'open' => [
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\App\Http\Middleware\HttpsRedirect::class,
|
||||
\App\Http\Middleware\FrameGuard::class,
|
||||
\App\Http\Middleware\CustomHandle::class,
|
||||
],
|
||||
|
||||
// 'api' => [
|
||||
|
||||
33
app/Http/Middleware/CheckBrowser.php
Normal file
33
app/Http/Middleware/CheckBrowser.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
|
||||
class CheckBrowser
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param string|null $guard
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $guard = null)
|
||||
{
|
||||
$result = \Helper::checkBrowser($request);
|
||||
if ($result['msg']) {
|
||||
if (config('app.disable_browser_check')) {
|
||||
//\Log::error($result['msg']);
|
||||
} else {
|
||||
abort(403, __($result['msg'].'[display]'));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/ContentSecurityPolicy.php
Normal file
34
app/Http/Middleware/ContentSecurityPolicy.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
|
||||
/**
|
||||
* On regular pages CSP is added via <meta> tag.
|
||||
* For /ajax-html/ requests CSP is added as a response header.
|
||||
*/
|
||||
class ContentSecurityPolicy
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param string|null $guard
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $guard = null)
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (str_contains($request->path(), '/ajax-html/')) {
|
||||
$response->header('Content-Security-Policy', \Helper::getCspValue());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class EncryptCookies extends Middleware
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
'in_app'
|
||||
'in_app',
|
||||
'customers_view',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use Closure;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HttpsRedirect {
|
||||
|
||||
class HttpsRedirect
|
||||
{
|
||||
/**
|
||||
* The current proxy header mappings.
|
||||
*
|
||||
@@ -28,7 +28,7 @@ class HttpsRedirect {
|
||||
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (\Helper::isHttps()) {
|
||||
if (\Helper::isHttps() && !\Helper::isConsole()) {
|
||||
//$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
|
||||
//!$request->secure()
|
||||
if (!\Helper::isCurrentUrlHttps()) {
|
||||
@@ -45,4 +45,4 @@ class HttpsRedirect {
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,13 @@ class ResponseHeaders
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
// $response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
// $response->headers->set('X-Frame-Options', 'DENY');
|
||||
//$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
//$response->headers->set('Content-Security-Policy', "style-src 'self'");
|
||||
|
||||
// Instruct browsers to always use HTTPS for all future requests.
|
||||
if (\Helper::isHttps()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
}
|
||||
|
||||
// Disable caching.
|
||||
if (method_exists($response, 'header')) {
|
||||
$response->header('Pragma', 'no-cache');
|
||||
|
||||
@@ -18,14 +18,30 @@ class TokenAuth
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// This is needed to restore authentication when app session expires.
|
||||
if (!$request->user() && !empty($request->auth_token) && $request->cookie('in_app')) {
|
||||
try {
|
||||
$user = User::where(\DB::raw('md5(CONCAT(id, created_at, "'.config('app.key').'"))'), $request->auth_token)
|
||||
->first();
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, '[TokenAuth]');
|
||||
if (!$request->user() && !empty($request->auth_token) && is_string($request->auth_token) && \Helper::isInApp($request)) {
|
||||
|
||||
// Decode token (format: urlencode(base64_encode(user_id:expiry:hash)))
|
||||
$parts = explode(':', urldecode(base64_decode($request->auth_token)));
|
||||
if (count($parts) !== 3) {
|
||||
return $next($request);
|
||||
}
|
||||
if (!empty($user)) {
|
||||
list($user_id, $expiry, $token_hash) = $parts;
|
||||
|
||||
// Check expiration.
|
||||
if (time() > (int)$expiry) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get user.
|
||||
$user = User::find($user_id);
|
||||
if (!$user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Verify hash.
|
||||
$hash = hash_hmac('sha256', $user_id.':'.$expiry, config('app.key').$user->password);
|
||||
|
||||
if (hash_equals($hash, $token_hash)) {
|
||||
\Auth::login($user);
|
||||
}
|
||||
}
|
||||
|
||||
98
app/Http/Middleware/TrustHosts.php
Normal file
98
app/Http/Middleware/TrustHosts.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||
|
||||
/**
|
||||
* https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-822g-7rw5-53xj
|
||||
*/
|
||||
class TrustHosts
|
||||
{
|
||||
/**
|
||||
* The application instance.
|
||||
*
|
||||
* @var \Illuminate\Contracts\Foundation\Application
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* The trusted hosts that have been configured to always be trusted.
|
||||
*/
|
||||
protected static $trusted_hosts;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new middleware instance.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Foundation\Application $app
|
||||
*/
|
||||
public function __construct(Application $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function handle(Request $request, $next)
|
||||
{
|
||||
if (//! $this->app->environment('local') &&
|
||||
$this->app->runningUnitTests()
|
||||
) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if current host matches APP_URL.
|
||||
list($current_host) = explode(':', request()->getHttpHost());
|
||||
$current_host = mb_strtolower($current_host);
|
||||
|
||||
$app_host = mb_strtolower(\Helper::getDomain());
|
||||
|
||||
// Do not check during installation process.
|
||||
if ($app_host == 'example.com' && preg_match("#^install/#", $request->path())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($current_host == $app_host) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check hosts from APP_TRUSTED_HOSTS.
|
||||
$trusted_hosts = explode(',', config('app.trusted_hosts'));
|
||||
foreach ($trusted_hosts as $host) {
|
||||
$host = mb_strtolower(trim($host));
|
||||
if ($host && $current_host == $host) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
$is_trusted_host = \Eventy::filter('app.is_trusted_host', false, $current_host);
|
||||
|
||||
if ($is_trusted_host) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Throws: Untrusted Host...
|
||||
//Request::setTrustedHosts([$current_host]);
|
||||
//throw new SuspiciousOperationException(sprintf('Untrusted Host "%s".', $host));
|
||||
|
||||
\Helper::denyAccess("Untrusted Host: {$current_host}. Add the following to your .env file and clear cache: APP_TRUSTED_HOSTS ='$current_host'", true);
|
||||
|
||||
//return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a regular expression matching the application URL and all of its subdomains.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
// protected function allSubdomainsOfAppUrl()
|
||||
// {
|
||||
// if ($host = parse_url($this->app['config']->get('app.url'), PHP_URL_HOST)) {
|
||||
// return '^(.+\.)?'.preg_quote($host).'$';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
23
app/Job.php
23
app/Job.php
@@ -27,9 +27,9 @@ class Job extends Model
|
||||
return $this->payload_decoded;
|
||||
}
|
||||
|
||||
public function getCommand()
|
||||
public function getCommand($allowed_classes = [])
|
||||
{
|
||||
return self::getPayloadCommand($this->getPayloadDecoded());
|
||||
return self::getPayloadCommand($this->getPayloadDecoded(), $allowed_classes);
|
||||
}
|
||||
|
||||
public function getCommandLastThread()
|
||||
@@ -42,17 +42,32 @@ class Job extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getPayloadCommand($payload)
|
||||
public static function getPayloadCommand($payload, $allowed_classes = [])
|
||||
{
|
||||
if (empty($payload['data']) || empty($payload['data']['command'])) {
|
||||
return null;
|
||||
}
|
||||
if (!$allowed_classes) {
|
||||
$allowed_classes = [
|
||||
'App\Jobs\SendReplyToCustomer',
|
||||
'App\Jobs\SendNotificationToUsers',
|
||||
'App\Jobs\SendAutoReply',
|
||||
'App\Jobs\SendAlert',
|
||||
'App\Jobs\SendEmailReplyError',
|
||||
'Illuminate\Contracts\Database\ModelIdentifier',
|
||||
];
|
||||
}
|
||||
try {
|
||||
// If some record has been deleted from DB, there will be an error:
|
||||
// No query results for model [App\Conversation].
|
||||
return unserialize($payload['data']['command']);
|
||||
return unserialize($payload['data']['command'], ['allowed_classes' => $allowed_classes]);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getTriggerActionName($payload)
|
||||
{
|
||||
return preg_replace('/^.*?action";s:\d+:"([^"]+)".*$/s', '$1', $payload['data']['command'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,23 @@ class SendAutoReply implements ShouldQueue
|
||||
$failures = [];
|
||||
$exception = null;
|
||||
|
||||
// Reply from the alias the customer emailed (if "Allow to reply from aliases" is enabled).
|
||||
$from_alias = '';
|
||||
$aliases = $this->mailbox->getAliases(true, true);
|
||||
if (count($aliases)) {
|
||||
$original_recipients = array_merge($this->thread->getToArray(), $this->thread->getCcArray());
|
||||
foreach ($original_recipients as $original_recipient) {
|
||||
$original_recipient = \App\Email::sanitizeEmail($original_recipient);
|
||||
if ($original_recipient && array_key_exists($original_recipient, $aliases)) {
|
||||
$from_alias = $original_recipient;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to([['name' => $this->customer->getFullName(), 'email' => $customer_email]])
|
||||
->send(new AutoReply($this->conversation, $this->mailbox, $this->customer, $headers));
|
||||
->send(new AutoReply($this->conversation, $this->mailbox, $this->customer, $headers, $from_alias));
|
||||
} catch (\Exception $e) {
|
||||
// We come here in case SMTP server unavailable for example
|
||||
activity()
|
||||
|
||||
@@ -24,6 +24,8 @@ class SendEmailReplyError implements ShouldQueue
|
||||
|
||||
public $mailbox;
|
||||
|
||||
public $text;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
* fwrite() function in /vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/StreamBuffer.php
|
||||
@@ -37,11 +39,12 @@ class SendEmailReplyError implements ShouldQueue
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($from, $user, $mailbox)
|
||||
public function __construct($from, $user, $mailbox, $text = null)
|
||||
{
|
||||
$this->from = $from;
|
||||
$this->user = $user;
|
||||
$this->mailbox = $mailbox;
|
||||
$this->text = $text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +61,7 @@ class SendEmailReplyError implements ShouldQueue
|
||||
|
||||
try {
|
||||
Mail::to([['name' => '', 'email' => $this->from]])
|
||||
->send(new UserEmailReplyError());
|
||||
->send(new UserEmailReplyError($this->text));
|
||||
} catch (\Exception $e) {
|
||||
// We come here in case SMTP server unavailable for example
|
||||
activity()
|
||||
|
||||
@@ -96,10 +96,13 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
$headers['X-Auto-Response-Suppress'] = 'All';
|
||||
|
||||
// We throw an exception if any of the send attempts throws an exception (connection error, etc)
|
||||
$global_exception = null;
|
||||
$last_send_exception = null;
|
||||
|
||||
foreach ($this->users as $user) {
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-2j2c-g32w-96vm
|
||||
$user->refresh();
|
||||
|
||||
// User can ne deleted from DB.
|
||||
if (!isset($user->id)) {
|
||||
continue;
|
||||
@@ -109,6 +112,10 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$user->can('view', $this->conversation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If for one user sending fails the job is marked as failed and retried after some time.
|
||||
// So we have to check if notification email has already been successfully sent to this user.
|
||||
if ($this->attempts() > 1) {
|
||||
@@ -123,7 +130,7 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION.'-'.$last_thread->id.'-'.$user->id.'-'.time().'@'.$mailbox->getEmailDomain();
|
||||
$message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION.'-'.$last_thread->id.'-'.$user->id.'-'.\MailHelper::getMessageIdHash($last_thread->id).'@'.$mailbox->getEmailDomain();
|
||||
$headers['Message-ID'] = $message_id;
|
||||
|
||||
// If this is notification on message from customer, set customer as sender name
|
||||
@@ -154,17 +161,18 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
Mail::to([['name' => $user->getFullName(), 'email' => $user->email]])
|
||||
->send(new UserNotification($user, $this->conversation, $this->threads, $headers, $from, $mailbox));
|
||||
} catch (\Exception $e) {
|
||||
// We come here in case SMTP server unavailable for example
|
||||
// We come here if SMTP server unavailable for example
|
||||
activity()
|
||||
->causedBy($user)
|
||||
->withProperties([
|
||||
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
|
||||
'conversation' => '#'.$this->conversation->number.'-'.($last_thread ? $last_thread->id : ''),
|
||||
])
|
||||
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
|
||||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_USER);
|
||||
|
||||
$exception = $e;
|
||||
$global_exception = $e;
|
||||
$last_send_exception = $e;
|
||||
}
|
||||
|
||||
$status_message = '';
|
||||
@@ -185,7 +193,7 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
SendLog::log($last_thread->id, $message_id, $user->email, SendLog::MAIL_TYPE_USER_NOTIFICATION, $status, null, $user->id, $status_message);
|
||||
}
|
||||
|
||||
if ($global_exception) {
|
||||
if ($last_send_exception) {
|
||||
// Retry job with delay.
|
||||
// https://stackoverflow.com/questions/35258175/how-can-i-create-delays-between-failed-queued-job-attempts-in-laravel
|
||||
// We do not try to resend Bounce messages: https://github.com/freescout-helpdesk/freescout/issues/3156
|
||||
@@ -194,13 +202,24 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
// Second attempt after 5 min.
|
||||
$this->release(300);
|
||||
} else {
|
||||
// Mail data has been passed to the mail server but after that
|
||||
// we have not receved a response from the mail server (due to timeout).
|
||||
// So we may consider that the email has been successfully sent.
|
||||
// Otherwise this email may be sent again and again.
|
||||
if ($this->attempts() >= 3
|
||||
&& preg_match("#^Connection to .* Timed Out$#", $last_send_exception->getMessage())
|
||||
&& \MailHelper::$smtp_data_sent
|
||||
) {
|
||||
$this->fail($last_send_exception);
|
||||
}
|
||||
|
||||
// Others - after 1 hour.
|
||||
$this->release(3600);
|
||||
}
|
||||
|
||||
throw $global_exception;
|
||||
throw $last_send_exception;
|
||||
} else {
|
||||
$this->fail($global_exception);
|
||||
$this->fail($last_send_exception);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
|
||||
public $threads;
|
||||
|
||||
// Recipient.
|
||||
public $customer;
|
||||
|
||||
// Needed to show proper signature when conversation is being moved between mailboxes.
|
||||
public $mailbox_change_history = [];
|
||||
|
||||
private $failures = [];
|
||||
private $recipients = [];
|
||||
private $last_thread = null;
|
||||
@@ -51,6 +55,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->threads = $threads;
|
||||
// Recipient.
|
||||
$this->customer = $customer;
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
if ($forward_child_thread->isForwarded() && $forward_child_thread->getForwardParentConversation()) {
|
||||
|
||||
// Add replies from original conversation.
|
||||
$forwarded_replies = $forward_child_thread->getForwardParentConversation()->getReplies();
|
||||
$forwarded_replies = $forward_child_thread->getForwardParentConversation()->getThreads(null, null, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_LINEITEM]);
|
||||
$forwarded_replies = Thread::sortThreads($forwarded_replies);
|
||||
$forward_parent_thread = Thread::find($forward_child_thread->getMetaFw(Thread::META_FORWARD_PARENT_THREAD_ID));
|
||||
|
||||
@@ -95,6 +100,22 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
}
|
||||
$this->threads = $this->threads->merge($forwarded_replies);
|
||||
$is_forward = true;
|
||||
|
||||
// In order to show proper signature.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5419
|
||||
$new_mailbox_id = null;
|
||||
foreach ($this->threads as $reply) {
|
||||
if ($reply->action_type == Thread::ACTION_TYPE_MOVED_FROM_MAILBOX && is_numeric($reply->action_data)) {
|
||||
$new_mailbox_id = (int)$reply->action_data;
|
||||
}
|
||||
if ($reply->isReply() && $new_mailbox_id !== null) {
|
||||
$this->mailbox_change_history[$reply->id] = $new_mailbox_id;
|
||||
$new_mailbox_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove line items.
|
||||
$this->threads = $this->threads->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +167,9 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
$references = '';
|
||||
if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
|
||||
|
||||
$headers['In-Reply-To'] = '<'.$last_customer_thread->message_id.'>';
|
||||
// Always put real Message-ID into In-Reply-To field.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5620
|
||||
$headers['In-Reply-To'] = '<'.$last_customer_thread->getMessageId().'>';
|
||||
//$headers['References'] = '<'.$last_customer_thread->message_id.'>';
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3175
|
||||
$i = 0;
|
||||
@@ -161,7 +184,36 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
$i++;
|
||||
}
|
||||
if ($references_array) {
|
||||
$references = '<'.implode('> <', array_reverse($references_array)).'>';
|
||||
// Trim the list of References: some SMTP relays reject messages
|
||||
// whose headers block exceeds 4096 chars.
|
||||
// Preserve the first and the last reference.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5542
|
||||
$max_references_length = 1500;
|
||||
$recent = [];
|
||||
$recent[] = $references_array[0];
|
||||
$length = strlen($references_array[0]) + 3;
|
||||
|
||||
foreach ($references_array as $i => $reference) {
|
||||
// The first and the last Reference are added manually.
|
||||
if ($i == 0 || $i == count($references_array)-1) {
|
||||
continue;
|
||||
}
|
||||
$length += strlen($reference) + 3;
|
||||
if ($length > $max_references_length) {
|
||||
break;
|
||||
}
|
||||
$recent[] = $reference;
|
||||
}
|
||||
|
||||
// Append the last.
|
||||
if (!empty($references_array[count($references_array)-1])) {
|
||||
$recent[] = $references_array[count($references_array)-1];
|
||||
}
|
||||
|
||||
// Oldest first, per RFC 5322 3.6.4.
|
||||
$references_array = array_reverse($recent);
|
||||
|
||||
$references = '<'.implode('> <', $references_array).'>';
|
||||
}
|
||||
if ($references) {
|
||||
$headers['References'] = $references;
|
||||
@@ -173,7 +225,13 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
if ($last_customer_thread) {
|
||||
$outloook_thread_index = $last_customer_thread->getHeader('Thread-Index');
|
||||
if (!empty($outloook_thread_index)) {
|
||||
$headers['Thread-Index'] = $outloook_thread_index;
|
||||
// A reply must extend the parent's conversation index with
|
||||
// a 5-byte child block (MS-OXOMSG 2.2.1.3) rather than echo
|
||||
// it verbatim, otherwise Outlook files the reply as a new
|
||||
// conversation and the customer's original message is left
|
||||
// on its own. Falls back to the verbatim copy if the value
|
||||
// does not decode as a conversation index.
|
||||
$headers['Thread-Index'] = self::extendThreadIndex($outloook_thread_index);
|
||||
$outloook_thread_topic = $last_customer_thread->getHeader('Thread-Topic');
|
||||
if ($outloook_thread_topic) {
|
||||
$headers['Thread-Topic'] = $outloook_thread_topic;
|
||||
@@ -222,7 +280,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
}
|
||||
|
||||
// Configure mail driver according to Mailbox settings
|
||||
\MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation);
|
||||
\MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation, $this->last_thread);
|
||||
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3330
|
||||
if (!\MailHelper::$smtp_queue_id_plugin_registered) {
|
||||
@@ -233,7 +291,12 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
$this->message_id = $this->last_thread->getMessageId($mailbox);
|
||||
$headers['Message-ID'] = $this->message_id;
|
||||
|
||||
$this->customer_email = $this->conversation->customer_email;
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5121
|
||||
if ($this->customer->id == $this->conversation->customer_id) {
|
||||
$this->customer_email = $this->conversation->customer_email;
|
||||
} else {
|
||||
$this->customer_email = $this->last_thread->getToArray()[0] ?: $this->conversation->customer_email;
|
||||
}
|
||||
|
||||
// For phone conversations we may need to get customer email.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3270
|
||||
@@ -247,7 +310,9 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
// Try to get customer by email
|
||||
if (!$this->customer) {
|
||||
$this->customer = Customer::getByEmail($this->customer_email);
|
||||
return;
|
||||
if (!$this->customer) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$to_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getToArray());
|
||||
@@ -295,7 +360,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
|
||||
$headers['X-FreeScout-Mail-Type'] = 'customer.message';
|
||||
|
||||
$reply_mail = new ReplyToCustomer($this->conversation, $this->threads, $headers, $mailbox, $subject, $threads_count);
|
||||
$reply_mail = new ReplyToCustomer($this->conversation, $this->threads, $headers, $mailbox, $subject, $threads_count, $this->mailbox_change_history);
|
||||
|
||||
$smtp_queue_id = null;
|
||||
|
||||
@@ -316,6 +381,8 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
->causedBy($this->customer)
|
||||
->withProperties([
|
||||
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
|
||||
'to' => $this->customer_email,
|
||||
'conversation' => '#'.$this->conversation->number.'-'.$this->last_thread->id,
|
||||
])
|
||||
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
|
||||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
|
||||
@@ -471,7 +538,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
|
||||
try {
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3502
|
||||
$imap_sent_folder = mb_convert_encoding($imap_sent_folder, "UTF7-IMAP","UTF-8");
|
||||
$imap_sent_folder = mb_convert_encoding($imap_sent_folder, "UTF7-IMAP", "UTF-8");
|
||||
|
||||
// https://github.com/Webklex/php-imap/issues/380
|
||||
if (method_exists($client, 'getFolderByPath')) {
|
||||
@@ -570,11 +637,18 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
*/
|
||||
public function failed(\Exception $e)
|
||||
{
|
||||
// failed() is called by unserializing the original queue payload.
|
||||
if (!$this->last_thread && $this->threads && count($this->threads)) {
|
||||
$this->threads = Thread::sortThreads($this->threads);
|
||||
$this->last_thread = $this->threads->first();
|
||||
}
|
||||
|
||||
activity()
|
||||
->causedBy($this->customer)
|
||||
->withProperties([
|
||||
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
|
||||
'to' => $this->customer_email,
|
||||
'conversation' => '#'.$this->conversation->number.'-'.($this->last_thread ? $this->last_thread->id : ''),
|
||||
])
|
||||
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
|
||||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
|
||||
@@ -603,4 +677,41 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
SendLog::log($this->last_thread->id, $this->message_id, $recipient, SendLog::MAIL_TYPE_EMAIL_TO_CUSTOMER, $status, $customer_id, null, $status_message, $smtp_queue_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a 5-byte child block to an Outlook conversation index
|
||||
* (Thread-Index header, MS-OXOMSG 2.2.1.3) so the reply nests under the
|
||||
* customer's message instead of being read by Outlook as a new
|
||||
* conversation. Header = 0x01 + 5 bytes of FILETIME (bits 16-55) + 16-byte
|
||||
* GUID; each child = code(1 bit) + time delta(31) + random(4) + sequence(4).
|
||||
* Returns the input unchanged when it does not decode as a valid index.
|
||||
* https://github.com/freescout-help-desk/freescout/issues/4922
|
||||
*/
|
||||
public static function extendThreadIndex($thread_index)
|
||||
{
|
||||
$raw = base64_decode(trim((string)$thread_index), true);
|
||||
if ($raw === false || strlen($raw) < 22 || (strlen($raw) - 22) % 5 !== 0) {
|
||||
return $thread_index;
|
||||
}
|
||||
// Header timestamp: FILETIME bits 16-55, big-endian, in bytes 1-5.
|
||||
$hdr = unpack('Nhi/Clo', substr($raw, 1, 5));
|
||||
$hdr_bits = ($hdr['hi'] << 8) | $hdr['lo'];
|
||||
$now_ft = (int) round((microtime(true) + 11644473600) * 10000000);
|
||||
$now_bits = ($now_ft >> 16) & 0xFFFFFFFFFF;
|
||||
$delta = ($now_bits - $hdr_bits) << 16;
|
||||
if ($delta < 0) {
|
||||
$delta = 0;
|
||||
}
|
||||
$code = 0;
|
||||
$diff = $delta >> 18;
|
||||
if ($diff > 0x7FFFFFFF) {
|
||||
$code = 1;
|
||||
$diff = min($delta >> 23, 0x7FFFFFFF);
|
||||
}
|
||||
// 40-bit child block: code(1) | diff(31) | random(4) | sequence(4)=0
|
||||
$block = (($code << 39) | ($diff << 8) | (random_int(0, 15) << 4)) & 0xFFFFFFFFFF;
|
||||
$bytes = pack('C5', ($block >> 32) & 0xFF, ($block >> 24) & 0xFF, ($block >> 16) & 0xFF, ($block >> 8) & 0xFF, $block & 0xFF);
|
||||
|
||||
return base64_encode($raw.$bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class RefreshConversations
|
||||
*/
|
||||
public function handle($event)
|
||||
{
|
||||
Conversation::refreshConversations($event->conversation, $event->thread ?? $event->last_thread);
|
||||
$thread = $event->thread ?? $event->last_thread;
|
||||
if ($thread) {
|
||||
Conversation::refreshConversations($event->conversation, $thread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class SendAutoReply
|
||||
}
|
||||
|
||||
// Do not send auto replies to spam messages.
|
||||
if ($conversation->status == Conversation::STATUS_SPAM) {
|
||||
if ($conversation->isSpam()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class SendNotificationToUsers
|
||||
{
|
||||
$event_type = null;
|
||||
$caused_by_user_id = null;
|
||||
$extra_data = [];
|
||||
|
||||
// Detect event type by event class
|
||||
switch (get_class($event)) {
|
||||
@@ -47,7 +48,7 @@ class SendNotificationToUsers
|
||||
break;
|
||||
case 'App\Events\CustomerCreatedConversation':
|
||||
// Do not send notification if conversation is spam.
|
||||
if ($event->conversation->status != Conversation::STATUS_SPAM) {
|
||||
if (!$event->conversation->isSpam()) {
|
||||
$event_type = Subscription::EVENT_TYPE_NEW;
|
||||
}
|
||||
break;
|
||||
@@ -55,7 +56,17 @@ class SendNotificationToUsers
|
||||
$caused_by_user_id = $event->user->id;
|
||||
$event_type = Subscription::EVENT_TYPE_ASSIGNED;
|
||||
break;
|
||||
case 'App\Events\UserMovedConversation':
|
||||
$caused_by_user_id = $event->caused_by_user_id;
|
||||
$extra_data['from_mailbox'] = $event->from_mailbox;
|
||||
$event_type = Subscription::EVENT_TYPE_USER_MOVED;
|
||||
break;
|
||||
case 'App\Events\CustomerReplied':
|
||||
// Do not send notifications to users if customer sent a reply
|
||||
// to the conversation marked as Spam.
|
||||
if (!empty($event->conversation) && $event->conversation->isSpam()) {
|
||||
return;
|
||||
}
|
||||
$event_type = Subscription::EVENT_TYPE_CUSTOMER_REPLIED;
|
||||
break;
|
||||
}
|
||||
@@ -70,6 +81,6 @@ class SendNotificationToUsers
|
||||
$conversation = $event->conversation;
|
||||
|
||||
// Using the last argument you can make event to be processed immediately
|
||||
Subscription::registerEvent($event_type, $conversation, $caused_by_user_id/*, true*/);
|
||||
Subscription::registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Conversation;
|
||||
use App\Customer;
|
||||
use App\Thread;
|
||||
|
||||
class SendReplyToCustomer
|
||||
{
|
||||
@@ -23,14 +25,20 @@ class SendReplyToCustomer
|
||||
{
|
||||
$conversation = $event->conversation;
|
||||
|
||||
$main_customer_email = $conversation->customer->getMainEmail();
|
||||
|
||||
// Do not send email if this is a Phone conversation and customer has no email.
|
||||
if ($conversation->isPhone()) {
|
||||
if (!$conversation->customer->getMainEmail()) {
|
||||
if (!$main_customer_email) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$replies = $conversation->getReplies();
|
||||
// Get threads with line items, line items are needed
|
||||
// to show proper signature when conversation is being moved
|
||||
// between mailboxes.
|
||||
//$replies = $conversation->getReplies();
|
||||
$replies = $conversation->getThreads(null, null, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_LINEITEM]);
|
||||
|
||||
// Ignore imported messages.
|
||||
if ($replies && $replies->first() && $replies->first()->imported) {
|
||||
@@ -47,6 +55,33 @@ class SendReplyToCustomer
|
||||
$replies->forget($i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If thread is not provided - stop.
|
||||
// It allows to trigger event but avoid sending an email to customer.
|
||||
return;
|
||||
}
|
||||
|
||||
// In order to show proper signature.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5419
|
||||
$mailbox_change_history = [];
|
||||
$new_mailbox_id = null;
|
||||
foreach ($replies as $i => $reply) {
|
||||
if ($reply->action_type == Thread::ACTION_TYPE_MOVED_FROM_MAILBOX && is_numeric($reply->action_data)) {
|
||||
$new_mailbox_id = (int)$reply->action_data;
|
||||
}
|
||||
if ($reply->isReply() && $new_mailbox_id !== null) {
|
||||
$mailbox_change_history[$reply->id] = $new_mailbox_id;
|
||||
$new_mailbox_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove line items.
|
||||
$replies = $replies->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]);
|
||||
|
||||
// Allow to cancel mail sending if needed.
|
||||
$skip_send = \Eventy::filter('conversation.skip_send_reply_to_customer', false, $conversation, $replies);
|
||||
if ($skip_send) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat conversation.
|
||||
@@ -60,7 +95,23 @@ class SendReplyToCustomer
|
||||
//if (!$conversation->imported) {
|
||||
$delay = \Eventy::filter('conversation.send_reply_to_customer_delay', now()->addSeconds(Conversation::UNDO_TIMOUT), $conversation, $replies);
|
||||
|
||||
\App\Jobs\SendReplyToCustomer::dispatch($conversation, $replies, $conversation->customer)
|
||||
$recipient_customer = $conversation->customer;
|
||||
|
||||
// The reply may be sent to some other customer from previous threads.
|
||||
// https://github.com/freescout-help-desk/freescout/pull/5199
|
||||
if ($thread
|
||||
&& ($to_array = $thread->getToArray())
|
||||
&& !empty($to_array[0])
|
||||
&& ($customer_email = $to_array[0])
|
||||
&& $customer_email != $main_customer_email
|
||||
) {
|
||||
$other_customer = Customer::getByEmail($customer_email);
|
||||
if ($other_customer) {
|
||||
$recipient_customer = $other_customer;
|
||||
}
|
||||
}
|
||||
|
||||
\App\Jobs\SendReplyToCustomer::dispatch($conversation, $replies, $recipient_customer)
|
||||
->delay($delay)
|
||||
->onQueue('emails');
|
||||
}
|
||||
|
||||
@@ -26,15 +26,21 @@ class AutoReply extends Mailable
|
||||
*/
|
||||
public $headers = [];
|
||||
|
||||
/**
|
||||
* Mailbox alias to send From (empty to use mailbox default email).
|
||||
*/
|
||||
public $from_alias = '';
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($conversation, $mailbox, $customer, $headers)
|
||||
public function __construct($conversation, $mailbox, $customer, $headers, $from_alias = '')
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->mailbox = $mailbox;
|
||||
$this->customer = $customer;
|
||||
$this->headers = $headers;
|
||||
$this->from_alias = $from_alias;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,15 +83,54 @@ class AutoReply extends Mailable
|
||||
public function setHeaders()
|
||||
{
|
||||
$new_headers = $this->headers;
|
||||
if (!empty($new_headers)) {
|
||||
$this->withSwiftMessage(function ($swiftmessage) use ($new_headers) {
|
||||
if (!empty($new_headers['Message-ID'])) {
|
||||
$swiftmessage->setId($new_headers['Message-ID']);
|
||||
$from_alias = $this->from_alias;
|
||||
if (!empty($new_headers) || $from_alias) {
|
||||
$mailbox = $this->mailbox;
|
||||
$conversation = $this->conversation;
|
||||
$this->withSwiftMessage(function ($swiftmessage) use ($new_headers, $from_alias, $mailbox, $conversation) {
|
||||
$headers = null;
|
||||
|
||||
if (!empty($new_headers)) {
|
||||
if (!empty($new_headers['Message-ID'])) {
|
||||
$swiftmessage->setId($new_headers['Message-ID']);
|
||||
}
|
||||
$headers = $swiftmessage->getHeaders();
|
||||
foreach ($new_headers as $header => $value) {
|
||||
if ($header != 'Message-ID') {
|
||||
$headers->addTextHeader($header, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
$headers = $swiftmessage->getHeaders();
|
||||
foreach ($new_headers as $header => $value) {
|
||||
if ($header != 'Message-ID') {
|
||||
$headers->addTextHeader($header, $value);
|
||||
|
||||
if (!empty($from_alias)) {
|
||||
$aliases = $mailbox->getAliases();
|
||||
|
||||
if (array_key_exists($from_alias, $aliases)) {
|
||||
$from_alias_name = $aliases[$from_alias] ?? '';
|
||||
|
||||
// Take into account mailbox From Name setting.
|
||||
$mailbox_mail_from = $mailbox->getMailFrom(null, $conversation);
|
||||
if ($mailbox_mail_from['name'] == $mailbox->name && $from_alias_name) {
|
||||
// Use name from alias.
|
||||
} else {
|
||||
$from_alias_name = $mailbox_mail_from['name'];
|
||||
}
|
||||
|
||||
if (!$headers) {
|
||||
$headers = $swiftmessage->getHeaders();
|
||||
}
|
||||
|
||||
$swift_from = $headers->get('From');
|
||||
|
||||
if ($from_alias_name) {
|
||||
$swift_from->setNameAddresses([
|
||||
$from_alias => $from_alias_name,
|
||||
]);
|
||||
} else {
|
||||
$swift_from->setAddresses([
|
||||
$from_alias,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,12 +48,17 @@ class ReplyToCustomer extends Mailable
|
||||
*/
|
||||
public $threads_count;
|
||||
|
||||
/**
|
||||
* Needed to show proper signature when conversation is being moved between mailboxes.
|
||||
*/
|
||||
public $mailbox_change_history;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($conversation, $threads, $headers, $mailbox, $subject, $threads_count = 1)
|
||||
public function __construct($conversation, $threads, $headers, $mailbox, $subject, $threads_count = 1, $mailbox_change_history = [])
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->threads = $threads;
|
||||
@@ -61,6 +66,7 @@ class ReplyToCustomer extends Mailable
|
||||
$this->mailbox = $mailbox;
|
||||
$this->subject = $subject;
|
||||
$this->threads_count = $threads_count;
|
||||
$this->mailbox_change_history = $mailbox_change_history;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,11 +129,11 @@ class ReplyToCustomer extends Mailable
|
||||
|
||||
if ($from_alias_name) {
|
||||
$swift_from->setNameAddresses([
|
||||
$from_alias => $from_alias_name
|
||||
$from_alias => $from_alias_name,
|
||||
]);
|
||||
} else {
|
||||
$swift_from->setAddresses([
|
||||
$from_alias
|
||||
$from_alias,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -143,8 +149,8 @@ class ReplyToCustomer extends Mailable
|
||||
});
|
||||
}
|
||||
|
||||
$template_html = \Eventy::filter('email.reply_to_customer.template_name_html','emails/customer/reply_fancy');
|
||||
$template_text = \Eventy::filter('email.reply_to_customer.template_name_text','emails/customer/reply_fancy_text');
|
||||
$template_html = \Eventy::filter('email.reply_to_customer.template_name_html', 'emails/customer/reply_fancy');
|
||||
$template_text = \Eventy::filter('email.reply_to_customer.template_name_text', 'emails/customer/reply_fancy_text');
|
||||
|
||||
// from($this->from) Sets only email, name stays empty.
|
||||
// So we set from in Mail::setMailDriver
|
||||
@@ -154,10 +160,14 @@ class ReplyToCustomer extends Mailable
|
||||
|
||||
if ($thread->has_attachments) {
|
||||
foreach ($thread->attachments as $attachment) {
|
||||
if ($attachment->fileExists()) {
|
||||
$message->attach($attachment->getLocalFilePath());
|
||||
if (\Helper::isLocalStorage()) {
|
||||
if ($attachment->fileExists()) {
|
||||
$message->attach($attachment->getLocalFilePath());
|
||||
} else {
|
||||
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
|
||||
}
|
||||
} else {
|
||||
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
|
||||
\Eventy::filter('email.reply_to_customer.attach', false, $message, $attachment, $thread->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ use Illuminate\Mail\Mailable;
|
||||
|
||||
class UserEmailReplyError extends Mailable
|
||||
{
|
||||
public $text;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
public function __construct($text = null)
|
||||
{
|
||||
$this->text = $text;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
208
app/Mailbox.php
208
app/Mailbox.php
@@ -3,6 +3,7 @@
|
||||
namespace App;
|
||||
|
||||
use App\Email;
|
||||
use App\Thread;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -14,6 +15,12 @@ class Mailbox extends Model
|
||||
// This is obligatory.
|
||||
public $rememberCacheDriver = 'array';
|
||||
|
||||
/**
|
||||
* State.
|
||||
*/
|
||||
const STATE_ACTIVE = 1;
|
||||
const STATE_ARCHIVED = 2;
|
||||
|
||||
/**
|
||||
* From Name: name that will appear in the From field when a customer views your email.
|
||||
*/
|
||||
@@ -132,6 +139,8 @@ class Mailbox extends Model
|
||||
const DEFAULT_SIGNATURE = '<br><span style="color:#808080;">--<br>
|
||||
{%mailbox.name%}</span>';
|
||||
|
||||
const META_STATE = 'st';
|
||||
|
||||
/**
|
||||
* Default values.
|
||||
*/
|
||||
@@ -217,6 +226,23 @@ class Mailbox extends Model
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mailbox state: stored in meta for now.
|
||||
*/
|
||||
public function getStateAttribute()
|
||||
{
|
||||
return $this->getMeta(self::META_STATE, self::STATE_ACTIVE);
|
||||
}
|
||||
|
||||
public function setStateAttribute($value)
|
||||
{
|
||||
$value = (int)$value;
|
||||
if (!in_array($value, [self::STATE_ACTIVE, self::STATE_ARCHIVED])) {
|
||||
$value = self::STATE_ACTIVE;
|
||||
}
|
||||
$this->setMeta(self::META_STATE, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users having access to the mailbox.
|
||||
*/
|
||||
@@ -402,12 +428,22 @@ class Mailbox extends Model
|
||||
}
|
||||
}
|
||||
|
||||
public function isActive()
|
||||
{
|
||||
return $this->state == self::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
public function isArchived()
|
||||
{
|
||||
return $this->state == self::STATE_ARCHIVED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is mailbox available for using.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isActive()
|
||||
public function isConnected()
|
||||
{
|
||||
return $this->isInActive() && $this->isOutActive();
|
||||
}
|
||||
@@ -543,6 +579,9 @@ class Mailbox extends Model
|
||||
$user = User::find($user_id);
|
||||
}
|
||||
}
|
||||
if (!$user->isAdmin() && !$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
$filter = \Eventy::filter('mailbox.user_has_access', -1, $this, $user);
|
||||
if ($filter != -1) {
|
||||
return (bool)$filter;
|
||||
@@ -561,7 +600,7 @@ class Mailbox extends Model
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getMailFrom($from_user = null, $conversation = null)
|
||||
public function getMailFrom($from_user = null, $conversation = null, $thread = null)
|
||||
{
|
||||
// Mailbox name by default
|
||||
$name = $this->name;
|
||||
@@ -579,8 +618,8 @@ class Mailbox extends Model
|
||||
}
|
||||
|
||||
return [
|
||||
'address' => \Eventy::filter('mailbox.get_mail_from_address', $this->email, $from_user, $conversation),
|
||||
'name' => \Eventy::filter('mailbox.get_mail_from_name', $name, $from_user, $conversation)
|
||||
'address' => \Eventy::filter('mailbox.get_mail_from_address', $this->email, $from_user, $conversation, $thread, $this),
|
||||
'name' => \Eventy::filter('mailbox.get_mail_from_name', $name, $from_user, $conversation, $thread, $this)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -669,6 +708,22 @@ class Mailbox extends Model
|
||||
return \Eventy::filter('mailbox.in_protocols', self::$in_protocols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if Fetching settings for the mailbox have been saved by admin.
|
||||
*/
|
||||
public function inSettingsSaved()
|
||||
{
|
||||
return ($this->attributes['in_password'] !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if Sending settings for the mailbox have been saved by admin.
|
||||
*/
|
||||
public function outSettingsSaved()
|
||||
{
|
||||
return ($this->attributes['out_password'] !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pivot table parameters for the user.
|
||||
*/
|
||||
@@ -684,7 +739,8 @@ class Mailbox extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dummy object with default parameters
|
||||
* Create dummy object with default parameters.
|
||||
*
|
||||
* @return [type] [description]
|
||||
*/
|
||||
public static function getDummySettings()
|
||||
@@ -793,7 +849,7 @@ class Mailbox extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active mailboxes.
|
||||
* Get all active & connected mailboxes.
|
||||
*
|
||||
* @return [type] [description]
|
||||
*/
|
||||
@@ -804,7 +860,7 @@ class Mailbox extends Model
|
||||
// It is more effective to retrive all mailboxes and filter them in PHP.
|
||||
$mailboxes = self::all();
|
||||
foreach ($mailboxes as $mailbox) {
|
||||
if ($mailbox->isActive()) {
|
||||
if ($mailbox->isActive() && $mailbox->isConnected()) {
|
||||
$active[] = $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -930,6 +986,15 @@ class Mailbox extends Model
|
||||
|
||||
public function setMetaParam($param, $value, $save = false)
|
||||
{
|
||||
// Encrypt some values.
|
||||
if ($param == 'oauth' && is_array($value)) {
|
||||
if (!empty($value['a_token'])) {
|
||||
$value['a_token'] = \Helper::encrypt($value['a_token']);
|
||||
}
|
||||
if (!empty($value['r_token'])) {
|
||||
$value['r_token'] = \Helper::encrypt($value['r_token']);
|
||||
}
|
||||
}
|
||||
$meta = $this->meta;
|
||||
$meta[$param] = $value;
|
||||
$this->meta = $meta;
|
||||
@@ -974,20 +1039,84 @@ class Mailbox extends Model
|
||||
|
||||
public function oauthGetParam($param)
|
||||
{
|
||||
return $this->meta['oauth'][$param] ?? '';
|
||||
$value = $this->meta['oauth'][$param] ?? '';
|
||||
|
||||
// Decrypt some values.
|
||||
if (in_array($param, ['a_token', 'r_token'])) {
|
||||
$value = \Helper::decrypt($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function inOauthEnabled()
|
||||
{
|
||||
return $this->oauthEnabled()
|
||||
&& $this->in_username !== null && !strstr($this->in_username, '@');
|
||||
&& $this->in_username !== null
|
||||
&& $this->isInUsernameOauth();
|
||||
}
|
||||
|
||||
public function outOauthEnabled()
|
||||
{
|
||||
return $this->oauthEnabled()
|
||||
&& $this->out_username !== null && !strstr($this->out_username, '@')
|
||||
&& $this->out_server !== null && trim($this->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP;
|
||||
&& $this->out_username !== null
|
||||
&& $this->isOutUsernameOauth()
|
||||
&& $this->out_server !== null
|
||||
&& $this->isOutServerOauth();
|
||||
}
|
||||
|
||||
// For oAuth Username may have the following format:
|
||||
// test@example.org:123-456-789
|
||||
public function getInOauthUsername()
|
||||
{
|
||||
$username = preg_replace("#:.*#", '', $this->in_username ?? '');
|
||||
|
||||
if (strstr($username, '@')) {
|
||||
return $username;
|
||||
} else {
|
||||
return $this->email;
|
||||
}
|
||||
}
|
||||
|
||||
public function getInOauthClientId()
|
||||
{
|
||||
return preg_replace("#.*:#", '', $this->in_username ?? '');
|
||||
}
|
||||
|
||||
public function getOutOauthUsername()
|
||||
{
|
||||
$username = preg_replace("#:.*#", '', $this->out_username ?? '');
|
||||
|
||||
if (strstr($username, '@')) {
|
||||
return $username;
|
||||
} else {
|
||||
return $this->email;
|
||||
}
|
||||
}
|
||||
|
||||
public function getOutOauthClientId()
|
||||
{
|
||||
return preg_replace("#.*:#", '', $this->out_username ?? '');
|
||||
}
|
||||
|
||||
public function isInUsernameOauth()
|
||||
{
|
||||
return (!strstr($this->in_username, '@') || preg_match("#.*@.*:.*#", $this->in_username));
|
||||
}
|
||||
|
||||
public function isOutUsernameOauth()
|
||||
{
|
||||
return (!strstr($this->out_username, '@') || preg_match("#.*@.*:.*#", $this->out_username));
|
||||
}
|
||||
|
||||
public function isOauthProvider($provider)
|
||||
{
|
||||
return ($this->oauthGetParam('provider') == $provider);
|
||||
}
|
||||
|
||||
public function isOutServerOauth()
|
||||
{
|
||||
return in_array(trim($this->out_server), [\MailHelper::OAUTH_MICROSOFT_SMTP, \MailHelper::OAUTH_GOOGLE_SMTP]);
|
||||
}
|
||||
|
||||
public function setEmailAttribute($value)
|
||||
@@ -996,4 +1125,61 @@ class Mailbox extends Model
|
||||
$this->attributes['email'] = Email::sanitizeEmail($value);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteMailbox()
|
||||
{
|
||||
// Remove threads and conversations.
|
||||
$conversation_ids = $this->conversations()->pluck('id')->toArray();
|
||||
|
||||
// for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) {
|
||||
// $slice_ids = array_slice($conversation_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT);
|
||||
// Thread::whereIn('conversation_id', $slice_ids)->delete();
|
||||
// }
|
||||
// $this->conversations()->delete();
|
||||
Conversation::deleteConversationsForever($conversation_ids);
|
||||
|
||||
$this->users()->sync([]);
|
||||
$this->folders()->delete();
|
||||
// Maybe remove notifications on events in this mailbox?
|
||||
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
// Purify signature HTML to avoid sending unsafe HTML to customers by email.
|
||||
public static function sanitizeSignature($signature)
|
||||
{
|
||||
$signature = \Helper::purifyHtml($signature);
|
||||
// Decode URL encoded variables back.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5565
|
||||
$signature = strtr($signature, [
|
||||
'%7B%25' => '{%',
|
||||
'%25%7D' => '%}',
|
||||
]);
|
||||
|
||||
return $signature;
|
||||
}
|
||||
|
||||
public static function excludeArchived($mailboxes)
|
||||
{
|
||||
if (is_array($mailboxes)) {
|
||||
// Array.
|
||||
$found = false;
|
||||
foreach ($mailboxes as $i => $mailbox) {
|
||||
if ($mailbox->isArchived()) {
|
||||
$found = true;
|
||||
unset($mailboxes[$i]);
|
||||
}
|
||||
}
|
||||
if ($found) {
|
||||
$mailboxes = array_values($mailboxes);
|
||||
}
|
||||
} else {
|
||||
// Collection.
|
||||
$mailboxes = $mailboxes->reject(function ($mailbox) {
|
||||
return $mailbox->isArchived();
|
||||
})->values();
|
||||
}
|
||||
|
||||
return $mailboxes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,33 @@ if (! function_exists('__j')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the given message and prepare it for using in HTML.
|
||||
*
|
||||
* Translates and escapes the text for using in HTML.
|
||||
*/
|
||||
function __h($key, $replace = [], $locale = null)
|
||||
function __h($key, $replace = [], $locale = null, $escape_replacements = false)
|
||||
{
|
||||
$text = __($key, $replace, $locale);
|
||||
if ($escape_replacements) {
|
||||
foreach ($replace as $key => $value) {
|
||||
$replace[$key] = htmlspecialchars($value);
|
||||
}
|
||||
}
|
||||
return __(htmlspecialchars($key), $replace, $locale);
|
||||
}
|
||||
|
||||
return htmlspecialchars($text);
|
||||
/**
|
||||
* Strips unsafe tags.
|
||||
*/
|
||||
function safe_raw_html($text, $allowed_tags = [])
|
||||
{
|
||||
return \Helper::stripDangerousTags($text ?? '', $allowed_tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates and strips unsafe tags.
|
||||
*/
|
||||
function __safe_raw_html($key, $replace = [], $locale = null, $allowed_tags = [])
|
||||
{
|
||||
$text = __($key ?? '', $replace, $locale);
|
||||
|
||||
return \Helper::stripDangerousTags($text, $allowed_tags);
|
||||
}
|
||||
}
|
||||
1409
app/Misc/Helper.php
1409
app/Misc/Helper.php
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,20 @@ class Mail
|
||||
|
||||
/**
|
||||
* Message-ID prefixes for outgoing emails.
|
||||
* Message-IDs should not match Spam Assasin patters.
|
||||
* https://github.com/freescout-help-desk/freescout/issues/5245
|
||||
*/
|
||||
const MESSAGE_ID_PREFIX_NOTIFICATION = 'notify';
|
||||
const MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY = 'conversation';
|
||||
const MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER = 'reply';
|
||||
const MESSAGE_ID_PREFIX_AUTO_REPLY = 'autoreply';
|
||||
const MESSAGE_ID_PREFIX_NOTIFICATION = 'FS_notify';
|
||||
const MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY = 'FS_conversation';
|
||||
const MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER = 'FS_reply';
|
||||
const MESSAGE_ID_PREFIX_AUTO_REPLY = 'FS_autoreply';
|
||||
|
||||
public static $all_message_id_prefixes = [
|
||||
self::MESSAGE_ID_PREFIX_NOTIFICATION,
|
||||
self::MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY,
|
||||
self::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||||
self::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mail drivers.
|
||||
@@ -51,6 +60,16 @@ class Mail
|
||||
const OAUTH_PROVIDER_MICROSOFT = 'ms';
|
||||
const OAUTH_MICROSOFT_SMTP = 'smtp.office365.com';
|
||||
|
||||
// Google Workspace
|
||||
const OAUTH_PROVIDER_GOOGLE = 'gw';
|
||||
const OAUTH_GOOGLE_SMTP = 'smtp.gmail.com';
|
||||
|
||||
/**
|
||||
* Number of messages to fetch the headers of at a time
|
||||
* when scanning a folder in findMessageByHeaders().
|
||||
*/
|
||||
const SCAN_CHUNK_SIZE = 50;
|
||||
|
||||
/**
|
||||
* If reply is not extracted properly from the incoming email, add here a new separator.
|
||||
* Order is not important.
|
||||
@@ -58,6 +77,8 @@ class Mail
|
||||
* Regex separators has "regex:" in the beginning.
|
||||
*/
|
||||
public static $alternative_reply_separators = [
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5504
|
||||
'regex:/<\s*div[^>]+'.self::REPLY_SEPARATOR_HTML.'[^>]+>/',
|
||||
self::REPLY_SEPARATOR_HTML, // Our HTML separator
|
||||
self::REPLY_SEPARATOR_TEXT, // Our plain text separator
|
||||
|
||||
@@ -73,6 +94,8 @@ class Mail
|
||||
'------------------ 原始邮件 ------------------', // QQ
|
||||
'------------------ Original ------------------', // QQ English
|
||||
'<div id=3D"divRplyFwdMsg" dir=', // Outlook
|
||||
'<div id="divRplyFwdMsg" dir=', // Outlook
|
||||
'regex:/<hr[^>]*>\s*<div[^>]+id="divRplyFwdMsg"/', // Outlook
|
||||
'regex:/<div style="border:none;border\-top:solid \#[A-Z0-9]{6} 1\.0pt;padding:3\.0pt 0in 0in 0in">[^<]*<p class="MsoNormal"><b>/', // MS Outlook
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4629#issuecomment-2870297514
|
||||
'regex:/<div style="border:none;border\-top:solid \#[A-Z0-9]{6} 1\.0pt;padding:3\.0pt 0cm 0cm 0cm">[^<]*<p class="MsoNormal"><b>/', // MS Outlook
|
||||
@@ -128,6 +151,11 @@ class Mail
|
||||
*/
|
||||
public static $smtp_mime_message = '';
|
||||
|
||||
/**
|
||||
* Indicates that DATA command and mail content has been sent to the mail server.
|
||||
*/
|
||||
public static $smtp_data_sent = false;
|
||||
|
||||
/**
|
||||
* Configure mail sending parameters.
|
||||
*
|
||||
@@ -145,13 +173,17 @@ class Mail
|
||||
if ($oauth) {
|
||||
if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) {
|
||||
// Try to get an access token (using the authorization code grant)
|
||||
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
|
||||
'client_id' => $mailbox->out_username,
|
||||
$token_data = \MailHelper::oauthGetAccessToken($mailbox->oauthGetParam('provider'), [
|
||||
'client_id' => $mailbox->getOutOauthClientId(),
|
||||
'client_secret' => $mailbox->out_password,
|
||||
'refresh_token' => $mailbox->oauthGetParam('r_token'),
|
||||
]);
|
||||
|
||||
if (!empty($token_data['a_token'])) {
|
||||
// In Google Workspace new refresh token is not returned after refreshing the token.
|
||||
if (empty($token_data['r_token'])) {
|
||||
$token_data['r_token'] = $mailbox->oauthGetParam('r_token');
|
||||
}
|
||||
$mailbox->setMetaParam('oauth', $token_data, true);
|
||||
} elseif (!empty($token_data['error'])) {
|
||||
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
|
||||
@@ -174,9 +206,10 @@ class Mail
|
||||
\Config::set('mail.port', $mailbox->out_port);
|
||||
if ($oauth) {
|
||||
\Config::set('mail.auth_mode', 'XOAUTH2');
|
||||
\Config::set('mail.username', $mailbox->email);
|
||||
\Config::set('mail.username', $mailbox->getOutOauthUsername());
|
||||
\Config::set('mail.password', $mailbox->oauthGetParam('a_token'));
|
||||
} else {
|
||||
\Config::set('mail.auth_mode', '');
|
||||
if (!$mailbox->out_username) {
|
||||
\Config::set('mail.username', null);
|
||||
\Config::set('mail.password', null);
|
||||
@@ -307,7 +340,7 @@ class Mail
|
||||
);
|
||||
|
||||
// Add fallback values to the $vars array, if present.
|
||||
foreach($matches['var'] as $i => $var) {
|
||||
foreach ($matches['var'] as $i => $var) {
|
||||
$merge_code = "{%{$var}%}";
|
||||
$full_match = $matches[0][$i];
|
||||
$has_fallback = false !== strpos($full_match, ',fallback=');
|
||||
@@ -477,20 +510,13 @@ class Mail
|
||||
// Get unseen messages for a period
|
||||
$messages = $folder->query()->unseen()->since(now()->subDays(1))->leaveUnread()->get();
|
||||
|
||||
$last_error = '';
|
||||
if (method_exists($client, 'getLastError')) {
|
||||
$last_error = $client->getLastError();
|
||||
}
|
||||
$last_error = $client->getLastError();
|
||||
|
||||
if ($last_error && stristr($last_error, 'The specified charset is not supported')) {
|
||||
// Solution for MS mailboxes.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/176
|
||||
$messages = $folder->query()->unseen()->since(now()->subDays(1))->leaveUnread()->setCharset(null)->get();
|
||||
if (count($client->getErrors()) > 1) {
|
||||
$last_error = $client->getLastError();
|
||||
} else {
|
||||
$last_error = null;
|
||||
}
|
||||
$last_error = $client->getLastError();
|
||||
}
|
||||
|
||||
if ($last_error) {
|
||||
@@ -722,33 +748,54 @@ class Mail
|
||||
if (function_exists('imap_utf8')) {
|
||||
return imap_utf8($mime_encoded_text);
|
||||
} else {
|
||||
return iconv_mime_decode($mime_encoded_text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8");
|
||||
return \Helper::iconvMimeDecode($mime_encoded_text);
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts both header name with "-" and "_".
|
||||
public static function getHeader($headers_str, $header)
|
||||
{
|
||||
// Quick check to same resources.
|
||||
if (!stristr($headers_str, $header)) {
|
||||
return '';
|
||||
}
|
||||
$value = '';
|
||||
$headers_str = $headers_str ?? '';
|
||||
|
||||
$header = strtolower($header);
|
||||
$header = str_replace('-', '_', $header);
|
||||
|
||||
$headers = self::parseHeaders($headers_str);
|
||||
if (!$headers) {
|
||||
return;
|
||||
}
|
||||
$value = null;
|
||||
if (property_exists($headers, $header)) {
|
||||
$value = $headers->$header;
|
||||
} else {
|
||||
$header_dashed = str_replace('_', '-', $header);
|
||||
// Quick check to save resources.
|
||||
if (!stristr($headers_str, $header_dashed)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For single-line headers try to get haeder by using regular expression.
|
||||
if ($header == 'message_id') {
|
||||
if (preg_match('/^'.preg_quote($header_dashed).'\s*:((?:[^\r\n]|\r?\n[ \t])*)/im', $headers_str ?? '', $m)
|
||||
&& !empty($m[1])
|
||||
) {
|
||||
$value = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$value) {
|
||||
$headers = self::parseHeaders($headers_str);
|
||||
if (!$headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
$value = null;
|
||||
if (property_exists($headers, $header)) {
|
||||
$value = $headers->$header;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize.
|
||||
switch ($header) {
|
||||
case 'message_id':
|
||||
$value = str_replace(['<', '>'], '', $value);
|
||||
// Message-ID can't contain spaces, so it's safe to remove them
|
||||
// along with the line folding.
|
||||
$value = str_replace(['<', '>', "\r", "\n", "\t", ' '], '', $value);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -761,28 +808,45 @@ class Mail
|
||||
public static function getMailboxClient($mailbox)
|
||||
{
|
||||
$oauth = $mailbox->oauthEnabled();
|
||||
/*$new_library = config('app.new_fetching_library');
|
||||
|
||||
if (!$new_library) {
|
||||
// Old.
|
||||
return new \Webklex\IMAP\Client([
|
||||
'host' => $mailbox->in_server,
|
||||
'port' => $mailbox->in_port,
|
||||
'encryption' => $mailbox->getInEncryptionName(),
|
||||
'validate_cert' => $mailbox->in_validate_cert,
|
||||
'username' => $mailbox->in_username,
|
||||
'password' => $mailbox->in_password,
|
||||
'protocol' => $mailbox->getInProtocolName(),
|
||||
]);
|
||||
} else {*/
|
||||
// New
|
||||
// Refresh Access Token.
|
||||
if ($oauth) {
|
||||
if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) {
|
||||
// Try to get an access token (using the authorization code grant)
|
||||
$token_data = \MailHelper::oauthGetAccessToken($mailbox->oauthGetParam('provider'), [
|
||||
'client_id' => $mailbox->getInOauthClientId(),
|
||||
'client_secret' => $mailbox->in_password,
|
||||
'refresh_token' => $mailbox->oauthGetParam('r_token'),
|
||||
]);
|
||||
|
||||
if (!empty($token_data['a_token'])) {
|
||||
// In Google Workspace new refresh token is not returned after refreshing the token.
|
||||
if (empty($token_data['r_token'])) {
|
||||
$token_data['r_token'] = $mailbox->oauthGetParam('r_token');
|
||||
}
|
||||
$mailbox->setMetaParam('oauth', $token_data, true);
|
||||
} elseif (!empty($token_data['error'])) {
|
||||
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
|
||||
\Helper::log(
|
||||
\App\ActivityLog::NAME_EMAILS_FETCHING,
|
||||
\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR, [
|
||||
'error' => $error_message,
|
||||
'mailbox' => $mailbox->name,
|
||||
]
|
||||
);
|
||||
throw new \Exception($error_message, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set config.
|
||||
if ($oauth) {
|
||||
\Config::set('imap.accounts.default', [
|
||||
'host' => $mailbox->in_server,
|
||||
'port' => $mailbox->in_port,
|
||||
'encryption' => $mailbox->getInEncryptionName(),
|
||||
'validate_cert' => $mailbox->in_validate_cert,
|
||||
'username' => $mailbox->email,
|
||||
'username' => $mailbox->getInOauthUsername(),
|
||||
'password' => $mailbox->oauthGetParam('a_token'),
|
||||
'protocol' => $mailbox->getInProtocolName(),
|
||||
'authentication' => 'oauth',
|
||||
@@ -802,38 +866,15 @@ class Mail
|
||||
'protocol' => $mailbox->getInProtocolName(),
|
||||
]);
|
||||
}
|
||||
|
||||
// To enable debug: /vendor/webklex/php-imap/src/Connection/Protocols
|
||||
// Debug in console
|
||||
if (app()->runningInConsole()) {
|
||||
\Config::set('imap.options.debug', config('app.debug'));
|
||||
}
|
||||
|
||||
|
||||
$cm = new \Webklex\PHPIMAP\ClientManager(config('imap'));
|
||||
|
||||
// Refresh Access Token.
|
||||
if ($oauth) {
|
||||
if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) {
|
||||
// Try to get an access token (using the authorization code grant)
|
||||
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
|
||||
'client_id' => $mailbox->in_username,
|
||||
'client_secret' => $mailbox->in_password,
|
||||
'refresh_token' => $mailbox->oauthGetParam('r_token'),
|
||||
]);
|
||||
|
||||
if (!empty($token_data['a_token'])) {
|
||||
$mailbox->setMetaParam('oauth', $token_data, true);
|
||||
} elseif (!empty($token_data['error'])) {
|
||||
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
|
||||
\Helper::log(\App\ActivityLog::NAME_EMAILS_FETCHING,
|
||||
\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR, [
|
||||
'error' => $error_message,
|
||||
'mailbox' => $mailbox->name,
|
||||
]);
|
||||
throw new \Exception($error_message, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This makes it authenticate two times.
|
||||
//$cm->setTimeout(60);
|
||||
|
||||
@@ -854,6 +895,11 @@ class Mail
|
||||
return 'fs-'.$hash.'@'.preg_replace("/.*@/", '', $email_address);
|
||||
}
|
||||
|
||||
public static function isGeneratedMessageId($message_id)
|
||||
{
|
||||
return preg_match("#^fs\-[a-z0-9]+@#", $message_id ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch IMAP message by Message-ID.
|
||||
*/
|
||||
@@ -893,9 +939,8 @@ class Mail
|
||||
|
||||
// Limit using date to speed up the search.
|
||||
if ($message_date) {
|
||||
$query->since($message_date->subDays(7));
|
||||
// Here we should add 14 days, as previous line subtracts 7 days.
|
||||
$query->before($message_date->addDays(14));
|
||||
$query->since($message_date->copy()->subDays(7));
|
||||
$query->before($message_date->copy()->addDays(7));
|
||||
}
|
||||
|
||||
if ($no_charset) {
|
||||
@@ -915,8 +960,8 @@ class Mail
|
||||
//$query = $folder->query()->text('<'.$message_id.'>')->leaveUnread()->limit(1)->setCharset(null);
|
||||
$query = $folder->query()->whereMessageId('"<'.$search_message_id.'>"')->leaveUnread()->limit(1)->setCharset(null);
|
||||
if ($message_date) {
|
||||
$query->since($message_date->subDays(7));
|
||||
$query->before($message_date->addDays(14));
|
||||
$query->since($message_date->copy()->subDays(7));
|
||||
$query->before($message_date->copy()->addDays(7));
|
||||
}
|
||||
$messages = $query->get();
|
||||
$no_charset = true;
|
||||
@@ -931,6 +976,100 @@ class Mail
|
||||
}
|
||||
}
|
||||
|
||||
return self::findMessageByHeaders($mailbox, $client, $imap_folders, $message_id, $message_date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an IMAP message by Message-ID without using IMAP search.
|
||||
*
|
||||
* Some IMAP servers don't return search results by Message-ID: imap.yandex.ru
|
||||
* for example responds with "NO [UNAVAILABLE] UID SEARCH Backend error".
|
||||
* For those we fetch the headers of the messages received around the date of
|
||||
* the message and compare Message-IDs locally.
|
||||
*/
|
||||
public static function findMessageByHeaders($mailbox, $client, $imap_folders, $message_id, $message_date)
|
||||
{
|
||||
// Without a date we would have to scan the whole mailbox.
|
||||
if (!$message_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$limit = (int) \Eventy::filter('mail.fetch_message.scan_limit', 500, $mailbox);
|
||||
|
||||
if ($limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message_id = trim($message_id);
|
||||
|
||||
// IMAP searches by INTERNALDATE which may differ from the date in the
|
||||
// headers, so if the message is not found on its own date, try the
|
||||
// neighbouring days too.
|
||||
$date_ranges = [
|
||||
[$message_date->copy(), $message_date->copy()->addDay()],
|
||||
[$message_date->copy()->subDay(), $message_date->copy()->addDays(2)],
|
||||
];
|
||||
|
||||
$connection = $client->getConnection();
|
||||
|
||||
foreach ($imap_folders as $folder_name) {
|
||||
try {
|
||||
$folder = self::getImapFolder($client, $folder_name);
|
||||
|
||||
if (!$folder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$limit_reached = false;
|
||||
|
||||
foreach ($date_ranges as $date_range) {
|
||||
$client->openFolder($folder->path);
|
||||
|
||||
$uids = array_values($connection->search([
|
||||
'SINCE "'.$date_range[0]->format('d-M-Y').'"'
|
||||
.' BEFORE "'.$date_range[1]->format('d-M-Y').'"'
|
||||
]));
|
||||
|
||||
// Calling the protocol directly here, so the server response
|
||||
// has to be checked here as well.
|
||||
if (!count($uids)
|
||||
&& method_exists($connection, 'getLastError')
|
||||
&& $connection->getLastError()
|
||||
) {
|
||||
\Log::error('('.$mailbox->name.') Show Original - IMAP search rejected by the server'
|
||||
.' in "'.$folder_name.'"; server response: '.$connection->getLastError());
|
||||
}
|
||||
|
||||
if (count($uids) > $limit) {
|
||||
$uids = array_slice($uids, 0, $limit);
|
||||
$limit_reached = true;
|
||||
}
|
||||
|
||||
// Fetching the headers only and in chunks, to stop as soon as
|
||||
// the message is found. Note that fetching RFC822.HEADER does
|
||||
// not set the \Seen flag.
|
||||
foreach (array_chunk($uids, self::SCAN_CHUNK_SIZE) as $uids_chunk) {
|
||||
$headers = $connection->headers($uids_chunk);
|
||||
|
||||
foreach ($headers as $uid => $header) {
|
||||
if (self::getHeader($header, 'Message-ID') != $message_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $folder->query()->leaveUnread()->getMessageByUid($uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($limit_reached) {
|
||||
\Log::error('('.$mailbox->name.') MailHelper::findMessageByHeaders(): the number of messages to scan in "'
|
||||
.$folder_name.'" exceeds the limit ('.$limit.'), message not found: '.$message_id);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, '('.$mailbox->name.') Could not find specific message by Message-ID in headers:');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -939,6 +1078,7 @@ class Mail
|
||||
$args = [];
|
||||
|
||||
switch ($provider_code) {
|
||||
|
||||
case self::OAUTH_PROVIDER_MICROSOFT:
|
||||
// https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
|
||||
$args = [
|
||||
@@ -946,10 +1086,24 @@ class Mail
|
||||
'response_type' => 'code',
|
||||
'approval_prompt' => 'auto',
|
||||
'redirect_uri' => route('mailboxes.oauth_callback'),
|
||||
//'state' => // passed in $params
|
||||
];
|
||||
$args = array_merge($args, $params);
|
||||
$url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?'.http_build_query($args);
|
||||
break;
|
||||
|
||||
case self::OAUTH_PROVIDER_GOOGLE:
|
||||
$args = [
|
||||
'scope' => 'https://mail.google.com/',
|
||||
'response_type' => 'code',
|
||||
'prompt' => 'consent', // Without this there will be no refresh_token in response
|
||||
'redirect_uri' => route('mailboxes.oauth_callback'),
|
||||
'access_type' => 'offline', // Without this there will be no refresh_token in response
|
||||
//'state' => // passed in $params
|
||||
];
|
||||
$args = array_merge($args, $params);
|
||||
$url = 'https://accounts.google.com/o/oauth2/v2/auth?'.http_build_query($args);
|
||||
break;
|
||||
}
|
||||
|
||||
return $url;
|
||||
@@ -961,6 +1115,7 @@ class Mail
|
||||
$post_params = [];
|
||||
|
||||
switch ($provider_code) {
|
||||
|
||||
case self::OAUTH_PROVIDER_MICROSOFT:
|
||||
$post_params = [
|
||||
'scope' => 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send',
|
||||
@@ -1023,20 +1178,103 @@ class Mail
|
||||
$token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
}
|
||||
}
|
||||
curl_close($curl);
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
curl_close($curl);
|
||||
}
|
||||
break;
|
||||
|
||||
case self::OAUTH_PROVIDER_GOOGLE:
|
||||
$post_params = [
|
||||
//'scope' => 'https://mail.google.com/', // Just in case
|
||||
"grant_type" => "authorization_code",
|
||||
'redirect_uri' => route('mailboxes.oauth_callback'),
|
||||
];
|
||||
|
||||
$post_params = array_merge($post_params, $params);
|
||||
|
||||
// Refreshing Access Token.
|
||||
if (!empty($post_params['refresh_token'])) {
|
||||
$post_params['grant_type'] = 'refresh_token';
|
||||
}
|
||||
|
||||
$full_url = "https://oauth2.googleapis.com/token";
|
||||
|
||||
$curl = curl_init($full_url);
|
||||
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
//curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $post_params);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, array("application/x-www-form-urlencoded"));
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
|
||||
\Helper::setCurlDefaultOptions($curl);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 180);
|
||||
|
||||
$response = curl_exec($curl);
|
||||
|
||||
if ($response) {
|
||||
$result = json_decode($response, true);
|
||||
// {
|
||||
// "access_token": "...",
|
||||
// "refresh_token": "...",
|
||||
// "expires_in": 3598,
|
||||
// "scope": "https://mail.google.com/",
|
||||
// "token_type": "Bearer"
|
||||
// }
|
||||
if (!empty($result['access_token'])) {
|
||||
$token_data['provider'] = self::OAUTH_PROVIDER_GOOGLE;
|
||||
$token_data['a_token'] = $result['access_token'];
|
||||
if ($post_params['grant_type'] != 'refresh_token') {
|
||||
$token_data['r_token'] = $result['refresh_token'];
|
||||
}
|
||||
//$token_data['id_token'] = $result['id_token'];
|
||||
$token_data['issued_on'] = now()->toDateTimeString();
|
||||
$token_data['expires_in'] = $result['expires_in'];
|
||||
} elseif ($response) {
|
||||
$token_data['error'] = $response;
|
||||
} else {
|
||||
$token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
}
|
||||
}
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
curl_close($curl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $token_data;
|
||||
}
|
||||
|
||||
public static function oauthDisconnect($provider_code, $redirect_uri)
|
||||
public static function oauthDisconnect($provider_code, $redirect_uri, $mailbox)
|
||||
{
|
||||
switch ($provider_code) {
|
||||
|
||||
case self::OAUTH_PROVIDER_MICROSOFT:
|
||||
return redirect()->away('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri='.urlencode($redirect_uri));
|
||||
break;
|
||||
|
||||
case self::OAUTH_PROVIDER_GOOGLE:
|
||||
// This does not work.
|
||||
// $post_params = [
|
||||
// 'token' => $mailbox->oauthGetParam('a_token')
|
||||
// ];
|
||||
|
||||
// $full_url = "https://oauth2.googleapis.com/revoke";
|
||||
// $curl = curl_init($full_url);
|
||||
|
||||
// curl_setopt($curl, CURLOPT_POST, true);
|
||||
// curl_setopt($curl, CURLOPT_POSTFIELDS, $post_params);
|
||||
// curl_setopt($curl, CURLOPT_HTTPHEADER, array("application/x-www-form-urlencoded"));
|
||||
// curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
|
||||
// \Helper::setCurlDefaultOptions($curl);
|
||||
// curl_setopt($curl, CURLOPT_TIMEOUT, 180);
|
||||
|
||||
// $response = curl_exec($curl);
|
||||
// $http_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
// if ($http_status != 200) {
|
||||
// \Log::error('[Google Workspace OAuth] Error revoking token on logout: HTTPS Status Code - '.$http_status.'; '.json_encode($response));
|
||||
// }
|
||||
return redirect()->away($redirect_uri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,7 +1307,7 @@ class Mail
|
||||
public static function getImapFolder($client, $folder_name)
|
||||
{
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3502
|
||||
$folder_name = mb_convert_encoding($folder_name, "UTF7-IMAP","UTF-8");
|
||||
$folder_name = mb_convert_encoding($folder_name, "UTF7-IMAP", "UTF-8");
|
||||
|
||||
if (method_exists($client, 'getFolderByPath')) {
|
||||
return $client->getFolderByPath($folder_name);
|
||||
@@ -1115,7 +1353,7 @@ class Mail
|
||||
// Only one type of encoding should be used.
|
||||
preg_match_all("/(=\?[^\?]+\?[BQ]\?)([^\?]+)(\?=)/i", $subject, $m);
|
||||
$encodings = $m[1] ?? [];
|
||||
array_walk($encodings, function($value) {
|
||||
array_walk($encodings, function ($value) {
|
||||
$value = strtolower($value);
|
||||
});
|
||||
$one_encoding = count(array_unique($encodings)) == 1;
|
||||
@@ -1165,7 +1403,7 @@ class Mail
|
||||
|
||||
// iconv_mime_decode() can't decode:
|
||||
// =?iso-2022-jp?B?IBskQiFaSEcyPDpuQC4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?=
|
||||
$subject_decoded = iconv_mime_decode($subject, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8");
|
||||
$subject_decoded = \Helper::iconvMimeDecode($subject);
|
||||
|
||||
// Sometimes iconv_mime_decode() can't decode some parts of the subject:
|
||||
// =?iso-2022-jp?B?IBskQiFaSEcyPDpuQC4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?=
|
||||
@@ -1189,7 +1427,8 @@ class Mail
|
||||
return $subject_decoded;
|
||||
}
|
||||
|
||||
public static function isNotYetFullyDecoded($subject_decoded) {
|
||||
public static function isNotYetFullyDecoded($subject_decoded)
|
||||
{
|
||||
// https://stackoverflow.com/questions/15276191/why-does-a-diamond-with-a-questionmark-in-it-appear-in-my-html
|
||||
$invalid_utf_symbols = ['<27>'];
|
||||
|
||||
@@ -1198,7 +1437,8 @@ class Mail
|
||||
|| \Str::contains($subject_decoded, $invalid_utf_symbols);
|
||||
}
|
||||
|
||||
public static function getHashedReplySeparator($message_id) {
|
||||
public static function getHashedReplySeparator($message_id)
|
||||
{
|
||||
$separator = \MailHelper::REPLY_SEPARATOR_HTML;
|
||||
|
||||
if ($message_id) {
|
||||
@@ -1208,15 +1448,62 @@ class Mail
|
||||
return $separator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make images fit into the email container so that wide images
|
||||
* do not stretch the layout.
|
||||
* - Scale down width/height attributes (Outlook ignores CSS max-width).
|
||||
* - Add "max-width:100%; height:auto" inline styles.
|
||||
*/
|
||||
public static function fitImages($html, $max_width = 0)
|
||||
{
|
||||
if (!$html || stripos($html, '<img') === false) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return preg_replace_callback('#<img\b[^>]*>#i', function ($m) use ($max_width) {
|
||||
$tag = $m[0];
|
||||
|
||||
// Scale down dimensions preserving aspect ratio.
|
||||
if ($max_width
|
||||
&& preg_match('#\swidth\s*=\s*["\']?(\d+)#i', $tag, $w)
|
||||
&& (int)$w[1] > $max_width
|
||||
) {
|
||||
$width = (int)$w[1];
|
||||
$tag = preg_replace('#(\swidth\s*=\s*["\']?)\d+#i', '${1}'.$max_width, $tag, 1);
|
||||
if (preg_match('#\sheight\s*=\s*["\']?(\d+)#i', $tag, $h)) {
|
||||
$height = (int)round((int)$h[1] * $max_width / $width);
|
||||
$tag = preg_replace('#(\sheight\s*=\s*["\']?)\d+#i', '${1}'.$height, $tag, 1);
|
||||
}
|
||||
}
|
||||
|
||||
$fit_style = 'max-width:100%;height:auto;';
|
||||
|
||||
if (preg_match('#\sstyle\s*=\s*(["\'])(.*?)\1#is', $tag, $s)) {
|
||||
// Remove existing max-width and height declarations.
|
||||
$style = preg_replace('#(^|;)\s*(max-width|height)\s*:[^;]*#i', '$1', $s[2]);
|
||||
$style = trim(preg_replace('#;(\s*;)+#', ';', $style), " \t\r\n;");
|
||||
$style = ($style !== '' ? $style.';' : '').$fit_style;
|
||||
$tag = str_replace($s[0], ' style='.$s[1].$style.$s[1], $tag);
|
||||
} else {
|
||||
$self_closing = preg_match('#/\s*>$#', $tag);
|
||||
$tag = preg_replace('#\s*/?\s*>$#', '', $tag).' style="'.$fit_style.'"'.($self_closing ? ' />' : '>');
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}, $html) ?: $html;
|
||||
}
|
||||
|
||||
// Sanitize status message - remove SMTP username and password.
|
||||
public static function sanitizeSmtpStatusMessage($status_message) {
|
||||
public static function sanitizeSmtpStatusMessage($status_message)
|
||||
{
|
||||
$status_message = preg_replace('#(username ")[^"]+(")#', '$1***$2', $status_message ?? '');
|
||||
$status_message = preg_replace("#(Swift_Transport_Esmtp_Auth_LoginAuthenticator\->authenticate\(Object\(Swift_SmtpTransport\), ')[^\']+(', ')[^\']+('\))#", '$1***$2***$3', $status_message ?? '');
|
||||
|
||||
return $status_message;
|
||||
}
|
||||
|
||||
public static function parseEml($content, $mailbox) {
|
||||
public static function parseEml($content, $mailbox)
|
||||
{
|
||||
if (!str_contains($content, "\r\n")){
|
||||
$content = str_replace("\n", "\r\n", $content);
|
||||
}
|
||||
@@ -1253,6 +1540,38 @@ class Mail
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove quotes surrounding a display name in an email address.
|
||||
*
|
||||
* In `From: "Tatiana Ivanova" <ti@example.org>` the quotes are just
|
||||
* a delimiter of the quoted-string, they are not a part of the name itself:
|
||||
* https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.4
|
||||
*/
|
||||
public static function unquotePersonalName($name)
|
||||
{
|
||||
$name = trim((string)$name);
|
||||
|
||||
if (strlen($name) < 2 || !\Str::startsWith($name, '"') || !\Str::endsWith($name, '"')) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$unquoted = substr($name, 1, -1);
|
||||
|
||||
// Make sure that it's a single quoted-string and not something like
|
||||
// `"Foo" bar "Baz"` - all quotes inside have to be escaped.
|
||||
if (!preg_match('/^(?:[^"\\\\]|\\\\.)*$/s', $unquoted)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
// Unescape quoted-pairs: \" => " and \\ => \
|
||||
return trim(preg_replace('/\\\\(.)/s', '$1', $unquoted));
|
||||
}
|
||||
|
||||
public static function isFsMessageId($message_id)
|
||||
{
|
||||
return preg_match('/^('.implode('|', self::$all_message_id_prefixes).')/i', $message_id);
|
||||
}
|
||||
|
||||
// public static function oauthGetProvider($provider_code, $params)
|
||||
// {
|
||||
// $provider = null;
|
||||
|
||||
@@ -10,10 +10,10 @@ class SwiftGetSmtpQueueId implements \Swift_Events_ResponseListener
|
||||
{
|
||||
$response_text = $evt->getResponse();
|
||||
if (strpos($response_text, 'queued') !== false) {
|
||||
preg_match("#queued as ([^\$\r\n ]+)[$\r\n]#", $response_text, $m);
|
||||
if (!empty($m[1]) && trim($m[1])) {
|
||||
self::$last_smtp_queue_id = trim($m[1]);
|
||||
}
|
||||
preg_match("#queued as ([^\$\r\n ]+)[$\r\n]#", $response_text, $m);
|
||||
if (!empty($m[1]) && trim($m[1])) {
|
||||
self::$last_smtp_queue_id = trim($m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,14 @@ class WpApi
|
||||
return $client->request('POST', $url, \Helper::setGuzzleDefaultOptions([
|
||||
'connect_timeout' => 10,
|
||||
'form_params' => $params,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]));
|
||||
} else {
|
||||
$params['v'] = config('app.version');
|
||||
return $client->request('GET', $url, \Helper::setGuzzleDefaultOptions([
|
||||
'connect_timeout' => 10,
|
||||
'query' => $params,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ class Module extends Model
|
||||
return self::$modules;
|
||||
}
|
||||
|
||||
public static function clearModulesCache()
|
||||
{
|
||||
self::$modules = null;
|
||||
}
|
||||
|
||||
public static function isActive($alias)
|
||||
{
|
||||
$module = self::getByAlias($alias);
|
||||
@@ -50,7 +55,21 @@ class Module extends Model
|
||||
$module = self::getByAliasOrCreate($alias);
|
||||
$module->active = $active;
|
||||
if ($save) {
|
||||
$module->save();
|
||||
try {
|
||||
$module->save();
|
||||
} catch (\Exception $e) {
|
||||
if (strstr($e->getMessage(), 'Integrity constraint violation')) {
|
||||
// SQLSTATE[23000]: Integrity constraint violation.
|
||||
self::clearModulesCache();
|
||||
$module = self::getByAliasOrCreate($alias);
|
||||
if ($module) {
|
||||
$module->active = $active;
|
||||
$module->save();
|
||||
}
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -134,6 +153,30 @@ class Module extends Model
|
||||
$module->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically encrypt license key.
|
||||
*/
|
||||
public function setLicenseAttribute($value)
|
||||
{
|
||||
if ($value != '') {
|
||||
$this->attributes['license'] = \Helper::encrypt($value);
|
||||
} else {
|
||||
$this->attributes['license'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically decrypt license key.
|
||||
*/
|
||||
public function getLicenseAttribute($value)
|
||||
{
|
||||
if (!$value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \Helper::decrypt($value) ?: $value;
|
||||
}
|
||||
|
||||
public static function normalizeAlias($alias)
|
||||
{
|
||||
return trim(strtolower($alias));
|
||||
@@ -239,10 +282,7 @@ class Module extends Model
|
||||
public static function formatModuleData($module_data)
|
||||
{
|
||||
// Add (Third-Party).
|
||||
if (\App\Module::isOfficial($module_data['authorUrl'])
|
||||
&& $module_data['author'] != 'FreeScout'
|
||||
&& mb_substr(trim($module_data['name']), -1) != ']'
|
||||
) {
|
||||
if (self::isThirdParty($module_data) && mb_substr(trim($module_data['name']), -1) != ']') {
|
||||
$module_data['name'] = $module_data['name'].' ['.__('Third-Party').']';
|
||||
}
|
||||
return $module_data;
|
||||
@@ -250,7 +290,7 @@ class Module extends Model
|
||||
|
||||
public static function isThirdParty($module_data)
|
||||
{
|
||||
if (\App\Module::isOfficial($module_data['authorUrl'])
|
||||
if (\App\Module::isOfficial($module_data['detailsUrl'])
|
||||
&& $module_data['author'] != 'FreeScout'
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -68,16 +68,32 @@ class BroadcastNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
$data = [];
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (empty($payload->thread_id) || empty($payload->mediums)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Try to convert to array.
|
||||
$mediums = (array)$payload->mediums;
|
||||
|
||||
$thread = Thread::find($payload->thread_id);
|
||||
$mailbox = ($thread && $thread->conversation) ? $thread->conversation->mailbox : null;
|
||||
|
||||
if (empty($thread)) {
|
||||
if (empty($thread) || !$user || !$mailbox || !$user->can('viewCached', $mailbox)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$conversation = $thread->conversation;
|
||||
|
||||
// Play audio notification if this is Chat conversation.
|
||||
//$now = time();
|
||||
if ($conversation->isChat() /*&& $conversation->last_reply_at && $conversation->last_reply_at->timestamp < ($now - 10*60)*/) {
|
||||
$data['audio'] = [
|
||||
'thread_id' => $thread->id,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($payload->mediums)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -90,7 +106,7 @@ class BroadcastNotification extends Notification implements ShouldQueue
|
||||
|
||||
// Get last reply or note of the conversation to display it's text
|
||||
$last_thread_body = '';
|
||||
$last_thread = Thread::where('conversation_id', $thread->conversation_id)
|
||||
$last_thread = Thread::where('conversation_id', $conversation->id)
|
||||
// Select must contain all fields from orderBy() to avoid:
|
||||
// General error: 3065 Expression #1 of ORDER BY clause is not in SELECT
|
||||
->select(['body', 'created_at'])
|
||||
@@ -107,7 +123,7 @@ class BroadcastNotification extends Notification implements ShouldQueue
|
||||
$web_notifications_info['created_at'] = \Carbon\Carbon::now();
|
||||
// ['notification']->read_at
|
||||
// ['notification']->id
|
||||
$web_notifications_info['conversation'] = $thread->conversation;
|
||||
$web_notifications_info['conversation'] = $conversation;
|
||||
$web_notifications_info['thread'] = $thread;
|
||||
$web_notifications_info['last_thread_body'] = $last_thread_body;
|
||||
|
||||
@@ -118,8 +134,8 @@ class BroadcastNotification extends Notification implements ShouldQueue
|
||||
|
||||
// Text and url for the browser push notification
|
||||
if (in_array(Subscription::MEDIUM_BROWSER, $mediums)) {
|
||||
$data['browser']['text'] = strip_tags($thread->getActionDescription($thread->conversation->number));
|
||||
$data['browser']['url'] = $thread->conversation->url(null, $thread->id, ['mark_as_read' => $db_notification->id]);
|
||||
$data['browser']['text'] = \Helper::stripTags($thread->getActionDescription($conversation->number));
|
||||
$data['browser']['url'] = $conversation->url(null, $thread->id, ['mark_as_read' => $db_notification->id]);
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -19,15 +19,18 @@ class WebsiteNotification extends Notification implements ShouldQueue
|
||||
|
||||
public $thread;
|
||||
|
||||
public $data;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($conversation, $thread)
|
||||
public function __construct($conversation, $thread, $data = [])
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->thread = $thread;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,13 +57,14 @@ class WebsiteNotification extends Notification implements ShouldQueue
|
||||
return [
|
||||
'thread_id' => $this->thread->id,
|
||||
'conversation_id' => $this->conversation->id,
|
||||
'data' => $this->data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from DB for notifications list to display it.
|
||||
*/
|
||||
public static function fetchNotificationsData($notifications)
|
||||
public static function fetchNotificationsData($notifications, $auth_user = null)
|
||||
{
|
||||
$data = [];
|
||||
|
||||
@@ -141,6 +145,13 @@ class WebsiteNotification extends Notification implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check access to the conversation
|
||||
// and reset $last_thread_body if there is no access anymore.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6545-8c3f-pp6w
|
||||
if (!$auth_user->can('view', $conversation)) {
|
||||
$last_thread_body = '…';
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'notification' => $notification,
|
||||
'created_at' => $notification->created_at,
|
||||
@@ -150,6 +161,7 @@ class WebsiteNotification extends Notification implements ShouldQueue
|
||||
'user' => $user,
|
||||
'created_by_user' => $created_by_user,
|
||||
'created_by_customer' => $created_by_customer,
|
||||
'data' => $notification->data['data'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class ConversationObserver
|
||||
if ($conversation->source_via == Conversation::PERSON_USER) {
|
||||
$conversation->read_by_user = true;
|
||||
}
|
||||
|
||||
$conversation->subject = mb_substr($conversation->subject ?? '', 0, Conversation::SUBJECT_MAXLENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,17 @@ class ConversationObserver
|
||||
//$conversation->mailbox->updateFoldersCounters();
|
||||
}
|
||||
|
||||
/**
|
||||
* On before updating.
|
||||
*
|
||||
* @param Conversation $conversation
|
||||
*/
|
||||
public function updating(Conversation $conversation)
|
||||
{
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5201
|
||||
$conversation->subject = mb_substr($conversation->subject ?? '', 0, Conversation::SUBJECT_MAXLENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* On conversation delete.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,17 @@ use Illuminate\Notifications\DatabaseNotification;
|
||||
|
||||
class DatabaseNotificationObserver
|
||||
{
|
||||
/**
|
||||
* Populate "conversation_id" column.
|
||||
*/
|
||||
public function creating(DatabaseNotification $notification)
|
||||
{
|
||||
// $notification->data is already cast to array by the base model.
|
||||
if (!empty($notification->data['conversation_id'])) {
|
||||
$notification->conversation_id = $notification->data['conversation_id'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications DB record created.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,17 @@ use App\Email;
|
||||
|
||||
class EmailObserver
|
||||
{
|
||||
/**
|
||||
* On create before saving.
|
||||
*
|
||||
* @param Email $email
|
||||
*/
|
||||
public function creating(Email $email)
|
||||
{
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5106
|
||||
$email->email = Email::sanitizeLength($email->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email created.
|
||||
*
|
||||
|
||||
@@ -33,10 +33,16 @@ class MailboxObserver
|
||||
*/
|
||||
public function deleting(Mailbox $mailbox)
|
||||
{
|
||||
// Same things are deleted in Mailbox->deleteMailbox().
|
||||
$mailbox->users()->delete();
|
||||
$mailbox->conversations()->delete();
|
||||
$mailbox->folders()->delete();
|
||||
|
||||
\Eventy::action('mailbox.before_delete', $mailbox);
|
||||
}
|
||||
|
||||
public function deleted(Mailbox $mailbox)
|
||||
{
|
||||
\Eventy::action('mailbox.deleted', $mailbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ class ThreadObserver
|
||||
$use_mail_date_on_fetching = config('app.use_mail_date_on_fetching');
|
||||
|
||||
if ($use_mail_date_on_fetching) {
|
||||
$now = $thread->created_at;
|
||||
}else{
|
||||
$now = $thread->created_at->format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
@@ -38,12 +38,18 @@ class ThreadObserver
|
||||
}
|
||||
|
||||
if ((in_array($thread->type, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE])
|
||||
|| ($conversation->isPhone() && in_array($thread->type, [Thread::TYPE_NOTE])))
|
||||
|| ($conversation->isPhone() && in_array($thread->type, [Thread::TYPE_NOTE]))
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5105
|
||||
// This is not a normal situtaion: email conversation containing just one Note thread.
|
||||
|| ($conversation->threads_count == 0 && $thread->type == Thread::TYPE_NOTE)
|
||||
)
|
||||
&& $thread->state == Thread::STATE_PUBLISHED
|
||||
) {
|
||||
// $conversation->cc = $thread->cc;
|
||||
// $conversation->bcc = $thread->bcc;
|
||||
$conversation->last_reply_at = $now;
|
||||
|
||||
$conversation->setLastReplyAt($now, $thread->source_via);
|
||||
// "last_reply_from" may be already set at this stage (by FetchEmails for example).
|
||||
$conversation->last_reply_from = $thread->source_via;
|
||||
}
|
||||
if ($conversation->source_via == Conversation::PERSON_CUSTOMER) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* todo: implement caching by saving all options in one cache variable on register_shutdown_function.
|
||||
* Todo: implement caching by saving all options in one cache variable on register_shutdown_function.
|
||||
*/
|
||||
|
||||
namespace App;
|
||||
@@ -51,7 +51,8 @@ class Option extends Model
|
||||
$serialized_value = self::maybeSerialize($value);
|
||||
|
||||
$option = self::firstOrCreate(
|
||||
['name' => $name], ['value' => $serialized_value]
|
||||
['name' => $name],
|
||||
['value' => $serialized_value]
|
||||
);
|
||||
|
||||
$old_value = $option['value'];
|
||||
@@ -229,7 +230,7 @@ class Option extends Model
|
||||
// Options are not seralized using serialize() anymore.
|
||||
if (self::isSerialized($original)) {
|
||||
try {
|
||||
$original = unserialize($original);
|
||||
$original = unserialize($original, ['allowed_classes' => false]);
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ class ConversationPolicy
|
||||
return true;
|
||||
} else {
|
||||
if ($conversation->userHasAccessToMailbox($user->id)) {
|
||||
// Regular users can not see Archived mailboxes.
|
||||
if ($conversation->isMailboxArchived()) {
|
||||
return false;
|
||||
}
|
||||
// Maybe user can see only assigned conversations.
|
||||
return $this->checkIsOnlyAssigned($conversation, $user);
|
||||
} else {
|
||||
@@ -46,6 +50,10 @@ class ConversationPolicy
|
||||
return true;
|
||||
} else {
|
||||
if ($conversation->mailbox->users_cached->contains($user)) {
|
||||
// Regular users can not see Archived mailboxes.
|
||||
if ($conversation->isMailboxArchived()) {
|
||||
return false;
|
||||
}
|
||||
// Maybe user can see only assigned conversations.
|
||||
return $this->checkIsOnlyAssigned($conversation, $user);
|
||||
} else {
|
||||
@@ -64,16 +72,7 @@ class ConversationPolicy
|
||||
*/
|
||||
public function update(User $user, Conversation $conversation)
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
if ($conversation->userHasAccessToMailbox($user->id)) {
|
||||
// Maybe user can see only assigned conversations.
|
||||
return $this->checkIsOnlyAssigned($conversation, $user);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return $this->view($user, $conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +90,10 @@ class ConversationPolicy
|
||||
return true;
|
||||
}
|
||||
if ($conversation->userHasAccessToMailbox($user->id)) {
|
||||
// Regular users can not see Archived mailboxes.
|
||||
if ($conversation->isMailboxArchived()) {
|
||||
return false;
|
||||
}
|
||||
// Maybe user can see only assigned conversations.
|
||||
return $this->checkIsOnlyAssigned($conversation, $user);
|
||||
} else {
|
||||
@@ -119,7 +122,7 @@ class ConversationPolicy
|
||||
public function checkIsOnlyAssigned($conversation, $user)
|
||||
{
|
||||
// Maybe user can see only assigned conversations.
|
||||
if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id)
|
||||
if (!$conversation->isAssignedToUser($user)
|
||||
&& $conversation->created_by_user_id != $user->id
|
||||
&& $user->canSeeOnlyAssignedConversations()
|
||||
) {
|
||||
|
||||
44
app/Policies/CustomerPolicy.php
Normal file
44
app/Policies/CustomerPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Conversation;
|
||||
use App\Customer;
|
||||
use App\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class CustomerPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*
|
||||
* @param \App\User $user
|
||||
* @param \App\User $model
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function view(User $user, Customer $customer)
|
||||
{
|
||||
if (!$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
|
||||
|
||||
if ($limited_visibility) {
|
||||
$mailbox_ids = $user->mailboxesIdsCanView();
|
||||
|
||||
$accesible = Conversation::where('customer_id', $customer->id)
|
||||
->whereIn('conversations.mailbox_id', $mailbox_ids)
|
||||
->exists();
|
||||
|
||||
if (!$accesible) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class MailboxPolicy
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
if ($mailbox->users->contains($user)) {
|
||||
if ($mailbox->users->contains($user) && !$mailbox->isArchived()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -59,7 +59,7 @@ class MailboxPolicy
|
||||
return true;
|
||||
} else {
|
||||
// Use cached users for Realtime events
|
||||
if ($mailbox->users_cached->contains($user)) {
|
||||
if ($mailbox->users_cached->contains($user) && !$mailbox->isArchived()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -90,7 +90,8 @@ class MailboxPolicy
|
||||
*/
|
||||
public function update(User $user, Mailbox $mailbox)
|
||||
{
|
||||
if ($user->isAdmin() || $user->canManageMailbox($mailbox->id)) {
|
||||
// canManageMailbox() checks if mailbox is archived.
|
||||
if ($user->isAdmin() || $user->canManageMailbox($mailbox)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -107,10 +108,16 @@ class MailboxPolicy
|
||||
*/
|
||||
public function updateAutoReply(User $user, Mailbox $mailbox)
|
||||
{
|
||||
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_AUTO_REPLIES)) {
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_AUTO_REPLIES)
|
||||
&& !$mailbox->isArchived()
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +131,16 @@ class MailboxPolicy
|
||||
*/
|
||||
public function updatePermissions(User $user, Mailbox $mailbox)
|
||||
{
|
||||
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_PERMISSIONS)) {
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_PERMISSIONS)
|
||||
&& !$mailbox->isArchived()
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,10 +154,16 @@ class MailboxPolicy
|
||||
*/
|
||||
public function updateSettings(User $user, Mailbox $mailbox)
|
||||
{
|
||||
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_EDIT)) {
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_EDIT)
|
||||
&& !$mailbox->isArchived()
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +177,16 @@ class MailboxPolicy
|
||||
*/
|
||||
public function updateEmailSignature(User $user, Mailbox $mailbox)
|
||||
{
|
||||
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_SIGNATURE)) {
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
if ($user->hasManageMailboxPermission($mailbox->id, [Mailbox::ACCESS_PERM_SIGNATURE, Mailbox::ACCESS_PERM_EDIT])
|
||||
&& !$mailbox->isArchived()
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,24 @@ class ThreadPolicy
|
||||
*/
|
||||
public function edit(User $user, Thread $thread)
|
||||
{
|
||||
if (($thread->created_by_user_id
|
||||
&& in_array($thread->type, [Thread::TYPE_MESSAGE, Thread::TYPE_NOTE])
|
||||
&& ($user->isAdmin() || ($user->hasPermission(User::PERM_EDIT_CONVERSATIONS) && $thread->created_by_user_id == $user->id)))
|
||||
|| ($thread->created_by_customer_id && in_array($thread->type, [Thread::TYPE_CUSTOMER]))
|
||||
if ((
|
||||
// Thread created by user.
|
||||
$thread->created_by_user_id
|
||||
&& in_array($thread->type, [Thread::TYPE_MESSAGE, Thread::TYPE_NOTE])
|
||||
&& ($user->isAdmin() || (
|
||||
$user->hasPermission(User::PERM_EDIT_CONVERSATIONS)
|
||||
&& $thread->created_by_user_id == $user->id
|
||||
&& $thread->conversation
|
||||
&& $thread->conversation->userHasAccessToMailbox($user->id)
|
||||
&& $this->checkIsOnlyAssigned($thread->conversation, $user)
|
||||
)
|
||||
)) || (
|
||||
// Thread created by customer.
|
||||
$thread->created_by_customer_id
|
||||
&& in_array($thread->type, [Thread::TYPE_CUSTOMER])
|
||||
&& $thread->conversation
|
||||
&& ($user->isAdmin() || ($thread->conversation->userHasAccessToMailbox($user->id) && $this->checkIsOnlyAssigned($thread->conversation, $user)))
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -33,10 +47,32 @@ class ThreadPolicy
|
||||
|
||||
public function delete(User $user, Thread $thread)
|
||||
{
|
||||
// Admin also can delete only own notes.
|
||||
if ($thread->created_by_user_id == $user->id) {
|
||||
return true;
|
||||
if ($thread->conversation
|
||||
&& (!$thread->conversation->userHasAccessToMailbox($user->id) || !$this->checkIsOnlyAssigned($thread->conversation, $user))
|
||||
) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If user can see only assigned conversations,
|
||||
// check if he/she has access to the conversation.
|
||||
public function checkIsOnlyAssigned($conversation, $user)
|
||||
{
|
||||
// Maybe user can see only assigned conversations.
|
||||
if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id)
|
||||
&& $conversation->created_by_user_id != $user->id
|
||||
&& $user->canSeeOnlyAssignedConversations()
|
||||
) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,28 @@ class UserPolicy
|
||||
{
|
||||
if ($user->isAdmin()
|
||||
|| $user->id == $model->id
|
||||
|| $user->hasPermission(User::PERM_EDIT_USERS)
|
||||
// Do not allow regular users edit admin profiles.
|
||||
|| ($user->hasPermission(User::PERM_EDIT_USERS) && !$model->isAdmin())
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update model's notification settings.
|
||||
*
|
||||
* @param \App\User $user
|
||||
* @param \App\User $model
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function updateNotifications(User $user, User $model)
|
||||
{
|
||||
if ($user->isAdmin()
|
||||
|| $user->id == $model->id
|
||||
|| ($user->hasPermission(User::PERM_EDIT_USERS) && !$model->isAdmin())
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,28 @@ class AppServiceProvider extends ServiceProvider
|
||||
\App\Attachment::observe(\App\Observers\AttachmentObserver::class);
|
||||
\App\Follower::observe(\App\Observers\FollowerObserver::class);
|
||||
\Illuminate\Notifications\DatabaseNotification::observe(\App\Observers\DatabaseNotificationObserver::class);
|
||||
|
||||
\Validator::extend('safehost', function ($attribute, $value, $parameters, $validator) {
|
||||
if (!$value) {
|
||||
return true;
|
||||
}
|
||||
$msg = '';
|
||||
try {
|
||||
$url = $value;
|
||||
if (!preg_match("#^https?://#", $value)) {
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
\Helper::sanitizeRemoteUrl($url, true);
|
||||
} catch (\Exception $e) {
|
||||
$msg = $e->getMessage();
|
||||
}
|
||||
if ($msg) {
|
||||
$validator->errors()->add($attribute, $msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
'App\Folder' => 'App\Policies\FolderPolicy',
|
||||
'App\Conversation' => 'App\Policies\ConversationPolicy',
|
||||
'App\Thread' => 'App\Policies\ThreadPolicy',
|
||||
'App\Customer' => 'App\Policies\CustomerPolicy',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,6 +94,10 @@ class EventServiceProvider extends ServiceProvider
|
||||
'App\Listeners\SendNotificationToUsers',
|
||||
'App\Listeners\RefreshConversations',
|
||||
],
|
||||
|
||||
'App\Events\UserMovedConversation' => [
|
||||
'App\Listeners\SendNotificationToUsers',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,8 @@ class PolycastServiceProvider extends ServiceProvider
|
||||
|
||||
// establish connection and send current time
|
||||
$this->app['router']->post('polycast/connect', function (Request $request) {
|
||||
\Broadcast::auth($request);
|
||||
|
||||
return ['status' => 'success', 'time' => Carbon::now()->toDateTimeString()];
|
||||
});
|
||||
|
||||
@@ -62,7 +64,17 @@ class PolycastServiceProvider extends ServiceProvider
|
||||
|
||||
$channels = $request->get('channels', []);
|
||||
|
||||
// $request->channels must contain a list of channels.
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-7x4c-p73f-67x5
|
||||
if (empty($channels)) {
|
||||
\Helper::denyAccess();
|
||||
}
|
||||
|
||||
foreach ($channels as $channel => $events) {
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-gh3r-jh6q-wrvj
|
||||
$channel = \Helper::sqlEscapeLike($channel);
|
||||
|
||||
foreach ($events as $event) {
|
||||
// No need to add index to DB for this query.
|
||||
$query->orWhere(function ($query) use ($channel, $event, $request) {
|
||||
@@ -81,14 +93,18 @@ class PolycastServiceProvider extends ServiceProvider
|
||||
$requested = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', $request->get('time'));
|
||||
$item->channels = json_decode($item->channels, false);
|
||||
$item->payload = json_decode($item->payload, false);
|
||||
// Add extra data to the payload
|
||||
// Add extra data to the payload - used in RealtimeBroadcastNotificationCreated.
|
||||
// This works only if payload has medius and thread_id
|
||||
$item->data = BroadcastNotification::fetchPayloadData($item->payload);
|
||||
|
||||
$event_class = '\\'.$item->event;
|
||||
if (method_exists($event_class, "processPayload")) {
|
||||
// If user is not allowed to access this event, data will be sent to empty array.
|
||||
// If user is not allowed to access this event, payload will be set to empty array.
|
||||
$item->payload = $event_class::processPayload($item->payload);
|
||||
|
||||
if (!$item->payload) {
|
||||
$item->data = [];
|
||||
}
|
||||
}
|
||||
|
||||
$item->delay = $requested->diffInSeconds($created);
|
||||
|
||||
@@ -38,6 +38,7 @@ class RouteServiceProvider extends ServiceProvider
|
||||
//$this->mapApiRoutes();
|
||||
|
||||
$this->mapWebRoutes();
|
||||
$this->mapOpenRoutes();
|
||||
|
||||
//
|
||||
}
|
||||
@@ -64,6 +65,21 @@ class RouteServiceProvider extends ServiceProvider
|
||||
->group(base_path('routes/web.php'));
|
||||
}
|
||||
|
||||
protected function mapOpenRoutes()
|
||||
{
|
||||
$subdirectory = \Helper::getSubdirectory();
|
||||
|
||||
if ($subdirectory) {
|
||||
$route = Route::prefix($subdirectory)
|
||||
->middleware('open');
|
||||
} else {
|
||||
$route = Route::middleware('open');
|
||||
}
|
||||
|
||||
$route->namespace($this->namespace)
|
||||
->group(base_path('routes/open.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
|
||||
@@ -16,10 +16,12 @@ class Subscription extends Model
|
||||
// Changing ticket status does not fire event
|
||||
const EVENT_TYPE_NEW = 1;
|
||||
const EVENT_TYPE_ASSIGNED = 2;
|
||||
// This event type exists but not used anywhere for now.
|
||||
const EVENT_TYPE_UPDATED = 3;
|
||||
const EVENT_TYPE_CUSTOMER_REPLIED = 4;
|
||||
const EVENT_TYPE_USER_REPLIED = 5;
|
||||
const EVENT_TYPE_USER_ADDED_NOTE = 6;
|
||||
const EVENT_TYPE_USER_MOVED = 7;
|
||||
|
||||
// Events
|
||||
// Notify me when…
|
||||
@@ -187,6 +189,7 @@ class Subscription extends Model
|
||||
|
||||
switch ($event_type) {
|
||||
case self::EVENT_TYPE_NEW:
|
||||
case self::EVENT_TYPE_USER_MOVED:
|
||||
$events[] = self::EVENT_NEW_CONVERSATION;
|
||||
break;
|
||||
|
||||
@@ -221,7 +224,7 @@ class Subscription extends Model
|
||||
|
||||
// Check if assigned user changed
|
||||
$user_changed = false;
|
||||
if ($event_type != self::EVENT_TYPE_ASSIGNED && $event_type != self::EVENT_TYPE_NEW) {
|
||||
if (!in_array($event_type, [self::EVENT_TYPE_ASSIGNED, self::EVENT_TYPE_NEW, self::EVENT_TYPE_USER_MOVED])) {
|
||||
if ($thread->type == Thread::TYPE_LINEITEM && $thread->action_type == Thread::ACTION_TYPE_USER_CHANGED) {
|
||||
$user_changed = true;
|
||||
} elseif ($prev_thread) {
|
||||
@@ -243,7 +246,7 @@ class Subscription extends Model
|
||||
$events = array_unique($events);
|
||||
|
||||
// Detect subscribed users
|
||||
if (!$mailbox_user_ids) {
|
||||
if ($mailbox_user_ids === null) {
|
||||
$mailbox_user_ids = $conversation->mailbox->userIdsHavingAccess();
|
||||
}
|
||||
|
||||
@@ -255,7 +258,7 @@ class Subscription extends Model
|
||||
|
||||
// Filter subscribers
|
||||
foreach ($subscriptions as $i => $subscription) {
|
||||
// Actions on conversation where user is assignee
|
||||
// Actions on conversation where user must be assignee
|
||||
if (in_array($subscription->event, [self::EVENT_CONVERSATION_ASSIGNED_TO_ME, self::EVENT_CUSTOMER_REPLIED_TO_MY, self::EVENT_USER_REPLIED_TO_MY])
|
||||
&& ($conversation->user_id != $subscription->user_id && !\Eventy::filter('subscription.is_user_assignee', false, $subscription, $conversation))
|
||||
) {
|
||||
@@ -316,18 +319,30 @@ class Subscription extends Model
|
||||
|
||||
// Collect into notify array information about all users who need to be notified
|
||||
foreach (self::$occurred_events as $event) {
|
||||
// Get mailbox users ids
|
||||
$mailbox_user_ids = [];
|
||||
// Get mailbox users ids (from previous $notify record).
|
||||
/*$mailbox_user_ids = null;
|
||||
foreach (self::$mediums as $medium) {
|
||||
if (!empty($notify[$medium])) {
|
||||
foreach ($notify[$medium] as $conversation_id => $notify_info) {
|
||||
if ($notify_info['conversation']->mailbox_id == $event['conversation']->mailbox_id) {
|
||||
$mailbox_user_ids = $notify_info['mailbox_user_ids'];
|
||||
foreach ($notify[$medium] as $conversation_id => $notify_row) {
|
||||
if ($notify_row['conversation']->mailbox_id == $event['conversation']->mailbox_id) {
|
||||
$mailbox_user_ids = $notify_row['mailbox_user_ids'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($mailbox_user_ids === null) {
|
||||
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
|
||||
}*/
|
||||
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
|
||||
|
||||
// When conversation is moved to another mailbox
|
||||
// notify only users NOT having access to the original mailbox.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5580
|
||||
if ($event['event_type'] == self::EVENT_TYPE_USER_MOVED && !empty($event['extra_data']['from_mailbox'])) {
|
||||
$original_mailbox_user_ids = $event['extra_data']['from_mailbox']->userIdsHavingAccess();
|
||||
$mailbox_user_ids = array_diff($mailbox_user_ids, $original_mailbox_user_ids);
|
||||
}
|
||||
|
||||
// Get users and threads from previous results to avoid repeated SQL queries.
|
||||
$users = [];
|
||||
@@ -364,7 +379,7 @@ class Subscription extends Model
|
||||
'users' => array_unique(array_merge($users[$medium] ?? [], $medium_users_to_notify)),
|
||||
'conversation' => $event['conversation'],
|
||||
'threads' => $threads,
|
||||
'mailbox_user_ids' => $mailbox_user_ids,
|
||||
//'mailbox_user_ids' => $mailbox_user_ids,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -384,15 +399,25 @@ class Subscription extends Model
|
||||
|| !empty($notify[self::MEDIUM_BROWSER])
|
||||
|| !empty($notify[self::MEDIUM_MENU])
|
||||
) {
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5491
|
||||
$notify_menu = [];
|
||||
if (!empty($notify[self::MEDIUM_EMAIL])) {
|
||||
$notify_menu = $notify[self::MEDIUM_EMAIL] ?? [];
|
||||
} else {
|
||||
$notify_menu = $notify[self::MEDIUM_BROWSER] ?? [];
|
||||
}
|
||||
foreach ($notify[self::MEDIUM_BROWSER] ?? [] as $conversation_id => $notify_info) {
|
||||
if (empty($notify_menu[$conversation_id])) {
|
||||
$notify_menu[$conversation_id] = $notify_info;
|
||||
} else {
|
||||
$notify_menu[$conversation_id]['users'] = collect($notify_menu[$conversation_id]['users'])
|
||||
->merge($notify_info['users'])->unique('id')->values()->all();
|
||||
}
|
||||
}
|
||||
$notify_menu = $notify_menu + ($notify[self::MEDIUM_MENU] ?? []);
|
||||
|
||||
foreach ($notify_menu as $notify_info) {
|
||||
$website_notification = new WebsiteNotification($notify_info['conversation'], self::chooseThread($notify_info['threads']));
|
||||
$website_notification->delay($delay);
|
||||
|
||||
\Notification::send($notify_info['users'], $website_notification);
|
||||
}
|
||||
}
|
||||
@@ -440,6 +465,8 @@ class Subscription extends Model
|
||||
*/
|
||||
public static function chooseThread($threads)
|
||||
{
|
||||
//$last_meaningful_thread = null;
|
||||
|
||||
$actions_types = [
|
||||
Thread::ACTION_TYPE_USER_CHANGED,
|
||||
];
|
||||
@@ -448,21 +475,31 @@ class Subscription extends Model
|
||||
if ($thread->type == Thread::TYPE_LINEITEM && !in_array($thread->action_type, $actions_types)) {
|
||||
continue;
|
||||
} else {
|
||||
// if (!$last_meaningful_thread) {
|
||||
// $last_meaningful_thread = $thread;
|
||||
// }
|
||||
|
||||
// // Make sure that user is not Deleted to filter out automatic messages.
|
||||
// if (($thread->isUserMessage() || $thread->isNote()) && $thread->created_by_user && $thread->created_by_user->isDeleted()) {
|
||||
// continue;
|
||||
// }
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
//return $last_meaningful_thread ?? $threads[0];
|
||||
return $threads[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember event type to process in ProcessSubscriptionEvents middleware on terminate.
|
||||
*/
|
||||
public static function registerEvent($event_type, $conversation, $caused_by_user_id, $process_now = false)
|
||||
public static function registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data = [], $process_now = false)
|
||||
{
|
||||
self::$occurred_events[] = [
|
||||
'event_type' => $event_type,
|
||||
'conversation' => $conversation,
|
||||
'caused_by_user_id' => $caused_by_user_id,
|
||||
'extra_data' => $extra_data,
|
||||
];
|
||||
|
||||
// Automatically add EVENT_TYPE_UPDATED
|
||||
@@ -471,6 +508,7 @@ class Subscription extends Model
|
||||
'event_type' => self::EVENT_TYPE_UPDATED,
|
||||
'conversation' => $conversation,
|
||||
'caused_by_user_id' => $caused_by_user_id,
|
||||
'extra_data' => $extra_data,
|
||||
];
|
||||
}
|
||||
if ($process_now) {
|
||||
|
||||
184
app/Thread.php
184
app/Thread.php
@@ -298,7 +298,7 @@ class Thread extends Model
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCleanBody($body = '')
|
||||
public function getCleanBody($body = '', $escape_non_ascii_characters = false)
|
||||
{
|
||||
if (!$body) {
|
||||
$body = $this->body;
|
||||
@@ -316,15 +316,41 @@ class Thread extends Model
|
||||
// Cut out "collapse" class as it hides elements.
|
||||
$body = preg_replace("/(<[^<>\r\n]+class=([\"'][^\"']* |[\"']))(collapse|hidden)([\"' ])/", '$1$4', $body) ?: $body;
|
||||
|
||||
// Take care of MSO-comments.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5068
|
||||
// Step 1: Completely remove the MSO-specific blocks.
|
||||
// The 's' flag allows '.' to match newlines, and 'i' makes it case-insensitive.
|
||||
$body = preg_replace('/<!--\[if mso\]>.*?<!\[endif\]-->/is', '', $body);
|
||||
// Step 2: Unwrap the standard HTML from the "not mso" comments.
|
||||
// This reveals the standard <a> tag for the purifier.
|
||||
$body = preg_replace('/<!--\[if !mso\]><!--\s*-->(.*?)<!--\s*<!\[endif\]-->/is', '$1', $body);
|
||||
|
||||
// Remove only the <!--[if !mso]><!--> and <!--<![endif]--> around the elements.
|
||||
// https://github.com/freescout-helpdesk/freescout/pull/3865#issuecomment-1990758149
|
||||
$body = preg_replace('/<!\-\-\[if [^>]+\]><!\-\->(.*?)<![ ]+\-\-<!\[endif\]\-\->/s', '$1', $body);
|
||||
|
||||
// Remove Outlook "downlevel-revealed" conditional comments (<![if !vml]>...<![endif]>),
|
||||
// preserving the data inside. Browsers ignore them, but the purifier escapes them as text.
|
||||
// Emails fetched before the fix have them already escaped (<![if !vml]>) in the DB.
|
||||
$body = preg_replace('/(?:<|<)!\[(?:if\s[^\]]*|endif)\](?:>|>)/i', '', $body) ?: $body;
|
||||
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3894
|
||||
// Remove <!--[if !mso]><!--> and <!--<![endif]--> comments, preserving the data inside
|
||||
//$body = preg_replace('/(<!\-\-\[if [^>]+\]>|<!\[endif\]\-\->)/', '', $body);
|
||||
|
||||
return \Helper::purifyHtml($body);
|
||||
// This option causes issues on some instances:
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5481#issuecomment-5113750894
|
||||
if ($escape_non_ascii_characters) {
|
||||
\Config::set('purifier.settings.default.Core.EscapeNonASCIICharacters', true);
|
||||
}
|
||||
|
||||
$body = \Helper::purifyHtml($body);
|
||||
|
||||
if ($escape_non_ascii_characters) {
|
||||
\Config::set('purifier.settings.default.Core.EscapeNonASCIICharacters', false);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,23 +369,29 @@ class Thread extends Model
|
||||
|
||||
$body = \Helper::linkify($this->getCleanBody($body));
|
||||
|
||||
// Add target="_blank" to links.
|
||||
// Add target="_blank" to external links.
|
||||
$pattern = '/<a(.*?)?href=[\'"]?[\'"]?(.*?)?>/i';
|
||||
|
||||
$body = preg_replace_callback($pattern, function($m){
|
||||
$body = preg_replace_callback($pattern, function ($m) {
|
||||
$tpl = array_shift($m);
|
||||
$href = isset($m[1]) ? $m[1] : null;
|
||||
$href = trim(isset($m[1]) ? $m[1] : '');
|
||||
|
||||
if (preg_match('/target=[\'"]?(.*?)[\'"]?/i', $tpl)) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
if (trim($href) && 0 === strpos($href, '#')) {
|
||||
// Anchor links.
|
||||
// Skip anchor link.
|
||||
if ($href && 0 === strpos($href, '#')) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
return preg_replace_callback('/href=/i', function($m2){
|
||||
// Skip non-external links.
|
||||
if (parse_url($href, PHP_URL_HOST) == \Helper::getDomain()) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
// Add target.
|
||||
return preg_replace_callback('/href=/i', function ($m2) {
|
||||
return sprintf('target="_blank" %s', array_shift($m2));
|
||||
}, $tpl);
|
||||
|
||||
@@ -378,6 +410,12 @@ class Thread extends Model
|
||||
return $this->getCleanBody($this->body_original);
|
||||
}
|
||||
|
||||
// Get "to" as array.
|
||||
public function getTo($exclude_array = [])
|
||||
{
|
||||
return $this->getToArray($exclude_array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread recipients.
|
||||
*
|
||||
@@ -411,6 +449,12 @@ class Thread extends Model
|
||||
return self::$types[$this->type];
|
||||
}
|
||||
|
||||
// Get "Cc" as array.
|
||||
public function getCc($exclude_array = [])
|
||||
{
|
||||
return $this->getCcArray($exclude_array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread CC recipients.
|
||||
*
|
||||
@@ -426,6 +470,12 @@ class Thread extends Model
|
||||
return implode(', ', $this->getCcArray($exclude_array));
|
||||
}
|
||||
|
||||
// Get "Bcc" as array.
|
||||
public function getBcc($exclude_array = [])
|
||||
{
|
||||
return $this->getBccArray($exclude_array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread BCC recipients.
|
||||
*
|
||||
@@ -496,31 +546,13 @@ class Thread extends Model
|
||||
*/
|
||||
public static function statusCodeToName($status)
|
||||
{
|
||||
switch ($status) {
|
||||
case self::STATUS_ACTIVE:
|
||||
return __('Active');
|
||||
break;
|
||||
return Conversation::statusCodeToName($status);
|
||||
}
|
||||
|
||||
case self::STATUS_PENDING:
|
||||
return __('Pending');
|
||||
break;
|
||||
|
||||
case self::STATUS_CLOSED:
|
||||
return __('Closed');
|
||||
break;
|
||||
|
||||
case self::STATUS_SPAM:
|
||||
return __('Spam');
|
||||
break;
|
||||
|
||||
case self::STATUS_NOCHANGE:
|
||||
return __('Not changed');
|
||||
break;
|
||||
|
||||
default:
|
||||
return '';
|
||||
break;
|
||||
}
|
||||
// Get normalized status.
|
||||
public function getMainStatus()
|
||||
{
|
||||
return Conversation::toMainStatus($this->status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -721,6 +753,9 @@ class Thread extends Model
|
||||
$did_this = strip_tags($did_this);
|
||||
}
|
||||
|
||||
if (!$person) {
|
||||
$person = __('System');
|
||||
}
|
||||
if ($person) {
|
||||
// This causes double escaping.
|
||||
// if ($escape) {
|
||||
@@ -739,10 +774,13 @@ class Thread extends Model
|
||||
{
|
||||
// Person
|
||||
$person = $this->getActionPerson($conversation_number);
|
||||
$did_this = $this->getActionText($conversation_number, false, false, null, '', $viewed_by_user);
|
||||
|
||||
if ($escape) {
|
||||
$person = htmlspecialchars($person);
|
||||
}
|
||||
|
||||
$did_this = $this->getActionText($conversation_number, false, false, null, $person, $viewed_by_user);
|
||||
|
||||
if ($escape) {
|
||||
$did_this = htmlspecialchars($did_this);
|
||||
}
|
||||
|
||||
@@ -968,6 +1006,9 @@ class Thread extends Model
|
||||
if (!empty($data['action_type'])) {
|
||||
$thread->action_type = $data['action_type'];
|
||||
}
|
||||
if (!empty($data['action_data'])) {
|
||||
$thread->action_data = $data['action_data'];
|
||||
}
|
||||
if (!empty($data['meta'])) {
|
||||
$thread->setMetas($data['meta']);
|
||||
}
|
||||
@@ -1115,7 +1156,8 @@ class Thread extends Model
|
||||
$uploaded_file,
|
||||
$embedded = false,
|
||||
$thread->id,
|
||||
$user_id ?? null
|
||||
$user_id ?? null,
|
||||
$data['attachments_upload_mode'] ?? \Helper::UPLOAD_MODE_DEFAULT
|
||||
);
|
||||
|
||||
if ($attachment) {
|
||||
@@ -1165,7 +1207,7 @@ class Thread extends Model
|
||||
}
|
||||
$conversation->status = $data['status'];
|
||||
} else {
|
||||
if ((int)$conversation->status != Conversation::STATUS_ACTIVE) {
|
||||
if (!$conversation->isActive()) {
|
||||
$update_folder = true;
|
||||
}
|
||||
// Reply from customer makes conversation active
|
||||
@@ -1181,7 +1223,7 @@ class Thread extends Model
|
||||
}
|
||||
$conversation->status = $data['status'];
|
||||
} else {
|
||||
if ((int)$conversation->status != Conversation::STATUS_PENDING) {
|
||||
if (!$conversation->isPending()) {
|
||||
$update_folder = true;
|
||||
}
|
||||
// Reply from customer makes conversation active
|
||||
@@ -1463,8 +1505,49 @@ class Thread extends Model
|
||||
if (empty($this->headers)) {
|
||||
return '';
|
||||
}
|
||||
preg_match("#From:\s*.*[^\s]*\s*<\s*(.*[^\s])\s*>\s*\n#", $this->headers ?? '', $m);
|
||||
return $m[1] ?? '';
|
||||
preg_match("#From:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
|
||||
return \Helper::sanitizeEmail($m[1] ?? '');
|
||||
}
|
||||
|
||||
// Used to highlight sender when From is different from Reply-To.
|
||||
public function getFromIfDifferentFromReplyTo($customer = null)
|
||||
{
|
||||
if (empty($this->headers) || !$this->isCustomerMessage() || !$customer) {
|
||||
return '';
|
||||
}
|
||||
preg_match("#Reply\-To:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
|
||||
|
||||
$reply_to = \Helper::sanitizeEmail($m[1] ?? '');
|
||||
|
||||
if (!$reply_to) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$from = $this->getFromHeader();
|
||||
if (!$from) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($from == $reply_to) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->created_by_customer_id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($this->created_by_customer_id != $customer->id) {
|
||||
if (!$this->created_by_customer) {
|
||||
return '';
|
||||
}
|
||||
$customer = $this->created_by_customer;
|
||||
}
|
||||
|
||||
if (!$customer->hasEmail($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getMailDate()
|
||||
@@ -1503,7 +1586,8 @@ class Thread extends Model
|
||||
{
|
||||
\Helper::setPcreBacktrackLimit();
|
||||
|
||||
$body = preg_replace_callback("#(<img[^<>]+src=[\"'])data:image/([^;]+);base64,([^\"']+)([\"'])#",
|
||||
$body = preg_replace_callback(
|
||||
"#(<img[^<>]+src=[\"'])data:image/([^;]+);base64,([^\"']+)([\"'])#",
|
||||
function ($match) {
|
||||
$attachment = null;
|
||||
$data = base64_decode($match[3]);
|
||||
@@ -1531,10 +1615,17 @@ class Thread extends Model
|
||||
return $body;
|
||||
}
|
||||
|
||||
// For customer messages: returns Message-ID from headers (if message_id is artificial).
|
||||
// For user threads: hashed 'FS_reply_' prefixed message ID.
|
||||
public function getMessageId($mailbox = null)
|
||||
{
|
||||
if ($this->isCustomerMessage() && $this->message_id) {
|
||||
return $this->message_id;
|
||||
if ($this->isCustomerMessage()) {
|
||||
$message_id = $this->message_id;
|
||||
if (\MailHelper::isGeneratedMessageId($message_id)) {
|
||||
// Use Message-ID from customer message headers.
|
||||
$message_id = $this->getHeader('Message-Id') ?: $message_id;
|
||||
}
|
||||
return $message_id;
|
||||
}
|
||||
if ($this->isUserMessage()) {
|
||||
if (!$mailbox) {
|
||||
@@ -1608,4 +1699,13 @@ class Thread extends Model
|
||||
->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%{i:0;i:'.$this->id.';%')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-qjr9-6v9q-3r72
|
||||
public static function getOpenTrackingHash($thread, $conversation, $mailbox)
|
||||
{
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5431
|
||||
$hash = \Helper::hmacHash($thread->id.$thread->body.$thread->customer_id.$thread->created_at.$conversation->created_at.$mailbox->created_at);
|
||||
|
||||
return substr($hash, 0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
174
app/User.php
174
app/User.php
@@ -37,6 +37,8 @@ class User extends Authenticatable
|
||||
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
|
||||
const INVITE_HASH_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Roles.
|
||||
*/
|
||||
@@ -68,6 +70,11 @@ class User extends Authenticatable
|
||||
const INVITE_STATE_SENT = 2;
|
||||
const INVITE_STATE_NOT_INVITED = 3;
|
||||
|
||||
/**
|
||||
* For how long invitation link is valid.
|
||||
*/
|
||||
const INVITE_TTL_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Time formats.
|
||||
*/
|
||||
@@ -75,7 +82,9 @@ class User extends Authenticatable
|
||||
const TIME_FORMAT_24 = 2;
|
||||
|
||||
/**
|
||||
* Global user permissions.
|
||||
* User permissions.
|
||||
* Core permissions have IDs below 100.
|
||||
* Permissions in custom modules should use IDs above 1000.
|
||||
*/
|
||||
const PERM_DELETE_CONVERSATIONS = 1;
|
||||
const PERM_EDIT_CONVERSATIONS = 2;
|
||||
@@ -83,6 +92,7 @@ class User extends Authenticatable
|
||||
const PERM_EDIT_TAGS = 4;
|
||||
const PERM_EDIT_CUSTOM_FOLDERS = 5;
|
||||
const PERM_EDIT_USERS = 10;
|
||||
const PERM_ONLY_ASSIGNED_TICKETS = 11;
|
||||
|
||||
public static $user_permissions = [
|
||||
self::PERM_DELETE_CONVERSATIONS,
|
||||
@@ -117,7 +127,7 @@ class User extends Authenticatable
|
||||
*
|
||||
* @var [type]
|
||||
*/
|
||||
protected $fillable = ['role', 'status', 'first_name', 'last_name', 'email', 'password', 'timezone', 'photo_url', 'type', 'emails', 'job_title', 'phone', 'time_format', 'locale'];
|
||||
protected $fillable = ['status', 'first_name', 'last_name', 'email', 'password', 'timezone', 'photo_url', 'type', 'emails', 'job_title', 'phone', 'time_format', 'locale'];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
@@ -126,7 +136,7 @@ class User extends Authenticatable
|
||||
public function __construct(array $attributes = array())
|
||||
{
|
||||
$this->setRawAttributes(array_merge($this->attributes, array(
|
||||
'timezone' => config('app.timezone') ?: User::DEFAULT_TIMEZONE
|
||||
'timezone' => config('app.timezone') ?: User::DEFAULT_TIMEZONE,
|
||||
)), true);
|
||||
parent::__construct($attributes);
|
||||
}
|
||||
@@ -222,7 +232,7 @@ class User extends Authenticatable
|
||||
*/
|
||||
public function getFullName()
|
||||
{
|
||||
return \Eventy::filter('user.full_name', $this->first_name.' '.$this->last_name, $this);
|
||||
return \Eventy::filter('user.full_name', trim($this->first_name.' '.$this->last_name), $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,6 +262,8 @@ class User extends Authenticatable
|
||||
} else {
|
||||
$mailboxes = $this->mailboxes;
|
||||
}
|
||||
// Exclude archived mailboxes.
|
||||
$mailboxes = Mailbox::excludeArchived($mailboxes);
|
||||
}
|
||||
|
||||
return $mailboxes->sortBy('name');
|
||||
@@ -278,10 +290,17 @@ class User extends Authenticatable
|
||||
});
|
||||
}
|
||||
if ($cache) {
|
||||
return $query->rememberForever()->get();
|
||||
$mailboxes = $query->rememberForever()->get();
|
||||
} else {
|
||||
return $query->get();
|
||||
$mailboxes = $query->get();
|
||||
}
|
||||
|
||||
if (!$this->isAdmin()) {
|
||||
// Exclude archived mailboxes.
|
||||
$mailboxes = Mailbox::excludeArchived($mailboxes);
|
||||
}
|
||||
|
||||
return $mailboxes;
|
||||
}
|
||||
|
||||
public function mailboxesSettings($cache = true)
|
||||
@@ -316,12 +335,23 @@ class User extends Authenticatable
|
||||
if ($this->isAdmin()) {
|
||||
return Mailbox::pluck('id')->toArray();
|
||||
} else {
|
||||
return $this->mailboxes()->pluck('mailboxes.id')->toArray();
|
||||
$mailboxes = $this->mailboxes()->pluck('mailboxes.meta', 'mailboxes.id');
|
||||
// Exclude archived mailboxes.
|
||||
return $mailboxes->filter(function ($meta) {
|
||||
$meta = json_decode($meta, true);
|
||||
if (!isset($meta[Mailbox::META_STATE])) {
|
||||
return true;
|
||||
}
|
||||
return $meta[Mailbox::META_STATE] == Mailbox::STATE_ACTIVE;
|
||||
})->keys()->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
public function hasAccessToMailbox($mailbox_id)
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
$ids = $this->mailboxesIdsCanView();
|
||||
return in_array($mailbox_id, $ids);
|
||||
}
|
||||
@@ -329,7 +359,8 @@ class User extends Authenticatable
|
||||
/**
|
||||
* Check to see if the user can manage any mailboxes
|
||||
*/
|
||||
public function hasManageMailboxAccess() {
|
||||
public function hasManageMailboxAccess()
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -346,36 +377,47 @@ class User extends Authenticatable
|
||||
/**
|
||||
* Check to see if the user can manage a specific mailbox
|
||||
*/
|
||||
public function canManageMailbox($mailbox_id)
|
||||
public function canManageMailbox($mailbox)
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
} else {
|
||||
//$mailbox = $this->mailboxesCanViewWithSettings(true)->where('id', $mailbox_id)->first();
|
||||
$mailbox = $this->mailboxesSettings()->where('mailbox_id', $mailbox_id)->first();
|
||||
if ($mailbox && !empty(json_decode($mailbox->access ?? ''))) {
|
||||
return true;
|
||||
$mailbox_settings = $this->mailboxesSettings()->where('mailbox_id', $mailbox->id)->first();
|
||||
if ($mailbox_settings) {
|
||||
if ($mailbox->isArchived()) {
|
||||
return false;
|
||||
}
|
||||
if (!empty(json_decode($mailbox_settings->access ?? ''))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to check if user has some exta access permission
|
||||
* for a given mailbox.
|
||||
*
|
||||
* $perm can be a single value or an array.
|
||||
*/
|
||||
public function hasManageMailboxPermission($mailbox_id, $perm) {
|
||||
// Experimental feature.
|
||||
public function hasManageMailboxPermission($mailbox_id, $perm)
|
||||
{
|
||||
// User can see only assigned conversations.
|
||||
// This option does not affect admin users.
|
||||
if ($perm == Mailbox::ACCESS_PERM_ASSIGNED) {
|
||||
if ($this->isAdmin()) {
|
||||
return false;
|
||||
} else {
|
||||
$show_only_assigned_conversations = config('app.show_only_assigned_conversations') ?? '';
|
||||
return $this->hasPermission(self::PERM_ONLY_ASSIGNED_TICKETS);
|
||||
/*$show_only_assigned_conversations = config('app.show_only_assigned_conversations') ?? '';
|
||||
if (in_array($this->id, explode(',', $show_only_assigned_conversations))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,16 +426,27 @@ class User extends Authenticatable
|
||||
} else {
|
||||
//$mailbox = $this->mailboxesCanViewWithSettings(true)->where('id', $mailbox_id)->first();
|
||||
$mailbox = $this->mailboxesSettings()->where('mailbox_id', $mailbox_id)->first();
|
||||
if ($mailbox && !empty($mailbox->access) && in_array($perm, json_decode($mailbox->access))) {
|
||||
return true;
|
||||
|
||||
if ($mailbox && !empty($mailbox->access)) {
|
||||
$mailbox_access = json_decode($mailbox->access);
|
||||
|
||||
if (!is_array($perm)) {
|
||||
return in_array($perm, $mailbox_access);
|
||||
} else {
|
||||
// Check if any of permissions is included in mailbox access.
|
||||
foreach ($perm as $perm_value) {
|
||||
if (in_array($perm_value, $mailbox_access)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Generate random password for the user.
|
||||
*
|
||||
@@ -417,7 +470,7 @@ class User extends Authenticatable
|
||||
public function isDummyPassword()
|
||||
{
|
||||
$decrypted_password = \Helper::decrypt($this->password);
|
||||
return preg_match("#^dummy_#", $decrypted_password);
|
||||
return preg_match("#^dummy_#", $this->password) || preg_match("#^dummy_#", $decrypted_password);
|
||||
//return Hash::check($this->getDummyPassword(), $this->password);
|
||||
}
|
||||
|
||||
@@ -428,7 +481,7 @@ class User extends Authenticatable
|
||||
*/
|
||||
public function url()
|
||||
{
|
||||
return route('users.profile', ['id'=>$this->id]);
|
||||
return route('users.profile', ['id' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,7 +491,9 @@ class User extends Authenticatable
|
||||
*/
|
||||
public function urlSetup()
|
||||
{
|
||||
return route('user_setup', ['hash' => $this->invite_hash]);
|
||||
$invite_sent_at = \Helper::encrypt(time().'', $this->password);
|
||||
|
||||
return route('user_setup', ['hash' => $this->invite_hash, 'invite_sent_at' => $invite_sent_at]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,11 +694,12 @@ class User extends Authenticatable
|
||||
{
|
||||
$user_permission_names = [
|
||||
self::PERM_DELETE_CONVERSATIONS => __('Users are allowed to delete conversations'),
|
||||
self::PERM_EDIT_CONVERSATIONS => __('Users are allowed to edit notes/replies'),
|
||||
self::PERM_EDIT_CONVERSATIONS => __('Users are allowed to edit own notes/replies'),
|
||||
self::PERM_EDIT_SAVED_REPLIES => __('Users are allowed to edit/delete saved replies'),
|
||||
self::PERM_EDIT_TAGS => __('Users are allowed to manage tags'),
|
||||
self::PERM_EDIT_CUSTOM_FOLDERS => __('Users are allowed to manage custom folders'),
|
||||
self::PERM_EDIT_USERS => __('Users are allowed to manage users'),
|
||||
self::PERM_ONLY_ASSIGNED_TICKETS => __('User can see only assigned conversations'),
|
||||
];
|
||||
|
||||
if (!empty($user_permission_names[$user_permission])) {
|
||||
@@ -747,7 +803,7 @@ class User extends Authenticatable
|
||||
*/
|
||||
public function setInviteHash()
|
||||
{
|
||||
$this->invite_hash = Str::random(60);
|
||||
$this->invite_hash = Str::random(self::INVITE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -821,7 +877,7 @@ class User extends Authenticatable
|
||||
$notifications = $user->getWebsiteNotifications();
|
||||
|
||||
$info = [
|
||||
'data' => WebsiteNotification::fetchNotificationsData($notifications),
|
||||
'data' => WebsiteNotification::fetchNotificationsData($notifications, $this),
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $user->unreadNotifications()->count(),
|
||||
];
|
||||
@@ -834,7 +890,7 @@ class User extends Authenticatable
|
||||
$notifications = $this->getWebsiteNotifications();
|
||||
|
||||
$info = [
|
||||
'data' => WebsiteNotification::fetchNotificationsData($notifications),
|
||||
'data' => WebsiteNotification::fetchNotificationsData($notifications, $this),
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $this->unreadNotifications()->count(),
|
||||
];
|
||||
@@ -884,7 +940,8 @@ class User extends Authenticatable
|
||||
}
|
||||
|
||||
$file_name = md5(Hash::make($this->id)).'.jpg';
|
||||
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
|
||||
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
|
||||
$dest_path = Storage::disk('local')->path($storage_path);
|
||||
|
||||
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
|
||||
if (!file_exists($dest_dir)) {
|
||||
@@ -901,6 +958,8 @@ class User extends Authenticatable
|
||||
// User::PHOTO_DIRECTORY, !Hash::make($user->id).'.jpg'
|
||||
// );
|
||||
|
||||
$file_name = \Eventy::filter('user.save_photo', $file_name, $this, $dest_path, $storage_path);
|
||||
|
||||
return $file_name;
|
||||
}
|
||||
|
||||
@@ -915,6 +974,34 @@ class User extends Authenticatable
|
||||
$this->photo_url = '';
|
||||
}
|
||||
|
||||
public function addPermission($permission_id, $save = true)
|
||||
{
|
||||
$permissions = $this->permissions;
|
||||
|
||||
$permissions[$permission_id] = 1;
|
||||
|
||||
$this->permissions = $permissions;
|
||||
|
||||
if ($save) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function removePermission($permission_id, $save = true)
|
||||
{
|
||||
$permissions = $this->permissions;
|
||||
|
||||
if (isset($permissions[$permission_id])) {
|
||||
unset($permissions[$permission_id]);
|
||||
}
|
||||
|
||||
$this->permissions = $permissions;
|
||||
|
||||
if ($save) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function hasPermission($permission, $check_own_permissions = true)
|
||||
{
|
||||
$has_permission = false;
|
||||
@@ -969,7 +1056,7 @@ class User extends Authenticatable
|
||||
/**
|
||||
* Create user.
|
||||
*/
|
||||
public static function create($data)
|
||||
public static function create($data, $options = [])
|
||||
{
|
||||
$user = new self();
|
||||
|
||||
@@ -982,6 +1069,7 @@ class User extends Authenticatable
|
||||
try {
|
||||
$user->save();
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1011,7 +1099,7 @@ class User extends Authenticatable
|
||||
foreach ($fields_strip as $field) {
|
||||
if (in_array($field, array_keys($data))) {
|
||||
if ($data[$field] !== null) {
|
||||
$data[$field] = strip_tags($data[$field]);
|
||||
$data[$field] = \Helper::stripTags($data[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1114,10 @@ class User extends Authenticatable
|
||||
}
|
||||
}
|
||||
}
|
||||
// Role is not fillable.
|
||||
if (!empty($data['role']) && array_key_exists((int)$data['role'], self::$roles)) {
|
||||
$this->role = (int)$data['role'];
|
||||
}
|
||||
|
||||
\Eventy::action('user.set_data', $this, $data, $replace_data);
|
||||
|
||||
@@ -1148,7 +1240,10 @@ class User extends Authenticatable
|
||||
|
||||
public function getAuthToken()
|
||||
{
|
||||
return md5($this->id.''.$this->created_at.config('app.key'));
|
||||
$expiry = time()+2592000;
|
||||
$hash = hash_hmac('sha256', $this->id.':'.$expiry, config('app.key').$this->password);
|
||||
|
||||
return urlencode(base64_encode($this->id.':'.$expiry.':'.$hash));
|
||||
}
|
||||
|
||||
public static function findNonDeleted($id, $extended = false)
|
||||
@@ -1195,6 +1290,23 @@ class User extends Authenticatable
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function findByAlternateEmail($email)
|
||||
{
|
||||
$email = Email::sanitizeEmail($email);
|
||||
|
||||
$users = self::nonDeleted()
|
||||
->where('emails', \Helper::sqlLikeOperator(), '%'.$email.'%')
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasEmail($email)) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is a mailbox with specified email.
|
||||
*/
|
||||
|
||||
113
composer.json
113
composer.json
@@ -9,6 +9,16 @@
|
||||
"email": "support@freescout.net"
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/freescout-help-desk/laravel-selfupdater"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/freescout-help-desk/Purifier"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.1.0",
|
||||
|
||||
@@ -17,7 +27,7 @@
|
||||
"fideloper/proxy": "3.3.4",
|
||||
"laravel/framework": "v5.5.40",
|
||||
"laravel/tinker": "v1.0.7",
|
||||
"mews/purifier": "3.2.2",
|
||||
"mews/purifier": "dev-customized",
|
||||
"spatie/laravel-activitylog": "2.7.0",
|
||||
"lord/laroute": "2.4.7",
|
||||
"axn/laravel-laroute": "1.3.0",
|
||||
@@ -29,7 +39,7 @@
|
||||
"chumper/zipper": "v1.0.2",
|
||||
"rachidlaasri/laravel-installer": "4.0.2",
|
||||
"rap2hpoutre/laravel-log-viewer": "v2.0.0",
|
||||
"codedge/laravel-selfupdater": "1.4.3",
|
||||
"codedge/laravel-selfupdater": "dev-customized",
|
||||
"doctrine/dbal": "2.12.1",
|
||||
"egulias/email-validator": "2.1.10",
|
||||
"symfony/console": "v3.4.47",
|
||||
@@ -37,7 +47,7 @@
|
||||
"ramsey/uuid": "3.9.6",
|
||||
"symfony/polyfill-mbstring": "v1.24.0",
|
||||
"webklex/php-imap": "4.1.1",
|
||||
"enshrined/svg-sanitize": "0.15.4"
|
||||
"enshrined/svg-sanitize": "0.22.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "v3.2.0",
|
||||
@@ -53,6 +63,7 @@
|
||||
"symfony/routing": "v3.4.18",
|
||||
"symfony/process": "v3.4.18",
|
||||
"symfony/polyfill-php70": "v1.10.*",
|
||||
"symfony/polyfill-intl-idn": "v1.38.1",
|
||||
"symfony/http-foundation": "v3.4.18",
|
||||
"symfony/event-dispatcher": "v3.4.18",
|
||||
"psr/log": "1.0.*",
|
||||
@@ -65,8 +76,7 @@
|
||||
"league/flysystem": "1.1.4",
|
||||
"erusev/parsedown": "1.7.2",
|
||||
"doctrine/inflector": "v1.2.*",
|
||||
"guzzlehttp/guzzle": "6.5.8",
|
||||
"guzzlehttp/psr7": "1.9.1",
|
||||
"guzzlehttp/guzzle": "8.0.1",
|
||||
"tedivm/jshrink": "1.4.0",
|
||||
"nikic/php-parser": "^4.1",
|
||||
"doctrine/annotations": "v1.4.*",
|
||||
@@ -75,18 +85,17 @@
|
||||
"doctrine/instantiator": "1.3.1",
|
||||
"myclabs/deep-copy": "1.10.1",
|
||||
"webmozart/assert": "1.3.0",
|
||||
"psr/container": "1.0.0",
|
||||
"psr/http-message": "1.0.1"
|
||||
"psr/container": "1.0.0"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"database/seeds",
|
||||
"database/factories",
|
||||
"overrides/natxet/cssmin/src/"
|
||||
"overrides/natxet/cssmin/src/",
|
||||
"overrides/erusev/parsedown/"
|
||||
],
|
||||
"psr-0": {
|
||||
"Swift_": "overrides/swiftmailer/swiftmailer/lib/classes",
|
||||
"HTMLPurifier_": "overrides/ezyang/htmlpurifier/library"
|
||||
"Swift_": "overrides/swiftmailer/swiftmailer/lib/classes"
|
||||
},
|
||||
"files": [
|
||||
"app/Misc/Functions.php"
|
||||
@@ -103,6 +112,7 @@
|
||||
"RachidLaasri\\LaravelInstaller\\Providers\\": "overrides/rachidlaasri/laravel-installer/src/Providers/",
|
||||
"RachidLaasri\\LaravelInstaller\\Events\\": "overrides/rachidlaasri/laravel-installer/src/Events/",
|
||||
"Nwidart\\Modules\\": "overrides/nwidart/laravel-modules/src/",
|
||||
"Nwidart\\Modules\\Generators\\": "overrides/nwidart/laravel-modules/src/Generators/",
|
||||
"Codedge\\Updater\\SourceRepositoryTypes\\": "overrides/codedge/laravel-selfupdater/src/SourceRepositoryTypes/",
|
||||
"Barryvdh\\TranslationManager\\": "overrides/barryvdh/laravel-translation-manager/src/",
|
||||
"Illuminate\\Foundation\\": "overrides/laravel/framework/src/Illuminate/Foundation/",
|
||||
@@ -148,6 +158,7 @@
|
||||
"Illuminate\\Broadcasting\\": "overrides/laravel/framework/src/Illuminate/Broadcasting/",
|
||||
"Illuminate\\Bus\\": "overrides/laravel/framework/src/Illuminate/Bus/",
|
||||
"Illuminate\\Pipeline\\": "overrides/laravel/framework/src/Illuminate/Pipeline/",
|
||||
"Illuminate\\Events\\": "overrides/laravel/framework/src/Illuminate/Events/",
|
||||
"Doctrine\\DBAL\\Driver\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Driver/",
|
||||
"Doctrine\\DBAL\\Schema\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Schema/",
|
||||
"Doctrine\\DBAL\\Platforms\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Platforms/",
|
||||
@@ -169,7 +180,9 @@
|
||||
"Illuminate\\Console\\": "overrides/laravel/framework/src/Illuminate/Console/",
|
||||
"Illuminate\\Log\\": "overrides/laravel/framework/src/Illuminate/Log/",
|
||||
"Illuminate\\Validation\\": "overrides/laravel/framework/src/Illuminate/Validation/",
|
||||
"Illuminate\\Contracts\\Container\\": "overrides/laravel/framework/src/Illuminate/Contracts/Container/",
|
||||
"Symfony\\Component\\Routing\\": "overrides/symfony/routing/",
|
||||
"Symfony\\Component\\Routing\\Generator\\": "overrides/symfony/routing/generator/",
|
||||
"Symfony\\Component\\VarDumper\\Cloner\\": "overrides/symfony/var-dumper/Cloner/",
|
||||
"Symfony\\Component\\VarDumper\\Dumper\\": "overrides/symfony/var-dumper/Dumper/",
|
||||
"Devfactory\\Minify\\Providers\\": "overrides/devfactory/minify/src/Providers/",
|
||||
@@ -182,11 +195,6 @@
|
||||
"Symfony\\Component\\HttpKernel\\HttpCache\\": "overrides/symfony/http-kernel/HttpCache/",
|
||||
"Symfony\\Component\\Debug\\Exception\\": "overrides/symfony/debug/Exception/",
|
||||
"Spatie\\String\\": "overrides/spatie/string/src/",
|
||||
"GuzzleHttp\\": "overrides/guzzlehttp/guzzle/src/",
|
||||
"GuzzleHttp\\Cookie\\": "overrides/guzzlehttp/guzzle/src/Cookie/",
|
||||
"GuzzleHttp\\Handler\\": "overrides/guzzlehttp/guzzle/src/Handler/",
|
||||
"GuzzleHttp\\Psr7\\": "overrides/guzzlehttp/psr7/src/",
|
||||
"GuzzleHttp\\Promise\\": "overrides/guzzlehttp/promises/src/",
|
||||
"Ramsey\\Uuid\\": "overrides/ramsey/uuid/src/",
|
||||
"Rap2hpoutre\\LaravelLogViewer\\": "overrides/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/",
|
||||
"Symfony\\Component\\Console\\Descriptor\\": "overrides/symfony/console/Descriptor/",
|
||||
@@ -195,6 +203,8 @@
|
||||
"Symfony\\Component\\Console\\Exception\\": "overrides/symfony/console/Exception/",
|
||||
"Symfony\\Component\\Console\\Question\\": "overrides/symfony/console/",
|
||||
"Symfony\\Component\\CssSelector\\XPath\\Extension\\": "overrides/symfony/css-selector/XPath/Extension/",
|
||||
"Symfony\\Component\\CssSelector\\XPath\\": "overrides/symfony/css-selector/XPath/",
|
||||
"Symfony\\Component\\CssSelector\\Parser\\": "overrides/symfony/css-selector/Parser/",
|
||||
"Symfony\\Component\\Translation\\Loader\\": "overrides/symfony/translation/Loader/",
|
||||
"Symfony\\Component\\Translation\\Formatter\\": "overrides/symfony/translation/Formatter/",
|
||||
"Symfony\\Component\\Translation\\": "overrides/symfony/translation/",
|
||||
@@ -203,7 +213,6 @@
|
||||
"Whoops\\": "overrides/filp/whoops/src/Whoops/",
|
||||
"Whoops\\Handler\\": "overrides/filp/whoops/src/Whoops/Handler/",
|
||||
"Whoops\\Util\\": "overrides/filp/whoops/src/Whoops/Util/",
|
||||
"Mews\\Purifier\\": "overrides/mews/purifier/src/",
|
||||
"Cron\\": "overrides/mtdowling/cron-expression/src/Cron/",
|
||||
"Psy\\": "overrides/psy/psysh/src/",
|
||||
"Psy\\Input\\": "overrides/psy/psysh/src/Input/",
|
||||
@@ -212,7 +221,14 @@
|
||||
"Psy\\TimeitCommand\\": "overrides/psy/psysh/src/TimeitCommand/",
|
||||
"Psy\\Command\\ListCommand\\": "overrides/psy/psysh/src/Command/ListCommand/",
|
||||
"Psy\\Exception\\": "overrides/psy/psysh/src/ErrorException/",
|
||||
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/"
|
||||
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/",
|
||||
"TijsVerkoyen\\CssToInlineStyles\\": "overrides/tijsverkoyen/css-to-inline-styles/src/",
|
||||
"TijsVerkoyen\\CssToInlineStyles\\Css\\Property\\": "overrides/tijsverkoyen/css-to-inline-styles/src/Css/Property/",
|
||||
"Html2Text\\": "overrides/html2text/html2text/src/",
|
||||
"PHPUnit\\Runner\\": "overrides/phpunit/phpunit/src/Runner/",
|
||||
"PHPUnit\\Util\\PHP\\": "overrides/phpunit/phpunit/src/Util/PHP/",
|
||||
"PHPUnit\\Util\\Xml\\": "overrides/phpunit/phpunit/src/Util/Xml/",
|
||||
"Illuminate\\Http\\Exceptions\\": "overrides/laravel/framework/src/Illuminate/Http/Exceptions/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"Modules/",
|
||||
@@ -230,6 +246,7 @@
|
||||
"vendor/nwidart/laravel-modules/src/Module.php",
|
||||
"vendor/nwidart/laravel-modules/src/Repository.php",
|
||||
"vendor/nwidart/laravel-modules/src/Json.php",
|
||||
"vendor/nwidart/laravel-modules/src/Generators/ModuleGenerator.php",
|
||||
"vendor/codedge/laravel-selfupdater/src/SourceRepositoryTypes/GithubRepositoryType.php",
|
||||
"vendor/barryvdh/laravel-translation-manager/src/Manager.php",
|
||||
"vendor/barryvdh/laravel-translation-manager/src/Controller.php",
|
||||
@@ -244,6 +261,7 @@
|
||||
"vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Validation/Concerns/FormatsMessages.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Mail/TransportManager.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Mail/Message.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Container/Container.php",
|
||||
@@ -261,7 +279,6 @@
|
||||
"vendor/symfony/http-foundation/File/MimeType/FileBinaryMimeTypeGuesser.php",
|
||||
"vendor/chumper/zipper/src/Chumper/Zipper/Repositories/ZipRepository.php",
|
||||
"vendor/fzaninotto/faker/src/Faker/Provider/Base.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrDef/URI/Host.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/DetectsLostConnections.php",
|
||||
|
||||
"vendor/laravel/framework/src/Illuminate/Support/Collection.php",
|
||||
@@ -279,6 +296,7 @@
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/Relation.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Schema/Builder.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Database/Schema/Blueprint.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Pagination/AbstractPaginator.php",
|
||||
@@ -286,8 +304,13 @@
|
||||
"vendor/laravel/framework/src/Illuminate/Pagination/LengthAwarePaginator.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/FileSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/NullSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/CacheBasedSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/CookieSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/DatabaseSessionHandler.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Session/Middleware/StartSession.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Queue/Listener.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Queue/SerializesModels.php",
|
||||
"vendor/nesbot/carbon/src/Carbon/Carbon.php",
|
||||
"vendor/nesbot/carbon/src/Carbon/Translator.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Cache/Repository.php",
|
||||
@@ -300,6 +323,7 @@
|
||||
"vendor/doctrine/dbal/lib/Doctrine/DBAL/Platforms/PostgreSqlPlatform.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/Carbon.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/Str.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/Arr.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/ViewErrorBag.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/MessageBag.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Support/Optional.php",
|
||||
@@ -334,6 +358,7 @@
|
||||
"vendor/laravel/framework/src/Illuminate/View/Concerns/ManagesLayouts.php",
|
||||
"vendor/symfony/routing/Route.php",
|
||||
"vendor/symfony/routing/CompiledRoute.php",
|
||||
"vendor/symfony/routing/Generator/UrlGenerator.php",
|
||||
"vendor/symfony/var-dumper/Cloner/Data.php",
|
||||
"vendor/symfony/var-dumper/Cloner/Stub.php",
|
||||
"vendor/symfony/var-dumper/Cloner/AbstractCloner.php",
|
||||
@@ -341,6 +366,7 @@
|
||||
"vendor/devfactory/minify/src/Providers/BaseProvider.php",
|
||||
"vendor/barryvdh/laravel-debugbar/src/DataFormatter/QueryFormatter.php",
|
||||
"vendor/barryvdh/laravel-debugbar/src/DataCollector/QueryCollector.php",
|
||||
"vendor/barryvdh/laravel-debugbar/src/DataCollector/ViewCollector.php",
|
||||
"vendor/symfony/process/Process.php",
|
||||
"vendor/symfony/http-kernel/UriSigner.php",
|
||||
"vendor/symfony/http-kernel/Exception/HttpException.php",
|
||||
@@ -352,27 +378,16 @@
|
||||
"vendor/symfony/http-kernel/HttpCache/Store.php",
|
||||
"vendor/barryvdh/laravel-debugbar/src/JavascriptRenderer.php",
|
||||
"vendor/spatie/string/src/Str.php",
|
||||
"vendor/guzzlehttp/guzzle/src/Client.php",
|
||||
"vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php",
|
||||
"vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php",
|
||||
"vendor/guzzlehttp/guzzle/src/Handler/StreamHandler.php",
|
||||
"vendor/guzzlehttp/guzzle/src/HandlerStack.php",
|
||||
"vendor/guzzlehttp/guzzle/src/Middleware.php",
|
||||
"vendor/guzzlehttp/psr7/src/LazyOpenStream.php",
|
||||
"vendor/guzzlehttp/psr7/src/Uri.php",
|
||||
"vendor/guzzlehttp/promises/src/RejectedPromise.php",
|
||||
"vendor/guzzlehttp/promises/src/PromiseInterface.php",
|
||||
"vendor/guzzlehttp/promises/src/FulfilledPromise.php",
|
||||
"vendor/guzzlehttp/promises/src/Utils.php",
|
||||
"vendor/guzzlehttp/promises/src/Promise.php",
|
||||
"vendor/ramsey/uuid/src/Uuid.php",
|
||||
"vendor/ramsey/uuid/src/UuidFactory.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Routing/Router.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Console/Application.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Console/Command.php",
|
||||
"vendor/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/LaravelLogViewer.php",
|
||||
"vendor/rap2hpoutre/laravel-log-viewer/src/controllers/LogViewerController.php",
|
||||
"vendor/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/LogViewerController.php",
|
||||
"vendor/symfony/console/Descriptor/TextDescriptor.php",
|
||||
"vendor/symfony/console/Input/ArgvInput.php",
|
||||
"vendor/symfony/console/Input/ArrayInput.php",
|
||||
"vendor/symfony/console/Input/StringInput.php",
|
||||
"vendor/symfony/console/Input/Input.php",
|
||||
@@ -380,21 +395,17 @@
|
||||
"vendor/symfony/console/Helper/ProcessHelper.php",
|
||||
"vendor/symfony/console/Command/Command.php",
|
||||
"vendor/symfony/console/Output/Output.php",
|
||||
"vendor/symfony/console/Output/StreamOutput.php",
|
||||
"vendor/symfony/console/Formatter/OutputFormatterStyleStack.php",
|
||||
"vendor/symfony/console/Exception/CommandNotFoundException.php",
|
||||
"vendor/symfony/console/Question/Question.php",
|
||||
"vendor/symfony/finder/Iterator/SortableIterator.php",
|
||||
"vendor/symfony/css-selector/XPath/Extension/NodeExtension.php",
|
||||
"vendor/symfony/css-selector/XPath/Translator.php",
|
||||
"vendor/symfony/css-selector/Parser/Parser.php",
|
||||
"vendor/symfony/translation/Loader/ArrayLoader.php",
|
||||
"vendor/symfony/translation/Formatter/MessageFormatter.php",
|
||||
"vendor/symfony/translation/Translator.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrTransform/NameSync.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrValidator.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/ChildDef/List.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Length.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Encoder.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Lexer.php",
|
||||
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Lexer/DOMLex.php",
|
||||
"vendor/javoscript/laravel-macroable-models/src/MacroableModels.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Attachment.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/EmbeddedFile.php",
|
||||
@@ -419,12 +430,14 @@
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Mime/Headers/MailboxHeader.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/AbstractSmtpTransport.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/EsmtpTransport.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/SendmailTransport.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/MailInvoker.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/MailTransport.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/SimpleMailInvoker.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/StreamBuffer.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/Esmtp/AuthHandler.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Encoder/QpEncoder.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/ArrayKeyCache.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/DiskKeyCache.php",
|
||||
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/SimpleKeyCacheInputStream.php",
|
||||
"vendor/natxet/cssmin/src/CssMin.php",
|
||||
@@ -451,20 +464,22 @@
|
||||
"vendor/laravel/framework/src/Illuminate/Broadcasting/BroadcastManager.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php",
|
||||
"vendor/league/flysystem/src/Filesystem.php",
|
||||
"vendor/league/flysystem/src/FilesystemInterface.php",
|
||||
"vendor/league/flysystem/src/FileNotFoundException.php",
|
||||
"vendor/mews/purifier/src/Purifier.php",
|
||||
"vendor/mtdowling/cron-expression/src/Cron/CronExpression.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Mail/Mailer.php",
|
||||
"vendor/filp/whoops/src/Whoops/Run.php",
|
||||
"vendor/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php",
|
||||
"vendor/filp/whoops/src/Whoops/Handler/PlainTextHandler.php",
|
||||
"vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Validation/Factory.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Validation/ValidationRuleParser.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Foundation/Validation/ValidatesRequests.php",
|
||||
"vendor/chumper/zipper/src/Chumper/Zipper/Zipper.php",
|
||||
"vendor/psy/psysh/src/Shell.php",
|
||||
"vendor/psy/psysh/src/Configuration.php",
|
||||
"vendor/psy/psysh/src/CodeCleaner.php",
|
||||
"vendor/psy/psysh/src/Input/FilterOptions.php",
|
||||
"vendor/psy/psysh/src/Input/ShellInput.php",
|
||||
@@ -480,7 +495,16 @@
|
||||
"vendor/psy/psysh/src/Exception/ErrorException.php",
|
||||
"vendor/psy/psysh/src/Exception/BreakException.php",
|
||||
"vendor/psy/psysh/src/VarDumper/Dumper.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php"
|
||||
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Contracts/Container/Container.php",
|
||||
"vendor/erusev/parsedown/Parsedown.php",
|
||||
"vendor/tijsverkoyen/css-to-inline-styles/src/CssToInlineStyles.php",
|
||||
"vendor/tijsverkoyen/css-to-inline-styles/src/Css/Property/Processor.php",
|
||||
"vendor/html2text/html2text/src/Html2Text.php",
|
||||
"vendor/phpunit/phpunit/src/Runner/PhpTestCase.php",
|
||||
"vendor/phpunit/phpunit/src/Util/PHP/AbstractPhpProcess.php",
|
||||
"vendor/phpunit/phpunit/src/Util/Xml/Loader.php",
|
||||
"vendor/laravel/framework/src/Illuminate/Http/Exceptions/PostTooLargeException.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
@@ -504,9 +528,14 @@
|
||||
"pre-install-cmd": [
|
||||
"@php -r \"@mkdir('vendor/natxet/cssmin/src', 775);\""
|
||||
],
|
||||
"pre-autoload-dump": [
|
||||
"@php -r \"file_put_contents('vendor/psy/psysh/src/functions.php', '<?php require_once __DIR__ . \\'/../../../../overrides/psy/psysh/src/functions.php\\';');\"",
|
||||
"@php -r \"file_put_contents('vendor/laravel/framework/src/Illuminate/Support/helpers.php', '<?php require_once __DIR__ . \\'/../../../../../../overrides/laravel/framework/src/Illuminate/Support/helpers.php\\';');\"",
|
||||
"@php -r \"file_put_contents('vendor/spatie/laravel-activitylog/src/helpers.php', '<?php require_once __DIR__ . \\'/../../../../overrides/spatie/laravel-activitylog/src/helpers.php\\';');\""
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"@php -r \"file_put_contents('vendor/composer/ClassLoader.php', preg_replace('/function includeFile.*/s', base64_decode('DQpmdW5jdGlvbiBpbmNsdWRlRmlsZSgkZmlsZSkNCnsNCiAgICB0cnkgew0KICAgICAgICBpbmNsdWRlICRmaWxlOw0KICAgIH0gY2F0Y2ggKFxFeGNlcHRpb24gJGUpIHsNCiAgICAgICAgJG1zZyA9ICRlLT5nZXRNZXNzYWdlKCk7DQogICAgICAgIGlmIChzdHJzdHIoJG1zZywgJ05vIHN1Y2ggZmlsZSBvciBkaXJlY3RvcnknKSkgew0KICAgICAgICAgICAgaWYgKHN0cnN0cigkbXNnLCAnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJykpIHsNCiAgICAgICAgICAgICAgICAkbmV3X2ZpbGUgPSBzdHJfcmVwbGFjZSgnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJywgJy92ZW5kb3IvY29tcG9zZXIvLi4vJywgJGZpbGUpOw0KICAgICAgICAgICAgfSBlbHNlIHsNCiAgICAgICAgICAgICAgICAkbmV3X2ZpbGUgPSBzdHJfcmVwbGFjZSgnL3ZlbmRvci9jb21wb3Nlci8uLi8nLCAnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJywgJGZpbGUpOw0KICAgICAgICAgICAgfQ0KICAgICAgICAgICAgaW5jbHVkZSAkbmV3X2ZpbGU7DQogICAgICAgIH0gZWxzZSB7DQogICAgICAgICAgICB0aHJvdyAkZTsNCiAgICAgICAgfQ0KICAgIH0NCn0='), file_get_contents('vendor/composer/ClassLoader.php')));\"",
|
||||
"@php -r \"!error_reporting(0) || array_map('unlink', json_decode(file_get_contents('composer.json'), true)['autoload']['exclude-from-classmap']);\"",
|
||||
"@php -r \"(error_reporting(0) || !error_reporting(0)) && array_map('unlink', json_decode(file_get_contents('composer.json'), true)['autoload']['exclude-from-classmap']);\"",
|
||||
"@php -r \"file_put_contents('vendor/webklex/php-imap/src/config/imap.php', preg_replace('/, \\\\'inline\\\\'],/s', '/*, \\\\'inline\\\\'*/],', file_get_contents('vendor/webklex/php-imap/src/config/imap.php')));\"",
|
||||
"@php -r \"copy('vendor/nesbot/carbon/src/Carbon/Lang/pt.php', 'vendor/nesbot/carbon/src/Carbon/Lang/pt_PT.php');\"",
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
|
||||
1124
composer.lock
generated
1124
composer.lock
generated
File diff suppressed because it is too large
Load Diff
121
config/app.php
121
config/app.php
@@ -18,7 +18,7 @@ return [
|
||||
| or any other location as required by the application or its packages.
|
||||
*/
|
||||
|
||||
'version' => '1.8.188',
|
||||
'version' => '1.8.245',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -98,8 +98,8 @@ return [
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locales' => ['en', 'ar', 'zh-CN', 'hr', 'cs', 'da', 'nl', 'fi', 'fr', 'de', 'hu', 'it', 'ja', 'kz', 'ko', 'no', 'fa', 'pl', 'pt-PT', 'pt-BR', 'ro', 'ru', 'es', 'sk', 'sv', 'tr'],
|
||||
'locales_rtl' => ['ar', 'fa'],
|
||||
'locales' => ['en', 'ar', 'az', 'ca', 'zh-CN', 'zh-TW', 'hr', 'cs', 'da', 'nl', 'fi', 'fr', 'de', 'he', 'hu', 'it', 'ja', 'kz', 'ko', 'no', 'fa', 'pl', 'pt-PT', 'pt-BR', 'ro', 'ru', 'es', 'sk', 'sl', 'sv', 'tr', 'uk'],
|
||||
'locales_rtl' => ['ar', 'fa', 'he'],
|
||||
'default_locale' => 'en',
|
||||
|
||||
/*
|
||||
@@ -165,7 +165,7 @@ return [
|
||||
| FreeScout API
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'freescout_api' => 'https://freescout.net/wp-json/',
|
||||
'freescout_api' => 'https://api.freescout.net/wp-json/',
|
||||
'freescout_alt_api' => 'https://cdn.freescout.net/wp-json/',
|
||||
|
||||
/*
|
||||
@@ -188,7 +188,7 @@ return [
|
||||
| Checks for new jobs every --sleep seconds.
|
||||
| If --tries is set and job fails it is being processed right away without any delay.
|
||||
| --delay parameter does not work to set delays between retry attempts.
|
||||
| --timeout parameter sets job timeout and is used to avoid queue:work freezing.
|
||||
| --timeout parameter sets job timeout in seconds and is used to avoid queue:work freezing.
|
||||
|
|
||||
| Jobs sending emails are retried manually in handle().
|
||||
| Number of retries is set in each job class.
|
||||
@@ -220,6 +220,7 @@ return [
|
||||
*/
|
||||
'fetch_schedule' => env('APP_FETCH_SCHEDULE', 1),
|
||||
'fetch_unseen' => env('APP_FETCH_UNSEEN', 1),
|
||||
'fetch_max_execution_time' => env('APP_FETCH_MAX_EXECUTION_TIME', 30), // minutes
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -276,6 +277,37 @@ return [
|
||||
// If HTML file is renamed into .txt for example it will be shown by the browser as HTML.
|
||||
// Regular expressions (#...#)
|
||||
'viewable_mime_types' => env('APP_VIEWABLE_MIME_TYPES', ['image/.*', 'application/pdf', 'text/plain', 'text/x-diff', 'application/json', 'audio/.*']),
|
||||
'non_viewable_mime_types' => env('APP_NON_VIEWABLE_MIME_TYPES', ['image/svg.*']),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| File extensions that support agents are allowed to upload.
|
||||
| Files with other extensions will be rejected.
|
||||
| Must be in sync with corresponding parameter in .env.example.
|
||||
|
|
||||
| Should contain comma separated list: jpg,jpeg,png,gif...
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'allowed_extensions' => env('APP_ALLOWED_EXTENSIONS')
|
||||
? explode(',', env('APP_ALLOWED_EXTENSIONS'))
|
||||
: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'wav', 'ogg', 'wma', 'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed extensions and mime types for files uploaded by customers.
|
||||
| Files with other extensions and mime types will be renamed.
|
||||
|
|
||||
| APP_CUSTOMER_UPLOADABLE_EXTENSIONS should contain comma separated list: jpg,jpeg,png,gif...
|
||||
| Same for APP_CUSTOMER_UPLOADABLE_MIME_TYPES.
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'customer_allowed_extensions' => env('APP_CUSTOMER_ALLOWED_EXTENSIONS')
|
||||
? explode(',', env('APP_CUSTOMER_ALLOWED_EXTENSIONS'))
|
||||
: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'wav', 'ogg', 'wma', 'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx'],
|
||||
|
||||
'customer_allowed_mime_types' => env('APP_CUSTOMER_ALLOWED_MIME_TYPES')
|
||||
? explode(',', env('APP_CUSTOMER_ALLOWED_MIME_TYPES'))
|
||||
: ['image/jpg', 'image/jpeg', 'image/gif', 'image/bmp', 'image/x-ms-bmp', 'image/webp', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/ogg', 'audio/x-ms-wma', 'application/pdf', 'text/plain', 'text/csv', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -286,10 +318,11 @@ return [
|
||||
| to avoid sending multiple duplicate emails to other recipients.
|
||||
|
|
||||
| https://github.com/freescout-helpdesk/freescout/issues/870#issuecomment-786477909
|
||||
| https://github.com/freescout-help-desk/freescout/issues/5666
|
||||
|
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'no_retry_mail_errors' => env('APP_NO_RETRY_MAIL_ERRORS', '(no valid recipients|does not comply with RFC|message file too big|malformed address)'),
|
||||
'no_retry_mail_errors' => env('APP_NO_RETRY_MAIL_ERRORS', '(no valid recipients|does not comply with RFC|message file too big|malformed address|message size|message too large|size exceeded)'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -399,8 +432,12 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| When proxy is specified, all requests to external resources are made vie this proxy.
|
||||
|
|
||||
| Enter your proxy address in .env file if freescout.net is not available from your server
|
||||
| (access to freescout.net is required to obtain official modules)
|
||||
| (access to freescout.net is required to obtain official modules).
|
||||
|
|
||||
| Example: https://123.45.67.89:4321
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'proxy' => env('APP_PROXY', ''),
|
||||
@@ -426,7 +463,7 @@ return [
|
||||
// Need to be set for curl. Guzzle sends it's own user agent: GuzzleHttp/6.3.3 curl/7.58.0 PHP/8.2.5
|
||||
'curl_user_agent' => env('APP_CURL_USER_AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 7_1_4) AppleWebKit/603.26 (KHTML, like Gecko) Chrome/55.0.3544.220 Safari/534'),
|
||||
// Should be set for curl and Guzzle.
|
||||
'curl_ssl_verifypeer' => env('APP_CURL_SSL_VERIFYPEER', false),
|
||||
'curl_ssl_verifypeer' => env('APP_CURL_SSL_VERIFYPEER', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -455,28 +492,25 @@ return [
|
||||
*/
|
||||
'update_folder_counters_in_background' => env('APP_UPDATE_FOLDER_COUNTERS_IN_BACKGROUND', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Experimental feature allowing to specify users who can see only conversations
|
||||
| assigned to themselves. For such users only Mine folder shows actual number of conversations.
|
||||
| This option does not affect admin users.
|
||||
|
|
||||
| The option should be specified as a comma separated list of user IDs which
|
||||
| can be found in the their profile URL (/users/profile/7).
|
||||
|
|
||||
| Example: 7,5,31
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'show_only_assigned_conversations' => env('APP_SHOW_ONLY_ASSIGNED_CONVERSATIONS', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Limit non-admin users to only see customers with conversations
|
||||
| in mailboxes they are assigned to. This option does not affect admin users.
|
||||
|
|
||||
| Before creating an Security Advisory make sure to read about this parameter in Guidelines:
|
||||
| https://github.com/freescout-help-desk/freescout/security/policy
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'limit_user_customer_visibility' => env('APP_LIMIT_USER_CUSTOMER_VISIBILITY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Treat Waiting Since column as "Time of the first unanswered customer message"
|
||||
| instead of "Time of the last customer activity".
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'waiting_since_as_first_unanswered_customer_message' => env('WAITING_SINCE_AS_FIRST_UNANSWERED_CUSTOMER_MESSAGE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| By default X-Frame-Options header is enabled and set to SAMEORIGIN.
|
||||
@@ -489,10 +523,10 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable Content-Security-Policy meta tag to prevent possible XSS attacks.
|
||||
| Content-Security-Policy meta tag parameters.
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'csp_enabled' => env('APP_CSP_ENABLED', true),
|
||||
//'csp_enabled' => env('APP_CSP_ENABLED', true),
|
||||
'csp_script_src' => env('APP_CSP_SCRIPT_SRC', ''),
|
||||
'csp_custom' => env('APP_CSP_CUSTOM', ''),
|
||||
|
||||
@@ -507,12 +541,47 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
| When this option is enabled you may see an extra text below customer's replies, for example:
|
||||
| On Thu, Jan 4, 2024 at 8:41 AM John Doe | Demo <test@example.org> wrote:
|
||||
|
|
||||
| But overall reply separation in this case is more reliable.
|
||||
| No other reply separators are used in this case.
|
||||
| When this option is enabled the overall reply separation is more reliable.
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'alternative_reply_separation' => env('APP_ALTERNATIVE_REPLY_SEPARATION', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Comma separated list of white listed hosts.
|
||||
| If some input containing URL becomes blank after saving it - add its host or IP here.
|
||||
| Example: example.org,test.example.org,192.168.1.97
|
||||
|-------------------------------------------------------------------------
|
||||
*/
|
||||
'remote_host_white_list' => env('APP_REMOTE_HOST_WHITE_LIST', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| By default only the host specified in APP_URL is allowed.
|
||||
| To allow additional hosts list them separating by commas.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'trusted_hosts' => env('APP_TRUSTED_HOSTS', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Disable checking if visitor's browser supports CSP.
|
||||
| It's NOT recommended to disable this check.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'disable_browser_check' => env('APP_DISABLE_BROWSER_CHECK', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| If you get an error message saying that your browser doesn't support CSP,
|
||||
| list your User-Agent in this parameter (https://www.whatismybrowser.com/detect/what-is-my-user-agent/).
|
||||
| Use "|" symbol to separate multiple User-Agents.
|
||||
| And don't forget to send your User-Agent to this issue: https://github.com/freescout-help-desk/freescout/issues/5331
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'allowed_user_agents' => env('APP_ALLOWED_USER_AGENTS', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Autoloaded Service Providers
|
||||
|
||||
@@ -96,7 +96,8 @@ return [
|
||||
'provider' => 'users',
|
||||
'table' => 'password_resets',
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
// Has no effect.
|
||||
//'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -53,17 +53,19 @@ return [
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('DB_MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_SSL_CERT => env('DB_MYSQL_ATTR_SSL_CERT'),
|
||||
(defined('Pdo\Mysql::ATTR_SSL_CA') ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('DB_MYSQL_ATTR_SSL_CA'),
|
||||
(defined('Pdo\Mysql::ATTR_SSL_CERT') ? Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT) => env('DB_MYSQL_ATTR_SSL_CERT'),
|
||||
// https://github.com/freescout-help-desk/freescout/issues/5273
|
||||
(defined('Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT') ? Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') ? PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT : -1)) => env('DB_MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', true),
|
||||
PDO::ATTR_PERSISTENT => env('DB_ATTR_PERSISTENT'),
|
||||
]) : [],
|
||||
], function($value) { return $value !== null; }) : [],
|
||||
],
|
||||
|
||||
'testing' => [
|
||||
'driver' => 'mysql',
|
||||
//'url' => env('DB_TEST_DATABASE_URL'),
|
||||
'host' => '127.0.0.1',
|
||||
'database' => 'freescout-test',
|
||||
'host' => env('DB_TEST_HOST', '127.0.0.1'),
|
||||
'database' => env('DB_TEST_DATABASE', 'freescout-test'),
|
||||
'username' => env('DB_TEST_USERNAME', 'freescout-test'),
|
||||
'password' => env('DB_TEST_PASSWORD', 'freescout-test'),
|
||||
//'port' => env('DB_TEST_PORT', '3306'),
|
||||
@@ -100,7 +102,7 @@ return [
|
||||
'schema' => 'public',
|
||||
'sslmode' => env('DB_PGSQL_SSLMODE', 'prefer'),
|
||||
'options' => extension_loaded('pdo_pgsql') ? array_filter([
|
||||
PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_PGSQL_ATTR_DISABLE_PREPARES'),
|
||||
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pdo\Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_PGSQL_ATTR_DISABLE_PREPARES'),
|
||||
PDO::ATTR_PERSISTENT => env('DB_ATTR_PERSISTENT'),
|
||||
]) : [],
|
||||
],
|
||||
@@ -144,7 +146,7 @@ return [
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => 'predis',
|
||||
'client' => env('REDIS_CLIENT', 'predis'),
|
||||
|
||||
'default' => [
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user