Compare commits
1883 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92cb048fe | ||
|
|
917b4e0ce1 | ||
|
|
68fe730581 | ||
|
|
c2c2de734e | ||
|
|
628e3ae5b5 | ||
|
|
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 | ||
|
|
684f81409a | ||
|
|
2dfa1fcb39 | ||
|
|
1a9c5b8d08 | ||
|
|
243320ddb9 | ||
|
|
2265016523 | ||
|
|
1a1dbcc5e0 | ||
|
|
003d2070c6 | ||
|
|
c422882ff8 | ||
|
|
77175143d9 | ||
|
|
3e194faa76 | ||
|
|
17037f219d | ||
|
|
8db5546bab | ||
|
|
95c4acdd3a | ||
|
|
1a49198844 | ||
|
|
8453429753 | ||
|
|
12adf2470c | ||
|
|
e3e8b521f1 | ||
|
|
46f1bc4658 | ||
|
|
24d0177cf8 | ||
|
|
96523cf6d9 | ||
|
|
2221777f3a | ||
|
|
05d45d3037 | ||
|
|
fd24aa6dbd | ||
|
|
2f6b583bf1 | ||
|
|
0489a6c29f | ||
|
|
7c64e53126 | ||
|
|
dbbfff831f | ||
|
|
88734eec10 | ||
|
|
01f5a69e6a | ||
|
|
e37596f1b6 | ||
|
|
c0248c2a8e | ||
|
|
6bc4005fa1 | ||
|
|
ef99594188 | ||
|
|
97a561e99e | ||
|
|
504a7f71dc | ||
|
|
bd7fa38500 | ||
|
|
29f057aa4c | ||
|
|
cd129aa634 | ||
|
|
198d839a4a | ||
|
|
945fb2f583 | ||
|
|
7f977959df | ||
|
|
e60e1cd3c3 | ||
|
|
b00c3f5f5d | ||
|
|
ab1c70d413 | ||
|
|
9c594f406b | ||
|
|
5be2d61755 | ||
|
|
8ee81b6050 | ||
|
|
e5d1065743 | ||
|
|
6fae3eecb1 | ||
|
|
fe7d4711c0 | ||
|
|
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 | ||
|
|
3b471b17cf | ||
|
|
34537a1834 | ||
|
|
8293c064d9 | ||
|
|
1d115c6be8 | ||
|
|
2b71bbadbb | ||
|
|
ad8f305b13 | ||
|
|
09a18e6d0f | ||
|
|
a3cacca368 | ||
|
|
c1d63c7549 | ||
|
|
b792e25eec | ||
|
|
c5b8917747 | ||
|
|
891c971bb9 | ||
|
|
31a9856819 | ||
|
|
723598aa27 | ||
|
|
188a738528 | ||
|
|
5f140b117f | ||
|
|
4080415870 | ||
|
|
9e1ca45169 | ||
|
|
5e32a629a3 | ||
|
|
7390179434 | ||
|
|
95513f1832 | ||
|
|
ed1a7ca9e3 | ||
|
|
fa86462665 | ||
|
|
d8f21f070b | ||
|
|
e52e641636 | ||
|
|
4b17e534a7 | ||
|
|
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 | ||
|
|
a9572ca50a | ||
|
|
63b008c6d2 | ||
|
|
e42c59ab88 | ||
|
|
58a49aa806 | ||
|
|
0f47724cb7 | ||
|
|
38b9a236a8 | ||
|
|
c04444bfbf | ||
|
|
bbb8f94417 | ||
|
|
198a2afe75 | ||
|
|
002740b267 | ||
|
|
ef10136cb8 | ||
|
|
343a21e457 | ||
|
|
f706284996 | ||
|
|
b5c737cdf8 | ||
|
|
26ac7e2c62 | ||
|
|
34d2a019b0 | ||
|
|
661c000a0e | ||
|
|
e2a5930a5a | ||
|
|
7fad593186 | ||
|
|
752ce0f46b | ||
|
|
980c6533a8 | ||
|
|
9dc66919e1 | ||
|
|
68ba521efa | ||
|
|
ab27725368 | ||
|
|
84bf959276 | ||
|
|
3d14b81971 | ||
|
|
d3bb99dbc1 | ||
|
|
1407a1bfdd | ||
|
|
0f41f5cabb | ||
|
|
21768e1090 | ||
|
|
db4b4e4b4a | ||
|
|
2b3d120a49 | ||
|
|
c9347ee8a9 | ||
|
|
47c98e97a4 | ||
|
|
f9c8089ed5 | ||
|
|
9bd8a6527c | ||
|
|
ec0faecf7f | ||
|
|
71e77d7b5a | ||
|
|
a49b61d693 | ||
|
|
10e8d0c867 | ||
|
|
ae55bd7a36 | ||
|
|
3101b08d8b | ||
|
|
8d41a382f2 | ||
|
|
8a8dd09a0c | ||
|
|
d0ed4fda50 | ||
|
|
6e84ce7840 | ||
|
|
61c6fe0ec6 | ||
|
|
e67f09fe7e | ||
|
|
9492779dfc | ||
|
|
499fcfc6ca | ||
|
|
369125c24f | ||
|
|
14faec272c | ||
|
|
5c55597650 | ||
|
|
24e0a9a01b | ||
|
|
8ea4963b75 | ||
|
|
73832baf03 | ||
|
|
fab578bb5c | ||
|
|
ea2820d04a | ||
|
|
c333ea2780 | ||
|
|
ddadec3c2a | ||
|
|
f0533a5470 | ||
|
|
482b002a8e | ||
|
|
806f3d0228 | ||
|
|
2e83003030 | ||
|
|
756549c157 | ||
|
|
95fa038205 | ||
|
|
e081807436 | ||
|
|
49f52f7fd9 | ||
|
|
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 | ||
|
|
196f3663d5 | ||
|
|
e08cdd5288 | ||
|
|
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 | ||
|
|
5ce756de00 | ||
|
|
609f7be8d2 | ||
|
|
1b50b24c62 | ||
|
|
41fd15bb68 | ||
|
|
6a45fff348 | ||
|
|
3f508d41a0 | ||
|
|
a4c587cf1f | ||
|
|
09799fd4bc | ||
|
|
154b513af8 | ||
|
|
2f6fbd76dd | ||
|
|
39bc5401ef | ||
|
|
c98b075fca | ||
|
|
5a34f2a312 | ||
|
|
8cf431fc0f | ||
|
|
f454d3f445 | ||
|
|
72a8137b03 | ||
|
|
3e577e82ca | ||
|
|
b13ec05c2a | ||
|
|
9406e102ef | ||
|
|
41b4441ab5 | ||
|
|
85a5dbd38e | ||
|
|
bf6d0abf5c | ||
|
|
e02b943b3f | ||
|
|
1704473a77 | ||
|
|
f4dd553530 | ||
|
|
084cb6981b | ||
|
|
06fa6b2940 | ||
|
|
5f2925efd0 | ||
|
|
385a55b4b7 | ||
|
|
637fce7dbe | ||
|
|
e188765c21 | ||
|
|
5b5ce88a90 | ||
|
|
7337879a65 | ||
|
|
adec0de39a | ||
|
|
7c485c96be | ||
|
|
779adccea0 | ||
|
|
62465bce79 | ||
|
|
15c5aaaa15 | ||
|
|
29c6c2884a | ||
|
|
30ba029242 | ||
|
|
00f50ac6ca | ||
|
|
e7008fa267 | ||
|
|
460bc8f91b | ||
|
|
caa5aac1c3 | ||
|
|
4e197d8ce9 | ||
|
|
b7082a8867 | ||
|
|
e47a54a5af | ||
|
|
1f4deee636 | ||
|
|
656cb2349c | ||
|
|
38d49720e3 | ||
|
|
9ad0db4947 | ||
|
|
5fe3f44f09 | ||
|
|
73a7e1f9db | ||
|
|
74fa4b7d4f | ||
|
|
899548fafa | ||
|
|
193adb7e11 | ||
|
|
3d593022fe | ||
|
|
7f6a5042c1 | ||
|
|
315ad05f73 | ||
|
|
3193add63e | ||
|
|
4503d750cb | ||
|
|
2a804038ff | ||
|
|
2cd2e66b77 | ||
|
|
47da5838f7 | ||
|
|
e2b4151e78 | ||
|
|
cd9e8fea9c | ||
|
|
e903accecd | ||
|
|
8bb0594e95 | ||
|
|
11a542e3be | ||
|
|
8f7400492a | ||
|
|
135d2d6d0c | ||
|
|
5ec9169044 | ||
|
|
5e664f2e25 | ||
|
|
c7e21d9abe | ||
|
|
0aab1d715f | ||
|
|
d3c3148945 | ||
|
|
7cb9f8c713 | ||
|
|
fed0ecb533 | ||
|
|
b7b4ce4069 | ||
|
|
beef6af9b5 | ||
|
|
afa0cb6ba5 | ||
|
|
26ff8c73ab | ||
|
|
30c0f68c97 | ||
|
|
875ab40739 | ||
|
|
72525cce6c | ||
|
|
e39fa1299f | ||
|
|
e2dcf6dc7e | ||
|
|
28621dbfcf | ||
|
|
6ba03d32a0 | ||
|
|
25ea3d9388 | ||
|
|
a43239d8f0 | ||
|
|
2fda21b54e | ||
|
|
fc8f50c973 | ||
|
|
15db137829 | ||
|
|
4a678b8309 | ||
|
|
1261b8b84c | ||
|
|
ae68e194b1 | ||
|
|
5dab098380 | ||
|
|
215241ee2e | ||
|
|
f5c566c2e8 | ||
|
|
f3190f03c7 | ||
|
|
3e216820ba | ||
|
|
4008a22ef5 | ||
|
|
fcea5716e5 | ||
|
|
b455972062 | ||
|
|
a958553b72 | ||
|
|
d902f19038 | ||
|
|
49a9a4de64 | ||
|
|
8e5e73b4ce | ||
|
|
48f05f34e6 | ||
|
|
ab8d681219 | ||
|
|
68746b8c17 | ||
|
|
369eb9217a | ||
|
|
5a5a48e32c | ||
|
|
79d5955ab1 | ||
|
|
d7fb065a5f | ||
|
|
956f631661 | ||
|
|
d5b63611ad | ||
|
|
296a4c82a1 | ||
|
|
285ca977fa | ||
|
|
a0b3888b18 | ||
|
|
518058aacf | ||
|
|
9fa67358ae | ||
|
|
f58b6d8bda | ||
|
|
a7a2fe91e6 | ||
|
|
b0bf40295b | ||
|
|
da9bbfde8f | ||
|
|
65219613b9 | ||
|
|
45356bfbdf | ||
|
|
c837ad77e5 | ||
|
|
164349549d | ||
|
|
0c75dfde32 | ||
|
|
87dba690ef | ||
|
|
61adcbd890 | ||
|
|
190cdea748 | ||
|
|
53ce4523a4 | ||
|
|
291ff9e306 | ||
|
|
855b573672 | ||
|
|
3dcdbb85d6 | ||
|
|
d8ec060a9d | ||
|
|
4fc258f77b | ||
|
|
25edcebd77 | ||
|
|
deb6788773 | ||
|
|
c4e4220ae3 | ||
|
|
b729fcb2e1 | ||
|
|
1371340962 | ||
|
|
0dbf2e1665 | ||
|
|
2293911718 | ||
|
|
864ad08820 | ||
|
|
270bd58f61 | ||
|
|
60c01c3707 | ||
|
|
17d31b62ad | ||
|
|
e6fe63e71f | ||
|
|
34677264d5 | ||
|
|
66e5ac8e9d | ||
|
|
43f6e154a5 | ||
|
|
d61b74c306 | ||
|
|
95882ea8f1 | ||
|
|
a7d678ac21 | ||
|
|
4879a34b3d | ||
|
|
01fa0955d4 | ||
|
|
5f34e80a22 | ||
|
|
94485d86bb | ||
|
|
ce20dfa787 | ||
|
|
9a691124c6 | ||
|
|
c703f38f6c | ||
|
|
8a887502e1 | ||
|
|
3368437e4e | ||
|
|
59cababa70 | ||
|
|
b4d2846a3a | ||
|
|
e45213e537 | ||
|
|
c35fc2a502 | ||
|
|
6583d6f5a5 | ||
|
|
25f92844c2 | ||
|
|
f4590b8279 | ||
|
|
cdadaf621b | ||
|
|
414878eb79 | ||
|
|
aaf334e9bb | ||
|
|
f35fddb0cc | ||
|
|
fb130de64e | ||
|
|
5f182818e2 | ||
|
|
eb397efae2 | ||
|
|
14f17a5cd2 | ||
|
|
abf82ecc3e | ||
|
|
d0713d5a67 | ||
|
|
b33d1e56a8 | ||
|
|
c779afdda8 | ||
|
|
83eea1ca47 | ||
|
|
d459a3b0cf | ||
|
|
b3d7611e6e | ||
|
|
2e2fe37111 | ||
|
|
c2680bd9ec | ||
|
|
acf57a7a4b | ||
|
|
0425b26bc9 | ||
|
|
88baece2a4 | ||
|
|
265379b3ae | ||
|
|
39182e106a | ||
|
|
6f6419aafd | ||
|
|
efe82e31b4 | ||
|
|
8babf94028 | ||
|
|
1d83e1cffb | ||
|
|
b2a1be6b3c | ||
|
|
f45b9105d4 | ||
|
|
97511c182b | ||
|
|
b1d6c2c601 | ||
|
|
af3f1e2889 | ||
|
|
a894ae1312 | ||
|
|
ef5fbe30f4 | ||
|
|
1eb0469bd0 | ||
|
|
269e5ad424 | ||
|
|
5aa8d63321 | ||
|
|
e009c4675b | ||
|
|
9131b16f80 | ||
|
|
c921033e8c | ||
|
|
dc886e7bb8 | ||
|
|
7c73d5f168 | ||
|
|
f35b4249c7 | ||
|
|
dbdf8f2260 | ||
|
|
561eba496a | ||
|
|
00de71168b | ||
|
|
1de4e9b37a | ||
|
|
726ef147eb | ||
|
|
b395a11791 | ||
|
|
2353c0bd1a | ||
|
|
b341dd86f7 | ||
|
|
7e29e2463f | ||
|
|
a8f3e558d7 | ||
|
|
55ff20a5d5 | ||
|
|
d8708d55e9 | ||
|
|
afac233d5a | ||
|
|
999cb49b58 | ||
|
|
2810916a88 | ||
|
|
023882f9d9 | ||
|
|
889d75c8e3 | ||
|
|
de69907c9d | ||
|
|
b9ee220984 | ||
|
|
4cea7689a4 | ||
|
|
ca6d5bb572 | ||
|
|
33e8c60d89 | ||
|
|
e489c2cc1d | ||
|
|
a09a0f901d | ||
|
|
729ee307e9 | ||
|
|
486a2b9c15 | ||
|
|
b0b4e46139 | ||
|
|
0565ba97b7 | ||
|
|
9ee5066922 | ||
|
|
9272158c98 | ||
|
|
ebe1913e12 | ||
|
|
d5c34fb3c7 | ||
|
|
2b2d7b1c29 | ||
|
|
a664ac58a7 | ||
|
|
3329379db3 | ||
|
|
c1d2a1cbe5 | ||
|
|
69d0504572 | ||
|
|
cb86188457 | ||
|
|
8bb8ea4140 | ||
|
|
8045aaae95 | ||
|
|
f5c3fe5610 | ||
|
|
2a13430265 | ||
|
|
1e319c91e1 | ||
|
|
cb6ea3284f | ||
|
|
44c4cd0da8 | ||
|
|
996a7f9633 | ||
|
|
16e78f5b3e | ||
|
|
b28d007210 | ||
|
|
024c122758 | ||
|
|
c9d6ba4f68 | ||
|
|
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 | ||
|
|
2f9a824ece | ||
|
|
e8dfd0fe68 | ||
|
|
152e60f672 | ||
|
|
8c0e161f8d | ||
|
|
6f3dd89f75 | ||
|
|
fae6417c40 | ||
|
|
f7bc16c56a | ||
|
|
7c9f9635c5 | ||
|
|
ee9cda1f0c | ||
|
|
5a235a96a5 | ||
|
|
451ef5cf27 | ||
|
|
017a958b0e | ||
|
|
a53818c147 | ||
|
|
026989d523 | ||
|
|
1d78708570 | ||
|
|
893c878c37 | ||
|
|
accd83b835 | ||
|
|
b20a586989 | ||
|
|
3cd905274a | ||
|
|
d5708283d5 | ||
|
|
b8232fad6a | ||
|
|
6aca3bdda2 | ||
|
|
619db43999 | ||
|
|
2101f72f4e | ||
|
|
417e25dd44 | ||
|
|
3ef7bf2af8 | ||
|
|
5ab9cace19 | ||
|
|
63b225e380 | ||
|
|
5710da0ccb | ||
|
|
b134ee8939 | ||
|
|
db902c7f04 | ||
|
|
9984071e6f | ||
|
|
00d45dc954 | ||
|
|
004a8231f6 | ||
|
|
1bfe3d5444 | ||
|
|
587ae175c1 | ||
|
|
11b98a109a | ||
|
|
446e75e969 | ||
|
|
e19633ce1a | ||
|
|
6e36e39187 | ||
|
|
3991b68a0e | ||
|
|
b76361f27d | ||
|
|
387c94333e | ||
|
|
9760bf620e | ||
|
|
931c93506a | ||
|
|
b01e0f7968 | ||
|
|
d0d7f01924 | ||
|
|
2bd7e35b57 | ||
|
|
6fa9cd20f1 | ||
|
|
47c085df03 | ||
|
|
84c96abdd4 | ||
|
|
3711f0d0a3 | ||
|
|
625f3b623d | ||
|
|
743c3241b5 | ||
|
|
4e8e852530 | ||
|
|
3a5cb77690 | ||
|
|
2338a3dddf | ||
|
|
661f7093f7 | ||
|
|
06f6b4f6ac | ||
|
|
f79e56f7d1 | ||
|
|
e560512a03 | ||
|
|
4752adbf35 | ||
|
|
3454d28bd8 | ||
|
|
34d5973579 | ||
|
|
7152ee7902 | ||
|
|
cd56e5c3e0 | ||
|
|
bcee603c02 | ||
|
|
f3fb6ccb91 | ||
|
|
c4c4263772 | ||
|
|
2c781425c8 | ||
|
|
971a6915cc | ||
|
|
8c99abc8f4 | ||
|
|
1a4abad9c4 | ||
|
|
ec40bc80a0 | ||
|
|
e4e099d5ac | ||
|
|
7770ae10d2 | ||
|
|
c626c77411 | ||
|
|
19d306454e | ||
|
|
be4cd23e17 | ||
|
|
94ce31599a | ||
|
|
5883cbe9a4 | ||
|
|
c46e9a0acf | ||
|
|
64cebaed0d | ||
|
|
28b73fe679 | ||
|
|
d1087be216 | ||
|
|
6e9132526a | ||
|
|
7cf1dad4db | ||
|
|
43576f53ca | ||
|
|
061eabfadd | ||
|
|
16f97bc086 | ||
|
|
c7bc54cf15 | ||
|
|
e6cf128c3d | ||
|
|
d9186546f6 | ||
|
|
d8826584bc | ||
|
|
c632289e17 | ||
|
|
97006884b5 | ||
|
|
391a84ee06 | ||
|
|
22495f5847 | ||
|
|
694a89b757 | ||
|
|
b1d35954a6 | ||
|
|
2ecdad8114 | ||
|
|
c92e38a031 | ||
|
|
fc127d7dd4 | ||
|
|
6981620c15 | ||
|
|
4f1b0180c1 | ||
|
|
4e46be5470 | ||
|
|
69c58b4871 | ||
|
|
d4e1686ea0 | ||
|
|
ab2aa141ce | ||
|
|
d42a4dee4a | ||
|
|
b0c32f9166 | ||
|
|
62bd684642 | ||
|
|
ea8505e2e5 | ||
|
|
2855caeceb | ||
|
|
cc2bec702b | ||
|
|
7131be95fb | ||
|
|
229b156f7f | ||
|
|
49309ade2d | ||
|
|
3f813a0432 | ||
|
|
19e765f4b4 | ||
|
|
b1a7daa2d5 | ||
|
|
26527fa927 | ||
|
|
7c6d62b068 | ||
|
|
3a70c8b491 | ||
|
|
99e1f2a7ce | ||
|
|
3ac611f4b9 | ||
|
|
7d2b40c524 | ||
|
|
d0161321cf | ||
|
|
163a7f4745 | ||
|
|
ad06917b88 | ||
|
|
6c7db6c883 | ||
|
|
dfc6b002e0 | ||
|
|
0f47d93c8b | ||
|
|
3b721788f2 | ||
|
|
80a93ede92 | ||
|
|
037eec6bbf | ||
|
|
057c1d3e60 | ||
|
|
950859ccec | ||
|
|
a9ac255bc2 | ||
|
|
facbe21990 | ||
|
|
0ab988600d | ||
|
|
4e106cb0a7 | ||
|
|
44d7a28616 | ||
|
|
0870d9e94b | ||
|
|
e1c596a729 | ||
|
|
6ae2b0fdbe | ||
|
|
6890cc38c9 | ||
|
|
b590251393 | ||
|
|
22ed3a775b | ||
|
|
45aa0faf1d | ||
|
|
701acfead9 | ||
|
|
b0d589c3a4 | ||
|
|
f8f9400e06 | ||
|
|
3bf9bb9611 | ||
|
|
bc9bc6dffe | ||
|
|
27cef8055d | ||
|
|
76467c1771 | ||
|
|
c50226f4b3 | ||
|
|
f9e238bb2c | ||
|
|
1373b3b939 | ||
|
|
3ad3cf5ab9 | ||
|
|
ab7073f7fd | ||
|
|
68964b5f9c | ||
|
|
e53aece5b7 | ||
|
|
1e8483a886 | ||
|
|
45da3085db | ||
|
|
77e81c9598 | ||
|
|
324d8d861e | ||
|
|
dc4d2c86ba | ||
|
|
19622ffd60 | ||
|
|
251187d463 | ||
|
|
762e50d68e | ||
|
|
c0cb3fa898 | ||
|
|
5b6e261c6a | ||
|
|
dcee75c499 | ||
|
|
aba50ed13d | ||
|
|
baa8512d8e | ||
|
|
1e92ac8d7b | ||
|
|
ecb28aef60 | ||
|
|
be7e311ad0 | ||
|
|
d8bcb53367 | ||
|
|
a02cc2878e | ||
|
|
1426336439 | ||
|
|
d3263c2429 | ||
|
|
4594a539a2 | ||
|
|
cf18c3850e | ||
|
|
53f6100f2c | ||
|
|
26a98823f9 | ||
|
|
5fcc7de0a9 | ||
|
|
8814df7cfa | ||
|
|
b06e8928d2 | ||
|
|
304c0d4bca | ||
|
|
62768d2112 | ||
|
|
dedf9efe54 | ||
|
|
09aafd50b2 | ||
|
|
9669c57f1d | ||
|
|
e2de65f3f3 | ||
|
|
1794763bbb | ||
|
|
a4e9e89518 | ||
|
|
4038bbe6b5 | ||
|
|
09558dab75 | ||
|
|
0298d7f3c6 | ||
|
|
a58360926a | ||
|
|
b3f1868b42 | ||
|
|
86b30fe336 | ||
|
|
e4f471ef28 | ||
|
|
e85c3be9ff | ||
|
|
b3e64dcfb1 | ||
|
|
c0ae0ca567 | ||
|
|
5638e46fab | ||
|
|
b5b79fd298 | ||
|
|
806e094cd5 | ||
|
|
a454c0cd57 | ||
|
|
98a94c3e6b | ||
|
|
b1b24f3114 | ||
|
|
570e3b9932 | ||
|
|
fb2fd080fe | ||
|
|
6a27f13f3c | ||
|
|
382a938b14 | ||
|
|
e1abd03937 | ||
|
|
3e05686342 | ||
|
|
9aa8299720 | ||
|
|
fbc0376443 | ||
|
|
7a0f0d64e0 | ||
|
|
ca675b0815 | ||
|
|
a3e6826b2f | ||
|
|
395318b568 | ||
|
|
d6483f895c | ||
|
|
b740d55260 | ||
|
|
da62f64f34 | ||
|
|
0a9a51c73c | ||
|
|
fcc7437ffa | ||
|
|
18fc0bc54d | ||
|
|
95fe52218e | ||
|
|
fb346f2e2e | ||
|
|
b0804c6dca | ||
|
|
7442fbb522 | ||
|
|
54b9548f8c | ||
|
|
8810c48f97 | ||
|
|
4fd28b3038 | ||
|
|
c624e8e781 | ||
|
|
81bf63f8b0 | ||
|
|
5dd7a2effb | ||
|
|
8484785a17 | ||
|
|
3f5bb2841f | ||
|
|
508dda1685 | ||
|
|
9dc18b4b52 | ||
|
|
9879e29c20 | ||
|
|
59a2975cc0 | ||
|
|
cd98d8b559 | ||
|
|
305f45a03d | ||
|
|
7718d8e906 | ||
|
|
540b3cba6e | ||
|
|
b5574ea37b | ||
|
|
ac13321407 | ||
|
|
47c516ef9a | ||
|
|
20034ab376 | ||
|
|
4fb8b7f8d7 | ||
|
|
38316da137 | ||
|
|
507be36664 | ||
|
|
8e3c87ac55 | ||
|
|
d2048f59a8 | ||
|
|
119a2772ec | ||
|
|
1628567cfc | ||
|
|
f0b8e63897 | ||
|
|
6eb8bdad2e | ||
|
|
d797aacb9a | ||
|
|
e804038b4e | ||
|
|
1f154ce039 | ||
|
|
eb0be440ac | ||
|
|
cfdcdaf092 | ||
|
|
43dafd9130 | ||
|
|
87cdb65d6b | ||
|
|
0a8fe89a92 | ||
|
|
d9f96d4e03 | ||
|
|
ce607ff111 | ||
|
|
2552a2b842 | ||
|
|
01c91d2086 | ||
|
|
e136660e8d | ||
|
|
f411f8cf5a | ||
|
|
da7898446f | ||
|
|
5620c25830 | ||
|
|
fa3f6c8c63 | ||
|
|
7a2f6d1e12 | ||
|
|
eab9771102 | ||
|
|
8267be397f | ||
|
|
4e82464f48 | ||
|
|
f03810e380 | ||
|
|
fb33d672a2 | ||
|
|
311319199e | ||
|
|
17421bae24 | ||
|
|
f9f491c453 | ||
|
|
d7d7a020d3 | ||
|
|
f7548a7076 | ||
|
|
018bb1f982 | ||
|
|
916fe1102f | ||
|
|
9070732d2c | ||
|
|
4f8d998068 | ||
|
|
8b07e919ad | ||
|
|
e4b21c9797 | ||
|
|
0fb7bb3aa5 | ||
|
|
823816517e | ||
|
|
5f8a7feb2a | ||
|
|
9102717053 | ||
|
|
2cc4fb3c5c | ||
|
|
9ab1dae25e | ||
|
|
10cafafeef | ||
|
|
0f1d3fa9fc | ||
|
|
694403e0ce | ||
|
|
8274a96d7c | ||
|
|
c980d52ff0 | ||
|
|
0b6a314f01 | ||
|
|
03992b2061 | ||
|
|
44bf284fe3 | ||
|
|
1ced45029e | ||
|
|
639c08c1f3 | ||
|
|
9e0ef60e00 | ||
|
|
fd4b334d8a | ||
|
|
a8a2bded0a | ||
|
|
ca003cecb2 | ||
|
|
7ad231787b | ||
|
|
17cfded1f0 | ||
|
|
7a2f965951 | ||
|
|
22ef2b48d1 | ||
|
|
4ab35da9cf | ||
|
|
4b6171c9cd | ||
|
|
174b7dcf36 | ||
|
|
a086151b2f | ||
|
|
de4d6ed2fc | ||
|
|
f142f0e575 | ||
|
|
affa9325df | ||
|
|
56e8aa2669 | ||
|
|
1839a43359 | ||
|
|
64d21bcecf | ||
|
|
2db9233ba1 | ||
|
|
d154d31ec2 | ||
|
|
9fe476b742 | ||
|
|
f91396f833 | ||
|
|
beabd14cae | ||
|
|
a2d81deff9 | ||
|
|
0e83d360b5 | ||
|
|
f05fe74f4e | ||
|
|
cc87962474 | ||
|
|
10c32901d8 | ||
|
|
d1dffe8f60 | ||
|
|
04ec0b09fe | ||
|
|
3651fcdcc2 | ||
|
|
feda4104b0 | ||
|
|
8bf8c929fb | ||
|
|
7f824c685e | ||
|
|
7f2f83d32e | ||
|
|
b13e0fefb0 | ||
|
|
dbeba27553 | ||
|
|
90cd9061b4 | ||
|
|
7991cb39a5 | ||
|
|
31c72b8092 | ||
|
|
5e56a473e7 | ||
|
|
497e481d42 | ||
|
|
ade91429e8 | ||
|
|
b9effba813 | ||
|
|
0b1d72d68e | ||
|
|
6c5ad6997d | ||
|
|
4df0c3196f | ||
|
|
b8f82a15a9 | ||
|
|
792cb95718 | ||
|
|
cebd754983 | ||
|
|
e69a4105a1 | ||
|
|
1ff8dce33c | ||
|
|
5b74800cb4 | ||
|
|
199bfc5dca | ||
|
|
5ea7d041b2 | ||
|
|
ad183694bf | ||
|
|
d77fa741c8 | ||
|
|
ec4cf9a457 | ||
|
|
f7e20304b2 | ||
|
|
f51651e45a | ||
|
|
d179d96bde | ||
|
|
c7498f288e | ||
|
|
f691fe2d7f | ||
|
|
6f1c286af3 | ||
|
|
b43e300b7c | ||
|
|
a23f64d539 | ||
|
|
dcfa6d4f9c | ||
|
|
791bc1d643 | ||
|
|
bb178de5d2 | ||
|
|
33e000af6f | ||
|
|
8c1c4c1a03 | ||
|
|
35b628376b | ||
|
|
79bc595e14 | ||
|
|
d9ada9c6ec | ||
|
|
ee361fd95a | ||
|
|
3d517b4434 | ||
|
|
d95ab52c4d | ||
|
|
4d889e31e4 | ||
|
|
dabc022c4f | ||
|
|
3c709fa3ba | ||
|
|
95c2a1a2f4 | ||
|
|
bab2fc5fb8 | ||
|
|
cf94762736 | ||
|
|
94269e1dea | ||
|
|
34b910bbba | ||
|
|
fbcd1797f0 | ||
|
|
cb0e560ed8 | ||
|
|
589ac69594 | ||
|
|
e4dfd3cb2e | ||
|
|
490475fe56 | ||
|
|
65014d72b5 | ||
|
|
2dd4260103 | ||
|
|
7c00262494 | ||
|
|
4ab9e0beb8 | ||
|
|
6608107d70 | ||
|
|
cf08353ebc | ||
|
|
5c73a68d12 | ||
|
|
ef70f04594 | ||
|
|
ec83995838 | ||
|
|
e71de79923 | ||
|
|
20188aecb1 | ||
|
|
b2abca9af1 | ||
|
|
29491f663f | ||
|
|
2d821be5e1 | ||
|
|
08b8e1cf1d | ||
|
|
e1a493acd8 | ||
|
|
c8c0db5cb9 | ||
|
|
417bf07f69 | ||
|
|
17b8eeae96 | ||
|
|
d4a92e9bb9 | ||
|
|
020b9e7c18 | ||
|
|
9b0cec6b2b | ||
|
|
736e931529 | ||
|
|
e84fd891bf | ||
|
|
888701e066 | ||
|
|
b59144e704 | ||
|
|
eae50903f2 | ||
|
|
66e19c9b2c | ||
|
|
dcdb7e68cf | ||
|
|
a363fb264c | ||
|
|
877813cb73 | ||
|
|
0ced99564d | ||
|
|
d6db40d563 | ||
|
|
4591dec171 | ||
|
|
d8b7723a7c | ||
|
|
c7576c52e4 | ||
|
|
678cd45198 | ||
|
|
4b1afadf92 | ||
|
|
0422b29321 | ||
|
|
9989bf02d3 | ||
|
|
c35b7ff3c0 | ||
|
|
38e22ef69c | ||
|
|
d2b7a87bbb | ||
|
|
3208a8c1d9 | ||
|
|
ab8309bdab | ||
|
|
12584db7b5 | ||
|
|
6dac6eb1df | ||
|
|
3a33fd4628 | ||
|
|
e1b9d711d3 | ||
|
|
9e1b53d410 | ||
|
|
ac43d4628d | ||
|
|
33a7d06032 | ||
|
|
a110039125 | ||
|
|
dd654551c1 | ||
|
|
74048295fa | ||
|
|
e5cfdf58af | ||
|
|
1595be8c0d | ||
|
|
925f9f4bc8 | ||
|
|
a3f49ca25c | ||
|
|
177e54f1f2 | ||
|
|
7efa8f5a1c | ||
|
|
18a5efcc98 | ||
|
|
da99de9a44 | ||
|
|
2c8caef9a6 | ||
|
|
63b435ac42 | ||
|
|
898f1d3266 | ||
|
|
c92d52d055 | ||
|
|
486e777210 | ||
|
|
ef4bbef17e | ||
|
|
54226692a0 | ||
|
|
0d5944b95a | ||
|
|
4627ec463a | ||
|
|
ca0bc6ef2a | ||
|
|
e26f7c5e84 | ||
|
|
e2617dbbd8 | ||
|
|
185d660f8b | ||
|
|
a264424afb | ||
|
|
50f0f25e0b | ||
|
|
976b926d2d | ||
|
|
b26fb0e13a | ||
|
|
fa46d45662 | ||
|
|
c56d315eb5 | ||
|
|
222ffece76 | ||
|
|
cb8d99fe5a | ||
|
|
c771001d9f | ||
|
|
7d7f1e708e | ||
|
|
978138df1a | ||
|
|
095af77472 | ||
|
|
5eaa427f09 | ||
|
|
178ac6d19b | ||
|
|
18fb794c75 | ||
|
|
1148a35663 | ||
|
|
043b7dd5ce | ||
|
|
13bf22065f | ||
|
|
0e14e087a5 | ||
|
|
d41fac7a7f | ||
|
|
6abf8cde9e | ||
|
|
17b81de9e1 | ||
|
|
066871b1a3 | ||
|
|
2f2e010796 | ||
|
|
1ab7806356 | ||
|
|
88c00d5a02 | ||
|
|
b887113b50 | ||
|
|
51dbe56f3f | ||
|
|
f4fea342ed | ||
|
|
01bfd5c971 | ||
|
|
2ccc2cbd66 | ||
|
|
143d96af6b | ||
|
|
93391f826c | ||
|
|
0790a5114f | ||
|
|
11058803c7 | ||
|
|
df0ab1add9 | ||
|
|
b564d20627 | ||
|
|
3562298031 | ||
|
|
7706d52e63 | ||
|
|
ff2e92f1ff | ||
|
|
4b2f385e43 | ||
|
|
a15c29bea0 | ||
|
|
a0594bf6d9 | ||
|
|
6e0a213003 | ||
|
|
8024c72db5 | ||
|
|
ecc9ea9ab9 | ||
|
|
b3dbad9a6e | ||
|
|
ccdbd265c8 | ||
|
|
a0659a8971 | ||
|
|
ef4d37f308 | ||
|
|
a49fd0ad33 | ||
|
|
190d2daece | ||
|
|
bfbfeb2e66 | ||
|
|
a11df91aa4 | ||
|
|
4d51348487 | ||
|
|
0e7e1a54f4 | ||
|
|
b7938df35b | ||
|
|
b622bbb284 | ||
|
|
009c51e245 | ||
|
|
21fe296303 | ||
|
|
2a0200f46d | ||
|
|
f5971fb24f | ||
|
|
76c550ff99 | ||
|
|
2d1fec3cb8 | ||
|
|
097f10ec18 | ||
|
|
b63bad55ac | ||
|
|
94c1c01480 | ||
|
|
21053200f0 | ||
|
|
8d65bdf0bf | ||
|
|
e2e3682ca8 | ||
|
|
fc7f4bfc35 | ||
|
|
e2360f2146 | ||
|
|
910edcaea2 | ||
|
|
ffcce029f5 | ||
|
|
b6987ddb90 | ||
|
|
cbeffb850d | ||
|
|
bbcba6e266 | ||
|
|
9a6429ea97 | ||
|
|
52e5ecf905 | ||
|
|
2912422a31 | ||
|
|
e17da2b6c0 | ||
|
|
b276cea24a | ||
|
|
e7ec7e7364 | ||
|
|
46add74e4f | ||
|
|
4e8cd07303 | ||
|
|
5db8906a86 | ||
|
|
927afe44c7 | ||
|
|
30b07a82c7 | ||
|
|
c80ce3d4f8 | ||
|
|
980035fecc | ||
|
|
6af389655c | ||
|
|
eb5c8fac12 | ||
|
|
3f90a9bad3 | ||
|
|
0280e4f30d | ||
|
|
b92e502fb0 | ||
|
|
8cb8a180b7 | ||
|
|
5c9ab337ea | ||
|
|
f6e18818cd | ||
|
|
880205fc59 | ||
|
|
c073511378 | ||
|
|
275454d479 | ||
|
|
a96b31fb99 | ||
|
|
a19bfd421c | ||
|
|
fbae84ba3c | ||
|
|
e63904a53b | ||
|
|
5a989ec257 | ||
|
|
b90f3d7204 | ||
|
|
03c234edec | ||
|
|
af2b1254e8 | ||
|
|
f218fe17d0 | ||
|
|
a2979a4406 | ||
|
|
e4dd3b4ad1 | ||
|
|
aa713a3a57 | ||
|
|
adc9921108 | ||
|
|
f326b14776 | ||
|
|
afaf1f53ab | ||
|
|
23bb50eb86 | ||
|
|
39824c876d | ||
|
|
ca1a7d0900 | ||
|
|
351eff9b80 | ||
|
|
e037011c55 | ||
|
|
c0ce07a2b9 | ||
|
|
94f11a9155 | ||
|
|
dff234ffbe | ||
|
|
223b4d12dc | ||
|
|
b33fb5e1d3 | ||
|
|
3511e7acb2 | ||
|
|
be4a1234c3 | ||
|
|
823014d4e9 | ||
|
|
310d3290fe | ||
|
|
07765cf8ff | ||
|
|
c4a6585cc7 | ||
|
|
d99d991650 | ||
|
|
6e8682e693 | ||
|
|
87eab76574 | ||
|
|
a011948149 | ||
|
|
b9391d87dc | ||
|
|
7e7f0a0d10 | ||
|
|
8e62d70986 | ||
|
|
02e5fa8535 | ||
|
|
2dc8d061e1 | ||
|
|
efca2acbe2 | ||
|
|
029e5c8a1f | ||
|
|
7383b95864 | ||
|
|
5be99f03f3 | ||
|
|
da522d8d58 | ||
|
|
60ac03df04 | ||
|
|
5a22011fe6 | ||
|
|
09f4908def | ||
|
|
8172db9cdb | ||
|
|
05e7e1e739 | ||
|
|
b2c72775dd | ||
|
|
9eb7457097 | ||
|
|
0b7d1dc06f | ||
|
|
2f9d1bf92e | ||
|
|
de405fe60f | ||
|
|
9bb3ee9bc1 | ||
|
|
5ab6a42808 | ||
|
|
9b5286efca | ||
|
|
78e1067c07 | ||
|
|
c19c4ee217 | ||
|
|
5ef26eaf32 | ||
|
|
16843c9d6a | ||
|
|
a21f5c9d53 | ||
|
|
97cea6cdef | ||
|
|
12c6552259 | ||
|
|
04aaeb4cc1 | ||
|
|
7c3e57aa57 | ||
|
|
f90ba3d6d6 | ||
|
|
4fe2a9333e | ||
|
|
bd59fa5ef5 | ||
|
|
989c729834 | ||
|
|
a57a424067 | ||
|
|
755e4f0cd7 | ||
|
|
a2a0539316 | ||
|
|
e8975d04d6 | ||
|
|
d6eac44275 | ||
|
|
ea39e24727 | ||
|
|
5a290b3ad3 | ||
|
|
ce523c51e7 | ||
|
|
5bcde678d1 | ||
|
|
98e74e53f2 | ||
|
|
bde2da820c | ||
|
|
4b2dd40d99 | ||
|
|
4d7f8d1f50 | ||
|
|
8510ee8224 | ||
|
|
7e8e514e7b | ||
|
|
fb3b0d3a49 | ||
|
|
aaacc6d3fa | ||
|
|
3c199f697c | ||
|
|
5c01c3793c | ||
|
|
0ff901ef32 | ||
|
|
08a5d5f6ff | ||
|
|
1fd00def95 | ||
|
|
21a73fa5e6 | ||
|
|
c9b2c3cc36 | ||
|
|
4ee876c134 | ||
|
|
c1c713ef15 | ||
|
|
b926cd5cf2 | ||
|
|
325343c2cf | ||
|
|
d9502ff348 | ||
|
|
585f8f49a7 | ||
|
|
505db999ad | ||
|
|
d402dd5164 | ||
|
|
a93cc2a08f | ||
|
|
0ac8685c63 | ||
|
|
754b2fc40c | ||
|
|
c32a68ed93 | ||
|
|
c269311ef5 | ||
|
|
04c28a9637 | ||
|
|
6830a78e23 | ||
|
|
3a4921344b | ||
|
|
063b77f3df | ||
|
|
61e5f1fde5 | ||
|
|
4f253bfab1 | ||
|
|
c88ca44ff1 | ||
|
|
2d871f2759 | ||
|
|
2a0bac500c | ||
|
|
cedf4de2ba | ||
|
|
d9e1a7ae98 | ||
|
|
80118d579d | ||
|
|
0299c8be99 | ||
|
|
70a7ec54ea | ||
|
|
4876d37287 | ||
|
|
9894ca78f0 | ||
|
|
99a4b4b4e1 | ||
|
|
1169f5bae9 | ||
|
|
a59619c153 | ||
|
|
c4932c4c8e | ||
|
|
cb31699a6e | ||
|
|
41e6e10241 | ||
|
|
ce5c85f592 | ||
|
|
2614514bc6 | ||
|
|
a50c261f13 | ||
|
|
b474193f97 | ||
|
|
27a4f2e8ff | ||
|
|
17024f814a | ||
|
|
d18cc23149 | ||
|
|
047111ffbb | ||
|
|
8c5dbb4197 | ||
|
|
7a6cbab8af | ||
|
|
add6f179a4 | ||
|
|
29d8c41395 | ||
|
|
0ecfa248ee | ||
|
|
748c7c5de1 | ||
|
|
2b5be510b7 | ||
|
|
99ef4e6899 | ||
|
|
59b86a882e | ||
|
|
a6a729189c | ||
|
|
7829e9a3ba | ||
|
|
a2e27f3ced | ||
|
|
9175ce3646 | ||
|
|
0dbdf401eb | ||
|
|
db84078093 | ||
|
|
a0ab8069d2 | ||
|
|
ea068dc23e | ||
|
|
26756cc687 | ||
|
|
171a4e3b48 | ||
|
|
bfd5aef4d9 | ||
|
|
b3c2a48eec | ||
|
|
848cc9e43f | ||
|
|
f61230ff35 | ||
|
|
268740a142 | ||
|
|
abf8d32ff5 | ||
|
|
b769aec7e8 | ||
|
|
84832f011a | ||
|
|
b1dc766ffb | ||
|
|
3274b35468 | ||
|
|
ad76e2d3ce | ||
|
|
aec78d147a | ||
|
|
9333111a99 | ||
|
|
1f0d192fd5 | ||
|
|
21f04259cb | ||
|
|
a62eb20f26 | ||
|
|
b813a4af9d | ||
|
|
09ca411903 | ||
|
|
59c256f8cc | ||
|
|
c0e2a8a6fb | ||
|
|
c15278d3c6 | ||
|
|
7cf3c67432 | ||
|
|
1ef3796709 | ||
|
|
d3527f2aee | ||
|
|
31b3dff98e | ||
|
|
1b9cf02f1a | ||
|
|
a9963c2f43 | ||
|
|
b9bdf92065 | ||
|
|
b66b8f1880 | ||
|
|
356b211754 | ||
|
|
833389c1fc | ||
|
|
f5e30cdff3 | ||
|
|
50f6cd743a | ||
|
|
c4c6e861b5 | ||
|
|
cac293b75b | ||
|
|
2c39ba9d9e | ||
|
|
a1602fa57a | ||
|
|
f6f1adc9f4 | ||
|
|
9fbbba817e | ||
|
|
d973a8a2ca | ||
|
|
70cc044e57 | ||
|
|
f7498e5ba2 | ||
|
|
e103599984 | ||
|
|
40abe0b311 | ||
|
|
8b9e514ce9 | ||
|
|
582f3a6ae4 | ||
|
|
5589c0a80f | ||
|
|
71c67ad637 | ||
|
|
1c7aff0d5b | ||
|
|
9b359abcce | ||
|
|
67de1109fe | ||
|
|
3489567cde | ||
|
|
d69e6c3501 | ||
|
|
01dec5160e | ||
|
|
90c80c9946 | ||
|
|
0b9ca610b7 | ||
|
|
2a2782bc95 | ||
|
|
3b6b02ed19 | ||
|
|
443ae7f40b | ||
|
|
4c91ac44e1 | ||
|
|
42dc98fa45 | ||
|
|
e88bec86fb | ||
|
|
20c8ef7383 | ||
|
|
ff09db6244 | ||
|
|
c15a00d603 | ||
|
|
b18cf31bdc | ||
|
|
3ee1a96bde | ||
|
|
8c470d0e4e | ||
|
|
0826102f6e | ||
|
|
2ff52a0359 | ||
|
|
8d70999498 | ||
|
|
f6a4581dad | ||
|
|
2d74977ed3 | ||
|
|
8b06724b67 | ||
|
|
c4755fa920 | ||
|
|
36f146a71d | ||
|
|
a5515d8520 | ||
|
|
33514c6e25 | ||
|
|
d8a582aaa5 | ||
|
|
a428d33964 | ||
|
|
33639a8955 | ||
|
|
4021f07aec | ||
|
|
67bd456b63 | ||
|
|
78cc5877a3 | ||
|
|
c4bc505e03 | ||
|
|
ad372590a9 | ||
|
|
2771d526be | ||
|
|
263ec4d266 | ||
|
|
f86876328e | ||
|
|
e47167b17e | ||
|
|
5c32cf69e6 | ||
|
|
fd46017c45 | ||
|
|
95d3cca660 | ||
|
|
1fcb5560f0 | ||
|
|
06b3dc27d2 | ||
|
|
d2b683368c | ||
|
|
6b397ccd3f | ||
|
|
32006fe8e1 | ||
|
|
911e157322 | ||
|
|
100b4062bf | ||
|
|
b27e2b7a1c | ||
|
|
7085009667 | ||
|
|
4b6ceab070 | ||
|
|
49af472617 | ||
|
|
d7aeb0d3b3 | ||
|
|
35f8b775f9 | ||
|
|
079df0d6a6 | ||
|
|
36442cb3a2 | ||
|
|
217cd9183e | ||
|
|
e9c2eaebce | ||
|
|
954c790827 | ||
|
|
2d2b476688 | ||
|
|
ab58217cab | ||
|
|
6f0b05c1f6 | ||
|
|
58d35098c0 | ||
|
|
44e71e58e6 | ||
|
|
0e516bc407 | ||
|
|
9fc7b9d755 | ||
|
|
e1ff8fb520 | ||
|
|
adf308ac9f | ||
|
|
7b6abe858c | ||
|
|
6937346e5a | ||
|
|
fc1a354072 | ||
|
|
82464e4129 | ||
|
|
1a126ad743 | ||
|
|
9558194bf7 | ||
|
|
8aa2f0c018 | ||
|
|
d6de310363 | ||
|
|
1fdddb25ec | ||
|
|
a78f6f5ce3 | ||
|
|
2528727d81 | ||
|
|
97bb4dd956 | ||
|
|
3c6819c9e7 | ||
|
|
bb2fc15c07 | ||
|
|
fb129df215 | ||
|
|
050ccb921e | ||
|
|
9b9aae4c0e | ||
|
|
263569b163 | ||
|
|
2702232eea | ||
|
|
8f802b4d38 | ||
|
|
00b4aabb32 | ||
|
|
8ea84dca94 | ||
|
|
bc45b779ef | ||
|
|
c72286623f | ||
|
|
1d3c97fae5 | ||
|
|
17e22b8b93 | ||
|
|
e32714ebaf | ||
|
|
acd81548e6 | ||
|
|
2d71860fa1 | ||
|
|
6930924cc1 | ||
|
|
e5ce253daf | ||
|
|
b9cfe0e300 | ||
|
|
af91c8c9e3 | ||
|
|
08e50e3643 | ||
|
|
885001ec97 | ||
|
|
b2dae09cf1 | ||
|
|
c55d94d969 | ||
|
|
7d035f5604 | ||
|
|
a5f49080a7 | ||
|
|
8a52a85ca3 | ||
|
|
2bfdc99f89 | ||
|
|
e5da621736 | ||
|
|
359f6c455c | ||
|
|
e929af5c99 | ||
|
|
191e61400d | ||
|
|
d311b40faa | ||
|
|
7c7fc41b2c | ||
|
|
5ab0f84d41 | ||
|
|
5917b190eb | ||
|
|
828c7b814c | ||
|
|
6d3f1bbee8 | ||
|
|
85954ab7cc | ||
|
|
822b2f4ae7 | ||
|
|
ed6911a8dc | ||
|
|
d8cfe15373 | ||
|
|
dc4d6d2671 | ||
|
|
eb6b30864f | ||
|
|
1618bb1266 | ||
|
|
54e5aa28bd | ||
|
|
4e72e2ea50 | ||
|
|
134608625f | ||
|
|
11fbaa6be8 | ||
|
|
377dd08c96 | ||
|
|
6a9dfe6dd1 | ||
|
|
59da4e4f99 | ||
|
|
6d917f82d7 | ||
|
|
15780467eb | ||
|
|
1a53871517 | ||
|
|
ef0af3a494 | ||
|
|
ab854befd1 | ||
|
|
3d522c4f2e | ||
|
|
7908da3721 | ||
|
|
f9a3354741 | ||
|
|
fbf4d3b355 | ||
|
|
f1839bcfbf | ||
|
|
895af2d7ad | ||
|
|
b5510da291 | ||
|
|
3083738d00 | ||
|
|
ea84fe82dd | ||
|
|
ea8b7507e4 | ||
|
|
7dfd170983 | ||
|
|
06c7e709e6 | ||
|
|
e0f10670d7 | ||
|
|
b33d2dff4a | ||
|
|
2f1b9ebe0c | ||
|
|
9b024bda31 | ||
|
|
606dcf17c9 | ||
|
|
2fb0c29705 | ||
|
|
d673a7006b | ||
|
|
fbe3a795df | ||
|
|
0c614c3f7e | ||
|
|
ce4bebb8b4 | ||
|
|
a8b4a581e4 | ||
|
|
7d5cc80898 | ||
|
|
7fa6003156 | ||
|
|
87cdff17b8 | ||
|
|
4fbe3a3340 | ||
|
|
c2a8894d9f | ||
|
|
4bb7e224d7 | ||
|
|
c24efff92f | ||
|
|
9dc73c29ff | ||
|
|
c2d599b546 | ||
|
|
574d1d1d69 | ||
|
|
f3ff95cc57 | ||
|
|
4a2f4fdadc | ||
|
|
c2bd39a65f | ||
|
|
529b141f1b | ||
|
|
de643ee51b | ||
|
|
1dd59bc2b6 | ||
|
|
8b80bd4119 | ||
|
|
0ca0ea0e93 | ||
|
|
4f15b2f273 | ||
|
|
63e2384907 | ||
|
|
e9b66ff7fd | ||
|
|
0eff48267f | ||
|
|
4c9f247cf6 | ||
|
|
5aa7552232 | ||
|
|
0dd1a7308a | ||
|
|
315aeafd24 | ||
|
|
74fd2dcf5e | ||
|
|
da66ab2fc6 | ||
|
|
4835fba6dc | ||
|
|
c28b663f56 | ||
|
|
cc2ece53ee | ||
|
|
ccc23e6e52 | ||
|
|
cee5c16aa1 | ||
|
|
84db98515d | ||
|
|
4a24df3395 | ||
|
|
f21054f17f | ||
|
|
e12824008f | ||
|
|
0c24e04b8c | ||
|
|
e66aae1209 | ||
|
|
e652a1eced | ||
|
|
bbfbd43105 | ||
|
|
62bc9643fd | ||
|
|
a36bbb2ffa | ||
|
|
c58e2403ca | ||
|
|
926327156d | ||
|
|
45a7f9ae65 | ||
|
|
26536b965c | ||
|
|
896de29721 | ||
|
|
fdafd95584 | ||
|
|
e0f31775e5 | ||
|
|
67a67aef88 | ||
|
|
0c28152d43 | ||
|
|
a361b3ba41 | ||
|
|
20fe99a820 | ||
|
|
d380d5dd0e | ||
|
|
7df8b05e00 | ||
|
|
7708fa86bd | ||
|
|
ccb865821d | ||
|
|
7ff10f5510 | ||
|
|
fce9f83459 | ||
|
|
74609b0705 | ||
|
|
5efc10ede6 | ||
|
|
99bab479e0 | ||
|
|
70f11046f1 | ||
|
|
6094bddf6a | ||
|
|
490d571f3c | ||
|
|
18dc66f319 | ||
|
|
ba174ed435 | ||
|
|
33723056ec | ||
|
|
5f9c615ac5 | ||
|
|
76b3776d79 | ||
|
|
b3f2cddd2d | ||
|
|
d9dac047a0 | ||
|
|
a863377597 | ||
|
|
5420cd415b | ||
|
|
1d79d7fe00 | ||
|
|
fa6bc246de | ||
|
|
c9d5f9f7fa | ||
|
|
b0df2d6898 | ||
|
|
a0f4c6b6cd | ||
|
|
309775cce2 | ||
|
|
12dcdfbf4a | ||
|
|
1fc7042545 | ||
|
|
9089aba931 | ||
|
|
b12c6102c6 | ||
|
|
5548148150 | ||
|
|
aff6e4acb0 | ||
|
|
1e88014f38 | ||
|
|
5be74723d5 | ||
|
|
f0ee5ee2ae | ||
|
|
1daa34f604 | ||
|
|
258b774098 | ||
|
|
1b6f9e4f09 | ||
|
|
2598263cab | ||
|
|
98314a9a26 | ||
|
|
20074845de | ||
|
|
d0b1c816eb | ||
|
|
5f1a59f7b3 | ||
|
|
c7644e214d | ||
|
|
b6d026a2b1 | ||
|
|
23c6cb33a3 | ||
|
|
e68e8ef165 | ||
|
|
912b805777 | ||
|
|
cacdf09649 | ||
|
|
b32320d59c | ||
|
|
0e2cc687d4 | ||
|
|
1bc82611e9 | ||
|
|
6bb27b4e6f | ||
|
|
3a6d5b3e34 | ||
|
|
33ccc85fbf | ||
|
|
a21a7dbeda | ||
|
|
718dc2c691 | ||
|
|
7ca5a92e9f | ||
|
|
7a9ecfe329 | ||
|
|
ffd299f369 | ||
|
|
fbf2f1f063 | ||
|
|
66c1ca83d9 | ||
|
|
a7e542b7b2 | ||
|
|
90d73053f1 | ||
|
|
900f259620 | ||
|
|
b7367f89e2 | ||
|
|
35ba1d2ca5 | ||
|
|
cf072514a4 | ||
|
|
6133547617 | ||
|
|
d62bf49eb9 | ||
|
|
7b32dde0cf | ||
|
|
8c7e53aef8 | ||
|
|
2d9e9091b4 | ||
|
|
8363650304 | ||
|
|
21d86327b9 | ||
|
|
1e871813f8 | ||
|
|
822fb85f1c | ||
|
|
ae0e931588 | ||
|
|
6bb91df75f | ||
|
|
9c1c180619 | ||
|
|
4b27fe0f82 | ||
|
|
aab5869dc9 | ||
|
|
c6b50b2ccf | ||
|
|
ba8296ef50 | ||
|
|
5b5df2e51e | ||
|
|
73c039147e | ||
|
|
51ef78c2af | ||
|
|
42423bdf61 | ||
|
|
35e2769ba3 | ||
|
|
dc3e56d44b | ||
|
|
4d59b4b463 | ||
|
|
ae9f1f8b0d | ||
|
|
e30b9ed175 | ||
|
|
d06dbcb99d | ||
|
|
70af65c51f | ||
|
|
d288f3319a | ||
|
|
96b1ee7b66 | ||
|
|
89b37e778a | ||
|
|
a72b859f05 | ||
|
|
9d1c463247 | ||
|
|
62c7bed6bb | ||
|
|
3338ea6510 | ||
|
|
b6c015e3da | ||
|
|
547b05dcae | ||
|
|
ee7a2e692b | ||
|
|
d52161b44b | ||
|
|
9b5bf06a29 | ||
|
|
189942c45a | ||
|
|
8c68731283 | ||
|
|
ec24787b9b | ||
|
|
c220b8be8e | ||
|
|
550d8cc457 | ||
|
|
2de52008a2 | ||
|
|
4354742d3f | ||
|
|
5e1bf39839 | ||
|
|
74ab043a30 | ||
|
|
2abe265046 | ||
|
|
d4f3cd4e6c | ||
|
|
0c5a50c34e | ||
|
|
dfab29bbbc | ||
|
|
4bbbab7fa1 | ||
|
|
4c0b5d1d58 | ||
|
|
9f99e784f4 | ||
|
|
8c673d5242 | ||
|
|
6852d7e14d | ||
|
|
204808032e | ||
|
|
8952cdb813 | ||
|
|
bab3eb1e9f | ||
|
|
900bdfb199 | ||
|
|
097f404d94 | ||
|
|
f796523e3b | ||
|
|
44477d7b1a | ||
|
|
f3592d1d60 | ||
|
|
b935e20d63 | ||
|
|
0e4c8415a1 | ||
|
|
b342f14bf9 | ||
|
|
b8489059f3 | ||
|
|
ebfd86bcb9 | ||
|
|
1e37ca57bc | ||
|
|
eabd501dee | ||
|
|
252de7a589 | ||
|
|
9be2c1df5a | ||
|
|
a35c78d796 | ||
|
|
43a8a759af | ||
|
|
92abebaffa | ||
|
|
3b9b149555 | ||
|
|
8b3433b4ca | ||
|
|
b02e034621 | ||
|
|
196e484448 | ||
|
|
0cd3952d52 | ||
|
|
3640508db9 | ||
|
|
e75dcaf282 | ||
|
|
a7b6b94612 | ||
|
|
6ea0ea4619 | ||
|
|
0c71e9cf5f | ||
|
|
348fbb237b | ||
|
|
e2808ced1a | ||
|
|
30b481597b | ||
|
|
c31685b062 | ||
|
|
8dbe489d60 | ||
|
|
b11ffe9afa | ||
|
|
789da461f9 | ||
|
|
fb3d1752dc | ||
|
|
5219afb13f | ||
|
|
71e0888c65 | ||
|
|
e5456c8ca3 | ||
|
|
9987ba2fbb | ||
|
|
8ea51c3e41 | ||
|
|
fbd934d750 | ||
|
|
63d06ee32a | ||
|
|
561f1deb07 | ||
|
|
584d84475a | ||
|
|
c9e56723fb | ||
|
|
b5b6e42c2d | ||
|
|
14e56cd04f | ||
|
|
ecfbdd29a7 | ||
|
|
321a87c407 | ||
|
|
4403fa2238 | ||
|
|
f2623f0253 | ||
|
|
36f16d9f9b | ||
|
|
097d5e3f98 | ||
|
|
87267638f7 | ||
|
|
6bcc9b0cc5 | ||
|
|
40fc845461 | ||
|
|
18017c73ce | ||
|
|
9e69ae6ec0 | ||
|
|
0cb4bbcfe2 | ||
|
|
6a2f5657a5 | ||
|
|
624d84be41 | ||
|
|
8045da1f96 | ||
|
|
482f428a17 | ||
|
|
663908d17e | ||
|
|
3c61ff6895 | ||
|
|
be504fc15c | ||
|
|
60c2bd4808 | ||
|
|
03c584a5c3 | ||
|
|
3570c48420 | ||
|
|
aac5df9748 | ||
|
|
884fa08355 | ||
|
|
59dbc520b9 | ||
|
|
8075bf14ec | ||
|
|
8e0d993596 | ||
|
|
6c87713751 | ||
|
|
43ecd35402 | ||
|
|
424c89e2c8 | ||
|
|
744ccef561 | ||
|
|
7f877ee636 | ||
|
|
542d6dc061 | ||
|
|
bd1bdc03c2 | ||
|
|
b0be55ef2e | ||
|
|
6f332d8c2d | ||
|
|
cf2f488aaf | ||
|
|
8c91df4319 | ||
|
|
807eba8d98 | ||
|
|
71fb3b1313 | ||
|
|
2b9f56b456 | ||
|
|
9266e5c207 | ||
|
|
ffe2adc4c2 | ||
|
|
9b6fa32db5 | ||
|
|
fdea5e302f | ||
|
|
4fbd276fff | ||
|
|
90536704a3 | ||
|
|
f8787c67df | ||
|
|
2cea3a6585 | ||
|
|
61edffd0b6 | ||
|
|
6494ca8c26 | ||
|
|
480448ff79 | ||
|
|
8139f91db2 | ||
|
|
3d7f424b28 | ||
|
|
9e514447e6 | ||
|
|
06ed4c8da7 | ||
|
|
072c3f8be9 | ||
|
|
37b158e99b | ||
|
|
a327c0b321 | ||
|
|
d57dc285df | ||
|
|
e29cfc7b88 | ||
|
|
08c7a4033e | ||
|
|
b9dd2b7cbc | ||
|
|
e45eccd64c | ||
|
|
c93411e330 | ||
|
|
550e2d30cc | ||
|
|
48e4cfadfd | ||
|
|
c43e41c076 | ||
|
|
45af81513c | ||
|
|
5244b5c8f0 | ||
|
|
ff36b2fef1 | ||
|
|
b27997db2c | ||
|
|
b0b9a621a1 | ||
|
|
f851e70d28 | ||
|
|
09a5d1520d | ||
|
|
7ba097e34c | ||
|
|
2141e1f4da | ||
|
|
9c0fd7b027 | ||
|
|
fa9289293b | ||
|
|
3092424315 | ||
|
|
2abe327e90 | ||
|
|
47ebc2f694 | ||
|
|
9312036f69 | ||
|
|
5732b8456f | ||
|
|
7fd01b7e40 | ||
|
|
fc72dfc870 | ||
|
|
24dc14c44a | ||
|
|
1e291d4180 | ||
|
|
1af3310e0b | ||
|
|
e90c1838f1 | ||
|
|
88932bfc67 | ||
|
|
17205d39d2 | ||
|
|
9512104536 | ||
|
|
4eabdc489f | ||
|
|
fd9865b368 | ||
|
|
c5a8dca36f | ||
|
|
49d0a2aaf0 | ||
|
|
b5b941bf73 | ||
|
|
6cb39bd6ee | ||
|
|
b6b1292589 | ||
|
|
2d4e4e71ab | ||
|
|
8194d416c6 |
15
.env.example
15
.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.
|
||||
@@ -26,6 +27,16 @@ APP_URL=https://example.com
|
||||
# Comment it to use default timezone from php.ini
|
||||
#APP_TIMEZONE=Europe/London
|
||||
|
||||
# Comma separated list of trusted proxies for proper IP detection in FreeScout.
|
||||
# To trust all proxies that connect to your server use single asterisk: *
|
||||
# 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
|
||||
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: FreeScout
|
||||
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
|
||||
13
.github/ISSUE_TEMPLATE/general_help_request.md
vendored
13
.github/ISSUE_TEMPLATE/general_help_request.md
vendored
@@ -3,18 +3,23 @@ name: General Help Request
|
||||
about: Create a general help request
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
* If you have some error the first thing you should do is to check "Manage » Logs » App Logs"! If there are any errors there - make sure to provide them in the issue.
|
||||
* Have you read FAQ? https://github.com/freescout-helpdesk/freescout/wiki/FAQ
|
||||
* If you have "Whoops something went wrong" error see: https://github.com/freescout-helpdesk/freescout/wiki/Installation-Guide#11-troubleshooting
|
||||
* Before creating an issue try to disable all non-official modules and remove all custom code from your FreeScout and see if the issue still persists.
|
||||
* Feature requests for modules should be posted here: https://freescout.net/request-feature/
|
||||
* Docker questions should go here: https://github.com/tiredofit/docker-freescout/issues
|
||||
* Create separate issues for different topics - no need to mix all your problems into one issue.
|
||||
* Questions on issues with payments for modules should be sent by email: https://freescout.net/contact-us/
|
||||
* Don't forget that you are not the project manager. Try to be humble, polite, friendly and positive. Otherwise your account may be banned.
|
||||
* Mentioning how much you've spent on modules is the way to be banned.
|
||||
* Don't forget that you are not the project manager. Try to be humble, polite, friendly and positive. Otherwise your account may be banned (and it can't be undone).
|
||||
* Mentioning how much you've spent on modules is also the way to be banned.
|
||||
|
||||
Still here? Well clean this out and go ahead :)
|
||||
-->
|
||||
|
||||
PHP version:
|
||||
FreeScout version:
|
||||
FreeScout version:
|
||||
Database: MySQL / PostgreSQL
|
||||
Are you using CloudFlare: Yes / No
|
||||
Are you using non-official modules: Yes / No
|
||||
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...
|
||||
23
.github/workflows/phpcs.yml
vendored
Normal file
23
.github/workflows/phpcs.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: PHP Code Sniffer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Lint PHP
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
tools: phpcs
|
||||
|
||||
- name: Run check
|
||||
run: phpcs
|
||||
60
.github/workflows/test-pgsql.yml
vendored
Normal file
60
.github/workflows/test-pgsql.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Test App (PostgreSQL)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test App (PostgreSQL)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DB_CONNECTION: testing_pgsql
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: freescout-test
|
||||
POSTGRES_PASSWORD: freescout-test
|
||||
POSTGRES_DB: freescout-test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: pgsql, mbstring, xml, imap, zip, gd, curl, intl, json
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --ignore-platform-reqs --no-interaction
|
||||
|
||||
- name: Migrate and seed the database
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=testing_pgsql
|
||||
php${{ matrix.php }} artisan db:seed --force -n --database=testing_pgsql
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
|
||||
- name: Run PHP tests
|
||||
if: ${{ matrix.php < 8.4 }}
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
46
.github/workflows/test.yml
vendored
Normal file
46
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Test App (MySQL)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test App (MySQL)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mysql, mbstring, xml, imap, zip, gd, curl, intl, json
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Setup database
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `freescout-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'freescout-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'freescout-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`freescout-test\`.* TO 'freescout-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --ignore-platform-reqs --no-interaction
|
||||
|
||||
- name: Migrate and seed the database
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --database=testing
|
||||
|
||||
- name: Run PHP tests
|
||||
if: ${{ matrix.php < 8.4 }}
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -14,7 +14,7 @@ Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.env
|
||||
|
||||
app/Console/Commands/Test*
|
||||
/bootstrap/compiled.php
|
||||
composer.phar
|
||||
#composer.lock
|
||||
@@ -28,8 +28,12 @@ Thumbs.db
|
||||
/Modules/**/.git
|
||||
/public/modules
|
||||
/public/docs
|
||||
/public/.dh-diag
|
||||
/storage/.ignore_locales
|
||||
/storage/.installed
|
||||
/tools
|
||||
.well-known
|
||||
/resources/lang/module.*
|
||||
/resources/lang/module.*
|
||||
.phpunit.result.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
|
||||
113
README.md
113
README.md
@@ -2,95 +2,99 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/freescout-helpdesk/freescout/master/public/img/logo-300.png" width="180" height="180" />
|
||||
<br/><br/>
|
||||
|
||||
[](https://github.com/freescout-helpdesk/freescout#requirements) [](https://hits.seeyoufarm.com)
|
||||
<img src="https://raw.githubusercontent.com/freescout-help-desk/freescout/master/public/img/logo-300.png" width="180" height="180" />
|
||||
|
||||
</div>
|
||||
|
||||
**FreeScout** is the super lightweight free open source help desk and shared inbox written in PHP (Laravel framework) – self hosted clone of HelpScout. 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!
|
||||
|
||||

|
||||
|
||||
## Table of Contents
|
||||
* [Features](#features)
|
||||
* [Demo](#demo)
|
||||
* [Features](#features)
|
||||
* [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)
|
||||
* [Contributing](#contributing)
|
||||
* [Screenshots](#screenshots)
|
||||
|
||||
## Features
|
||||
|
||||
* No limitations on the number of users, tickets, mailboxes, etc.
|
||||
* 100% Mobile-friendly.
|
||||
* Multilingual (English, Croatian, Czech, Danish, Dutch, Finnish, French, German, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese, Russian, Spanish, Slovak, Swedish).
|
||||
* Seamless email integration.
|
||||
* Supports modern Microsoft Exchange authentication.
|
||||
* Web installer & updater.
|
||||
* Starred conversations.
|
||||
* Forwarding conversations.
|
||||
* Merging conversations.
|
||||
* Moving conversations between mailboxes.
|
||||
* 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.
|
||||
* Open tracking.
|
||||
* Editing threads.
|
||||
* Search.
|
||||
|
||||
Need anything else? Suggest features [here](https://freescout.net/request-feature/).
|
||||
|
||||
## Demo
|
||||
|
||||
**[Live Demo](https://demo.freescout.net)**
|
||||
|
||||
## Features
|
||||
|
||||
* 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.
|
||||
* Native [S3 storage](https://github.com/freescout-help-desk/S3Storage) support.
|
||||
* Fully supports screen readers (for visually impaired).
|
||||
* Web installer & updater.
|
||||
* Starred conversations.
|
||||
* 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.
|
||||
* Automatic refreshing of the conversations list without the need to reload the page.
|
||||
* Pasting screenshots from the clipboard into the reply area.
|
||||
* Flexible notifications for user agents.
|
||||
* Open tracking.
|
||||
* Powerful search.
|
||||
|
||||
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
|
||||
|
||||
Mobile apps support the same functionality and modules as the web version of your FreeScout installation. Both support agents and administrators can use mobile apps.
|
||||
|
||||
<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)
|
||||
|
||||
## Requirements
|
||||
|
||||
FreeScout is a pure PHP/MySQL application, so it can be easily deployed even on a shared hosting.
|
||||
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.2
|
||||
* PHP 7.4 - 8.x
|
||||
* MySQL 5.0+ / MariaDB 5.0+ / PostgreSQL
|
||||
|
||||
There is no minimum system requirements (CPU / RAM) – FreeScout will run on any system.
|
||||
There are no minimum system requirements (CPU / RAM) – FreeScout will run on any system.
|
||||
|
||||
## Installation
|
||||
|
||||
[Installation Guide](https://github.com/freescout-helpdesk/freescout/wiki/Installation-Guide)
|
||||
[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-helpdesk/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
|
||||
|
||||
@@ -108,14 +112,14 @@ Images & one-click installs:
|
||||
|
||||
Don't miss news, updates and new modules!
|
||||
|
||||
[Email Newsletter](https://freescout.net/subscribe/) | [Facebook](https://freescout.net/facebook/) | [Twitter](https://freescout.net/twitter/) | [YouTube](https://freescout.net/youtube/) | [RSS](https://freescout.net/feed/)
|
||||
[Email Newsletter](https://freescout.net/subscribe/) | [Facebook](https://freescout.net/facebook/) | [Twitter](https://freescout.net/twitter/) | [YouTube](https://freescout.net/youtube/) | [Telegram](https://freescout.net/telegram/) | [RSS](https://freescout.net/feed/)
|
||||
|
||||
## Contributing
|
||||
|
||||
* [Support the project by leaving a feedback](https://github.com/freescout-helpdesk/freescout/issues/288)
|
||||
* [Development Guide](https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide)
|
||||
* [Todo list](https://github.com/freescout-helpdesk/freescout/labels/help%20wanted)
|
||||
* [Translate](https://github.com/freescout-helpdesk/freescout/wiki/Translate)
|
||||
* [Support the project by leaving a feedback](https://github.com/freescout-help-desk/freescout/issues/288)
|
||||
* [Development Guide](https://github.com/freescout-help-desk/freescout/wiki/Development-Guide)
|
||||
* [Todo list](https://github.com/freescout-help-desk/freescout/labels/help%20wanted)
|
||||
* [Translate](https://github.com/freescout-help-desk/freescout/wiki/Translate)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -127,10 +131,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 `APP_LIMIT_USER_CUSTOMER_VISIBILITY=true` mode, 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,13 +78,13 @@ 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.
|
||||
$file_name = preg_replace('/[ #\/]/', '-', $file_name);
|
||||
$file_name = preg_replace('/[ #\/]/', '_', $file_name);
|
||||
// Replace soft hyphens.
|
||||
$file_name = str_replace(html_entity_decode('­'), '-', $file_name);
|
||||
$file_name = str_replace(html_entity_decode('­'), '_', $file_name);
|
||||
|
||||
if (!$file_name) {
|
||||
if (!$orig_extension) {
|
||||
@@ -97,6 +105,11 @@ class Attachment extends Model
|
||||
$file_name = 'RFC822.eml';
|
||||
}
|
||||
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/1412#issuecomment-1658881493
|
||||
if ($file_name == 'undefined' && $mime_type == 'text/calendar') {
|
||||
$file_name = 'calendar.ics';
|
||||
}
|
||||
|
||||
if (strlen($file_name) > 255) {
|
||||
$without_ext = pathinfo($file_name, PATHINFO_FILENAME);
|
||||
$extension = pathinfo($file_name, PATHINFO_EXTENSION);
|
||||
@@ -116,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;
|
||||
@@ -129,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)
|
||||
{
|
||||
@@ -141,17 +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;
|
||||
|
||||
if ($uploaded_file) {
|
||||
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]);
|
||||
} else {
|
||||
Storage::disk(self::DISK)->put($file_path, $content);
|
||||
try {
|
||||
if ($uploaded_file) {
|
||||
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, self::getDiskName());
|
||||
} else {
|
||||
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,
|
||||
@@ -243,7 +265,18 @@ class Attachment extends Model
|
||||
*/
|
||||
public function url()
|
||||
{
|
||||
return Storage::url($this->getStorageFilePath()).'?id='.$this->id.'&token='.$this->getToken();
|
||||
$file_path = $this->getStorageFilePath();
|
||||
|
||||
// URL must contain only forward slashes.
|
||||
$file_path = str_replace(DIRECTORY_SEPARATOR, '/', $file_path);
|
||||
|
||||
$file_url = self::getDisk()->url($file_path);
|
||||
|
||||
// Fix percents.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3530
|
||||
$file_url = str_replace('%', '%25', $file_url);
|
||||
|
||||
return $file_url.'?id='.$this->id.'&token='.$this->getToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,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'] = '';
|
||||
}
|
||||
@@ -272,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,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;
|
||||
}
|
||||
@@ -314,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)
|
||||
@@ -365,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
|
||||
@@ -406,20 +443,31 @@ class Attachment extends Model
|
||||
*/
|
||||
public function duplicate($thread_id = null)
|
||||
{
|
||||
$new_attachment = $this->replicate();
|
||||
if ($thread_id) {
|
||||
$new_attachment->thread_id = $thread_id;
|
||||
if (!$this->fileExists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$new_attachment = $this->replicate();
|
||||
|
||||
$new_attachment->thread_id = $thread_id;
|
||||
|
||||
$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'];
|
||||
@@ -434,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');
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Console/Commands/CleanNotificationsTable.php
Normal file
48
app/Console/Commands/CleanNotificationsTable.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanNotificationsTable extends Command
|
||||
{
|
||||
const PERIOD = '-6 months';
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'freescout:clean-notifications-table';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete old read records from notifications table.';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
\DB::table('notifications')->where('created_at', '<', \Carbon\Carbon::now()->modify(self::PERIOD))
|
||||
->whereNotNull('read_at')
|
||||
->delete();
|
||||
|
||||
$this->info('['.date('Y-m-d H:i:s').'] Deleted old read notifications for: '.self::PERIOD);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +39,14 @@ class CleanTmp extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
shell_exec('find '.\Helper::getTempDir().' -mtime +7 -type f -name '.\Helper::getTempFilePrefix().'* -exec rm -r -f {} \;');
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ class ClearCache extends Command
|
||||
{
|
||||
$this->call('clear-compiled');
|
||||
$this->call('cache:clear');
|
||||
|
||||
// Remove files from /bootstrap/cache folder.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4536
|
||||
$files = new \Illuminate\Filesystem\Filesystem;
|
||||
$files->delete($this->laravel->getCachedServicesPath());
|
||||
$files->delete($this->laravel->getCachedPackagesPath());
|
||||
|
||||
$this->call('view:clear');
|
||||
if ($this->option('doNotCacheConfig')) {
|
||||
$this->call('config:clear');
|
||||
@@ -50,10 +57,26 @@ class ClearCache extends Command
|
||||
opcache_invalidate(app()->getCachedConfigPath());
|
||||
}
|
||||
}
|
||||
|
||||
// In FreeScout routes caching does not increase performance
|
||||
// but it increases memory consumption and may lead to problems
|
||||
// during application updating or installing/updating modules.
|
||||
// try {
|
||||
// $this->call('route:cache');
|
||||
// } catch (\Exception $e) {
|
||||
// // 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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,24 +49,44 @@ 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();
|
||||
|
||||
//$filesystem->put($file_path, $content);
|
||||
// Save vars only if content changed
|
||||
// Escape quotes in json values.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4369
|
||||
$content = preg_replace_callback(
|
||||
"#(:[ ]*\")(.*)(\"[,\r\n])#",
|
||||
function ($v) {
|
||||
return $v[1].str_replace('"', '\"', $v[2]).$v[3];
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class ModuleInstall extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install module or all modules (if module_alias is empty)';
|
||||
protected $description = 'Install module or all modules (if module_alias is empty): run migrations and create a symlink';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
@@ -86,25 +86,53 @@ class ModuleInstall extends Command
|
||||
$this->call('freescout:clear-cache');
|
||||
}
|
||||
|
||||
// There is similar function in \App\Module.
|
||||
public function createModulePublicSymlink($module)
|
||||
{
|
||||
$from = public_path('modules').DIRECTORY_SEPARATOR.$module->alias;
|
||||
$to = $module->getExtraPath('Public');
|
||||
|
||||
// Symlimk may exist but lead to the module folder in a wrong case.
|
||||
// So we need first try to remove it.
|
||||
if (!file_exists($from)) {
|
||||
@unlink($from);
|
||||
}
|
||||
|
||||
if (file_exists($from)) {
|
||||
return $this->info('Public symlink already exists');
|
||||
}
|
||||
|
||||
// file_exists() may throw "open_basedir restriction in effect".
|
||||
try {
|
||||
symlink($to, $from);
|
||||
// If module's Public is symlink.
|
||||
if (is_link($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
|
||||
// Symlimk may exist but lead to the module folder in a wrong case.
|
||||
// So we need first try to remove it.
|
||||
if (!file_exists($from) || !is_link($from)) {
|
||||
if (is_dir($from)) {
|
||||
@rename($from, $from.'_'.date('YmdHis'));
|
||||
} else {
|
||||
@unlink($from);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($from)) {
|
||||
return $this->info('Public symlink already exists');
|
||||
}
|
||||
|
||||
// Check target.
|
||||
if (!file_exists($to)) {
|
||||
// Try to create Public folder.
|
||||
try {
|
||||
\File::makeDirectory($to, \Helper::DIR_PERMISSIONS);
|
||||
} catch (\Exception $e) {
|
||||
// If it's a broken symlink.
|
||||
if (is_link($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
symlink($to, $from);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error occurred creating ['.$from.'] symlink: '.$e->getMessage());
|
||||
$this->error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->info('The ['.$from.'] symlink has been created');
|
||||
|
||||
@@ -21,6 +21,8 @@ class ModuleLaroute extends Command
|
||||
*/
|
||||
protected $description = 'Generate a laravel routes JS-file for a module or all modules (if module_alias is empty)';
|
||||
|
||||
public $routes;
|
||||
|
||||
public $config;
|
||||
|
||||
public $generator;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
169
app/Console/Commands/ModuleUpdate.php
Normal file
169
app/Console/Commands/ModuleUpdate.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* php artisan freescout:module-install modulealias.
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ModuleUpdate extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'freescout:module-update {module_alias?}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Update all modules or a single module (if module_alias is set)';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$install_all = false;
|
||||
$modules = [];
|
||||
|
||||
// We have to clear modules cache first to update modules cache
|
||||
\Artisan::call('cache:clear');
|
||||
|
||||
// Create a symlink for the module (or all modules)
|
||||
$module_alias = $this->argument('module_alias');
|
||||
|
||||
$modules_directory = \WpApi::getModules();
|
||||
if (\WpApi::$lastError) {
|
||||
$this->error(__('Error occurred').': '.$lastError['message'].' ('.$lastError['code'].')');
|
||||
return;
|
||||
}
|
||||
|
||||
$installed_modules = \Module::all();
|
||||
|
||||
$counter = 0;
|
||||
$found = false;
|
||||
foreach ($modules_directory as $dir_module) {
|
||||
// Update single module.
|
||||
if ($module_alias && $dir_module['alias'] != $module_alias) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found = true;
|
||||
|
||||
// Detect if new version is available.
|
||||
foreach ($installed_modules as $module) {
|
||||
if ($module->getAlias() != $dir_module['alias'] /*|| !$module->active()*/) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($dir_module['version']) && version_compare($dir_module['version'], $module->get('version'), '>')) {
|
||||
|
||||
$update_result = \App\Module::updateModule($dir_module['alias']);
|
||||
|
||||
$this->info('['.$update_result['module_name'].' Module'.']');
|
||||
if ($update_result['status'] == 'success') {
|
||||
$this->line($update_result['msg_success']);
|
||||
} else {
|
||||
$msg = $update_result['msg'];
|
||||
if ($update_result['download_msg']) {
|
||||
$msg .= ' ('.$update_result['download_msg'].')';
|
||||
}
|
||||
$this->error('ERROR: '.$msg);
|
||||
}
|
||||
if (trim($update_result['output'])) {
|
||||
$this->line(preg_replace("#\n#", "\n> ", '> '.trim($update_result['output'])));
|
||||
}
|
||||
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update custom modules.
|
||||
// Loop through each installed module.
|
||||
foreach ($installed_modules as $module) {
|
||||
// Skip if the module is an official one
|
||||
if (\App\Module::isOfficial($module->get('authorUrl'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the URL for the latest version of the module
|
||||
$latest_version_number_url = $module->get('latestVersionUrl');
|
||||
if (! $latest_version_number_url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a new Guzzle HTTP client
|
||||
$client = new \GuzzleHttp\Client();
|
||||
|
||||
try {
|
||||
// Send a GET request to the latest version URL
|
||||
$response = $client->request('GET', $latest_version_number_url, \Helper::setGuzzleDefaultOptions());
|
||||
|
||||
// Get the latest version number from the response body
|
||||
$latest_version = trim((string) $response->getBody());
|
||||
|
||||
if (empty($latest_version)) {
|
||||
continue;
|
||||
} else {
|
||||
// Get the current version of the module
|
||||
$current_version = $module->get('version');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If there's an exception, skip to the next iteration
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the latest version is greater than the current version
|
||||
if (version_compare($latest_version, $current_version, '>')) {
|
||||
// Update the module
|
||||
$update_result = \App\Module::updateModule($module->getAlias());
|
||||
|
||||
// Print the module name and status
|
||||
$this->info('[' . $update_result['module_name'] . ' Module' . ']');
|
||||
if ($update_result['status'] == 'success') {
|
||||
// If the update was successful, print the success message
|
||||
$this->line($update_result['msg_success']);
|
||||
} else {
|
||||
// If the update failed, print the error message
|
||||
$msg = $update_result['msg'];
|
||||
if ($update_result['download_msg']) {
|
||||
$msg .= ' (' . $update_result['download_msg'] . ')';
|
||||
}
|
||||
$this->error('ERROR: ' . $msg);
|
||||
}
|
||||
// If there's any output from the update, print it
|
||||
if (trim($update_result['output'])) {
|
||||
$this->line(preg_replace("#\n#", "\n> ", '> ' . trim($update_result['output'])));
|
||||
}
|
||||
|
||||
// Increment the counter
|
||||
$counter ++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($module_alias && !$found) {
|
||||
$this->error('Module with the following alias not found: '.$module_alias);
|
||||
} elseif (!$counter) {
|
||||
$this->line('All modules are up-to-date');
|
||||
}
|
||||
|
||||
\Artisan::call('freescout:clear-cache');
|
||||
}
|
||||
}
|
||||
126
app/Console/Commands/ParseEml.php
Normal file
126
app/Console/Commands/ParseEml.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mailbox;
|
||||
use Webklex\PHPIMAP\IMAP;
|
||||
use Webklex\PHPIMAP\Message;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
|
||||
class ParseEml extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'freescout:parse-eml';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Parse EML file';
|
||||
|
||||
/**
|
||||
* Current mailbox.
|
||||
*
|
||||
* Used to process emails sent to multiple mailboxes.
|
||||
*/
|
||||
public $mailbox;
|
||||
|
||||
/**
|
||||
* Used to process emails sent to multiple mailboxes.
|
||||
*/
|
||||
public $mailboxes;
|
||||
|
||||
public $extra_import = [];
|
||||
|
||||
/**
|
||||
* Page size when requesting emails from mail server.
|
||||
*/
|
||||
const PAGE_SIZE = 300;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$email = file_get_contents(storage_path('logs/email.eml'));
|
||||
|
||||
if (!str_contains($email, "\r\n")) {
|
||||
$email = str_replace("\n", "\r\n", $email);
|
||||
}
|
||||
|
||||
// $raw_header = substr($email, 0, strpos($email, "\r\n\r\n"));
|
||||
// $raw_body = substr($email, strlen($raw_header)+4);
|
||||
|
||||
// $mailbox = Mailbox::find($this->option('mailbox'));
|
||||
|
||||
// //\Config::set('app.new_fetching_library', 'true');
|
||||
// $client = \MailHelper::getMailboxClient($mailbox);
|
||||
// $client->openFolder("INBOX");
|
||||
|
||||
// $message = Message::make(/*$this->option('uid')*/null, null, $client, $raw_header, $raw_body, [/*0 => "\\Seen"*/], IMAP::ST_UID);
|
||||
|
||||
$manager = new \Webklex\PHPIMAP\ClientManager([
|
||||
// 'options' => [
|
||||
// "debug" => $_ENV["LIVE_MAILBOX_DEBUG"] ?? false,
|
||||
// ],
|
||||
// 'accounts' => [
|
||||
// 'default' => [
|
||||
// 'host' => getenv("LIVE_MAILBOX_HOST"),
|
||||
// 'port' => getenv("LIVE_MAILBOX_PORT"),
|
||||
// 'encryption' => getenv("LIVE_MAILBOX_ENCRYPTION"),
|
||||
// 'validate_cert' => getenv("LIVE_MAILBOX_VALIDATE_CERT"),
|
||||
// 'username' => getenv("LIVE_MAILBOX_USERNAME"),
|
||||
// 'password' => getenv("LIVE_MAILBOX_PASSWORD"),
|
||||
// 'protocol' => 'imap', //might also use imap, [pop3 or nntp (untested)]
|
||||
// ],
|
||||
// ],
|
||||
]);
|
||||
$message = \Webklex\PHPIMAP\Message::fromString($email);
|
||||
|
||||
$this->line('Headers: ');
|
||||
$this->info($message->getHeader()->raw);
|
||||
$this->line('From: ');
|
||||
$this->info(json_encode($message->getFrom()[0] ?? [], JSON_UNESCAPED_UNICODE));
|
||||
$this->line('Reply-To: ');
|
||||
$this->info(json_encode($message->getReplyTo()[0] ?? [], JSON_UNESCAPED_UNICODE));
|
||||
$this->line('In-Reply-To: ');
|
||||
$this->info($message->getInReplyTo());
|
||||
$this->line('References: ');
|
||||
$this->info(json_encode(array_values(array_filter(preg_split('/[, <>]/', $message->getReferences() ?? ''))), JSON_UNESCAPED_UNICODE));
|
||||
$this->line('Date: ');
|
||||
$this->info($message->getDate());
|
||||
$this->line('Subject: ');
|
||||
$this->info($message->getSubject());
|
||||
$this->line('Text Body: ');
|
||||
$this->info($message->getTextBody());
|
||||
$this->line('HTML Body: ');
|
||||
$html_body = $message->getHTMLBody(false) ?? '';
|
||||
$this->info($html_body);
|
||||
|
||||
$attachments = $message->getAttachments();
|
||||
if (count($attachments)) {
|
||||
$this->line('Attachments: ');
|
||||
foreach ($attachments as $attachment) {
|
||||
$this->info('— '.$attachment->getName().(strstr($html_body, 'cid:'.$attachment->id) ? ' (embedded)' : ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class SendMonitor extends Command
|
||||
// Get SendReplyToCustomer jobs.
|
||||
$pending_jobs = \App\Job::where('queue', 'emails')
|
||||
->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%')
|
||||
->where('created_at', '<', time() - self::CHECK_PERIOD)
|
||||
->where('available_at', '<', time() - self::CHECK_PERIOD)
|
||||
->exists();
|
||||
|
||||
// Check failed_jobs.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -32,7 +30,10 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// Keep in mind that this function is also called on clearing cache.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3970
|
||||
if (!$this->isScheduleRun() && !\Helper::isRoute('system.cron')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove failed jobs
|
||||
$schedule->command('queue:flush')
|
||||
@@ -69,6 +70,9 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('freescout:clean-send-log')
|
||||
->monthly();
|
||||
|
||||
$schedule->command('freescout:clean-notifications-table')
|
||||
->weekly();
|
||||
|
||||
$schedule->command('freescout:clean-tmp')
|
||||
->daily();
|
||||
|
||||
@@ -97,22 +101,32 @@ class Kernel extends ConsoleKernel
|
||||
}
|
||||
}
|
||||
|
||||
$fetch_unseen = (int)config('app.fetch_unseen');
|
||||
$fetch_command_identifier = \Helper::getWorkerIdentifier('freescout:fetch-emails');
|
||||
$fetch_command_name = 'freescout:fetch-emails'
|
||||
. ' --identifier='.$fetch_command_identifier
|
||||
. ' --unseen='.$fetch_unseen;
|
||||
|
||||
// Kill fetch commands running for too long.
|
||||
// In shedule:run this code is executed every time $schedule->command() in this function is executed.
|
||||
if ($this->isScheduleRun() && function_exists('shell_exec')) {
|
||||
if (function_exists('shell_exec')) {
|
||||
$fetch_command_pids = \Helper::getRunningProcesses($fetch_command_identifier);
|
||||
|
||||
$mutex_name = $schedule->command('freescout:fetch-emails')
|
||||
->skip(function () {
|
||||
return true;
|
||||
})
|
||||
->mutexName();
|
||||
|
||||
// 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.
|
||||
// The name of the command here must be exactly the same as below!
|
||||
// Otherwise long fetching will be killed and won't run longer than 1 mintue.
|
||||
// $schedule->command() creates a new mutex, so we need to store mutex name in cache.
|
||||
// $mutex_name = $schedule->command($fetch_command_name)
|
||||
// ->everyMinute() - this also need to be set
|
||||
// ->skip(function () {
|
||||
// return true;
|
||||
// })
|
||||
// ->mutexName();
|
||||
|
||||
$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 '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));
|
||||
@@ -125,22 +139,33 @@ 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch emails from mailboxes
|
||||
$fetch_command = $schedule->command('freescout:fetch-emails --identifier='.$fetch_command_identifier)
|
||||
// withoutOverlapping() option creates a mutex in the cache
|
||||
$fetch_command = $schedule->command($fetch_command_name)
|
||||
// withoutOverlapping() option creates a mutex in the cache
|
||||
// which by default expires in 24 hours.
|
||||
// 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 */)
|
||||
// does not remove the mutex from the cache.
|
||||
->withoutOverlapping($expiresAt = (int)config('app.fetch_max_execution_time') /* minutes */)
|
||||
->sendOutputTo(storage_path().'/logs/fetch-emails.log');
|
||||
|
||||
switch (config('app.fetch_schedule')) {
|
||||
case Mail::FETCH_SCHEDULE_EVERY_TWO_MINUTES:
|
||||
$fetch_command->cron('*/2 * * * *');
|
||||
break;
|
||||
case Mail::FETCH_SCHEDULE_EVERY_THREE_MINUTES:
|
||||
$fetch_command->cron('*/3 * * * *');
|
||||
break;
|
||||
case Mail::FETCH_SCHEDULE_EVERY_FIVE_MINUTES:
|
||||
$fetch_command->everyFiveMinutes();
|
||||
break;
|
||||
@@ -160,6 +185,7 @@ class Kernel extends ConsoleKernel
|
||||
$fetch_command->everyMinute();
|
||||
break;
|
||||
}
|
||||
\Cache::put('fetch_mutex_name', $fetch_command->mutexName(), (int)config('app.fetch_max_execution_time'));
|
||||
|
||||
$schedule = \Eventy::filter('schedule', $schedule);
|
||||
|
||||
@@ -181,10 +207,10 @@ class Kernel extends ConsoleKernel
|
||||
// $schedule->command('queue:work') command below has withoutOverlapping() option,
|
||||
// which works via special mutex stored in the cache preventing several 'queue:work' to work at the same time.
|
||||
// So when the cache is cleared the mutex indicating that the 'queue:work' is running is removed,
|
||||
// and the second 'queue:work' command is launched by cron. When `artisan schedule:run` is executed it sees
|
||||
// and the second 'queue:work' command is launched by cron. When `artisan schedule:run` is executed it sees
|
||||
// that there are two 'queue:work' processes running and kills them.
|
||||
// After one minute 'queue:work' is executed by cron via `artisan schedule:run` and works in the background.
|
||||
if ($this->isScheduleRun() && function_exists('shell_exec')) {
|
||||
if (function_exists('shell_exec')) {
|
||||
$running_commands = \Helper::getRunningProcesses();
|
||||
|
||||
if (count($running_commands) > 1) {
|
||||
@@ -196,7 +222,7 @@ class Kernel extends ConsoleKernel
|
||||
sleep(1);
|
||||
// Check processes again.
|
||||
$worker_pids = \Helper::getRunningProcesses();
|
||||
|
||||
|
||||
if (count($worker_pids) > 1) {
|
||||
// Current process also has to be killed, as otherwise it "stucks"
|
||||
// $current_pid = getmypid();
|
||||
@@ -220,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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,12 +266,12 @@ class Kernel extends ConsoleKernel
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is needed because every time $schedule->command() is executed
|
||||
* This function is needed because every time $schedule->command() is executed
|
||||
* the schedule() is executed also.
|
||||
*/
|
||||
public function isScheduleRun()
|
||||
{
|
||||
if (!\Helper::isConsole()) {
|
||||
if (\Helper::isConsole()) {
|
||||
return true;
|
||||
} else {
|
||||
return !empty($_SERVER['argv']) && in_array('schedule:run', $_SERVER['argv']);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
318
app/Customer.php
318
app/Customer.php
@@ -3,6 +3,8 @@
|
||||
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;
|
||||
@@ -453,7 +455,7 @@ class Customer extends Model
|
||||
*
|
||||
* @var [type]
|
||||
*/
|
||||
protected $fillable = ['first_name', 'last_name', 'company', 'job_title', 'address', 'city', 'state', 'zip', 'country', 'photo_url', 'age', 'gender', 'notes', 'channel', 'channel_id', 'social_profiles'];
|
||||
protected $fillable = ['first_name', 'last_name', 'company', 'job_title', 'address', 'city', 'state', 'zip', 'country', 'photo_url', 'notes', 'channel', 'channel_id', 'social_profiles'];
|
||||
|
||||
/**
|
||||
* Fields stored as JSON.
|
||||
@@ -493,11 +495,27 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the customer has an email address among his emails.
|
||||
*/
|
||||
public function hasEmail($email_address)
|
||||
{
|
||||
return $this->emails_cached()->where('email', Email::sanitizeEmail($email_address))->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -641,9 +659,9 @@ class Customer extends Model
|
||||
return $phones;
|
||||
} elseif ($dummy_if_empty) {
|
||||
return [[
|
||||
'type' => self::PHONE_TYPE_WORK,
|
||||
'value' => '',
|
||||
]];
|
||||
'type' => self::PHONE_TYPE_WORK,
|
||||
],];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -767,7 +785,7 @@ class Customer extends Model
|
||||
return [[
|
||||
'type' => '',
|
||||
'value' => '',
|
||||
]];
|
||||
],];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -813,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;
|
||||
@@ -866,12 +884,16 @@ class Customer extends Model
|
||||
}
|
||||
|
||||
$social_profiles[] = [
|
||||
// Order of elements in array is important as we rely on it
|
||||
// when searching customers by social profiles using "like".
|
||||
'value' => (string) $social_profile['value'],
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$social_profiles[] = [
|
||||
// Order of elements in array is important as we rely on it
|
||||
// when searching customers by social profiles using "like".
|
||||
'value' => (string) $social_profile,
|
||||
'type' => self::SOCIAL_TYPE_OTHER,
|
||||
];
|
||||
@@ -919,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;
|
||||
@@ -940,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;
|
||||
}
|
||||
@@ -996,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;
|
||||
@@ -1008,6 +1036,14 @@ class Customer extends Model
|
||||
$result = true;
|
||||
} else {
|
||||
// Update empty fields.
|
||||
|
||||
// Do not set last name if first name is already set (and vise versa).
|
||||
if (!empty($this->first_name) && !empty($data['last_name'])) {
|
||||
unset($data['last_name']);
|
||||
}
|
||||
if (!empty($this->last_name) && !empty($data['first_name'])) {
|
||||
unset($data['first_name']);
|
||||
}
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, $this->fillable) && empty($this->$key)) {
|
||||
$this->$key = $value;
|
||||
@@ -1121,7 +1157,7 @@ class Customer extends Model
|
||||
*/
|
||||
public function url()
|
||||
{
|
||||
return route('customers.update', ['id'=>$this->id]);
|
||||
return route('customers.update', ['id' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1131,7 +1167,7 @@ class Customer extends Model
|
||||
*/
|
||||
public function urlView()
|
||||
{
|
||||
return route('customers.conversations', ['id'=>$this->id]);
|
||||
return route('customers.conversations', ['id' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1266,6 +1302,95 @@ class Customer extends Model
|
||||
return Storage::url(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge customerю
|
||||
*/
|
||||
public function mergeWith(Customer $customer2)
|
||||
{
|
||||
if ($this->id == $customer2->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$customer2->conversations()->update(['customer_id' => $this->id]);
|
||||
|
||||
// do {
|
||||
// $conversations = $customer2->conversations()->limit(1000);
|
||||
// foreach ($conversations as $conversation) {
|
||||
// $conversation->changeCustomer($customer_email, $this, $user);
|
||||
// }
|
||||
// } while (count($conversations) > 0);
|
||||
|
||||
// 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;
|
||||
// }
|
||||
foreach ($this->fillable as $attr_name) {
|
||||
// Skip some attribues.
|
||||
if (in_array($attr_name, ['channel', 'channel_id'])) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->$attr_name) {
|
||||
$this->$attr_name = $customer2->$attr_name;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Phones.
|
||||
$phones = self::mergeTypeValueLists($this->getPhones(), $customer2->getPhones());
|
||||
if (count($phones) != count($this->getPhones())) {
|
||||
$this->setPhones($phones);
|
||||
}
|
||||
|
||||
// Merge websites.
|
||||
$websites = array_merge($this->getWebsites(), $customer2->getWebsites());
|
||||
$this->setWebsites(array_unique($websites));
|
||||
|
||||
// Merge social profiles.
|
||||
$social = self::mergeTypeValueLists($this->getSocialProfiles(), $customer2->getSocialProfiles());
|
||||
if (count($social) != count($this->getSocialProfiles())) {
|
||||
$this->setSocialProfiles($social);
|
||||
}
|
||||
|
||||
$this->save();
|
||||
|
||||
\Eventy::action('customer.merged', $this, $customer2, $user);
|
||||
|
||||
$customer2->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function mergeTypeValueLists($list1, $list2)
|
||||
{
|
||||
foreach ($list2 as $data2) {
|
||||
if (empty($data2['type']) || empty($data2['value'])) {
|
||||
continue;
|
||||
}
|
||||
$exists = false;
|
||||
foreach ($list1 as $data) {
|
||||
if (!empty($data['type']) && !empty($data['value'])
|
||||
&& $data['type'] == $data2['type'] && $data['value'] == $data2['value']
|
||||
) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$list1[] = $data2;
|
||||
}
|
||||
}
|
||||
return $list1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize and save photo.
|
||||
*/
|
||||
@@ -1279,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)) {
|
||||
@@ -1293,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;
|
||||
}
|
||||
|
||||
@@ -1327,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;
|
||||
@@ -1366,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;
|
||||
@@ -1446,4 +1605,127 @@ class Customer extends Model
|
||||
Email::where('customer_id', $this->id)->delete();
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
public function getChannels()
|
||||
{
|
||||
if (!$this->channel || !$this->channel_id) {
|
||||
return collect([]);
|
||||
}
|
||||
return CustomerChannel::where('customer_id', $this->id)->get();
|
||||
}
|
||||
|
||||
public function addChannel($channel, $channel_id)
|
||||
{
|
||||
// We are doing this to let existing modules not to throw error
|
||||
// and as a flag that this customer has record(s) in cucstomer_channel table.
|
||||
if (!$this->channel || !$this->channel_id) {
|
||||
$this->channel = $channel;
|
||||
$this->channel_id = $channel_id;
|
||||
$this->save();
|
||||
}
|
||||
$customer_channel = CustomerChannel::where('customer_id', $this->id)
|
||||
->where('channel', $channel)
|
||||
->first();
|
||||
|
||||
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)
|
||||
{
|
||||
$customer_channel = CustomerChannel::where('channel', $channel)
|
||||
->where('channel_id', $channel_id)
|
||||
->first();
|
||||
|
||||
if ($customer_channel) {
|
||||
return $customer_channel->customer;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getChannelId($channel)
|
||||
{
|
||||
return CustomerChannel::where('customer_id', $this->id)
|
||||
->where('channel', $channel)
|
||||
->value('channel_id');
|
||||
}
|
||||
|
||||
public static function findCustomersBySocialProfile($type, $value, $exclude_channel = null)
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
|
||||
$like = '%'.$value.'","type":'.$type.'}]';
|
||||
$customers = Customer::where('social_profiles', \Helper::sqlLikeOperator(), $like)->get();
|
||||
|
||||
// Now more prcise filtering.
|
||||
foreach ($customers as $i => $customer) {
|
||||
$ok = false;
|
||||
foreach ($customer->getSocialProfiles() as $social_profile) {
|
||||
if ($social_profile['type'] == $type
|
||||
// Try to check username written in different ways:
|
||||
// - username
|
||||
// - @username
|
||||
// - https://example.org/username
|
||||
&& preg_match("#(^|/|@)".preg_quote($value)."$#", trim(mb_strtolower($social_profile['value'])))
|
||||
) {
|
||||
$ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$ok) {
|
||||
$customers->forget($i);
|
||||
}
|
||||
}
|
||||
|
||||
if ($exclude_channel && count($customers)) {
|
||||
$exclude_customer_ids = CustomerChannel::whereIn('customer_id', $customers->pluck('id'))
|
||||
->where('channel', $exclude_channel)
|
||||
->pluck('customer_id');
|
||||
return $customers->whereNotIn('customer_id', $exclude_customer_ids);
|
||||
} else {
|
||||
return $customers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dummy customer.
|
||||
*/
|
||||
public static function getDummyCustomer()
|
||||
{
|
||||
$customer = new self();
|
||||
$customer->first_name = __('Customer');
|
||||
$customer->last_name = '';
|
||||
|
||||
return $customer;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
app/CustomerChannel.php
Normal file
58
app/CustomerChannel.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomerChannel extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'customer_channel';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Cached communication channels.
|
||||
*/
|
||||
public static $channels = null;
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo('App\Customer');
|
||||
}
|
||||
|
||||
public static function create($customer_id, $channel, $channel_id)
|
||||
{
|
||||
try {
|
||||
$customer_channel = new self();
|
||||
$customer_channel->customer_id = $customer_id;
|
||||
$customer_channel->channel = $channel;
|
||||
$customer_channel->channel_id = $channel_id;
|
||||
$customer_channel->save();
|
||||
|
||||
return $customer_channel;
|
||||
} catch (\Exception $e) {
|
||||
// Already exists.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getChannelName()
|
||||
{
|
||||
return \Eventy::filter('channel.name', '', $this->channel);
|
||||
}
|
||||
|
||||
public static function getChannels()
|
||||
{
|
||||
if (self::$channels !== null) {
|
||||
return self::$channels;
|
||||
} else {
|
||||
self::$channels = \Eventy::filter('channels.list', []);
|
||||
return self::$channels;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
111
app/Events/RealtimeChat.php
Normal file
111
app/Events/RealtimeChat.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* Fefresh chats list when new thread created in mailbox.
|
||||
*/
|
||||
namespace App\Events;
|
||||
|
||||
use App\Conversation;
|
||||
use App\Mailbox;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RealtimeChat implements ShouldBroadcastNow
|
||||
{
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* The notification data.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $data = [];
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new \Illuminate\Broadcasting\Channel($this->channelName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function broadcastWith()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the broadcast channel name for the event.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function channelName()
|
||||
{
|
||||
if (!empty($this->data['mailbox_id'])) {
|
||||
return 'chat.'.$this->data['mailbox_id'];
|
||||
} else {
|
||||
return 'chat.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper funciton.
|
||||
*/
|
||||
public static function dispatchSelf($mailbox_id, $thread_id, $is_chat)
|
||||
{
|
||||
if (!\Helper::isChatModeAvailable()) {
|
||||
return;
|
||||
}
|
||||
$notification_data = [
|
||||
'mailbox_id' => $mailbox_id,
|
||||
'thread_id' => $thread_id,
|
||||
'is_chat' => (int)$is_chat,
|
||||
];
|
||||
event(new \App\Events\RealtimeChat($notification_data));
|
||||
}
|
||||
|
||||
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)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Chats are retrieved in the template.
|
||||
$template_data = [
|
||||
'mailbox' => $mailbox,
|
||||
];
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,27 @@ class FailedJob extends Model
|
||||
* Automatically converted into Carbon dates.
|
||||
*/
|
||||
protected $dates = ['failed_at'];
|
||||
|
||||
public $payload_decoded = null;
|
||||
|
||||
public function getPayloadDecoded()
|
||||
{
|
||||
if ($this->payload_decoded !== null) {
|
||||
return $this->payload_decoded;
|
||||
}
|
||||
|
||||
$this->payload_decoded = json_decode($this->payload, true);
|
||||
|
||||
return $this->payload_decoded;
|
||||
}
|
||||
|
||||
public function getCommand()
|
||||
{
|
||||
return \App\Job::getPayloadCommand($this->getPayloadDecoded());
|
||||
}
|
||||
|
||||
public static function retry($job_id)
|
||||
{
|
||||
\Artisan::call('queue:retry', ['id' => $job_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ class Folder extends Model
|
||||
public function updateCounters()
|
||||
{
|
||||
if (config('app.update_folder_counters_in_background')) {
|
||||
\App\Jobs\UpdateFolderCounters::dispatch($this);
|
||||
if (!\Illuminate\Support\Facades\Cache::has("folder_update_lock_{$this->id}")) {
|
||||
\App\Jobs\UpdateFolderCounters::dispatch($this);
|
||||
}
|
||||
} else {
|
||||
$this->updateCountersNow();
|
||||
}
|
||||
@@ -380,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)
|
||||
@@ -394,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -38,4 +40,40 @@ class LoginController extends Controller
|
||||
{
|
||||
$this->middleware('guest')->except('logout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a login request to the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
|
||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
||||
// the login attempts for this application. We'll key this by the username and
|
||||
// the IP address of the client making these requests into this application.
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
$this->fireLockoutEvent($request);
|
||||
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
$custom_errors = \Eventy::filter('login.custom_check', [], $request);
|
||||
if ($custom_errors) {
|
||||
throw ValidationException::withMessages($custom_errors);
|
||||
}
|
||||
|
||||
if ($this->attemptLogin($request)) {
|
||||
return $this->sendLoginResponse($request);
|
||||
}
|
||||
|
||||
// If the login attempt was unsuccessful we will increment the number of attempts
|
||||
// to login and redirect the user back to the login form. Of course, when this
|
||||
// user surpasses their maximum number of attempts they will get locked out.
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,12 @@ class CustomersController extends Controller
|
||||
/**
|
||||
* Edit customer.
|
||||
*/
|
||||
public function update($id)
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$customer = Customer::findOrFail($id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
$customer_emails = $customer->emails;
|
||||
if (count($customer_emails)) {
|
||||
foreach ($customer_emails as $row) {
|
||||
@@ -56,6 +58,8 @@ class CustomersController extends Controller
|
||||
$customer = Customer::findOrFail($id);
|
||||
$flash_message = '';
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
// First name or email must be specified
|
||||
$validator = Validator::make($request->all(), [
|
||||
'first_name' => 'nullable|string|max:255|required_without:emails.0',
|
||||
@@ -77,7 +81,7 @@ class CustomersController extends Controller
|
||||
// Photo
|
||||
$validator->after(function ($validator) use ($customer, $request) {
|
||||
if ($request->hasFile('photo_url')) {
|
||||
$path_url = $customer->savePhoto($request->file('photo_url')->getRealPath(), $request->file('photo_url')->getMimeType());
|
||||
$path_url = $customer->savePhoto($request->file('photo_url')->getRealPath() ?: $request->file('photo_url')->getPathname(), $request->file('photo_url')->getMimeType());
|
||||
|
||||
if ($path_url) {
|
||||
$customer->photo_url = $path_url;
|
||||
@@ -109,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) {
|
||||
@@ -126,8 +141,8 @@ class CustomersController extends Controller
|
||||
'email' => $email->email,
|
||||
'tag_email_begin' => '<strong>',
|
||||
'tag_email_end' => '</strong>',
|
||||
'customer' => $email->customer->getFullName(),
|
||||
'a_begin' => '<strong><a href="'.$email->customer->url().'" target="_blank">',
|
||||
'customer' => htmlspecialchars($email->customer->getFullName()),
|
||||
'a_begin' => '<strong><a href="'.$email->customer->url().'">',
|
||||
'a_end' => '</a></strong>',
|
||||
]).' ';
|
||||
|
||||
@@ -147,6 +162,15 @@ class CustomersController extends Controller
|
||||
if (isset($request_data['photo_url'])) {
|
||||
unset($request_data['photo_url']);
|
||||
}
|
||||
$nonfillable_fields = [
|
||||
'channel',
|
||||
'channel_id',
|
||||
];
|
||||
foreach ($nonfillable_fields as $field) {
|
||||
if (isset($request_data[$field])) {
|
||||
unset($request_data[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$customer->setData($request_data);
|
||||
// Websites
|
||||
@@ -193,35 +217,19 @@ class CustomersController extends Controller
|
||||
return redirect()->route('customers.update', ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* User mailboxes.
|
||||
*/
|
||||
public function permissions($id)
|
||||
public function checkLimitVisibility($customer, $return_result = false)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->can('view', $customer)) {
|
||||
if (!$return_result) {
|
||||
\Helper::denyAccess();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,11 +241,20 @@ class CustomersController extends Controller
|
||||
{
|
||||
$customer = Customer::findOrFail($id);
|
||||
|
||||
$conversations = $customer->conversations()
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
$query = $customer->conversations()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('mailbox_id', auth()->user()->mailboxesIdsCanView())
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(Conversation::DEFAULT_LIST_SIZE);
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->canSeeOnlyAssignedConversations()) {
|
||||
$query->where('user_id', '=', $user->id);
|
||||
}
|
||||
|
||||
$conversations = $query->paginate(Conversation::DEFAULT_LIST_SIZE);
|
||||
|
||||
return view('customers/conversations', [
|
||||
'customer' => $customer,
|
||||
@@ -257,6 +274,9 @@ class CustomersController extends Controller
|
||||
|
||||
$q = $request->q;
|
||||
|
||||
$user = auth()->user();
|
||||
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
|
||||
|
||||
$join_emails = false;
|
||||
if ($request->search_by == 'all' || $request->search_by == 'email' || $request->exclude_email) {
|
||||
$join_emails = true;
|
||||
@@ -264,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';
|
||||
@@ -279,18 +304,36 @@ class CustomersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->search_by == 'all' || $request->search_by == 'email') {
|
||||
$customers_query->where('emails.email', 'like', '%'.$q.'%');
|
||||
}
|
||||
if ($request->exclude_email) {
|
||||
$customers_query->where('emails.email', '<>', $request->exclude_email);
|
||||
}
|
||||
if ($request->search_by == 'all' || $request->search_by == 'name') {
|
||||
$customers_query->orWhere('first_name', 'like', '%'.$q.'%')
|
||||
->orWhere('last_name', 'like', '%'.$q.'%');
|
||||
}
|
||||
if ($request->search_by == 'phone') {
|
||||
$customers_query->where('customers.phones', 'like', '%'.$q.'%');
|
||||
$customers_query->where(function ($query) use ($q, $request) {
|
||||
if ($request->search_by == 'all' || $request->search_by == 'email') {
|
||||
$query->where('emails.email', 'like', '%'.$q.'%');
|
||||
}
|
||||
if ($request->exclude_email) {
|
||||
$query->where('emails.email', '<>', $request->exclude_email);
|
||||
}
|
||||
if ($request->exclude_id) {
|
||||
$query->where('customers.id', '<>', $request->exclude_id);
|
||||
}
|
||||
if ($request->search_by == 'all' || $request->search_by == 'name') {
|
||||
$query->orWhere('first_name', 'like', '%'.$q.'%')
|
||||
->orWhere('last_name', 'like', '%'.$q.'%')
|
||||
->orWhere(\Helper::isPgSql() ? \DB::raw('(first_name || \' \' || last_name)') : \DB::raw('CONCAT(first_name, " ", last_name)'), 'like', '%'.$q.'%');
|
||||
}
|
||||
if ($request->search_by == 'phone') {
|
||||
$phone_numeric = \Helper::phoneToNumeric($q);
|
||||
if (!$phone_numeric) {
|
||||
$phone_numeric = $q;
|
||||
}
|
||||
$query->where('customers.phones', 'like', '%'.$phone_numeric.'%');
|
||||
}
|
||||
});
|
||||
|
||||
if ($limited_visibility) {
|
||||
$mailbox_ids = $user->mailboxesIdsCanView();
|
||||
|
||||
$customers_query->join('conversations', 'conversations.customer_id', '=', 'customers.id');
|
||||
$customers_query->whereIn('conversations.mailbox_id', $mailbox_ids);
|
||||
$customers_query->groupby('customers.id');
|
||||
}
|
||||
|
||||
$customers = $customers_query->paginate(20);
|
||||
@@ -311,7 +354,8 @@ class CustomersController extends Controller
|
||||
// Get phone which matches
|
||||
$phones = $customer->getPhones();
|
||||
foreach ($phones as $phone) {
|
||||
if (strstr($phone['value'], $q)) {
|
||||
$phone_numeric = \Helper::phoneToNumeric($q);
|
||||
if (strstr($phone['value'], $q) || strstr($phone['n'] ?? '', $phone_numeric)) {
|
||||
$text = $phone['value'];
|
||||
if ($customer->getFullName()) {
|
||||
$text .= ' — '.$customer->getFullName();
|
||||
@@ -333,7 +377,8 @@ class CustomersController extends Controller
|
||||
if (!empty($request->use_id)) {
|
||||
$id = $customer->id;
|
||||
} else {
|
||||
$id = $customer->email;
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/4057
|
||||
$id = $customer->email ?? $customer->getMainEmail();
|
||||
}
|
||||
}
|
||||
$response['results'][] = [
|
||||
@@ -361,27 +406,46 @@ class CustomersController extends Controller
|
||||
|
||||
switch ($request->action) {
|
||||
|
||||
// Change conversation user
|
||||
// Change conversation customer.
|
||||
case 'create':
|
||||
// First name or email must be specified
|
||||
$validator = Validator::make($request->all(), [
|
||||
$validator_config = [
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'nullable|string|max:255',
|
||||
'email' => 'required|email|unique:emails,email',
|
||||
]);
|
||||
];
|
||||
|
||||
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
|
||||
if ($limited_visibility) {
|
||||
$validator_config['email'] = 'required|email';
|
||||
}
|
||||
|
||||
// First name or email must be specified.
|
||||
$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';
|
||||
}
|
||||
@@ -411,4 +475,48 @@ class CustomersController extends Controller
|
||||
|
||||
return \Response::json($response);
|
||||
}
|
||||
}
|
||||
|
||||
public function merge(Request $request, $id)
|
||||
{
|
||||
$customer = Customer::findOrFail($id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
|
||||
// $customers = Customer::where('id', '!=', $id)
|
||||
// ->orderBy('first_name')
|
||||
// ->orderBy('last_name')
|
||||
// ->get();
|
||||
|
||||
return view('customers/merge', ['customer' => $customer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge handling function.
|
||||
*/
|
||||
public function mergeSave(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'customer2_id' => 'required|exists:customers,id|not_in:'.$id,
|
||||
//'keep_attributes' => 'array'
|
||||
], [], [
|
||||
'customer2_id' => __('Merge With'),
|
||||
]);
|
||||
|
||||
$customer = Customer::findOrFail($id);
|
||||
$customer2 = Customer::findOrFail($request->customer2_id);
|
||||
|
||||
$this->checkLimitVisibility($customer);
|
||||
$this->checkLimitVisibility($customer2);
|
||||
|
||||
// Ensure customers are different
|
||||
if ($id === $customer2->id) {
|
||||
return redirect()->back()->with('error', __('Cannot merge the same customer'));
|
||||
}
|
||||
|
||||
$customer->mergeWith($customer2/*, $request->keep_attributes ?? []*/);
|
||||
|
||||
\Session::flash('flash_success_floating', __('Customers merged successfully'));
|
||||
|
||||
return redirect()->route('customers.update', ['id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Conversation;
|
||||
use App\Folder;
|
||||
use App\Mailbox;
|
||||
use App\MailboxUser;
|
||||
use App\Thread;
|
||||
use App\User;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -35,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);
|
||||
}
|
||||
}
|
||||
@@ -85,12 +86,18 @@ class MailboxesController extends Controller
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$request_data = [
|
||||
'email' => $request->email,
|
||||
'name' => trim(strip_tags($request->name)),
|
||||
'ratings' => $request->ratings ?? 1,
|
||||
];
|
||||
|
||||
$mailbox = new Mailbox();
|
||||
$mailbox->fill($request->all());
|
||||
$mailbox->fill($request_data);
|
||||
|
||||
$mailbox->save();
|
||||
|
||||
$mailbox->users()->sync($request->users);
|
||||
$mailbox->users()->sync($request->users ?: []);
|
||||
$mailbox->syncPersonalFolders($request->users);
|
||||
|
||||
\Session::flash('flash_success_floating', __('Mailbox created successfully'));
|
||||
@@ -105,6 +112,8 @@ class MailboxesController extends Controller
|
||||
{
|
||||
$mailbox = Mailbox::findOrFail($id);
|
||||
$user = auth()->user();
|
||||
|
||||
// Redirect to the accessible page.
|
||||
if (!$user->can('updateSettings', $mailbox) && !$user->can('updateEmailSignature', $mailbox)) {
|
||||
$accessible_route = '';
|
||||
|
||||
@@ -159,30 +168,35 @@ class MailboxesController extends Controller
|
||||
$mailbox = Mailbox::findOrFail($id);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$can_update_settings = $user->can('updateSettings', $mailbox);
|
||||
$can_update_signature = $user->can('updateEmailSignature', $mailbox);
|
||||
|
||||
if (!$user->can('updateSettings', $mailbox) && !$user->can('updateEmailSignature', $mailbox)) {
|
||||
if (!$can_update_settings && !$can_update_signature) {
|
||||
\Helper::denyAccess();
|
||||
}
|
||||
|
||||
// Checkboxes
|
||||
$request->merge([
|
||||
'aliases_reply' => ($request->filled('aliases_reply') ?? false),
|
||||
]);
|
||||
$allowed_fields = [];
|
||||
|
||||
if ($user->can('updateSettings', $mailbox)) {
|
||||
if ($can_update_settings) {
|
||||
|
||||
// Checkboxes
|
||||
$request->merge([
|
||||
'aliases_reply' => ($request->filled('aliases_reply') ?? false),
|
||||
]);
|
||||
|
||||
// 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,
|
||||
]);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => 'required|string|max:40',
|
||||
'email' => 'required|string|email|max:128|unique:mailboxes,email,'.$id,
|
||||
'aliases' => 'nullable|string|max:255',
|
||||
'aliases' => 'nullable|string',
|
||||
'from_name' => 'required|integer',
|
||||
'from_name_custom' => 'nullable|string|max:128',
|
||||
'ticket_status' => 'required|integer',
|
||||
@@ -203,9 +217,22 @@ class MailboxesController extends Controller
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$allowed_fields = [
|
||||
'name',
|
||||
'email',
|
||||
'aliases',
|
||||
'aliases_reply',
|
||||
'auto_bcc',
|
||||
'from_name',
|
||||
'from_name_custom',
|
||||
'ticket_status',
|
||||
'ticket_assignee',
|
||||
'before_reply',
|
||||
];
|
||||
}
|
||||
|
||||
if ($user->can('updateEmailSignature', $mailbox)) {
|
||||
if ($can_update_signature) {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'signature' => 'nullable|string',
|
||||
]);
|
||||
@@ -215,11 +242,42 @@ class MailboxesController extends Controller
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
$allowed_fields[] = 'signature';
|
||||
}
|
||||
|
||||
\Eventy::action('mailbox.settings_before_save', $mailbox, $request);
|
||||
|
||||
$mailbox->fill($request->all());
|
||||
// Leave only allowed fields.
|
||||
$fields = $request->all();
|
||||
foreach ($fields as $field_name => $value) {
|
||||
if (!in_array($field_name, $allowed_fields)) {
|
||||
unset($fields[$field_name]);
|
||||
}
|
||||
}
|
||||
|
||||
$mailbox->fill($fields);
|
||||
|
||||
// Save state.
|
||||
if ($user->isAdmin()) {
|
||||
if ($request->filled('state')) {
|
||||
$mailbox->state = Mailbox::STATE_ARCHIVED;
|
||||
} else {
|
||||
$mailbox->state = Mailbox::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -268,8 +326,24 @@ class MailboxesController extends Controller
|
||||
$this->authorize('updatePermissions', $mailbox);
|
||||
|
||||
$user = auth()->user();
|
||||
$user_ids = \Eventy::filter('mailbox.permission_users', $request->users ?? [], $id);
|
||||
|
||||
// Get pivot data from mailbox_user to pass to sync() not to loose values.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4339
|
||||
$users_with_settings = [];
|
||||
$mailbox_users = MailboxUser::where('mailbox_id', $id)
|
||||
//->whereIn('user_id', $user_ids)
|
||||
->get()
|
||||
->keyBy('user_id')
|
||||
->toArray();
|
||||
foreach ($user_ids as $user_id) {
|
||||
$users_with_settings[$user_id] = $mailbox_users[$user_id] ?? [];
|
||||
}
|
||||
|
||||
// Settings for admins are being reset here.
|
||||
// So we restore them below.
|
||||
$mailbox->users()->sync($users_with_settings);
|
||||
|
||||
$mailbox->users()->sync(\Eventy::filter('mailbox.permission_users', $request->users, $id));
|
||||
$mailbox->syncPersonalFolders($request->users);
|
||||
|
||||
// Save admins settings.
|
||||
@@ -281,6 +355,15 @@ class MailboxesController extends Controller
|
||||
$admin->mailboxes()->attach($id);
|
||||
$mailbox_user = $admin->mailboxesWithSettings()->where('mailbox_id', $id)->first();
|
||||
}
|
||||
// Restore settings for admins.
|
||||
if (!empty($mailbox_users[$admin->id])) {
|
||||
$admin_settings = $mailbox_users[$admin->id];
|
||||
foreach (MailboxUser::$pivot_settings as $pivot_parameter) {
|
||||
if (isset($admin_settings[$pivot_parameter])) {
|
||||
$mailbox_user->settings->$pivot_parameter = $admin_settings[$pivot_parameter];
|
||||
}
|
||||
}
|
||||
}
|
||||
$mailbox_user->settings->hide = (isset($request->managers[$admin->id]['hide']) ? (int)$request->managers[$admin->id]['hide'] : false);
|
||||
$mailbox_user->settings->save();
|
||||
}
|
||||
@@ -332,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',
|
||||
]);
|
||||
@@ -352,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();
|
||||
|
||||
@@ -383,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,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([
|
||||
@@ -427,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.
|
||||
@@ -449,7 +566,7 @@ class MailboxesController extends Controller
|
||||
/**
|
||||
* View mailbox.
|
||||
*/
|
||||
public function view($id, $folder_id = null)
|
||||
public function view(Request $request, $id, $folder_id = null)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@@ -474,7 +591,9 @@ class MailboxesController extends Controller
|
||||
$this->authorize('view', $folder);
|
||||
|
||||
$query_conversations = Conversation::getQueryByFolder($folder, $user->id);
|
||||
$conversations = $folder->queryAddOrderBy($query_conversations)->paginate(Conversation::DEFAULT_LIST_SIZE);
|
||||
$conversations = $folder->queryAddOrderBy($query_conversations)->paginate(
|
||||
Conversation::DEFAULT_LIST_SIZE, ['*'], 'page', $request->get('page')
|
||||
);
|
||||
|
||||
return view('mailboxes/view', [
|
||||
'mailbox' => $mailbox,
|
||||
@@ -492,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,
|
||||
];
|
||||
}
|
||||
@@ -558,7 +677,16 @@ class MailboxesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$mailbox->fill($request->all());
|
||||
$data = [
|
||||
'auto_reply_enabled' => $request->auto_reply_enabled,
|
||||
'auto_reply_subject' => $request->auto_reply_subject,
|
||||
'auto_reply_message' => $request->auto_reply_message,
|
||||
];
|
||||
|
||||
$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();
|
||||
|
||||
@@ -613,24 +741,37 @@ 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 = false;
|
||||
$test_result = [
|
||||
'status' => 'error',
|
||||
];
|
||||
|
||||
try {
|
||||
$test_result = \App\Misc\Mail::sendTestMail($request->to, $mailbox);
|
||||
$test_result = \MailHelper::sendTestMail($request->to, $mailbox);
|
||||
} catch (\Exception $e) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
$test_result['msg'] = $e->getMessage();
|
||||
}
|
||||
|
||||
if (!$test_result && !$response['msg']) {
|
||||
$response['msg'] = __('Error occurred sending email. Please check your mail server logs for more details.');
|
||||
if ($test_result['status'] == 'error') {
|
||||
$response['msg'] = $test_result['msg']
|
||||
?: __('Error occurred sending email. Please check your mail server logs for more details.');
|
||||
}
|
||||
|
||||
$response['log'] = $test_result['log'] ?? '';
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
@@ -659,22 +800,30 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response['msg'] && !$tested) {
|
||||
$test_result = false;
|
||||
|
||||
try {
|
||||
$test_result = \MailHelper::fetchTest($mailbox);
|
||||
} catch (\Exception $e) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
$test_result = \MailHelper::fetchTest($mailbox);
|
||||
|
||||
if (!$test_result && !$response['msg']) {
|
||||
$response['log'] = $test_result['log'] ?? '';
|
||||
|
||||
if ($test_result['result'] != 'success' && $test_result['msg']) {
|
||||
$response['msg'] = $test_result['msg'];
|
||||
}
|
||||
if ($test_result['result'] != 'success' && !$response['msg']) {
|
||||
$response['msg'] = __('Error occurred connecting to the server');
|
||||
}
|
||||
}
|
||||
@@ -704,28 +853,33 @@ class MailboxesController extends Controller
|
||||
|
||||
$imap_folders = $client->getFolders();
|
||||
|
||||
$response['folders'] = [];
|
||||
|
||||
if (count($imap_folders)) {
|
||||
foreach ($imap_folders as $imap_folder) {
|
||||
if (!empty($imap_folder->name)) {
|
||||
$response['folders'][] = $imap_folder->name;
|
||||
}
|
||||
// Maybe we need a recursion here.
|
||||
if (!empty($imap_folder->children)) {
|
||||
foreach ($imap_folder->children as $child_imap_folder) {
|
||||
// Old library.
|
||||
if (!empty($child_imap_folder->fullName)) {
|
||||
$response['folders'][] = $child_imap_folder->fullName;
|
||||
}
|
||||
// New library.
|
||||
if (!empty($child_imap_folder->full_name)) {
|
||||
$response['folders'][] = $child_imap_folder->full_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$response = $this->interateFolders($response, $imap_folders);
|
||||
$response['folders'] = array_values(array_unique($response['folders']));
|
||||
}
|
||||
|
||||
if (count($response['folders'])) {
|
||||
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3933
|
||||
// Exclude duplicate INBOX.Name and Name folders.
|
||||
// $folder_excluded = false;
|
||||
// foreach ($response['folders'] as $i => $folder_name) {
|
||||
// if (\Str::startsWith($folder_name, 'INBOX.')) {
|
||||
// foreach ($response['folders'] as $folder_name2) {
|
||||
// if ($folder_name != $folder_name2 && $folder_name == 'INBOX.'.$folder_name2) {
|
||||
// unset($response['folders'][$i]);
|
||||
// $folder_excluded = true;
|
||||
// continue 2;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if ($folder_excluded) {
|
||||
// $response['folders'] = array_values($response['folders']);
|
||||
// }
|
||||
|
||||
$response['msg_success'] = __('IMAP folders retrieved: '.implode(', ', $response['folders']));
|
||||
} else {
|
||||
$response['msg_success'] = __('Connected, but no IMAP folders found');
|
||||
@@ -755,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'));
|
||||
|
||||
@@ -778,17 +919,23 @@ class MailboxesController extends Controller
|
||||
|
||||
// Mute notifications
|
||||
case 'mute':
|
||||
if (!$user) {
|
||||
\Helper::denyAccess();
|
||||
}
|
||||
|
||||
$mailbox = Mailbox::find($request->mailbox_id);
|
||||
|
||||
if (!$mailbox) {
|
||||
$response['msg'] = __('Mailbox not found');
|
||||
} elseif (!$user->can('view', $mailbox)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
$mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $mailbox->id)->first();
|
||||
if (!$mailbox_user) {
|
||||
// User may not be connected to the mailbox yet
|
||||
if (!$mailbox_user && $user->isAdmin()) {
|
||||
// Admin may not be connected to the mailbox yet
|
||||
$user->mailboxes()->attach($mailbox->id);
|
||||
$mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $mailbox->id)->first();
|
||||
}
|
||||
@@ -811,10 +958,36 @@ class MailboxesController extends Controller
|
||||
return \Response::json($response);
|
||||
}
|
||||
|
||||
// Recursively interate over folders.
|
||||
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;
|
||||
}
|
||||
|
||||
// Check for children and recurse.
|
||||
if (!empty($imap_folder->children)) {
|
||||
$response = $this->interateFolders($response, $imap_folder->children, true);
|
||||
}
|
||||
|
||||
// Old library.
|
||||
if (!empty($imap_folder->fullName)) {
|
||||
$response['folders'][] = $imap_folder->fullName;
|
||||
} elseif (!empty($imap_folder->full_name)) {
|
||||
// New library.
|
||||
$response['folders'][] = $imap_folder->full_name;
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function oauth(Request $request)
|
||||
{
|
||||
$mailbox_id = $request->id ?? '';
|
||||
$provider = $request->provider ?? '';
|
||||
$in_out = $request->in_out ?? 'in';
|
||||
|
||||
$state_data = [];
|
||||
if (!empty($request->state)) {
|
||||
@@ -825,6 +998,9 @@ class MailboxesController extends Controller
|
||||
if (!empty($state_data['provider'])) {
|
||||
$provider = $state_data['provider'];
|
||||
}
|
||||
if (!empty($state_data['in_out'])) {
|
||||
$in_out = $state_data['in_out'];
|
||||
}
|
||||
}
|
||||
|
||||
// MS Exchange.
|
||||
@@ -842,10 +1018,17 @@ class MailboxesController extends Controller
|
||||
if (empty($mailbox)) {
|
||||
return __('Mailbox not found').': '.$mailbox_id;
|
||||
}
|
||||
if (empty($mailbox->in_username)) {
|
||||
if ($in_out == 'in') {
|
||||
$username = $mailbox->getInOauthClientId();
|
||||
$password = $mailbox->in_password;
|
||||
} else {
|
||||
$username = $mailbox->getOutOauthClientId();
|
||||
$password = $mailbox->out_password;
|
||||
}
|
||||
if (empty($username)) {
|
||||
return 'Enter oAuth Client ID as Username and save mailbox settings';
|
||||
}
|
||||
if (empty($mailbox->in_password)) {
|
||||
if (empty($password)) {
|
||||
return 'Enter oAuth Client Secret as Password and save mailbox settings';
|
||||
}
|
||||
|
||||
@@ -855,14 +1038,16 @@ class MailboxesController extends Controller
|
||||
}
|
||||
|
||||
if (empty($request->code)) {
|
||||
// Start.
|
||||
$state = [
|
||||
'provider' => $provider,
|
||||
'mailbox_id' => $mailbox_id,
|
||||
'state' => crc32($mailbox->in_username.$mailbox->in_password),
|
||||
'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' => $mailbox->in_username,
|
||||
'client_id' => $username,
|
||||
]);
|
||||
if ($url) {
|
||||
\Session::put('mailbox_oauth_'.$provider.'_'.$mailbox_id, $state);
|
||||
@@ -883,21 +1068,40 @@ class MailboxesController extends Controller
|
||||
return 'Invalid oAuth state';
|
||||
|
||||
} else {
|
||||
|
||||
// state is set.
|
||||
// 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,
|
||||
$token_data = \MailHelper::oauthGetAccessToken($provider, [
|
||||
'client_id' => $username,
|
||||
'client_secret' => $password,
|
||||
'code' => $request->code,
|
||||
]);
|
||||
|
||||
if (!empty($token_data['a_token'])) {
|
||||
// Set username and password for the oppozite in_out.
|
||||
if ($in_out == 'in') {
|
||||
if (empty($mailbox->out_server)
|
||||
|| ($mailbox->isOutServerOauth()
|
||||
&& (!$mailbox->out_username || $mailbox->out_username == $username))
|
||||
) {
|
||||
$mailbox->out_username = $username;
|
||||
$mailbox->out_password = $password;
|
||||
}
|
||||
//$mailbox->out_method = Mailbox::OUT_METHOD_SMTP;
|
||||
} else {
|
||||
$mailbox->in_username = $username;
|
||||
$mailbox->in_password = $password;
|
||||
}
|
||||
$mailbox->setMetaParam('oauth', $token_data, true);
|
||||
} elseif (!empty($token_data['error'])) {
|
||||
return __('Error occurred').': '.htmlspecialchars($token_data['error']);
|
||||
}
|
||||
|
||||
return redirect()->route('mailboxes.connection.incoming', ['id' => $mailbox_id]);
|
||||
if ($in_out == 'in') {
|
||||
$route = 'mailboxes.connection.incoming';
|
||||
} else {
|
||||
$route = 'mailboxes.connection';
|
||||
}
|
||||
return redirect()->route($route, ['id' => $mailbox_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,12 +1109,24 @@ class MailboxesController extends Controller
|
||||
{
|
||||
$mailbox_id = $request->id ?? '';
|
||||
$provider = $request->provider ?? '';
|
||||
$in_out = $request->in_out ?? 'in';
|
||||
|
||||
$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);
|
||||
return \MailHelper::oauthDisconnect($provider, route('mailboxes.connection.incoming', ['id' => $mailbox_id]));
|
||||
|
||||
if ($in_out == 'in') {
|
||||
$route = 'mailboxes.connection.incoming';
|
||||
} else {
|
||||
$route = 'mailboxes.connection';
|
||||
}
|
||||
|
||||
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]), $mailbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ class ModulesController extends Controller
|
||||
|
||||
$flash = \Cache::get('modules_flash');
|
||||
if ($flash) {
|
||||
$flashes[] = $flash;
|
||||
if (is_array($flash) && !isset($flash['text'])) {
|
||||
$flashes = $flash;
|
||||
} else {
|
||||
$flashes[] = $flash;
|
||||
}
|
||||
\Cache::forget('modules_flash');
|
||||
}
|
||||
|
||||
@@ -72,6 +76,9 @@ class ModulesController extends Controller
|
||||
'installed' => true,
|
||||
'activated' => \App\Module::isLicenseActivated($module->getAlias(), $module->get('authorUrl')),
|
||||
'license' => \App\Module::getLicense($module->getAlias()),
|
||||
// Update configuration for third party modules
|
||||
'latestVersionNumberUrl' => $module->get('latestVersionUrl'),
|
||||
'latestVersionZipUrl' => $module->get('latestVersionZipUrl'),
|
||||
// Determined later
|
||||
'new_version' => '',
|
||||
];
|
||||
@@ -89,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]);
|
||||
|
||||
@@ -113,27 +127,86 @@ 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']);
|
||||
}
|
||||
$modules_directory[$i_dir]['active'] = \App\Module::isActive($dir_module['alias']);
|
||||
$modules_directory[$i_dir]['activated'] = false;
|
||||
|
||||
// Do not show third-party modules in Modules Derectory.
|
||||
// Do not show third-party modules in Modules Directory.
|
||||
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 {
|
||||
$modules_directory = [];
|
||||
}
|
||||
|
||||
// Loop through each installed module
|
||||
foreach ($installed_modules as $i_installed => $module) {
|
||||
// Check if the module is an official one
|
||||
if (\App\Module::isOfficial($module['authorUrl'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the URL for the latest version of the module
|
||||
$latest_version_number_url = $module['latestVersionNumberUrl'] ?? null;
|
||||
if (! $latest_version_number_url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a new Guzzle HTTP client
|
||||
$client = new \GuzzleHttp\Client();
|
||||
|
||||
try {
|
||||
// Send a GET request to the latest version URL
|
||||
$response = $client->request('GET', $latest_version_number_url, \Helper::setGuzzleDefaultOptions());
|
||||
|
||||
// Get the latest version number from the response body
|
||||
$latest_version = trim((string) $response->getBody());
|
||||
|
||||
if (empty($latest_version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it is the module.json file - try to parse the body.
|
||||
preg_match('#"version":[^"]*"([\d\.]+)"#', $latest_version, $m);
|
||||
if (!empty($m[1])) {
|
||||
$latest_version = $m[1];
|
||||
}
|
||||
|
||||
// Get the current version of the module
|
||||
$current_version = $module['version'];
|
||||
} catch (\Exception $e) {
|
||||
// If there's an exception, skip to the next iteration
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the latest version is greater than the current version
|
||||
if (version_compare($latest_version, $current_version, '>')) {
|
||||
// Update the installed module's version
|
||||
$installed_modules[ $i_installed ]['new_version'] = $latest_version;
|
||||
// Set the flag to indicate that updates are available
|
||||
$updates_available = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check modules symlinks. Somestimes instead of symlinks folders with files appear.
|
||||
$invalid_symlinks = \App\Module::checkSymlinks(
|
||||
collect($installed_modules)->where('active', true)->pluck('alias')->toArray()
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -141,6 +214,7 @@ class ModulesController extends Controller
|
||||
'flashes' => $flashes,
|
||||
'updates_available' => $updates_available,
|
||||
'all_modules' => $all_modules,
|
||||
'invalid_symlinks' => $invalid_symlinks,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -156,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');
|
||||
@@ -246,14 +321,17 @@ class ModulesController extends Controller
|
||||
$response['status'] = 'success';
|
||||
}
|
||||
} elseif (!empty($result['error'])) {
|
||||
$response['msg'] = $this->getErrorMessage($result['error'], $result);
|
||||
$response['msg'] = \App\Module::getErrorMessage($result['error'], $result);
|
||||
} else {
|
||||
$response['msg'] = __('Error occurred. Please try again later.');
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -266,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(),
|
||||
];
|
||||
@@ -290,8 +368,9 @@ class ModulesController extends Controller
|
||||
break;
|
||||
case 'inactive':
|
||||
$response['msg'] = __('License key has not been activated yet');
|
||||
break;
|
||||
case 'site_inactive':
|
||||
$response['msg'] = __('Your domain is deactivated');
|
||||
$response['msg'] = __('No activations left for this license key').' ('.__("Use 'Deactivate License' link above to transfer license key from another domain").')';
|
||||
break;
|
||||
}
|
||||
} elseif (!empty($license_result['status']) && $license_result['status'] == 'inactive') {
|
||||
@@ -305,18 +384,67 @@ class ModulesController extends Controller
|
||||
if (!empty($result['status']) && $result['status'] == 'valid') {
|
||||
// Success.
|
||||
} elseif (!empty($result['error'])) {
|
||||
$response['msg'] = $this->getErrorMessage($result['error'], $result);
|
||||
$response['msg'] = \App\Module::getErrorMessage($result['error'], $result);
|
||||
} else {
|
||||
// Some unknown error. Do nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
\App\Module::setActive($alias, true);
|
||||
|
||||
$user_locale = app()->getLocale();
|
||||
|
||||
$outputLog = new BufferedOutput();
|
||||
\Artisan::call('freescout:module-install', ['module_alias' => $alias], $outputLog);
|
||||
$output = $outputLog->fetch();
|
||||
@@ -327,6 +455,9 @@ class ModulesController extends Controller
|
||||
$name = $module->getName();
|
||||
}
|
||||
|
||||
// After clearing cache the locale may be not set.
|
||||
\Helper::setUserLocale($user_locale);
|
||||
|
||||
$type = 'danger';
|
||||
$msg = __('Error occurred activating ":name" module', ['name' => $name]);
|
||||
if (session('flashes_floating') && is_array(session('flashes_floating'))) {
|
||||
@@ -376,6 +507,8 @@ class ModulesController extends Controller
|
||||
$alias = $request->alias;
|
||||
\App\Module::setActive($alias, false);
|
||||
|
||||
$user_locale = app()->getLocale();
|
||||
|
||||
$outputLog = new BufferedOutput();
|
||||
\Artisan::call('freescout:clear-cache', [], $outputLog);
|
||||
$output = $outputLog->fetch();
|
||||
@@ -387,6 +520,9 @@ class ModulesController extends Controller
|
||||
$name = $module->getName();
|
||||
}
|
||||
|
||||
// After clearing cache the locale may be not set.
|
||||
\Helper::setUserLocale($user_locale);
|
||||
|
||||
$type = 'danger';
|
||||
$msg = __('Error occurred deactivating :name module', ['name' => $name]);
|
||||
if (strstr($output, 'Configuration cached successfully')) {
|
||||
@@ -446,7 +582,9 @@ class ModulesController extends Controller
|
||||
|
||||
$response['status'] = 'success';
|
||||
} elseif (!empty($result['error'])) {
|
||||
$response['msg'] = $this->getErrorMessage($result['error'], $result);
|
||||
$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.');
|
||||
}
|
||||
@@ -473,122 +611,34 @@ class ModulesController extends Controller
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$alias = $request->alias;
|
||||
$module = \Module::findByAlias($alias);
|
||||
$update_result = \App\Module::updateModule($request->alias);
|
||||
|
||||
if (!$module) {
|
||||
$response['msg'] = __('Module not found').': '.$alias;
|
||||
}
|
||||
if ($update_result['download_error']) {
|
||||
$response['reload'] = true;
|
||||
|
||||
// Download new version
|
||||
$download_error = false;
|
||||
if (!$response['msg']) {
|
||||
$params = [
|
||||
'license' => \App\Module::getLicense($alias),
|
||||
'module_alias' => $alias,
|
||||
'url' => \App\Module::getAppUrl(),
|
||||
];
|
||||
$license_details = WpApi::getVersion($params);
|
||||
if ($update_result['msg']) {
|
||||
\Session::flash('flash_error_floating', $update_result['msg']);
|
||||
}
|
||||
|
||||
if (WpApi::$lastError) {
|
||||
$response['msg'] = WpApi::$lastError['message'];
|
||||
} elseif (!empty($license_details['code']) && !empty($license_details['message'])) {
|
||||
$response['msg'] = $license_details['message'];
|
||||
} elseif (!empty($license_details['required_app_version']) && !\Helper::checkAppVersion($license_details['required_app_version'])) {
|
||||
$response['msg'] = 'Module requires app version:'.' '.$license_details['required_app_version'];
|
||||
} elseif (!empty($license_details['download_link'])) {
|
||||
// Download module
|
||||
$module_archive = \Module::getPath().DIRECTORY_SEPARATOR.$alias.'.zip';
|
||||
|
||||
try {
|
||||
\Helper::downloadRemoteFile($license_details['download_link'], $module_archive);
|
||||
} catch (\Exception $e) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
|
||||
if (!file_exists($module_archive)) {
|
||||
$download_error = true;
|
||||
} else {
|
||||
// Extract
|
||||
try {
|
||||
// Sometimes by some reason Public folder becomes a symlink leading to itself.
|
||||
// It causes an error during updating process.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2709
|
||||
$public_folder = $module->getPath().DIRECTORY_SEPARATOR.'Public';
|
||||
try {
|
||||
if (is_link($public_folder)) {
|
||||
unlink($public_folder);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
\Helper::unzip($module_archive, \Module::getPath());
|
||||
} catch (\Exception $e) {
|
||||
// We will show this as floating message on page reload
|
||||
$response['msg'] = $e->getMessage();
|
||||
}
|
||||
// Check if extracted module exists
|
||||
\Module::clearCache();
|
||||
$module = \Module::findByAlias($alias);
|
||||
if (!$module) {
|
||||
$download_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove archive
|
||||
if (file_exists($module_archive)) {
|
||||
\File::delete($module_archive);
|
||||
}
|
||||
|
||||
if ($download_error) {
|
||||
$response['reload'] = true;
|
||||
|
||||
if ($response['msg']) {
|
||||
\Session::flash('flash_error_floating', $response['msg']);
|
||||
}
|
||||
|
||||
\Session::flash('flash_error_unescaped', __('Error occurred downloading the module. Please :%a_being%download:%a_end% module manually and extract into :folder', ['%a_being%' => '<a href="'.$license_details['download_link'].'" target="_blank">', '%a_end%' => '</a>', 'folder' => '<strong>'.\Module::getPath().'</strong>']));
|
||||
}
|
||||
} elseif ($license_details['status'] && $response['msg'] = $this->getErrorMessage($license_details['status'])) {
|
||||
//$response['msg'] = ;
|
||||
} else {
|
||||
$response['msg'] = __('Error occurred. Please try again later.');
|
||||
if ($update_result['download_msg']) {
|
||||
\Session::flash('flash_error_unescaped', $update_result['download_msg']);
|
||||
}
|
||||
}
|
||||
|
||||
// Install updated module
|
||||
if (!$response['msg'] && !$download_error) {
|
||||
$outputLog = new BufferedOutput();
|
||||
\Artisan::call('freescout:module-install', ['module_alias' => $alias], $outputLog);
|
||||
$output = $outputLog->fetch();
|
||||
|
||||
// Get module name
|
||||
$name = '?';
|
||||
if ($module) {
|
||||
$name = $module->getName();
|
||||
}
|
||||
// Install updated module.
|
||||
if ($update_result['output'] || $update_result['status']) {
|
||||
|
||||
$type = 'danger';
|
||||
$msg = __('Error occurred activating ":name" module', ['name' => $name]);
|
||||
if (session('flashes_floating') && is_array(session('flashes_floating'))) {
|
||||
// If there was any error, module has been deactivated via modules.register_error filter
|
||||
$msg = '';
|
||||
foreach (session('flashes_floating') as $flash) {
|
||||
$msg .= $flash['text'].' ';
|
||||
}
|
||||
} elseif (strstr($output, 'Configuration cached successfully')) {
|
||||
$msg = $update_result['msg'];
|
||||
|
||||
if ($update_result['status'] == 'success') {
|
||||
$type = 'success';
|
||||
$msg = __('":name" module successfully updated!', ['name' => $name]);
|
||||
} else {
|
||||
// Deactivate module
|
||||
\App\Module::setActive($alias, false);
|
||||
\Artisan::call('freescout:clear-cache');
|
||||
$msg = $update_result['msg_success'];
|
||||
}
|
||||
|
||||
// \Session::flash does not work after BufferedOutput
|
||||
$flash = [
|
||||
'text' => '<strong>'.$msg.'</strong><pre class="margin-top">'.$output.'</pre>',
|
||||
'text' => '<strong>'.$msg.'</strong><pre class="margin-top">'.$update_result['output'].'</pre>',
|
||||
'unescaped' => true,
|
||||
'type' => $type,
|
||||
];
|
||||
@@ -598,6 +648,41 @@ class ModulesController extends Controller
|
||||
|
||||
break;
|
||||
|
||||
case 'update_all':
|
||||
$update_all_flashes = [];
|
||||
|
||||
foreach ($request->aliases as $alias) {
|
||||
$update_result = \App\Module::updateModule($alias);
|
||||
|
||||
$type = 'danger';
|
||||
$msg = $update_result['msg'];
|
||||
|
||||
if ($update_result['status'] == 'success') {
|
||||
$type = 'success';
|
||||
$msg = $update_result['msg_success'];
|
||||
} elseif ($update_result['download_msg']) {
|
||||
$msg .= '<br/>'.$update_result['download_msg'];
|
||||
}
|
||||
|
||||
$text = '<strong>'.$update_result['module_name'].':</strong> '.$msg;
|
||||
if (trim($update_result['output'])) {
|
||||
$text .= '<pre class="margin-top">'.$update_result['output'].'</pre>';
|
||||
}
|
||||
|
||||
// \Session::flash does not work after BufferedOutput
|
||||
$update_all_flashes[] = [
|
||||
'text' => $text,
|
||||
'unescaped' => true,
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
if ($update_all_flashes) {
|
||||
\Cache::forever('modules_flash', $update_all_flashes);
|
||||
}
|
||||
$response['status'] = 'success';
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
$response['msg'] = 'Unknown action';
|
||||
break;
|
||||
@@ -609,40 +694,4 @@ class ModulesController extends Controller
|
||||
|
||||
return \Response::json($response);
|
||||
}
|
||||
|
||||
public function getErrorMessage($code, $result = null)
|
||||
{
|
||||
$msg = '';
|
||||
|
||||
switch ($code) {
|
||||
case 'missing':
|
||||
$msg = __('License key does not exist');
|
||||
break;
|
||||
case 'license_not_activable':
|
||||
$msg = __("You have to activate each bundle's module separately");
|
||||
break;
|
||||
case 'disabled':
|
||||
$msg = __('License key has been revoked');
|
||||
break;
|
||||
case 'no_activations_left':
|
||||
$msg = __('No activations left for this license key').' ('.__('Use Deactivate License button to transfer license key from another domain').')';
|
||||
break;
|
||||
case 'expired':
|
||||
$msg = __('License key has expired');
|
||||
break;
|
||||
case 'key_mismatch':
|
||||
$msg = __('License key belongs to another module');
|
||||
break;
|
||||
case 'invalid_item_id':
|
||||
$msg = __('Module not found in the modules directory');
|
||||
break;
|
||||
default:
|
||||
if ($result && !empty($result['error'])) {
|
||||
$msg = __('Error code:'.' '.$result['error']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +104,20 @@ 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();
|
||||
}
|
||||
|
||||
$request_data = $request->all();
|
||||
// Do not allow user to set his role
|
||||
if (isset($request_data['role'])) {
|
||||
unset($request_data['role']);
|
||||
}
|
||||
if (isset($request_data['photo_url'])) {
|
||||
unset($request_data['photo_url']);
|
||||
}
|
||||
$request_data = [
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
'job_title' => $request->job_title,
|
||||
'phone' => $request->phone,
|
||||
'timezone' => $request->timezone,
|
||||
'time_format' => $request->time_format,
|
||||
];
|
||||
|
||||
$user->fill($request_data);
|
||||
|
||||
$user->password = bcrypt($request->password);
|
||||
@@ -108,26 +131,53 @@ class OpenController extends Controller
|
||||
// Login user
|
||||
Auth::guard()->login($user);
|
||||
|
||||
\Session::flash('flash_success_floating', __('Welcome to :company_name!', ['company_name' => Option::getCompanyName()]));
|
||||
\Session::flash('flash_success_floating', __('Welcome to :company_name!', ['company_name' => htmlspecialchars(Option::getCompanyName())]));
|
||||
|
||||
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);
|
||||
@@ -166,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();
|
||||
}
|
||||
|
||||
@@ -183,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'));
|
||||
@@ -112,12 +109,16 @@ class SecureController extends Controller
|
||||
'type',
|
||||
'email',
|
||||
'status',
|
||||
'conversation',
|
||||
'message',
|
||||
'user',
|
||||
'customer',
|
||||
];
|
||||
|
||||
$activities = SendLog::orderBy('created_at', 'desc')->paginate($page_size);
|
||||
$activities_query = SendLog::orderBy('created_at', 'desc');
|
||||
if ($request->get('thread_id')) {
|
||||
$activities_query->where('thread_id', $request->get('thread_id'));
|
||||
}
|
||||
$activities = $activities_query->paginate($page_size);
|
||||
|
||||
foreach ($activities as $record) {
|
||||
$conversation = '';
|
||||
@@ -132,13 +133,16 @@ class SecureController extends Controller
|
||||
$status .= '. Message-ID: '.$record->message_id;
|
||||
}
|
||||
}
|
||||
if ($record->smtp_queue_id) {
|
||||
$status .= '. SMTP ID: '.$record->smtp_queue_id;
|
||||
}
|
||||
|
||||
$logs[] = [
|
||||
'date' => $record->created_at,
|
||||
'type' => $record->getMailTypeName(),
|
||||
'email' => $record->email,
|
||||
'status' => $status,
|
||||
'conversation' => $conversation,
|
||||
'message' => $conversation,
|
||||
'user' => $record->user,
|
||||
'customer' => $record->customer,
|
||||
];
|
||||
@@ -193,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 = [
|
||||
@@ -213,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' => [
|
||||
@@ -103,7 +104,10 @@ class SettingsController extends Controller
|
||||
'mail_password' => [
|
||||
'safe_password' => true,
|
||||
'encrypt' => true,
|
||||
]
|
||||
],
|
||||
// 'use_mail_date_on_fetching' => [
|
||||
// 'env' => 'APP_USE_MAIL_DATE_ON_FETCHING',
|
||||
// ],
|
||||
],
|
||||
];
|
||||
break;
|
||||
@@ -113,6 +117,9 @@ class SettingsController extends Controller
|
||||
'custom_number' => [
|
||||
'env' => 'APP_CUSTOM_NUMBER',
|
||||
],
|
||||
'max_message_size' => [
|
||||
'env' => 'APP_MAX_MESSAGE_SIZE',
|
||||
],
|
||||
'email_conv_history' => [
|
||||
'env' => 'APP_EMAIL_CONV_HISTORY',
|
||||
],
|
||||
@@ -129,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;
|
||||
@@ -194,9 +204,11 @@ 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'),
|
||||
'max_message_size' => config('app.max_message_size'),
|
||||
'email_user_history' => config('app.email_user_history'),
|
||||
'enrich_customer_data' => Option::get('enrich_customer_data'),
|
||||
'time_format' => Option::get('time_format', User::TIME_FORMAT_24),
|
||||
@@ -214,6 +226,7 @@ class SettingsController extends Controller
|
||||
'mail_password' => \Helper::decrypt(Option::get('mail_password', \Config::get('mail.password'))),
|
||||
'mail_encryption' => Option::get('mail_encryption', \Config::get('mail.encryption')),
|
||||
'fetch_schedule' => config('app.fetch_schedule'),
|
||||
//'use_mail_date_on_fetching' => config('app.use_mail_date_on_fetching'),
|
||||
];
|
||||
break;
|
||||
case 'alerts':
|
||||
@@ -224,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:
|
||||
@@ -305,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);
|
||||
}
|
||||
|
||||
@@ -386,17 +401,22 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
$test_result = false;
|
||||
$test_result = [
|
||||
'status' => 'error',
|
||||
];
|
||||
|
||||
try {
|
||||
$test_result = \MailHelper::sendTestMail($request->to);
|
||||
} catch (\Exception $e) {
|
||||
$response['msg'] = $e->getMessage();
|
||||
$test_result['msg'] = $e->getMessage();
|
||||
}
|
||||
|
||||
if (!$test_result && !$response['msg']) {
|
||||
$response['msg'] = __('Error occurred sending email. Please check your mail server logs for more details.');
|
||||
if ($test_result['status'] == 'error') {
|
||||
$response['msg'] = $test_result['msg']
|
||||
?: __('Error occurred sending email. Please check your mail server logs for more details.');
|
||||
}
|
||||
|
||||
$response['log'] = $test_result['log'] ?? '';
|
||||
}
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
@@ -53,10 +53,16 @@ class SystemController extends Controller
|
||||
// Check if cache files are writable.
|
||||
$non_writable_cache_file = '';
|
||||
if (function_exists('shell_exec')) {
|
||||
$non_writable_cache_file = shell_exec('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 ?? '');
|
||||
if (!strstr($non_writable_cache_file, base_path('storage/framework/cache/data/'))) {
|
||||
$non_writable_cache_file = '';
|
||||
$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');
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,14 +84,14 @@ class SystemController extends Controller
|
||||
$env_is_writable = is_writable(base_path('.env'));
|
||||
|
||||
// Jobs
|
||||
$queued_jobs = \App\Job::orderBy('created_at', 'desc')->get();
|
||||
$failed_jobs = \App\FailedJob::orderBy('failed_at', 'desc')->get();
|
||||
$queued_jobs = \App\Job::orderBy('created_at', 'desc')->limit(100)->get();
|
||||
$failed_jobs = \App\FailedJob::orderBy('failed_at', 'desc')->limit(100)->get();
|
||||
$failed_queues = $failed_jobs->pluck('queue')->unique();
|
||||
|
||||
// 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 = [];
|
||||
@@ -95,19 +101,29 @@ class SystemController extends Controller
|
||||
$running_commands = 0;
|
||||
|
||||
try {
|
||||
$processes = preg_split("/[\r\n]/", shell_exec("ps aux | 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) {
|
||||
@@ -117,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) {
|
||||
@@ -127,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;
|
||||
@@ -149,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');
|
||||
@@ -157,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) {
|
||||
@@ -167,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]);
|
||||
}
|
||||
@@ -198,6 +214,11 @@ class SystemController extends Controller
|
||||
$latest_version = \Config::get('app.version');
|
||||
}
|
||||
|
||||
// Detect missing migrations.
|
||||
$migrations_output = \Helper::runCommand('migrate:status');
|
||||
preg_match_all("#\| N \| ([^\|]+)\|#", $migrations_output, $migrations_m);
|
||||
$missing_migrations = $migrations_m[1] ?? [];
|
||||
|
||||
return view('system/status', [
|
||||
'commands' => $commands,
|
||||
'queued_jobs' => $queued_jobs,
|
||||
@@ -212,6 +233,8 @@ class SystemController extends Controller
|
||||
'public_symlink_exists' => $public_symlink_exists,
|
||||
'env_is_writable' => $env_is_writable,
|
||||
'non_writable_cache_file' => $non_writable_cache_file,
|
||||
'missing_migrations' => $missing_migrations,
|
||||
'invalid_symlinks' => \App\Module::checkSymlinks(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -281,6 +304,7 @@ class SystemController extends Controller
|
||||
$params = [];
|
||||
$params['--days'] = (int)$request->days;
|
||||
$params['--unseen'] = (int)$request->unseen;
|
||||
$params['--debug'] = (int)$request->debug;
|
||||
\Artisan::call('freescout:fetch-emails', $params, $outputLog);
|
||||
break;
|
||||
|
||||
@@ -368,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)
|
||||
{
|
||||
@@ -394,10 +424,10 @@ class SystemController extends Controller
|
||||
$payload = json_decode($job->payload, true);
|
||||
|
||||
if (!empty($payload['data']['command'])) {
|
||||
$html .= '<pre>'.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>'.$job->exception.'</pre>';
|
||||
$html .= '<pre>'.\Helper::stripDangerousTags($job->exception).'</pre>';
|
||||
|
||||
return response($html);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\UserDeleted;
|
||||
use App\Folder;
|
||||
use App\Mailbox;
|
||||
use App\Subscription;
|
||||
@@ -33,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]);
|
||||
@@ -45,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]);
|
||||
}
|
||||
@@ -63,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))],
|
||||
];
|
||||
@@ -86,24 +92,44 @@ class UsersController extends Controller
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->fill($request->all());
|
||||
$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 ?? '',
|
||||
'email' => $request->email,
|
||||
'password' => $password,
|
||||
];
|
||||
|
||||
$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
|
||||
@@ -141,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 [];
|
||||
}
|
||||
@@ -161,10 +187,12 @@ class UsersController extends Controller
|
||||
$user = User::findOrFail($id);
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$auth_user = auth()->user();
|
||||
|
||||
// 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',
|
||||
@@ -213,7 +241,7 @@ class UsersController extends Controller
|
||||
}
|
||||
|
||||
// Save language into session.
|
||||
if (auth()->user()->id == $id && $request->locale) {
|
||||
if ($auth_user->id == $id && $request->locale) {
|
||||
session()->put('user_locale', $request->locale);
|
||||
}
|
||||
|
||||
@@ -222,7 +250,7 @@ class UsersController extends Controller
|
||||
if (isset($request_data['photo_url'])) {
|
||||
unset($request_data['photo_url']);
|
||||
}
|
||||
if (!auth()->user()->can('changeRole', $user)) {
|
||||
if (!$auth_user->can('changeRole', $user)) {
|
||||
unset($request_data['role']);
|
||||
}
|
||||
if ($user->status != User::STATUS_DELETED) {
|
||||
@@ -232,10 +260,51 @@ class UsersController extends Controller
|
||||
$request_data['status'] = User::STATUS_ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize $request_data array.
|
||||
// $allowed_fields = [
|
||||
// 'first_name',
|
||||
// 'last_name',
|
||||
// 'email',
|
||||
// 'emails',
|
||||
// 'job_title',
|
||||
// 'phone',
|
||||
// 'locale',
|
||||
// 'timezone'
|
||||
// 'time_format',
|
||||
// 'photo_url',
|
||||
// ];
|
||||
$admin_fields = [
|
||||
'role',
|
||||
'status',
|
||||
'email',
|
||||
'only_assigned_tickets',
|
||||
];
|
||||
$nonfillable_fields = [
|
||||
'type',
|
||||
'password',
|
||||
];
|
||||
if (!$auth_user->isAdmin()) {
|
||||
foreach ($admin_fields as $field) {
|
||||
if (isset($request_data[$field])) {
|
||||
unset($request_data[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($nonfillable_fields as $field) {
|
||||
if (isset($request_data[$field])) {
|
||||
unset($request_data[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$user->setData($request_data);
|
||||
|
||||
if (empty($request->input('enable_kb_shortcuts'))) {
|
||||
$user->enable_kb_shortcuts = false;
|
||||
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);
|
||||
@@ -259,7 +328,11 @@ class UsersController extends Controller
|
||||
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$mailboxes = Mailbox::all();
|
||||
if ($user->isDeleted()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mailboxes = Mailbox::all()->sortBy('name');
|
||||
|
||||
$users = $this->getUsersForSidebar($id);
|
||||
|
||||
@@ -286,7 +359,7 @@ class UsersController extends Controller
|
||||
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$user->mailboxes()->sync($request->mailboxes);
|
||||
$user->mailboxes()->sync($request->mailboxes ?: []);
|
||||
$user->syncPersonalFolders($request->mailboxes);
|
||||
|
||||
// Save permissions.
|
||||
@@ -315,8 +388,12 @@ 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);
|
||||
}
|
||||
|
||||
$subscriptions = $user->subscriptions()->select('medium', 'event')->get();
|
||||
|
||||
$person = '';
|
||||
@@ -344,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);
|
||||
|
||||
@@ -479,108 +556,26 @@ class UsersController extends Controller
|
||||
$response['msg'] = __('User not found');
|
||||
} elseif (!$auth_user->can('delete', $user)) {
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
} elseif ($auth_user->id == $user->id) {
|
||||
// Do not allow admin delete himself.
|
||||
$response['msg'] = __('Not enough permissions');
|
||||
}
|
||||
|
||||
// Check if the user is the only one admin
|
||||
if (!$response['msg'] && $user->isAdmin()) {
|
||||
$admins_count = User::where('role', User::ROLE_ADMIN)->count();
|
||||
if ($admins_count < 2) {
|
||||
$response['msg'] = __('Administrator can not be deleted');
|
||||
}
|
||||
}
|
||||
// - not needed as only admin can delete users and
|
||||
// current admin can not delete himself.
|
||||
// if (!$response['msg'] && $user->isAdmin()) {
|
||||
// $admins_count = User::where('role', User::ROLE_ADMIN)->count();
|
||||
// if ($admins_count < 2) {
|
||||
// $response['msg'] = __('Administrator can not be deleted');
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!$response['msg']) {
|
||||
|
||||
// We have to process conversations one by one to move them to Unassigned folder,
|
||||
// as conversations may be in different mailboxes
|
||||
// $user->conversations()->update(['user_id' => null, 'folder_id' => ]);
|
||||
$mailbox_unassigned_folders = [];
|
||||
$user->deleteUser($auth_user, $request->assign_user);
|
||||
|
||||
$user->conversations->each(function ($conversation) use ($auth_user, $request) {
|
||||
// We don't fire ConversationUserChanged event to avoid sending notifications to users
|
||||
if (!empty($request->assign_user)
|
||||
&& !empty($request->assign_user[$conversation->mailbox_id])
|
||||
&& (int) $request->assign_user[$conversation->mailbox_id] != -1
|
||||
) {
|
||||
// Set assignee.
|
||||
// In this case conversation stays assigned, just assignee changes.
|
||||
$conversation->user_id = $request->assign_user[$conversation->mailbox_id];
|
||||
|
||||
} else {
|
||||
|
||||
// Make convesation Unassigned.
|
||||
|
||||
// Unset assignee.
|
||||
// Maybe use changeUser() here.
|
||||
$conversation->user_id = null;
|
||||
|
||||
if ($conversation->isPublished()
|
||||
&& ($conversation->isActive() || $conversation->isPending())
|
||||
) {
|
||||
// Change conversation folder to UNASSIGNED.
|
||||
$folder_id = null;
|
||||
if (!empty($mailbox_unassigned_folders[$conversation->mailbox_id])) {
|
||||
$folder_id = $mailbox_unassigned_folders[$conversation->mailbox_id];
|
||||
} else {
|
||||
$folder = $conversation->mailbox->folders()
|
||||
->where('type', Folder::TYPE_UNASSIGNED)
|
||||
->first();
|
||||
|
||||
if ($folder) {
|
||||
$folder_id = $folder->id;
|
||||
$mailbox_unassigned_folders[$conversation->mailbox_id] = $folder_id;
|
||||
}
|
||||
}
|
||||
if ($folder_id) {
|
||||
$conversation->folder_id = $folder_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$conversation->save();
|
||||
|
||||
// Create lineitem thread
|
||||
$thread = new Thread();
|
||||
$thread->conversation_id = $conversation->id;
|
||||
$thread->user_id = $conversation->user_id;
|
||||
$thread->type = Thread::TYPE_LINEITEM;
|
||||
$thread->state = Thread::STATE_PUBLISHED;
|
||||
$thread->status = Thread::STATUS_NOCHANGE;
|
||||
$thread->action_type = Thread::ACTION_TYPE_USER_CHANGED;
|
||||
$thread->source_via = Thread::PERSON_USER;
|
||||
$thread->source_type = Thread::SOURCE_TYPE_WEB;
|
||||
$thread->customer_id = $conversation->customer_id;
|
||||
$thread->created_by_user_id = $auth_user->id;
|
||||
$thread->save();
|
||||
});
|
||||
|
||||
// Recalculate counters for folders
|
||||
//if ($user->isAdmin()) {
|
||||
// Admin has access to all mailboxes
|
||||
Mailbox::all()->each(function ($mailbox) {
|
||||
$mailbox->updateFoldersCounters();
|
||||
});
|
||||
// } else {
|
||||
// $user->mailboxes->each(function ($mailbox) {
|
||||
// $mailbox->updateFoldersCounters();
|
||||
// });
|
||||
// }
|
||||
|
||||
// Disconnect user from mailboxes.
|
||||
$user->mailboxes()->sync([]);
|
||||
$user->folders()->delete();
|
||||
|
||||
$user->status = \App\User::STATUS_DELETED;
|
||||
// Update email.
|
||||
$email_suffix = User::EMAIL_DELETED_SUFFIX.date('YmdHis');
|
||||
// We have to truncate email to avoid "Data too long" error.
|
||||
$user->email = mb_substr($user->email, 0, User::EMAIL_MAX_LENGTH - mb_strlen($email_suffix)).$email_suffix;
|
||||
|
||||
$user->save();
|
||||
|
||||
event(new UserDeleted($user, $auth_user));
|
||||
|
||||
\Session::flash('flash_success_floating', __('User deleted').': '.$user->getFullName());
|
||||
\Session::flash('flash_success_floating', __('User deleted').': '.htmlspecialchars($user->getFullName()));
|
||||
|
||||
$response['status'] = 'success';
|
||||
}
|
||||
@@ -588,6 +583,7 @@ class UsersController extends Controller
|
||||
|
||||
default:
|
||||
$response['msg'] = 'Unknown action';
|
||||
$response = \Eventy::filter('users.ajax.response_default', $response, $request);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +40,18 @@ 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,
|
||||
],
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ class CustomHandle
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// Enable/disable chat mode
|
||||
if ($request->exists('chat_mode')) {
|
||||
\Helper::setChatMode((int)$request->chat_mode);
|
||||
}
|
||||
|
||||
// Hook.
|
||||
\Eventy::action('middleware.web.custom_handle', $request);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class EncryptCookies extends Middleware
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
'in_app'
|
||||
'in_app',
|
||||
'customers_view',
|
||||
];
|
||||
}
|
||||
|
||||
32
app/Http/Middleware/FrameGuard.php
Normal file
32
app/Http/Middleware/FrameGuard.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
||||
class FrameGuard
|
||||
{
|
||||
/**
|
||||
* Handle the given request and get the response.
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$x_frame_options = config('app.x_frame_options');
|
||||
|
||||
if (false !== $x_frame_options
|
||||
&& $x_frame_options !== 'false'
|
||||
&& $x_frame_options !== '0'
|
||||
) {
|
||||
$value = 'SAMEORIGIN';
|
||||
|
||||
if (is_string($x_frame_options) && preg_match("#(DENY|SAMEORIGIN|ALLOW-FROM)#i", $x_frame_options)) {
|
||||
$value = $x_frame_options;
|
||||
}
|
||||
$response->headers->set('X-Frame-Options', $value, false);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -11,41 +11,38 @@ use Closure;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HttpsRedirect {
|
||||
|
||||
class HttpsRedirect
|
||||
{
|
||||
/**
|
||||
* The current proxy header mappings.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $headers = [
|
||||
Request::HEADER_FORWARDED => 'FORWARDED',
|
||||
Request::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR',
|
||||
Request::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST',
|
||||
Request::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT',
|
||||
Request::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO',
|
||||
];
|
||||
// protected $headers = [
|
||||
// Request::HEADER_FORWARDED => 'FORWARDED',
|
||||
// Request::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR',
|
||||
// Request::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST',
|
||||
// Request::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT',
|
||||
// Request::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO',
|
||||
// ];
|
||||
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (\Helper::isHttps()) {
|
||||
$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
|
||||
|
||||
if (!$request->secure() && strtolower($_SERVER['HTTPS'] ?? '') != 'on'
|
||||
&& ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') != 'https'
|
||||
&& ($_SERVER['HTTP_CF_VISITOR'] ?? '') != '{"scheme":"https"}'
|
||||
) {
|
||||
if (\Helper::isHttps() && !\Helper::isConsole()) {
|
||||
//$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
|
||||
//!$request->secure()
|
||||
if (!\Helper::isCurrentUrlHttps()) {
|
||||
return redirect()->secure($request->getRequestUri());
|
||||
}
|
||||
}
|
||||
|
||||
// Correct protocol in $_SERVER
|
||||
if (\Helper::isHttps()
|
||||
&& !$request->secure()
|
||||
//&& !$request->secure()
|
||||
&& strtolower($_SERVER['HTTPS'] ?? '') != 'on'
|
||||
) {
|
||||
$_SERVER['HTTPS'] = 'on';
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,30 @@ use Closure;
|
||||
|
||||
class ResponseHeaders
|
||||
{
|
||||
private $unwanted_headers = [
|
||||
'X-Powered-By',
|
||||
'Server',
|
||||
];
|
||||
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
$this->removeUnwantedHeaders($this->unwanted_headers);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Disable caching
|
||||
// Secure headers.
|
||||
//$response->headers->set('Referrer-Policy', 'no-referrer-when-downgrade');
|
||||
$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('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');
|
||||
$response->header('Cache-Control', 'no-cache, max-age=0, must-revalidate, no-store');
|
||||
@@ -18,4 +37,11 @@ class ResponseHeaders
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function removeUnwantedHeaders($headerList)
|
||||
{
|
||||
foreach ($headerList as $header) {
|
||||
header_remove($header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,31 @@ class TokenAuth
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (!$request->user() && !empty($request->auth_token) && $request->cookie('in_app')) {
|
||||
try {
|
||||
$user = User::where(\DB::raw('md5(CONCAT(id, "'.config('app.key').'"))') , $request->auth_token)
|
||||
->first();
|
||||
} catch (\Exception $e) {
|
||||
\Helper::logException($e, '[TokenAuth]');
|
||||
// This is needed to restore authentication when app session expires.
|
||||
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).'$';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
58
app/Job.php
58
app/Job.php
@@ -2,14 +2,72 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Thread;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Job extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public $payload_decoded = null;
|
||||
|
||||
/**
|
||||
* Automatically converted into Carbon dates.
|
||||
*/
|
||||
protected $dates = ['created_at', 'available_at', 'reserved_at'];
|
||||
|
||||
public function getPayloadDecoded()
|
||||
{
|
||||
if ($this->payload_decoded !== null) {
|
||||
return $this->payload_decoded;
|
||||
}
|
||||
|
||||
$this->payload_decoded = json_decode($this->payload, true);
|
||||
|
||||
return $this->payload_decoded;
|
||||
}
|
||||
|
||||
public function getCommand($allowed_classes = [])
|
||||
{
|
||||
return self::getPayloadCommand($this->getPayloadDecoded(), $allowed_classes);
|
||||
}
|
||||
|
||||
public function getCommandLastThread()
|
||||
{
|
||||
$command = $this->getCommand();
|
||||
if ($command && !empty($command->threads)) {
|
||||
return Thread::getLastThread($command->threads);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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'], ['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()
|
||||
|
||||
@@ -58,11 +58,7 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
\App\Misc\Mail::setMailDriver($mailbox, null, $this->conversation);
|
||||
|
||||
// Threads has to be sorted here, if sorted before, they come here in wrong order
|
||||
$this->threads = $this->threads->sortByDesc(function ($item, $key) {
|
||||
// Threads has to be sorted by created_at and not by id.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2938
|
||||
return $item->created_at->getTimestamp();
|
||||
});
|
||||
$this->threads = Thread::sortThreads($this->threads);
|
||||
|
||||
$headers = [];
|
||||
$last_thread = $this->threads->first();
|
||||
@@ -76,6 +72,13 @@ class SendNotificationToUsers implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not send email notifications to support agents
|
||||
// if the email is a bounce and mail server send limit reached.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4806
|
||||
if ($last_thread->isBounce() && strstr($last_thread->body, 'message limit exceeded')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit conversation history
|
||||
if (config('app.email_user_history') == 'last') {
|
||||
$this->threads = $this->threads->slice(0, 2);
|
||||
@@ -93,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;
|
||||
@@ -106,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) {
|
||||
@@ -120,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
|
||||
@@ -151,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 = '';
|
||||
@@ -182,21 +193,33 @@ 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
|
||||
if ($this->attempts() < $this->tries) {
|
||||
// We do not try to resend Bounce messages: https://github.com/freescout-helpdesk/freescout/issues/3156
|
||||
if ($this->attempts() < $this->tries && !$last_thread->isBounce()) {
|
||||
if ($this->attempts() == 1) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Mail\ReplyToCustomer;
|
||||
use App\Customer;
|
||||
use App\SendLog;
|
||||
use App\Thread;
|
||||
use App\Misc\SwiftGetSmtpQueueId;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Mail;
|
||||
use Webklex\IMAP\Client;
|
||||
//use Webklex\IMAP\Client;
|
||||
|
||||
class SendReplyToCustomer implements ShouldQueue
|
||||
{
|
||||
@@ -21,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;
|
||||
@@ -49,6 +55,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
{
|
||||
$this->conversation = $conversation;
|
||||
$this->threads = $threads;
|
||||
// Recipient.
|
||||
$this->customer = $customer;
|
||||
}
|
||||
|
||||
@@ -80,10 +87,8 @@ 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 = $forwarded_replies->sortByDesc(function ($item, $key) {
|
||||
return $item->created_at;
|
||||
});
|
||||
$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));
|
||||
|
||||
if ($forward_parent_thread) {
|
||||
@@ -95,20 +100,34 @@ 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Threads has to be sorted here, if sorted before, they come here in wrong order
|
||||
$this->threads = $this->threads->sortByDesc(function ($item, $key) {
|
||||
// Threads has to be sorted by created_at and not by id.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2938
|
||||
return $item->created_at->getTimestamp();
|
||||
});
|
||||
$this->threads = Thread::sortThreads($this->threads);
|
||||
|
||||
$new = false;
|
||||
$headers = [];
|
||||
|
||||
$this->last_thread = $this->threads->first();
|
||||
|
||||
if ($this->last_thread === null) {
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +138,17 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// After sending an email we are saving it into "IMAP Folder To Save Outgoing Replies".
|
||||
// This process may stuck or make SendReplyToCustomer job die with
|
||||
// "Allowed memory size of NNN bytes exhausted" error.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3632
|
||||
if ($this->attempts() >= 1 &&
|
||||
($this->last_thread->send_status == SendLog::STATUS_ACCEPTED
|
||||
|| $this->last_thread->isSendStatusSuccess())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (count($this->threads) == 1) {
|
||||
$new = true;
|
||||
}
|
||||
@@ -133,6 +163,82 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// In-Reply-To and References headers.
|
||||
$references = '';
|
||||
if (!$new && !empty($last_customer_thread) && $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;
|
||||
$references_array = [];
|
||||
foreach ($this->threads as $thread) {
|
||||
if ($i > 0) {
|
||||
$reference = $thread->getMessageId();
|
||||
if ($reference) {
|
||||
$references_array[] = $reference;
|
||||
}
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
if ($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;
|
||||
}
|
||||
}
|
||||
|
||||
// For Outlook threading.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4922
|
||||
if ($last_customer_thread) {
|
||||
$outloook_thread_index = $last_customer_thread->getHeader('Thread-Index');
|
||||
if (!empty($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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation history.
|
||||
$email_conv_history = config('app.email_conv_history');
|
||||
|
||||
@@ -174,19 +280,41 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
}
|
||||
|
||||
// Configure mail driver according to Mailbox settings
|
||||
\App\Misc\Mail::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation);
|
||||
\MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation, $this->last_thread);
|
||||
|
||||
// Get penultimate email Message-Id if reply
|
||||
if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
|
||||
|
||||
$headers['In-Reply-To'] = '<'.$last_customer_thread->message_id.'>';
|
||||
$headers['References'] = '<'.$last_customer_thread->message_id.'>';
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3330
|
||||
if (!\MailHelper::$smtp_queue_id_plugin_registered) {
|
||||
\Mail::getSwiftMailer()->registerPlugin(new SwiftGetSmtpQueueId());
|
||||
\MailHelper::$smtp_queue_id_plugin_registered = true;
|
||||
}
|
||||
|
||||
$this->message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER.'-'.$this->last_thread->id.'-'.\MailHelper::getMessageIdHash($this->last_thread->id).'@'.$mailbox->getEmailDomain();
|
||||
$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
|
||||
if (!$this->customer_email && $this->conversation->isPhone()) {
|
||||
$this->customer_email = $this->conversation->customer->getMainEmail();
|
||||
if (!$this->customer_email) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get customer by email
|
||||
if (!$this->customer) {
|
||||
$this->customer = Customer::getByEmail($this->customer_email);
|
||||
if (!$this->customer) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$to_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getToArray());
|
||||
$cc_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getCcArray());
|
||||
$bcc_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getBccArray());
|
||||
@@ -227,17 +355,25 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
if (!$new && !$is_forward) {
|
||||
$subject = 'Re: '.$subject;
|
||||
}
|
||||
$subject = \Eventy::filter('email.reply_to_customer.subject', $subject, $this->conversation);
|
||||
$subject = \Eventy::filter('email.reply_to_customer.subject', $subject, $this->conversation, $this->last_thread);
|
||||
$this->threads = \Eventy::filter('email.reply_to_customer.threads', $this->threads, $this->conversation, $mailbox);
|
||||
|
||||
$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;
|
||||
|
||||
try {
|
||||
Mail::to($to)
|
||||
->cc($cc_array)
|
||||
->bcc($bcc_array)
|
||||
->send($reply_mail);
|
||||
|
||||
$this->last_thread->send_status = SendLog::STATUS_ACCEPTED;
|
||||
$this->last_thread->save();
|
||||
|
||||
$smtp_queue_id = SwiftGetSmtpQueueId::$last_smtp_queue_id;
|
||||
} catch (\Exception $e) {
|
||||
// We come here in case SMTP server unavailable for example
|
||||
if ($this->attempts() == 1) {
|
||||
@@ -245,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);
|
||||
@@ -261,6 +399,14 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
|
||||
$error_message = $e->getMessage();
|
||||
|
||||
// Remove stack trace from error message.
|
||||
$error_message = preg_replace('#[\r\n]*" in /.*#s', '"', $error_message);
|
||||
|
||||
// SMTP response code is stored in the exception message:
|
||||
// Expected response code 235 but got code "535", with message...
|
||||
preg_match('#but got code "(\d+)",#', $error_message, $response_m);
|
||||
$response_code = (int)($response_m[1] ?? 0);
|
||||
|
||||
// Retry job with delay.
|
||||
// https://stackoverflow.com/questions/35258175/how-can-i-create-delays-between-failed-queued-job-attempts-in-laravel
|
||||
if ($this->attempts() < $this->tries && !preg_match("/".config("app.no_retry_mail_errors")."/i", $error_message)) {
|
||||
@@ -272,6 +418,13 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
$this->release(3600);
|
||||
}
|
||||
|
||||
// If an email has not been sent after 1 hour - show an error message to support agent.
|
||||
if ($this->attempts() >= 3 || $response_code >= 500) {
|
||||
$this->last_thread->send_status = SendLog::STATUS_SEND_INTERMEDIATE_ERROR;
|
||||
$this->last_thread->updateSendStatusData(['msg' => $error_message]);
|
||||
$this->last_thread->save();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
} else {
|
||||
$this->last_thread->send_status = SendLog::STATUS_SEND_ERROR;
|
||||
@@ -285,78 +438,108 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
SwiftGetSmtpQueueId::$last_smtp_queue_id = null;
|
||||
|
||||
// Clean error message if email finally has been sent.
|
||||
if ($this->last_thread->send_status == SendLog::STATUS_SEND_ERROR) {
|
||||
$this->last_thread->send_status = null;
|
||||
$this->last_thread->updateSendStatusData(['msg' => '']);
|
||||
$this->last_thread->save();
|
||||
}
|
||||
|
||||
$imap_sent_folder = $mailbox->imap_sent_folder;
|
||||
if ($imap_sent_folder) {
|
||||
|
||||
if ($imap_sent_folder && \MailHelper::$smtp_mime_message) {
|
||||
try {
|
||||
$client = \MailHelper::getMailboxClient($mailbox);
|
||||
|
||||
$client->connect();
|
||||
|
||||
$envelope['from'] = $mailbox->getMailFrom(null, $this->conversation)['address'];
|
||||
$envelope['to'] = $this->customer_email;
|
||||
$envelope['subject'] = $subject;
|
||||
$envelope['date'] = now()->toRfc2822String();
|
||||
$envelope['message_id'] = $this->message_id;
|
||||
// $mail_from = $mailbox->getMailFrom($this->last_thread->created_by_user ?? null, $this->conversation);
|
||||
|
||||
// Get penultimate email Message-Id if reply
|
||||
if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
|
||||
$envelope['custom_headers'] = [
|
||||
'In-Reply-To: <'.$last_customer_thread->message_id.'>',
|
||||
'References: <'.$last_customer_thread->message_id.'>'
|
||||
];
|
||||
}
|
||||
// Remove new lines to avoid "imap_mail_compose(): header injection attempt in subject".
|
||||
foreach ($envelope as $i => $envelope_value) {
|
||||
$envelope[$i] = preg_replace("/[\r\n]/", '', $envelope_value);
|
||||
}
|
||||
// if (!empty($mail_from['name'])) {
|
||||
// $envelope['from'] = '"'.$mail_from['name'].'" <'.$mail_from['address'].'>';
|
||||
// } else {
|
||||
// $envelope['from'] = $mail_from['address'];
|
||||
// }
|
||||
// if (is_array($to) && !empty($to[0]) && !empty($to[0]['name']) && !empty($to[0]['email'])) {
|
||||
// $envelope['to'] = '"'.$to[0]['name'].'" <'.$to[0]['email'].'>';
|
||||
// } else {
|
||||
// $envelope['to'] = $this->customer_email;
|
||||
// }
|
||||
// $envelope['subject'] = $subject;
|
||||
// $envelope['date'] = now()->toRfc2822String();
|
||||
// $envelope['message_id'] = $this->message_id;
|
||||
|
||||
$parts = [];
|
||||
// // CC.
|
||||
// if (count($cc_array)) {
|
||||
// $envelope['cc'] = implode(',', $cc_array);
|
||||
// }
|
||||
|
||||
// Multipart flag.
|
||||
if ($this->last_thread->has_attachments) {
|
||||
$multipart = [];
|
||||
$multipart["type"] = TYPEMULTIPART;
|
||||
$multipart["subtype"] = "alternative";
|
||||
$parts[] = $multipart;
|
||||
}
|
||||
// // Get penultimate email Message-Id if reply
|
||||
// if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
|
||||
// $envelope['custom_headers'] = [
|
||||
// 'In-Reply-To: <'.$last_customer_thread->message_id.'>',
|
||||
// 'References: '.$references,
|
||||
// ];
|
||||
// }
|
||||
// // Remove new lines to avoid "imap_mail_compose(): header injection attempt in subject".
|
||||
// foreach ($envelope as $i => $envelope_value) {
|
||||
// $envelope[$i] = preg_replace("/[\r\n]/", '', $envelope_value);
|
||||
// }
|
||||
|
||||
// Body.
|
||||
$part_body['type'] = TYPETEXT;
|
||||
$part_body['subtype'] = 'html';
|
||||
$part_body['contents.data'] = $reply_mail->render();
|
||||
$part_body['charset'] = 'utf-8';
|
||||
// $parts = [];
|
||||
|
||||
$parts[] = $part_body;
|
||||
// // Multipart flag.
|
||||
// if ($this->last_thread->has_attachments) {
|
||||
// $multipart = [];
|
||||
// $multipart["type"] = \Webklex\PHPIMAP\IMAP::TYPEMULTIPART;
|
||||
// $multipart["subtype"] = "mixed";
|
||||
// // https://github.com/freescout-helpdesk/freescout/issues/3934
|
||||
// //$multipart["subtype"] = "alternative";
|
||||
// $parts[] = $multipart;
|
||||
// }
|
||||
|
||||
// Add attachments.
|
||||
if ($this->last_thread->has_attachments) {
|
||||
// // Body.
|
||||
// $part_body['type'] = \Webklex\PHPIMAP\IMAP::TYPETEXT;
|
||||
// $part_body['subtype'] = 'html';
|
||||
// $part_body['contents.data'] = $reply_mail->render();
|
||||
// $part_body['charset'] = 'utf-8';
|
||||
|
||||
foreach ($this->last_thread->attachments as $attachment) {
|
||||
// $parts[] = $part_body;
|
||||
|
||||
if ($attachment->embedded) {
|
||||
continue;
|
||||
}
|
||||
// // Add attachments.
|
||||
// if ($this->last_thread->has_attachments) {
|
||||
|
||||
if ($attachment->fileExists()) {
|
||||
$part = [];
|
||||
$part["type"] = 'APPLICATION';
|
||||
$part["encoding"] = ENCBASE64;
|
||||
$part["subtype"] = "octet-stream";
|
||||
$part["description"] = $attachment->file_name;
|
||||
$part['disposition.type'] = 'attachment';
|
||||
$part['disposition'] = array('filename' => $attachment->file_name);
|
||||
$part['type.parameters'] = array('name' => $attachment->file_name);
|
||||
$part["description"] = '';
|
||||
$part["contents.data"] = base64_encode($attachment->getFileContents());
|
||||
// foreach ($this->last_thread->attachments as $attachment) {
|
||||
|
||||
// if ($attachment->embedded) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// if ($attachment->fileExists()) {
|
||||
// $part = [];
|
||||
// $part["type"] = 'APPLICATION';
|
||||
// $part["encoding"] = \Webklex\PHPIMAP\IMAP::ENCBASE64;
|
||||
// $part["subtype"] = "octet-stream";
|
||||
// $part["description"] = $attachment->file_name;
|
||||
// $part['disposition.type'] = 'attachment';
|
||||
// $part['disposition'] = array('filename' => $attachment->file_name);
|
||||
// $part['type.parameters'] = array('name' => $attachment->file_name);
|
||||
// $part["description"] = '';
|
||||
// $part["contents.data"] = base64_encode($attachment->getFileContents());
|
||||
|
||||
$parts[] = $part;
|
||||
} else {
|
||||
\Log::error('[IMAP Folder To Save Outgoing Replies] Thread: '.$this->last_thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
// $parts[] = $part;
|
||||
// } else {
|
||||
// \Log::error('[IMAP Folder To Save Outgoing Replies] Thread: '.$this->last_thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
try {
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/3502
|
||||
$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')) {
|
||||
$folder = $client->getFolderByPath($imap_sent_folder);
|
||||
@@ -366,29 +549,35 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
// Get folder method does not work if sent folder has spaces.
|
||||
if ($folder) {
|
||||
try {
|
||||
$save_result = $this->saveEmailToFolder($client, $folder, $envelope, $parts);
|
||||
//$save_result = $this->saveEmailToFolder($client, $folder, $envelope, $parts, $bcc_array);
|
||||
$save_result = $this->saveEmailToFolder($folder, \MailHelper::$smtp_mime_message);
|
||||
\MailHelper::$smtp_mime_message = '';
|
||||
|
||||
// Sometimes emails with attachments by some reason are not saved.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2749
|
||||
// if (!$save_result) {
|
||||
// // Save without attachments.
|
||||
// $save_result = $this->saveEmailToFolder($client, $folder, $envelope, [$part_body], $bcc_array);
|
||||
if (!$save_result) {
|
||||
// Save without attachments.
|
||||
$this->saveEmailToFolder($client, $folder, $envelope, [$part_body]);
|
||||
\Log::error($this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder (check folder name and make sure IMAP folder does not have spaces - folders with spaces do not work): '.$imap_sent_folder);
|
||||
}
|
||||
//}
|
||||
} catch (\Exception $e) {
|
||||
// Just log error and continue.
|
||||
\Helper::logException($e, 'Could not save outgoing reply to the IMAP folder: ');
|
||||
\Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder: ');
|
||||
}
|
||||
} else {
|
||||
\Log::error('Could not save outgoing reply to the IMAP folder (make sure IMAP folder does not have spaces - folders with spaces do not work): '.$imap_sent_folder);
|
||||
\Log::error($this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder (check folder name and make sure IMAP folder does not have spaces - folders with spaces do not work): '.$imap_sent_folder);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Just log error and continue.
|
||||
\Helper::logException($e, 'Could not save outgoing reply to the IMAP folder, IMAP folder not found: '.$imap_sent_folder.' - ');
|
||||
\Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder, IMAP folder not found: '.$imap_sent_folder.' - ');
|
||||
//$this->saveToSendLog('['.date('Y-m-d H:i:s').'] Could not save outgoing reply to the IMAP folder: '.$imap_sent_folder);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Just log error and continue.
|
||||
//$this->saveToSendLog('['.date('Y-m-d H:i:s').'] Could not get mailbox IMAP folder: '.$imap_sent_folder);
|
||||
\Helper::logException($e, 'Could not save outgoing reply to the IMAP folder: '.$imap_sent_folder.' - ');
|
||||
\Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder: '.$imap_sent_folder.' - ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,19 +590,42 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
$this->failures = Mail::failures();
|
||||
|
||||
// Save to send log
|
||||
$this->saveToSendLog();
|
||||
$this->saveToSendLog('', $smtp_queue_id);
|
||||
}
|
||||
|
||||
public function getImapSaveErrorPrefix($mailbox)
|
||||
{
|
||||
return '['.$mailbox->name.' » Connection Settings » Fetching Emails » IMAP Folder To Save Outgoing Replies] ';
|
||||
}
|
||||
|
||||
// Save an email to IMAP folder.
|
||||
public function saveEmailToFolder($client, $folder, $envelope, $parts)
|
||||
public function saveEmailToFolder($folder, $message_str)
|
||||
{
|
||||
if (get_class($client) == 'Webklex\PHPIMAP\Client') {
|
||||
return $folder->appendMessage(imap_mail_compose($envelope, $parts), ['Seen'], now()->format('d-M-Y H:i:s O'));
|
||||
} else {
|
||||
return $folder->appendMessage(imap_mail_compose($envelope, $parts), '\Seen', now()->format('d-M-Y H:i:s O'));
|
||||
}
|
||||
return $folder->appendMessage($message_str, ['\Seen'], now()->format('d-M-Y H:i:s O'));
|
||||
}
|
||||
|
||||
// public function saveEmailToFolder($client, $folder, $envelope, $parts, $bcc = [])
|
||||
// {
|
||||
// $envelope_str = imap_mail_compose($envelope, $parts);
|
||||
|
||||
// // Add BCC.
|
||||
// // https://stackoverflow.com/questions/47353938/php-imap-append-with-bcc
|
||||
// if (!empty($bcc)) {
|
||||
// // There will be a "To:" parameter for sure.
|
||||
// $to_pos = strpos($envelope_str , "To:");
|
||||
// if ($to_pos !== false) {
|
||||
// $bcc_str = "Bcc: " . implode(',', $bcc) . "\r\n";
|
||||
// $envelope_str = substr_replace($envelope_str , $bcc_str, $to_pos, 0);
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (get_class($client) == 'Webklex\PHPIMAP\Client') {
|
||||
// return $folder->appendMessage($envelope_str, ['\Seen'], now()->format('d-M-Y H:i:s O'));
|
||||
// } else {
|
||||
// return $folder->appendMessage($envelope_str, '\Seen', now()->format('d-M-Y H:i:s O'));
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* The job failed to process.
|
||||
* This method is called after attempts had finished.
|
||||
@@ -425,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);
|
||||
@@ -440,7 +659,7 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
/**
|
||||
* Save emails to send log.
|
||||
*/
|
||||
public function saveToSendLog($error_message = '')
|
||||
public function saveToSendLog($error_message = '', $smtp_queue_id = '')
|
||||
{
|
||||
foreach ($this->recipients as $recipient) {
|
||||
if (in_array($recipient, $this->failures)) {
|
||||
@@ -455,7 +674,44 @@ class SendReplyToCustomer implements ShouldQueue
|
||||
} else {
|
||||
$customer_id = null;
|
||||
}
|
||||
SendLog::log($this->last_thread->id, $this->message_id, $recipient, SendLog::MAIL_TYPE_EMAIL_TO_CUSTOMER, $status, $customer_id, null, $status_message);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Conversation;
|
||||
use App\ConversationFolder;
|
||||
use App\Folder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
@@ -15,28 +14,33 @@ class UpdateFolderCounters implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var Folder
|
||||
*/
|
||||
private $folder;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
// Cache lock key
|
||||
private $lockKey;
|
||||
|
||||
public function __construct(Folder $folder)
|
||||
{
|
||||
$this->folder = $folder;
|
||||
$this->lockKey = "folder_update_lock_{$folder->id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->folder->updateCountersNow();
|
||||
// Check if another job is processing this folder
|
||||
if (Cache::has($this->lockKey)) {
|
||||
return; // Exit if already processing
|
||||
}
|
||||
|
||||
// Set lock with a timeout of 5 minutes (adjust as needed)
|
||||
Cache::put($this->lockKey, true, now()->addMinutes(5));
|
||||
|
||||
try {
|
||||
// Perform the folder update logic
|
||||
$this->folder->updateCountersNow();
|
||||
} finally {
|
||||
// Release the lock after processing
|
||||
Cache::forget($this->lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ class LogFailedLogin
|
||||
*/
|
||||
public function handle(Failed $event)
|
||||
{
|
||||
\Eventy::action('listeners.failed_login', $event);
|
||||
|
||||
activity()
|
||||
//->causedBy($event->user)
|
||||
->withProperties(['ip' => app('request')->ip(), 'email' => request()->email])
|
||||
|
||||
@@ -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,12 +25,20 @@ class SendReplyToCustomer
|
||||
{
|
||||
$conversation = $event->conversation;
|
||||
|
||||
// Do not send email if this is a Phone 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()) {
|
||||
return;
|
||||
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) {
|
||||
@@ -45,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.
|
||||
@@ -58,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +87,10 @@ class ReplyToCustomer extends Mailable
|
||||
if (!empty($new_headers) || $from_alias) {
|
||||
$mailbox = $this->mailbox;
|
||||
$this->withSwiftMessage(function ($swiftmessage) use ($new_headers, $from_alias, $mailbox, $thread) {
|
||||
\MailHelper::$smtp_mime_message = '';
|
||||
|
||||
$headers = null;
|
||||
|
||||
if (!empty($new_headers)) {
|
||||
if (!empty($new_headers['Message-ID'])) {
|
||||
$swiftmessage->setId($new_headers['Message-ID']);
|
||||
@@ -111,36 +121,53 @@ class ReplyToCustomer extends Mailable
|
||||
$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
|
||||
$from_alias => $from_alias_name,
|
||||
]);
|
||||
} else {
|
||||
$swift_from->setAddresses([
|
||||
$from_alias
|
||||
$from_alias,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
\Eventy::action('email.reply_to_customer.swiftmessage', $swiftmessage, $from_alias, $thread, $mailbox);
|
||||
|
||||
if ($mailbox->imap_sent_folder) {
|
||||
\MailHelper::$smtp_mime_message = $swiftmessage->toString();
|
||||
}
|
||||
|
||||
return $swiftmessage;
|
||||
});
|
||||
}
|
||||
|
||||
$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
|
||||
$message = $this->subject($this->subject)
|
||||
->view('emails/customer/reply_fancy')
|
||||
->text('emails/customer/reply_fancy_text');
|
||||
->view($template_html)
|
||||
->text($template_text);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,15 +91,20 @@ class UserNotification extends Mailable
|
||||
});
|
||||
}
|
||||
|
||||
$subject = '[#'.$this->conversation->number.'] '.$this->conversation->subject;
|
||||
$subject = \Eventy::filter('email.user_notification.subject', '[#' . (isset($this->conversation->number) ? $this->conversation->number : '') . '] ' . $this->conversation->subject, $this->conversation);
|
||||
|
||||
$customer = $this->conversation->customer;
|
||||
|
||||
$thread = $this->threads->first();
|
||||
|
||||
$template_html = \Eventy::filter('email.user_notification.template_name_html', 'emails/user/notification');
|
||||
$template_text = \Eventy::filter('email.user_notification.template_name_text', 'emails/user/notification_text');
|
||||
$template_fields = \Eventy::filter('email.user_notification.template_fields', ['customer' => $customer, 'thread' => $thread, 'mailbox' => $this->mailbox]);
|
||||
|
||||
return $this->subject($subject)
|
||||
->from($this->from['address'], $this->from['name'])
|
||||
->view('emails/user/notification', ['customer' => $customer, 'thread' => $thread, 'mailbox' => $this->mailbox])
|
||||
->text('emails/user/notification_text', ['customer' => $customer, 'thread' => $thread, 'mailbox' => $this->mailbox]);
|
||||
->view($template_html, $template_fields)
|
||||
->text($template_text, $template_fields);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
233
app/Mailbox.php
233
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.
|
||||
*/
|
||||
@@ -58,6 +65,9 @@ class Mailbox extends Model
|
||||
const OUT_ENCRYPTION_NONE = 1;
|
||||
const OUT_ENCRYPTION_SSL = 2;
|
||||
const OUT_ENCRYPTION_TLS = 3;
|
||||
// StartTLS is already included in TLS:
|
||||
// allow_self_signed = true
|
||||
// verify_peer = false
|
||||
|
||||
public static $out_encryptions = [
|
||||
self::OUT_ENCRYPTION_NONE => '',
|
||||
@@ -82,11 +92,16 @@ class Mailbox extends Model
|
||||
const IN_ENCRYPTION_NONE = 1;
|
||||
const IN_ENCRYPTION_SSL = 2;
|
||||
const IN_ENCRYPTION_TLS = 3;
|
||||
// For Webklex/laravel-imap this option is the same as 'none'.
|
||||
// For Webklex/php-imap it works:
|
||||
// https://github.com/Webklex/php-imap/pull/180
|
||||
const IN_ENCRYPTION_STARTTLS = 4;
|
||||
|
||||
public static $in_encryptions = [
|
||||
self::IN_ENCRYPTION_NONE => '',
|
||||
self::IN_ENCRYPTION_SSL => 'ssl',
|
||||
self::IN_ENCRYPTION_TLS => 'tls',
|
||||
self::IN_ENCRYPTION_STARTTLS => 'starttls',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -102,6 +117,7 @@ class Mailbox extends Model
|
||||
const ACCESS_PERM_PERMISSIONS = 'perm';
|
||||
const ACCESS_PERM_AUTO_REPLIES = 'auto';
|
||||
const ACCESS_PERM_SIGNATURE = 'sig';
|
||||
const ACCESS_PERM_ASSIGNED = 'asg';
|
||||
|
||||
public static $access_permissions = [
|
||||
self::ACCESS_PERM_EDIT,
|
||||
@@ -139,7 +155,7 @@ class Mailbox extends Model
|
||||
*
|
||||
* @var [type]
|
||||
*/
|
||||
protected $fillable = ['name', 'email', 'aliases', 'aliases_reply', 'auto_bcc', 'from_name', 'from_name_custom', 'ticket_status', 'ticket_assignee', 'template', 'before_reply', 'signature', 'out_method', 'out_server', 'out_username', 'out_password', 'out_port', 'out_encryption', 'in_server', 'in_port', 'in_username', 'in_password', 'in_protocol', 'in_encryption', 'in_validate_cert', 'auto_reply_enabled', 'auto_reply_subject', 'auto_reply_message', 'office_hours_enabled', 'ratings', 'ratings_placement', 'ratings_text', 'imap_sent_folder'];
|
||||
protected $fillable = ['name', 'email', 'aliases', 'aliases_reply', 'auto_bcc', 'from_name', 'from_name_custom', 'ticket_status', 'ticket_assignee', 'before_reply', 'signature', 'out_method', 'out_server', 'out_username', 'out_password', 'out_port', 'out_encryption', 'in_server', 'in_port', 'in_username', 'in_password', 'in_protocol', 'in_encryption', 'in_validate_cert', 'auto_reply_enabled', 'auto_reply_subject', 'auto_reply_message', 'office_hours_enabled', 'ratings', 'ratings_placement', 'ratings_text', 'imap_sent_folder'];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
@@ -208,6 +224,23 @@ class Mailbox extends Model
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mailbox state: stored in meta for now.
|
||||
*/
|
||||
public function getStateAttribute()
|
||||
{
|
||||
return $this->getMeta('st', 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('st', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users having access to the mailbox.
|
||||
*/
|
||||
@@ -393,12 +426,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();
|
||||
}
|
||||
@@ -534,6 +577,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;
|
||||
@@ -552,18 +598,27 @@ 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;
|
||||
|
||||
if ($this->from_name == self::FROM_NAME_CUSTOM && $this->from_name_custom) {
|
||||
$name = $this->from_name_custom;
|
||||
$data = [
|
||||
'mailbox' => $this,
|
||||
'mailbox_from_name' => '', // To avoid recursion.
|
||||
'conversation' => $conversation,
|
||||
'user' => $from_user ?: auth()->user(),
|
||||
];
|
||||
$name = \MailHelper::replaceMailVars($this->from_name_custom, $data, false, true);
|
||||
} elseif ($this->from_name == self::FROM_NAME_USER && $from_user) {
|
||||
$name = $from_user->getFullName();
|
||||
}
|
||||
|
||||
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 ) ];
|
||||
return [
|
||||
'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)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,6 +706,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.
|
||||
*/
|
||||
@@ -666,7 +737,8 @@ class Mailbox extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dummy object with default parameters
|
||||
* Create dummy object with default parameters.
|
||||
*
|
||||
* @return [type] [description]
|
||||
*/
|
||||
public static function getDummySettings()
|
||||
@@ -775,7 +847,7 @@ class Mailbox extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active mailboxes.
|
||||
* Get all active & connected mailboxes.
|
||||
*
|
||||
* @return [type] [description]
|
||||
*/
|
||||
@@ -786,7 +858,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;
|
||||
}
|
||||
}
|
||||
@@ -912,6 +984,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;
|
||||
@@ -956,7 +1037,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
|
||||
&& $this->isInUsernameOauth();
|
||||
}
|
||||
|
||||
public function outOauthEnabled()
|
||||
{
|
||||
return $this->oauthEnabled()
|
||||
&& $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)
|
||||
@@ -965,4 +1123,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ class MailboxUser extends Model
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public static $pivot_settings = [
|
||||
'after_send',
|
||||
'hide',
|
||||
'mute',
|
||||
'access',
|
||||
];
|
||||
|
||||
// Does not work as we receive it via pivot
|
||||
// protected $casts = [
|
||||
// 'access' => 'array',
|
||||
|
||||
178
app/Misc/ConversationActionButtons.php
Normal file
178
app/Misc/ConversationActionButtons.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Misc;
|
||||
|
||||
class ConversationActionButtons
|
||||
{
|
||||
// Location constants
|
||||
const LOCATION_TOOLBAR = 'toolbar';
|
||||
const LOCATION_DROPDOWN = 'dropdown';
|
||||
const LOCATION_BOTH = 'both';
|
||||
|
||||
/**
|
||||
* Get all available conversation actions
|
||||
*/
|
||||
public static function getActions($conversation, $user, $mailbox)
|
||||
{
|
||||
$actions = [
|
||||
// Default toolbar actions
|
||||
'reply' => [
|
||||
'icon' => 'glyphicon-share-alt',
|
||||
'location' => self::LOCATION_TOOLBAR,
|
||||
'label' => __('Reply'),
|
||||
'permission' => function ($conversation) {
|
||||
return ( ! $conversation->isPhone() || ( $conversation->customer && $conversation->customer->getMainEmail() ) )
|
||||
&& \Eventy::filter('conversation.reply_button.enabled', true, $conversation);
|
||||
},
|
||||
'class' => 'conv-reply',
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'note' => [
|
||||
'icon' => 'glyphicon-edit',
|
||||
'location' => self::LOCATION_TOOLBAR,
|
||||
'label' => __('Note'),
|
||||
'permission' => function ($conversation) {
|
||||
return \Eventy::filter('conversation.note_button.enabled', true, $conversation);
|
||||
},
|
||||
'class' => 'conv-add-note',
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'delete' => [
|
||||
'icon' => 'glyphicon-trash',
|
||||
'location' => self::LOCATION_TOOLBAR,
|
||||
'label' => $conversation->state != \App\Conversation::STATE_DELETED ? __('Delete') : __('Delete Forever'),
|
||||
'permission' => function ($conversation, $user) {
|
||||
return $user->can('delete', $conversation);
|
||||
},
|
||||
'class' => $conversation->state != \App\Conversation::STATE_DELETED ? 'conv-delete' : 'conv-delete-forever',
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'delete_mobile' => [
|
||||
'icon' => 'glyphicon-trash',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => $conversation->state != \App\Conversation::STATE_DELETED ? __('Delete') : __('Delete Forever'),
|
||||
'permission' => function ($conversation, $user) {
|
||||
return $user->can('delete', $conversation);
|
||||
},
|
||||
'class' => $conversation->state != \App\Conversation::STATE_DELETED ? 'conv-delete' : 'conv-delete-forever',
|
||||
'fixed_location' => true,
|
||||
'mobile_only' => true,
|
||||
],
|
||||
|
||||
// Default dropdown actions
|
||||
'follow' => [
|
||||
'icon' => 'glyphicon-bell',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => __('Follow'),
|
||||
'permission' => function () {
|
||||
return true;
|
||||
},
|
||||
'class' => 'conv-follow',
|
||||
'has_opposite' => true,
|
||||
'opposite' => [
|
||||
'label' => __('Unfollow'),
|
||||
'class' => 'conv-follow',
|
||||
],
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'forward' => [
|
||||
'icon' => 'glyphicon-arrow-right',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => __('Forward'),
|
||||
'permission' => function () {
|
||||
return true;
|
||||
},
|
||||
'class' => 'conv-forward',
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'merge' => [
|
||||
'icon' => 'glyphicon-indent-left',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => __('Merge'),
|
||||
'permission' => function ($conversation) {
|
||||
return ! $conversation->isChat();
|
||||
},
|
||||
'class' => '',
|
||||
'url' => function ($conversation) {
|
||||
return route('conversations.ajax_html', array_merge([ 'action' => 'merge_conv' ], \Request::all(), [ 'conversation_id' => $conversation->id ]));
|
||||
},
|
||||
'attrs' => [
|
||||
'data-trigger' => 'modal',
|
||||
'data-modal-title' => __('Merge Conversations'),
|
||||
'data-modal-no-footer' => 'true',
|
||||
'data-modal-on-show' => 'initMergeConv',
|
||||
],
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'move' => [
|
||||
'icon' => 'glyphicon-log-out',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => __('Move'),
|
||||
'permission' => function ($conversation, $user) {
|
||||
return $user->can('move', \App\Conversation::class);
|
||||
},
|
||||
'class' => '',
|
||||
'url' => function ($conversation) {
|
||||
return route('conversations.ajax_html', array_merge([ 'action' => 'move_conv' ], \Request::all(), [ 'conversation_id' => $conversation->id ]));
|
||||
},
|
||||
'attrs' => [
|
||||
'data-trigger' => 'modal',
|
||||
'data-modal-title' => __('Move Conversation'),
|
||||
'data-modal-no-footer' => 'true',
|
||||
'data-modal-on-show' => 'initMoveConv',
|
||||
],
|
||||
'fixed_location' => true,
|
||||
],
|
||||
'print' => [
|
||||
'icon' => 'glyphicon-print',
|
||||
'location' => self::LOCATION_DROPDOWN,
|
||||
'label' => __('Print'),
|
||||
'class' => '',
|
||||
'permission' => function () {
|
||||
return true;
|
||||
},
|
||||
'url' => function () {
|
||||
return \Request::getRequestUri() . '&print=1';
|
||||
},
|
||||
'attrs' => [
|
||||
'target' => '_blank',
|
||||
],
|
||||
'fixed_location' => true,
|
||||
],
|
||||
];
|
||||
|
||||
// Allow overriding default actions while preserving backwards compatibility
|
||||
$actions = \Eventy::filter('conversation.get_action_buttons', $actions, $conversation, $user, $mailbox);
|
||||
|
||||
// Filter actions based on permissions
|
||||
foreach ($actions as $key => $action) {
|
||||
if (! $action['permission']($conversation, $user)) {
|
||||
unset($actions[ $key ]);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actions for a specific location
|
||||
*/
|
||||
public static function getActionsByLocation($actions, $location)
|
||||
{
|
||||
return array_filter($actions, function ($action) use ($location) {
|
||||
if (! empty($action['fixed_location'])) {
|
||||
// Modified logic for responsive display
|
||||
if ($location === self::LOCATION_TOOLBAR) {
|
||||
// Show in toolbar if it's a toolbar action
|
||||
return $action['location'] === self::LOCATION_TOOLBAR;
|
||||
} elseif ($location === self::LOCATION_DROPDOWN) {
|
||||
// Show in dropdown if explicitly flagged or if it's a dropdown action
|
||||
return $action['location'] === self::LOCATION_DROPDOWN ||
|
||||
( ! empty($action['show_in_dropdown']) && $action['location'] === self::LOCATION_TOOLBAR );
|
||||
}
|
||||
}
|
||||
|
||||
return $action['location'] === $location;
|
||||
});
|
||||
}
|
||||
}
|
||||
47
app/Misc/Functions.php
Normal file
47
app/Misc/Functions.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
if (! function_exists('__j')) {
|
||||
/**
|
||||
* Translate the given message and prepare it for using in JS.
|
||||
*/
|
||||
function __j($key, $replace = [], $locale = null)
|
||||
{
|
||||
$text = __($key, $replace, $locale);
|
||||
|
||||
return strtr($text, [
|
||||
'"' => '"',
|
||||
"'" => ''',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates and escapes the text for using in HTML.
|
||||
*/
|
||||
function __h($key, $replace = [], $locale = null, $escape_replacements = false)
|
||||
{
|
||||
if ($escape_replacements) {
|
||||
foreach ($replace as $key => $value) {
|
||||
$replace[$key] = htmlspecialchars($value);
|
||||
}
|
||||
}
|
||||
return __(htmlspecialchars($key), $replace, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
1782
app/Misc/Helper.php
1782
app/Misc/Helper.php
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
19
app/Misc/SwiftGetSmtpQueueId.php
Normal file
19
app/Misc/SwiftGetSmtpQueueId.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Misc;
|
||||
|
||||
class SwiftGetSmtpQueueId implements \Swift_Events_ResponseListener
|
||||
{
|
||||
public static $last_smtp_queue_id = null;
|
||||
|
||||
public function responseReceived(\Swift_Events_ResponseEvent $evt)
|
||||
{
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,20 +37,18 @@ class WpApi
|
||||
$url .= '?';
|
||||
}
|
||||
$url .= 'v='.config('app.version');
|
||||
return $client->request('POST', $url, [
|
||||
'timeout' => config('app.curl_timeout'), // seconds
|
||||
return $client->request('POST', $url, \Helper::setGuzzleDefaultOptions([
|
||||
'connect_timeout' => 10,
|
||||
'form_params' => $params,
|
||||
'proxy' => config('app.proxy'),
|
||||
]);
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]));
|
||||
} else {
|
||||
$params['v'] = config('app.version');
|
||||
return $client->request('GET', $url, [
|
||||
'timeout' => config('app.curl_timeout'), // seconds
|
||||
return $client->request('GET', $url, \Helper::setGuzzleDefaultOptions([
|
||||
'connect_timeout' => 10,
|
||||
'query' => $params,
|
||||
'proxy' => config('app.proxy'),
|
||||
]);
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
383
app/Module.php
383
app/Module.php
@@ -6,7 +6,9 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Misc\WpApi;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
class Module extends Model
|
||||
{
|
||||
@@ -33,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);
|
||||
@@ -48,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;
|
||||
@@ -132,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));
|
||||
@@ -237,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;
|
||||
@@ -248,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;
|
||||
@@ -256,4 +298,333 @@ class Module extends Model
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSymlinkPath($alias)
|
||||
{
|
||||
return public_path().\Module::getPublicPath($alias);
|
||||
}
|
||||
|
||||
// Check and try to fix invalid or missing symlinks.
|
||||
public static function checkSymlinks($module_aliases = null)
|
||||
{
|
||||
$invalid_symlinks = [];
|
||||
|
||||
if ($module_aliases === null) {
|
||||
// Get all active modules.
|
||||
foreach (\Module::all() as $module) {
|
||||
if ($module->active()) {
|
||||
$module_aliases[] = $module->getAlias();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($module_aliases && count($module_aliases)) {
|
||||
foreach ($module_aliases as $module_alias) {
|
||||
$from = self::getSymlinkPath($module_alias);
|
||||
|
||||
$create = false;
|
||||
|
||||
// file_exists() also checks if symlink target exists.
|
||||
// file_exists() and is_dir() may throw "open_basedir restriction in effect".
|
||||
try {
|
||||
if (!file_exists($from) || !is_link($from)) {
|
||||
if (is_dir($from)) {
|
||||
@rename($from, $from.'_'.date('YmdHis'));
|
||||
} else {
|
||||
@unlink($from);
|
||||
}
|
||||
$create = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$create = true;
|
||||
}
|
||||
|
||||
// Skip this check.
|
||||
// elseif (is_link($from) && readlink($symlink_path) != '') {
|
||||
// // Symlink leads to the wrong place.
|
||||
// $create = true;
|
||||
// }
|
||||
|
||||
// Try to create the symlink.
|
||||
if ($create) {
|
||||
$to = self::createModuleSymlink($module_alias);
|
||||
|
||||
if ($to && (!is_link($from) || is_link($to) || !file_exists($from))) {
|
||||
$invalid_symlinks[$from] = $to;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $invalid_symlinks;
|
||||
}
|
||||
|
||||
// There is similar function in ModuleInstall.php
|
||||
public static function createModuleSymlink($alias)
|
||||
{
|
||||
$from = self::getSymlinkPath($alias);
|
||||
|
||||
$module = \Module::findByAlias($alias);
|
||||
if (!$module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$to = $module->getExtraPath('Public');
|
||||
|
||||
// file_exists() may throw "open_basedir restriction in effect".
|
||||
try {
|
||||
// If module's Public is symlink.
|
||||
if (is_link($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
|
||||
// Symlimk may exist but lead to the module folder in a wrong case.
|
||||
// So we need first try to remove it.
|
||||
if (!file_exists($from)) {
|
||||
@unlink($from);
|
||||
}
|
||||
|
||||
if (file_exists($from)) {
|
||||
return $to;
|
||||
}
|
||||
|
||||
if (!file_exists($to)) {
|
||||
// Try to create Public folder.
|
||||
try {
|
||||
\File::makeDirectory($to, \Helper::DIR_PERMISSIONS);
|
||||
} catch (\Exception $e) {
|
||||
// If it's a broken symlink.
|
||||
if (is_link($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
symlink($to, $from);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage());
|
||||
//return false;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $to;
|
||||
}
|
||||
|
||||
public static function updateModule($alias)
|
||||
{
|
||||
$result = [
|
||||
'status' => 'error',
|
||||
// Error message.
|
||||
'msg' => '',
|
||||
'msg_success' => '',
|
||||
'download_error' => false,
|
||||
// Error message with the link for downloading the module.
|
||||
'download_msg' => '',
|
||||
'output' => '',
|
||||
'module_name' => '',
|
||||
];
|
||||
|
||||
$module = \Module::findByAlias($alias);
|
||||
|
||||
if (!$module) {
|
||||
$result['msg'] = __('Module not found').': '.$alias;
|
||||
}
|
||||
|
||||
// Get module name.
|
||||
$name = '?';
|
||||
if ($module) {
|
||||
$name = $module->getName();
|
||||
$result['module_name'] = $name;
|
||||
}
|
||||
|
||||
// Download new version.
|
||||
if (!$result['msg']) {
|
||||
// Check if the module's author is officially recognized
|
||||
if (self::isOfficial($module->authorUrl)) {
|
||||
$params = [
|
||||
'license' => self::getLicense($alias),
|
||||
'module_alias' => $alias,
|
||||
'url' => self::getAppUrl(),
|
||||
];
|
||||
$license_details = WpApi::getVersion($params);
|
||||
|
||||
if (WpApi::$lastError) {
|
||||
$result['msg'] = WpApi::$lastError['message'];
|
||||
} elseif (!empty($license_details['code']) && !empty($license_details['message'])) {
|
||||
$result['msg'] = $license_details['message'];
|
||||
} elseif (!empty($license_details['required_app_version']) && !\Helper::checkAppVersion($license_details['required_app_version'])) {
|
||||
$result['msg'] = 'Module requires app version:'.' '.$license_details['required_app_version'];
|
||||
} elseif (!empty($license_details['download_link'])) {
|
||||
// If a download link is available, proceed to update the module from the URL
|
||||
$result = self::updateFromUrl($module, $license_details['download_link'], $result);
|
||||
} elseif ($license_details['status'] && $result['msg'] = self::getErrorMessage($license_details['status'])) {
|
||||
//$result['msg'] = ;
|
||||
} else {
|
||||
$result['msg'] = __('Error occurred').': '.json_encode($license_details);
|
||||
}
|
||||
} else {
|
||||
// If the module's author is not officially recognized, check for a direct download link
|
||||
$latest_version_zip_url = $module->latestVersionZipUrl ?? null;
|
||||
|
||||
if (!empty($latest_version_zip_url)) {
|
||||
// Update the module from the provided ZIP URL
|
||||
$result = self::updateFromUrl($module, $module->latestVersionZipUrl, $result);
|
||||
} else {
|
||||
// If no download link is available, set an error message indicating the module cannot be downloaded
|
||||
$result['msg'] = __('Error occurred') . ': module not available for download';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run post-update instructions.
|
||||
if (!$result['msg'] && !$result['download_error']) {
|
||||
$output_log = new BufferedOutput();
|
||||
\Artisan::call('freescout:module-install', ['module_alias' => $alias], $output_log);
|
||||
$result['output'] = $output_log->fetch() ?: ' ';
|
||||
|
||||
$result['msg'] = __('Error occurred activating ":name" module', ['name' => $name]);
|
||||
|
||||
if (session('flashes_floating') && is_array(session('flashes_floating'))) {
|
||||
// Error.
|
||||
// If there was any error, module has been deactivated via modules.register_error filter
|
||||
$result['msg'] = '';
|
||||
foreach (session('flashes_floating') as $flash) {
|
||||
$result['msg'] .= $flash['text'].' ';
|
||||
}
|
||||
} elseif (strstr($result['output'], 'Configuration cached successfully')) {
|
||||
// Success.
|
||||
$result['status'] = 'success';
|
||||
$result['msg'] = '';
|
||||
$result['msg_success'] = __('":name" module successfully updated!', ['name' => $name]);
|
||||
} else {
|
||||
// Error.
|
||||
// Deactivate module.
|
||||
\App\Module::setActive($alias, false);
|
||||
\Artisan::call('freescout:clear-cache');
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a module from a given URL.
|
||||
*
|
||||
* @param Module $module The module to be updated.
|
||||
* @param string $url The URL where the new version of the module can be downloaded.
|
||||
* @param array $result An associative array to store the result of the update operation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function updateFromUrl($module, $url, $result)
|
||||
{
|
||||
$alias = $module->alias;
|
||||
// Download module.
|
||||
$module_archive = \Module::getPath() . DIRECTORY_SEPARATOR . $alias . '.zip';
|
||||
|
||||
try {
|
||||
\Helper::downloadRemoteFile($url, $module_archive);
|
||||
} catch (\Exception $e) {
|
||||
$result['msg'] = $e->getMessage();
|
||||
}
|
||||
|
||||
if (!file_exists($module_archive)) {
|
||||
$result['download_error'] = true;
|
||||
} else {
|
||||
// Extract.
|
||||
try {
|
||||
$module_path = $module->getPath();
|
||||
|
||||
// Sometimes by some reason Public folder becomes a symlink leading to itself.
|
||||
// It causes an error during updating process.
|
||||
// https://github.com/freescout-helpdesk/freescout/issues/2709
|
||||
$public_folder = $module_path.DIRECTORY_SEPARATOR.'Public';
|
||||
try {
|
||||
if (is_link($public_folder)) {
|
||||
unlink($public_folder);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
\Helper::unzip($module_archive, \Module::getPath());
|
||||
|
||||
// Rename the folder if the archive has been downloaded from GitHub:
|
||||
// SomeModule-master >> SomeModule.
|
||||
// https://github.com/freescout-help-desk/freescout/issues/4611
|
||||
$basename = basename($url, '.zip');
|
||||
|
||||
if (file_exists($module_path.'-'.$basename)) {
|
||||
\File::moveDirectory($module_path.'-'.$basename, $module_path, true);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$result['msg'] = $e->getMessage();
|
||||
}
|
||||
// Check if extracted module exists.
|
||||
\Module::clearCache();
|
||||
$module = \Module::findByAlias($alias);
|
||||
if (!$module) {
|
||||
$result['download_error'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove archive.
|
||||
if (file_exists($module_archive)) {
|
||||
\File::delete($module_archive);
|
||||
}
|
||||
|
||||
if ($result['download_error']) {
|
||||
$result['download_msg'] = __('Error occurred downloading the module. Please :%a_being%download:%a_end% module manually and extract into :folder', [
|
||||
'%a_being%' => '<a href="' . $url . '" target="_blank">',
|
||||
'%a_end%' => '</a>',
|
||||
'folder' => '<strong>' . \Module::getPath() . '</strong>',
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function getErrorMessage($code, $result = null)
|
||||
{
|
||||
$msg = '';
|
||||
|
||||
switch ($code) {
|
||||
case 'missing':
|
||||
$msg = __('License key does not exist');
|
||||
break;
|
||||
case 'license_not_activable':
|
||||
$msg = __("You have to activate each bundle's module separately");
|
||||
break;
|
||||
case 'disabled':
|
||||
$msg = __('License key has been revoked');
|
||||
break;
|
||||
case 'no_activations_left':
|
||||
$msg = __('No activations left for this license key').' ('.__("Use 'Deactivate License' link above to transfer license key from another domain").')';
|
||||
break;
|
||||
case 'expired':
|
||||
$msg = __('License key has expired');
|
||||
break;
|
||||
case 'key_mismatch':
|
||||
$msg = __('License key belongs to another module');
|
||||
break;
|
||||
// This also happens when entering a valid license key for wrong module.
|
||||
case 'invalid_item_id':
|
||||
$msg = __('Invalid license key');
|
||||
//$msg = __('Module not found in the modules directory');
|
||||
break;
|
||||
case 'site_inactive':
|
||||
$msg = __('License key is activated on another domain.').' '.__("Use 'Deactivate License' link above to transfer license key from another domain");
|
||||
//$msg = __('Module not found in the modules directory');
|
||||
break;
|
||||
default:
|
||||
if ($result && !empty($result['error'])) {
|
||||
$msg = __('Error code:'.' '.$result['error']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Customer;
|
||||
use App\CustomerChannel;
|
||||
|
||||
class CustomerObserver
|
||||
{
|
||||
@@ -17,4 +18,16 @@ class CustomerObserver
|
||||
{
|
||||
\Eventy::action('customer.deleting', $customer);
|
||||
}
|
||||
|
||||
public function created(Customer $customer)
|
||||
{
|
||||
if ($customer->channel && $customer->channel_id) {
|
||||
CustomerChannel::create($customer->id, $customer->channel, $customer->channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleted(Customer $customer)
|
||||
{
|
||||
CustomerChannel::where('customer_id', $customer->id)->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.
|
||||
*
|
||||
|
||||
@@ -8,7 +8,7 @@ class FollowerObserver
|
||||
{
|
||||
public function created(Follower $follower)
|
||||
{
|
||||
\Eventy::action('follower.created', $attachment);
|
||||
\Eventy::action('follower.created', $follower);
|
||||
}
|
||||
|
||||
public function deleted(Follower $follower)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,15 @@ class ThreadObserver
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
// Fetch date & time setting.
|
||||
$use_mail_date_on_fetching = config('app.use_mail_date_on_fetching');
|
||||
|
||||
if ($use_mail_date_on_fetching) {
|
||||
$now = $thread->created_at->format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (!in_array($thread->type, [Thread::TYPE_LINEITEM, Thread::TYPE_NOTE]) && $thread->state == Thread::STATE_PUBLISHED) {
|
||||
$conversation->threads_count++;
|
||||
}
|
||||
@@ -30,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'];
|
||||
@@ -205,8 +206,16 @@ class Option extends Model
|
||||
*/
|
||||
public static function maybeSerialize($data)
|
||||
{
|
||||
if (is_array($data) || is_object($data)) {
|
||||
return serialize($data);
|
||||
// if (is_array($data) || is_object($data)) {
|
||||
// return serialize($data);
|
||||
// }
|
||||
// We don't use serialize() function as it is not safe.
|
||||
if (is_array($data)) {
|
||||
try {
|
||||
return \Helper::jsonEncodeSafe($data);
|
||||
} catch (\Exception $e) {
|
||||
// Don nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -217,9 +226,11 @@ class Option extends Model
|
||||
*/
|
||||
public static function maybeUnserialize($original)
|
||||
{
|
||||
// This is kept jsut for backward compatibility.
|
||||
// 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
|
||||
}
|
||||
@@ -227,6 +238,19 @@ class Option extends Model
|
||||
return $original;
|
||||
}
|
||||
|
||||
if (is_string($original) && !empty($original[0])
|
||||
&& ($original[0] == '{' || $original[0] == '[')
|
||||
) {
|
||||
try {
|
||||
$result = json_decode($original, true);
|
||||
if (is_array($result)) {
|
||||
return $result;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return $original;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user