Compare commits
795 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
896249f833 | |
|
|
cfed37ef8f | |
|
|
1cb618d043 | |
|
|
e7b32da062 | |
|
|
e2284695fc | |
|
|
665d5bded9 | |
|
|
598458f53c | |
|
|
8dc0b526ab | |
|
|
b88b2ce8a5 | |
|
|
10460baad8 | |
|
|
3287311b2d | |
|
|
0c5a48d851 | |
|
|
b388edbb60 | |
|
|
2e5ee5632b | |
|
|
5771fb0fe1 | |
|
|
2aaf051677 | |
|
|
32edadc0f9 | |
|
|
0af09cb4d1 | |
|
|
e9dc4463c1 | |
|
|
32c11a9c04 | |
|
|
5c4a50c68b | |
|
|
e29ee23490 | |
|
|
79f06f9f04 | |
|
|
c418337736 | |
|
|
0ef061de56 | |
|
|
841f25f28b | |
|
|
cf10ab021e | |
|
|
37e75ff8fa | |
|
|
9127f97cc9 | |
|
|
3b50d81fb4 | |
|
|
a030562071 | |
|
|
138ea70185 | |
|
|
71c25c778f | |
|
|
0165896a0b | |
|
|
1dde66a9f1 | |
|
|
eb4b5ca7bc | |
|
|
58c2651796 | |
|
|
80aaa0cab6 | |
|
|
4c5136adf4 | |
|
|
d318b621d5 | |
|
|
d3a9d78e2c | |
|
|
c42b2ff483 | |
|
|
0b3ff9e40b | |
|
|
ddd8fdac3a | |
|
|
e1dc0bbf32 | |
|
|
aff2ec4003 | |
|
|
064614f73f | |
|
|
81e665644c | |
|
|
d0101925de | |
|
|
521d8eafab | |
|
|
e2e3d74de1 | |
|
|
62b2242b75 | |
|
|
1228abe6f2 | |
|
|
f4238f205f | |
|
|
49ba04bffa | |
|
|
bbceb44c3d | |
|
|
d67e376220 | |
|
|
a5a08ec8a0 | |
|
|
0bb5fc2dc4 | |
|
|
a1c585ed05 | |
|
|
2a97539093 | |
|
|
1ca0515ee1 | |
|
|
5d62380c8b | |
|
|
b609aca2cc | |
|
|
20cc575792 | |
|
|
4639baeef9 | |
|
|
a272294fc0 | |
|
|
b239b73689 | |
|
|
7031acd46d | |
|
|
c1d71fba77 | |
|
|
167bb2c075 | |
|
|
dbd0ab5f0e | |
|
|
cfefb8cc56 | |
|
|
31bc4d60e4 | |
|
|
817782a766 | |
|
|
8280f66b6d | |
|
|
19521b432d | |
|
|
2639a0f60a | |
|
|
9485cd0769 | |
|
|
06ddd768aa | |
|
|
25037008de | |
|
|
9d93106d00 | |
|
|
1252c421bc | |
|
|
969875460f | |
|
|
251cfa35f3 | |
|
|
014ef05d05 | |
|
|
8eae3cf124 | |
|
|
35def007ca | |
|
|
82fb01384f | |
|
|
65a4dc7f18 | |
|
|
c38e5ef4a6 | |
|
|
4b330dff6c | |
|
|
b94a3df731 | |
|
|
785e9a84eb | |
|
|
f853cf137b | |
|
|
bcbffb62aa | |
|
|
1b01a0a0eb | |
|
|
5422d63d83 | |
|
|
b90d8099c1 | |
|
|
85ea44c1e3 | |
|
|
67cc2a67e8 | |
|
|
9908f555b2 | |
|
|
76385b78d5 | |
|
|
0f5f7a03e0 | |
|
|
b9d883fe41 | |
|
|
3cc5a510f5 | |
|
|
6c26b422e5 | |
|
|
51a9f75429 | |
|
|
619476e799 | |
|
|
34613d9748 | |
|
|
2ed49eb12d | |
|
|
26c9f49138 | |
|
|
77f5921dff | |
|
|
29c2ecf40c | |
|
|
656cd3c976 | |
|
|
d90320f5f9 | |
|
|
3695761b9e | |
|
|
692fec9bfe | |
|
|
5f9a0fe75b | |
|
|
d7b029c255 | |
|
|
77be82d8e6 | |
|
|
f79c1765b6 | |
|
|
de486ba7e7 | |
|
|
eebb815ad1 | |
|
|
49e147c5b5 | |
|
|
a77cf5b328 | |
|
|
f22fa76987 | |
|
|
08953c6272 | |
|
|
315b68f928 | |
|
|
056388be71 | |
|
|
dfcc1dc7d8 | |
|
|
d71f3f4f62 | |
|
|
3e72568141 | |
|
|
1993a7e2de | |
|
|
91cabf56e7 | |
|
|
aea05ce0e6 | |
|
|
85a489244d | |
|
|
37b65897df | |
|
|
361a1e6c6d | |
|
|
51f0b0b369 | |
|
|
30ea7ff5c0 | |
|
|
55829faf85 | |
|
|
37566b5122 | |
|
|
8203c10f37 | |
|
|
b1dae54aac | |
|
|
d65b7bcc55 | |
|
|
a0f3e84432 | |
|
|
c9490cf149 | |
|
|
ad8c833862 | |
|
|
47875289a8 | |
|
|
e544176cdd | |
|
|
65f9c4da3c | |
|
|
bdcc308188 | |
|
|
06f8229a8c | |
|
|
1c93864567 | |
|
|
522d84f203 | |
|
|
6617b3bb28 | |
|
|
c4800f4943 | |
|
|
f0820b05c2 | |
|
|
1b2ff95c1e | |
|
|
4867df89a1 | |
|
|
4715a26af7 | |
|
|
d366c1dd10 | |
|
|
2d0c280a0a | |
|
|
c2cfd503ee | |
|
|
6c8806965e | |
|
|
8745c0598f | |
|
|
69b40ca560 | |
|
|
c53591e9ec | |
|
|
24c844db49 | |
|
|
65f0758e82 | |
|
|
2f4b0b7aef | |
|
|
36e81104f1 | |
|
|
fd835dd058 | |
|
|
d886bcf755 | |
|
|
81b4187ae8 | |
|
|
bbccb54059 | |
|
|
1e975f7b18 | |
|
|
457c946946 | |
|
|
f84a337607 | |
|
|
fa3e75f722 | |
|
|
1f3d331b25 | |
|
|
315fbcb18f | |
|
|
6fdf123f9f | |
|
|
4a43d8cfc7 | |
|
|
adb8e42d61 | |
|
|
880c45025c | |
|
|
630f71ce4d | |
|
|
e8576d3e94 | |
|
|
ff28ebf753 | |
|
|
242b63317b | |
|
|
a004602ce2 | |
|
|
c749726a79 | |
|
|
761c27c0d3 | |
|
|
fe91a91081 | |
|
|
0447439f99 | |
|
|
ed1c305358 | |
|
|
13f2ca4fe4 | |
|
|
891e85b0bb | |
|
|
c2f0fe6793 | |
|
|
7dfbe49996 | |
|
|
ed0baf18d3 | |
|
|
8628985361 | |
|
|
facddb0d6d | |
|
|
9797caa58e | |
|
|
9c896d9e0e | |
|
|
bccd48014b | |
|
|
10083ff7af | |
|
|
cfa8540be9 | |
|
|
3657dd70cf | |
|
|
e88229bee2 | |
|
|
0b59072d9b | |
|
|
6542df9074 | |
|
|
40c646291e | |
|
|
4877354e8d | |
|
|
61d23cd8c2 | |
|
|
8dfd54eb9f | |
|
|
af8d86321f | |
|
|
26817c1bb6 | |
|
|
6443eb9b00 | |
|
|
14eefb99e9 | |
|
|
3915ca6633 | |
|
|
5246c47ee6 | |
|
|
221a6e8139 | |
|
|
b098096930 | |
|
|
47779baa5e | |
|
|
eebdbc409c | |
|
|
95530ed5f0 | |
|
|
0a7f3737c5 | |
|
|
9cb7812144 | |
|
|
4625321079 | |
|
|
1c2c1a76fa | |
|
|
8d64bf7c6e | |
|
|
45181c11c5 | |
|
|
e96480807c | |
|
|
9e94639657 | |
|
|
649a94c560 | |
|
|
8655258ac3 | |
|
|
67ba6b38c7 | |
|
|
16854f0f77 | |
|
|
582b5492fe | |
|
|
101f4c539a | |
|
|
522cded113 | |
|
|
f77e15bf44 | |
|
|
dbdbe1bf49 | |
|
|
d3f0c90272 | |
|
|
566b16190e | |
|
|
5281102e36 | |
|
|
b6420391e1 | |
|
|
f8118315e7 | |
|
|
d28d69d350 | |
|
|
2ca1714992 | |
|
|
d72dd5fabc | |
|
|
d253d87515 | |
|
|
9d3bb9ef04 | |
|
|
f27f3fe62f | |
|
|
0d4251b321 | |
|
|
11fa58bd6e | |
|
|
71b0448004 | |
|
|
eb0a603b8d | |
|
|
947a4e39c5 | |
|
|
7b7e44faf2 | |
|
|
ac1581e8de | |
|
|
e00599b4f0 | |
|
|
200f87ea48 | |
|
|
7676bcd1c1 | |
|
|
925df9b915 | |
|
|
7668ee2040 | |
|
|
90688016e8 | |
|
|
b4fabb6d59 | |
|
|
aa530c20d2 | |
|
|
69ce646bad | |
|
|
08c20fa2b9 | |
|
|
e2daf22ad7 | |
|
|
921f310ac1 | |
|
|
d9bee210d4 | |
|
|
2fc6940c11 | |
|
|
ecad8e2801 | |
|
|
4a18c344c8 | |
|
|
58633313e1 | |
|
|
0f6dda44b8 | |
|
|
b4b5a7ac8f | |
|
|
a45e064c18 | |
|
|
ecb4e0fab4 | |
|
|
035681ab28 | |
|
|
34779bb891 | |
|
|
c61f42792f | |
|
|
788167e251 | |
|
|
019f31cc05 | |
|
|
91aca75138 | |
|
|
66fb6bf576 | |
|
|
ad6ca25493 | |
|
|
4b4cac7cec | |
|
|
487c23da3e | |
|
|
4182ba6c1b | |
|
|
20094b5e42 | |
|
|
9d5f87d86f | |
|
|
f0b487ca36 | |
|
|
5327bde032 | |
|
|
c2f63f6121 | |
|
|
9d0056f0a6 | |
|
|
a399103305 | |
|
|
b7f8fce86e | |
|
|
c77b07b8a2 | |
|
|
6fc3629014 | |
|
|
2da13af04c | |
|
|
363fbf2a6b | |
|
|
3953546ace | |
|
|
b7e10363d0 | |
|
|
f53a3eef05 | |
|
|
ae8d84012b | |
|
|
ddb86eae51 | |
|
|
144dd6e742 | |
|
|
c465fbfdf4 | |
|
|
beafdf29fb | |
|
|
00e2a38087 | |
|
|
80bf3ee2ed | |
|
|
c32bbd518b | |
|
|
730a5c153e | |
|
|
3a9916e63b | |
|
|
3e9eb0d822 | |
|
|
ef97dda39b | |
|
|
31f4a99d20 | |
|
|
759059baad | |
|
|
cca0eb63a6 | |
|
|
6c37a082bf | |
|
|
d2a9280d7d | |
|
|
64d19f61f2 | |
|
|
cadc7b7750 | |
|
|
d84c015787 | |
|
|
27a4dca7c6 | |
|
|
9c9a306f55 | |
|
|
be77376d85 | |
|
|
eecd74cc0f | |
|
|
b4df4b785a | |
|
|
9b00e3d42c | |
|
|
170e885251 | |
|
|
a96b203021 | |
|
|
057cc6dca5 | |
|
|
11d4118e71 | |
|
|
f13cad57d8 | |
|
|
b552a80203 | |
|
|
b971a76662 | |
|
|
25da7331f0 | |
|
|
50b89f92ea | |
|
|
676e145349 | |
|
|
f952257c20 | |
|
|
e6e91b19d0 | |
|
|
26c7660bfa | |
|
|
e50ac96b50 | |
|
|
20a39f5c29 | |
|
|
6e4657e90f | |
|
|
779d3e0bf6 | |
|
|
a288d311c0 | |
|
|
f87c42a746 | |
|
|
299327cf29 | |
|
|
eb512c4c1b | |
|
|
7dfd50e19a | |
|
|
dfdb24a550 | |
|
|
e13bb7fc42 | |
|
|
828020d689 | |
|
|
4a8185839d | |
|
|
71d0984e9d | |
|
|
4e79b76377 | |
|
|
fc16bea465 | |
|
|
df200aae64 | |
|
|
06cc20fb2a | |
|
|
5a451115f4 | |
|
|
fc71cdd7f8 | |
|
|
e59920cfd0 | |
|
|
6e6f4f6694 | |
|
|
752f519ccc | |
|
|
ffe08f913b | |
|
|
1f75f81297 | |
|
|
b9e85c65bd | |
|
|
e3b8cccba3 | |
|
|
5f9702848e | |
|
|
5dc419b7a7 | |
|
|
aa2dcc027d | |
|
|
f0b98d3063 | |
|
|
5b24d098e4 | |
|
|
53b3965a32 | |
|
|
d0fa120202 | |
|
|
fc1ed97499 | |
|
|
e3f839bc56 | |
|
|
d45ba62805 | |
|
|
5321942da8 | |
|
|
405f58124d | |
|
|
1e4ebae652 | |
|
|
e932e4899c | |
|
|
7c8335d3e7 | |
|
|
81287a2c95 | |
|
|
9c3964da20 | |
|
|
3c9cce2c8b | |
|
|
35020a0108 | |
|
|
e85292b58f | |
|
|
9fd2af6538 | |
|
|
949ce27f63 | |
|
|
81b66db3c6 | |
|
|
b2fcaf6793 | |
|
|
55ab59372e | |
|
|
80a3068742 | |
|
|
a5b2653ed4 | |
|
|
378ecb8a14 | |
|
|
0cf4795fc7 | |
|
|
89076d3aeb | |
|
|
7e67b2907b | |
|
|
8b1fd2e2c1 | |
|
|
5982d5eef9 | |
|
|
d3f65939cd | |
|
|
5986993e45 | |
|
|
da4a35d506 | |
|
|
f1c63de8c0 | |
|
|
a8bf994ae5 | |
|
|
708a50bcf8 | |
|
|
01b2c26580 | |
|
|
d5e30400d0 | |
|
|
bc5ae76534 | |
|
|
b314cdd14d | |
|
|
4bfae911db | |
|
|
608946ddee | |
|
|
eae2a8a47c | |
|
|
9c129fcf76 | |
|
|
84354b183d | |
|
|
50b74a15db | |
|
|
8f32a79d0e | |
|
|
a076c28a30 | |
|
|
f4c008c65f | |
|
|
13947e2099 | |
|
|
0a17b947d7 | |
|
|
528f4829af | |
|
|
ee920d8e66 | |
|
|
65417f7d92 | |
|
|
020d0ee22d | |
|
|
68f2353c97 | |
|
|
db97101113 | |
|
|
a55a02c209 | |
|
|
95fb7f06d8 | |
|
|
589abf2731 | |
|
|
45d4fbb377 | |
|
|
9b8f92f2eb | |
|
|
8d0518c7ff | |
|
|
d15c6d6f1f | |
|
|
76f4e0e3c8 | |
|
|
0d05d66c0f | |
|
|
db6dabedec | |
|
|
bfa467996f | |
|
|
2a270dac74 | |
|
|
667695881c | |
|
|
bc1089be21 | |
|
|
a0747cfbc8 | |
|
|
0f72f3bea4 | |
|
|
38e4b002c8 | |
|
|
645e98cd6a | |
|
|
a31939cb87 | |
|
|
08394be35e | |
|
|
c78951da60 | |
|
|
f549940249 | |
|
|
fee0616ca4 | |
|
|
626fc4ba2b | |
|
|
f7e4aeb898 | |
|
|
1f7d42b083 | |
|
|
858cc264f1 | |
|
|
7d21406be4 | |
|
|
d8dc937e48 | |
|
|
06f6a3dfb7 | |
|
|
da08ad54ca | |
|
|
b18cca8075 | |
|
|
a6b0553393 | |
|
|
0808f573fc | |
|
|
eb998a555b | |
|
|
7add95dd1b | |
|
|
09c1669812 | |
|
|
e57ab435fc | |
|
|
161f74f6bd | |
|
|
b94613f049 | |
|
|
28f9fa1007 | |
|
|
da75076130 | |
|
|
4ba0faf20b | |
|
|
2bd6cf89f6 | |
|
|
c711d6f011 | |
|
|
3ce20c2069 | |
|
|
866af8acfe | |
|
|
a8a85b0666 | |
|
|
831c119636 | |
|
|
3279a565aa | |
|
|
69ac69f41c | |
|
|
4f5557f6ca | |
|
|
06bf414f41 | |
|
|
4b4a9603b9 | |
|
|
396b449bf2 | |
|
|
9562a7d0bb | |
|
|
51282eae38 | |
|
|
d1fe3a7cf5 | |
|
|
fac6e4ea83 | |
|
|
c7a161963f | |
|
|
16826b93bf | |
|
|
7ed19a6e48 | |
|
|
77f7e14f78 | |
|
|
f48ed80103 | |
|
|
89d4450796 | |
|
|
6fcc6da51c | |
|
|
43148d3f17 | |
|
|
8c37fea093 | |
|
|
1ad19492f6 | |
|
|
ade26b7267 | |
|
|
7a1f2b841e | |
|
|
c5e123ea2f | |
|
|
96de30f3e0 | |
|
|
b015ef275f | |
|
|
a272d50174 | |
|
|
d32e270598 | |
|
|
686d2c1153 | |
|
|
c245bbb3be | |
|
|
c40dc747d3 | |
|
|
04e89ddca9 | |
|
|
f59c758fbc | |
|
|
1aebb7faf9 | |
|
|
9993f3183c | |
|
|
55cefffa5e | |
|
|
e97092c46d | |
|
|
4fcf1b5fec | |
|
|
449e65bb91 | |
|
|
ca7c8d0909 | |
|
|
a971ff9bf1 | |
|
|
428348bada | |
|
|
2e2905e014 | |
|
|
75370231e9 | |
|
|
e2fb712098 | |
|
|
422413f45d | |
|
|
8ada088ebb | |
|
|
83fd64cf51 | |
|
|
e90f4ca020 | |
|
|
5220ab7fcd | |
|
|
213c627208 | |
|
|
d492bff7b7 | |
|
|
52e5afffbc | |
|
|
43b6547238 | |
|
|
13ec66548f | |
|
|
a65ead94ad | |
|
|
c00c1845f6 | |
|
|
7841b13fd8 | |
|
|
7d845b7998 | |
|
|
1bc6313e98 | |
|
|
0120abf246 | |
|
|
f8bf21e3e5 | |
|
|
681ba504aa | |
|
|
37642cedaa | |
|
|
1733e75dd1 | |
|
|
e084a04305 | |
|
|
5d715e4aac | |
|
|
eea3271fe6 | |
|
|
ad6b2d0799 | |
|
|
84901a77e0 | |
|
|
d629e2d9d3 | |
|
|
b79a0ac4da | |
|
|
3c85c16480 | |
|
|
5327a82685 | |
|
|
d51b16619f | |
|
|
e1265500cf | |
|
|
b67a915ebe | |
|
|
67380b1b3d | |
|
|
74e9285804 | |
|
|
a953dd386d | |
|
|
82962e0449 | |
|
|
20c36f9d02 | |
|
|
15798b08a7 | |
|
|
ced3ac484d | |
|
|
5f6de8d328 | |
|
|
1b03a8dcd1 | |
|
|
29f5e89acf | |
|
|
7628671241 | |
|
|
9a983ecca6 | |
|
|
93a65c0836 | |
|
|
e9325ee57b | |
|
|
27c2682c3f | |
|
|
be8f008eb4 | |
|
|
9bfb79d036 | |
|
|
b3e3a78ed9 | |
|
|
2bf3a423f4 | |
|
|
8840efebdb | |
|
|
fee54aa827 | |
|
|
36f287e169 | |
|
|
9b45d5df9e | |
|
|
73d429647c | |
|
|
ed9ce2bb07 | |
|
|
daf41efa4c | |
|
|
d5bc6a3c8d | |
|
|
cada9dd67c | |
|
|
f3457d5240 | |
|
|
e76c78fde4 | |
|
|
15d5626b20 | |
|
|
202d90d4fc | |
|
|
7e09815835 | |
|
|
0318670bce | |
|
|
6b4f344bfd | |
|
|
1d706803b1 | |
|
|
88a3907d29 | |
|
|
71c636f297 | |
|
|
c04fcb7d42 | |
|
|
4b1ab93474 | |
|
|
b81be03c4f | |
|
|
1756ee71cd | |
|
|
537486d591 | |
|
|
865d21e2aa | |
|
|
ef698c4fa7 | |
|
|
9fcaa76644 | |
|
|
0b9e1118b8 | |
|
|
d82219eea0 | |
|
|
37c5bdb4b4 | |
|
|
f4a64b6887 | |
|
|
b3f642c02b | |
|
|
fbe646823d | |
|
|
9c187c9550 | |
|
|
eb8104595e | |
|
|
2d8bc53195 | |
|
|
1468843cac | |
|
|
d0ef53a176 | |
|
|
0791a4c2c9 | |
|
|
4f0601cff7 | |
|
|
4e19a1c571 | |
|
|
784532c44d | |
|
|
4e827e62f0 | |
|
|
8bb2e8c838 | |
|
|
ff2bc61bda | |
|
|
b6c42fd4ec | |
|
|
37b0a289b2 | |
|
|
e110619835 | |
|
|
b72d4ea791 | |
|
|
37398b5986 | |
|
|
e1888cce8a | |
|
|
a585bdaaff | |
|
|
24c5870d33 | |
|
|
ed2a058c12 | |
|
|
ede9ecc7b6 | |
|
|
e5d1324126 | |
|
|
6cc1efff15 | |
|
|
ff2a96119e | |
|
|
eabbd67b9a | |
|
|
950ea8ff95 | |
|
|
1acb7126ec | |
|
|
6d07744ab7 | |
|
|
72b1442b77 | |
|
|
88e77f71ef | |
|
|
847dc280d3 | |
|
|
4786b0d1f7 | |
|
|
be0cd48c01 | |
|
|
527da25cdc | |
|
|
33dfbcdeea | |
|
|
1c710bef35 | |
|
|
f6362bfdc1 | |
|
|
5d06a7222c | |
|
|
76e061e1cb | |
|
|
2478f84e85 | |
|
|
8a2f082b09 | |
|
|
42b42e6fd2 | |
|
|
3170d87934 | |
|
|
6ec0981b0a | |
|
|
e0eee38726 | |
|
|
7cc8da562d | |
|
|
172a545acf | |
|
|
318f5356c0 | |
|
|
bed55c909d | |
|
|
8da45a06d0 | |
|
|
d2154fa63c | |
|
|
e195b653b1 | |
|
|
70163e1c5e | |
|
|
1efb3b6a17 | |
|
|
59c6706505 | |
|
|
6a48b54320 | |
|
|
da5e5ec4ae | |
|
|
573df0fe4f | |
|
|
fd949f6f4f | |
|
|
84664f8b5f | |
|
|
d2f4850d28 | |
|
|
f0f7d5b2d3 | |
|
|
06a534c2da | |
|
|
189d30bad5 | |
|
|
993474a754 | |
|
|
d64fc5cf56 | |
|
|
2d4205916b | |
|
|
de7133be3d | |
|
|
48a0cf9e86 | |
|
|
dc9462260d | |
|
|
b60208bea7 | |
|
|
7d85c9181d | |
|
|
f88fc0f819 | |
|
|
54d6ce2ec4 | |
|
|
0bab284e99 | |
|
|
cf7d417193 | |
|
|
1780233778 | |
|
|
666f3ca98b | |
|
|
1ba32d86f7 | |
|
|
d2213ca3e9 | |
|
|
c83baeee2f | |
|
|
cd7b78a2e5 | |
|
|
d854473a06 | |
|
|
bf197ae96b | |
|
|
2bbc4af068 | |
|
|
1cda74cd50 | |
|
|
3abafe2de7 | |
|
|
3a5eed933e | |
|
|
909fbc2626 | |
|
|
09667920a2 | |
|
|
ea4b85ddf7 | |
|
|
8ca9643fc2 | |
|
|
b02d72b29d | |
|
|
e84c880289 | |
|
|
1df19d9609 | |
|
|
a6cf2fe99b | |
|
|
f796e100f8 | |
|
|
8eed6afd46 | |
|
|
2cdfbe6e86 | |
|
|
9d3a4557c1 | |
|
|
acd5ff0a23 | |
|
|
9a3024cfa6 | |
|
|
20d9dc0e73 | |
|
|
8e123cf5f2 | |
|
|
8765fe22c0 | |
|
|
e6d6a10795 | |
|
|
aeb837fee5 | |
|
|
91aac20998 | |
|
|
c915bcba9e | |
|
|
0f868f07e2 | |
|
|
3943ba49b6 | |
|
|
8a4816a6ae | |
|
|
4b783a6b63 | |
|
|
6191d44944 | |
|
|
838b8ea9f2 | |
|
|
15c97859a4 | |
|
|
6199dad2c6 | |
|
|
cb040ee408 | |
|
|
2aaf2ac03f | |
|
|
27304513d8 | |
|
|
0cfa9d811e | |
|
|
54b998a1a8 | |
|
|
77e674bab5 | |
|
|
b6fc4a995d | |
|
|
ca95f3c27e | |
|
|
22e502c90c | |
|
|
ae7929b4e1 | |
|
|
80454de3f2 | |
|
|
8455a98ec5 | |
|
|
5d240b1dd8 | |
|
|
06ffde67f4 | |
|
|
8bbe4b6274 | |
|
|
001e6c5be7 | |
|
|
55b53ef30a | |
|
|
c011321403 | |
|
|
42f9738128 | |
|
|
c0c072884f | |
|
|
54ddc6577e | |
|
|
d9aaf97b81 | |
|
|
f76fd03794 | |
|
|
3e0729c6cc | |
|
|
16b7627b60 | |
|
|
c6e52df33a | |
|
|
7d459cf508 | |
|
|
350ba053e8 | |
|
|
efd7cd74a2 | |
|
|
c39e53906f | |
|
|
5463ea0cee | |
|
|
ba9d14f5d1 | |
|
|
793600ccb9 | |
|
|
ac42b6f591 | |
|
|
fa581f8fb7 | |
|
|
54678bc329 | |
|
|
6400854316 | |
|
|
462e7a45b8 | |
|
|
fa61eecb15 | |
|
|
2eb18f0e84 | |
|
|
d8a756e68a | |
|
|
e32815dde0 | |
|
|
825b84ddc8 | |
|
|
13a2f2482a | |
|
|
acb314f9fe | |
|
|
01a497a780 | |
|
|
cd83494654 | |
|
|
6596e97889 | |
|
|
7e5e68dc23 | |
|
|
08f69dcf05 | |
|
|
b9d7ac69f5 | |
|
|
05672680e4 | |
|
|
d5a628a200 | |
|
|
714800b5c0 | |
|
|
fefba65e25 | |
|
|
dc5d074690 | |
|
|
91fdba99b2 | |
|
|
4a9ee48705 | |
|
|
a4e644261a | |
|
|
38e15c8cf6 | |
|
|
2496d3fc93 | |
|
|
e85e9c577f | |
|
|
3b3a5c205a | |
|
|
18e215c92f |
|
|
@ -0,0 +1,121 @@
|
|||
name: PHP Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DB_TYPE: mariadb
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: jilo_test
|
||||
DB_USERNAME: test_jilo
|
||||
DB_PASSWORD: test_password
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: root
|
||||
MARIADB_DATABASE: jilo_test
|
||||
MARIADB_USER: test_jilo
|
||||
MARIADB_PASSWORD: test_password
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h127.0.0.1 -P3306 -uroot -proot"
|
||||
--health-interval=10s
|
||||
--health-timeout=10s
|
||||
--health-retries=5
|
||||
--health-start-period=30s
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: pdo, pdo_mysql, xdebug
|
||||
coverage: xdebug
|
||||
|
||||
- name: Wait for MariaDB
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mariadb-client
|
||||
while ! mysqladmin ping -h"127.0.0.1" -P"3306" -uroot -proot --silent; do
|
||||
echo "Waiting for database connection..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd tests
|
||||
composer install
|
||||
|
||||
- name: Test database connection
|
||||
run: |
|
||||
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SHOW DATABASES;"
|
||||
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SELECT User,Host FROM mysql.user;"
|
||||
mysql -h127.0.0.1 -P3306 -uroot -proot -e "GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';"
|
||||
mysql -h127.0.0.1 -P3306 -uroot -proot -e "FLUSH PRIVILEGES;"
|
||||
|
||||
- name: Update database config for CI
|
||||
run: |
|
||||
# Create temporary test config
|
||||
mkdir -p tests/config
|
||||
cat > tests/config/ci-config.php << 'EOF'
|
||||
<?php
|
||||
define('CI_DB_PASSWORD', 'test_password');
|
||||
define('CI_DB_HOST', '127.0.0.1');
|
||||
EOF
|
||||
|
||||
# Verify config file was created
|
||||
echo "Config file contents:"
|
||||
cat tests/config/ci-config.php
|
||||
echo "\nConfig file location:"
|
||||
ls -la tests/config/ci-config.php
|
||||
|
||||
# Grant access from Docker network
|
||||
mysql -h127.0.0.1 -P3306 -uroot -proot -e "
|
||||
DROP USER IF EXISTS 'test_jilo'@'%';
|
||||
CREATE USER 'test_jilo'@'%' IDENTIFIED BY 'test_password';
|
||||
GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';
|
||||
CREATE DATABASE IF NOT EXISTS jilo_test;
|
||||
FLUSH PRIVILEGES;
|
||||
"
|
||||
|
||||
# Update test files to require the config (using absolute path)
|
||||
CONFIG_PATH=$(realpath tests/config/ci-config.php)
|
||||
echo "\nConfig path: $CONFIG_PATH"
|
||||
|
||||
# Add require statement at the very start
|
||||
for file in tests/Unit/Classes/{DatabaseTest,UserTest}.php; do
|
||||
echo "<?php" > "$file.tmp"
|
||||
echo "require_once '$CONFIG_PATH';" >> "$file.tmp"
|
||||
tail -n +2 "$file" >> "$file.tmp"
|
||||
mv "$file.tmp" "$file"
|
||||
echo "\nFirst 5 lines of $file:"
|
||||
head -n 5 "$file"
|
||||
done
|
||||
|
||||
# Test database connection directly
|
||||
echo "\nTesting database connection:"
|
||||
mysql -h127.0.0.1 -P3306 -utest_jilo -ptest_password -e "SELECT 'Database connection successful!'" || echo "Database connection failed"
|
||||
|
||||
- name: Run test suite
|
||||
run: |
|
||||
cd tests
|
||||
./vendor/bin/phpunit
|
||||
# FIXME
|
||||
# XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage
|
||||
# env:
|
||||
# COMPOSER_PROCESS_TIMEOUT: 0
|
||||
# COMPOSER_NO_INTERACTION: 1
|
||||
# COMPOSER_NO_AUDIT: 1
|
||||
|
|
@ -4,3 +4,4 @@ jilo.db
|
|||
jilo-web.db
|
||||
packaging/deb-package/
|
||||
packaging/rpm-package/
|
||||
/public_html/uploads/avatars/
|
||||
|
|
|
|||
256
CHANGELOG.md
256
CHANGELOG.md
|
|
@ -7,10 +7,252 @@ All notable changes to this project will be documented in this file.
|
|||
## Unreleased
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web
|
||||
- github: https://github.com/lindeas/jilo-web
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4.1...HEAD
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4.1...HEAD
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.4.1...HEAD
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4.1...HEAD
|
||||
|
||||
### Added
|
||||
- CSS for dashboard widgets
|
||||
- Monthly dashboard statistics redesign
|
||||
- Tracking of applied database migrations in the database
|
||||
- Option to run database migrations one by one
|
||||
- Log Throttler to prevent log flooding
|
||||
- Logger helper with fallback when no log plugin is available
|
||||
- Plugin asset page
|
||||
- Plugin hooks for profile page, account menu, and asset loading
|
||||
- Email helper and email templates, including password reset email
|
||||
- Admin page and admin dashboard for all administrative tasks
|
||||
- Plugin namagement section for the admin dashboard
|
||||
|
||||
### Changed
|
||||
- Updated credentials pages and removed unused "credentials.php"
|
||||
- Redesigned admin tools, themes, profile, credentials/2FA, and authentication pages
|
||||
- Redesigned sidebar, main elements, menus, and overall CSS
|
||||
- Updated pagination styling
|
||||
- Reorganized dashboard layout
|
||||
- Switched profile edit and action pages to uniform action-card design
|
||||
- Replaced "error_log" with "app_log" in 2FA
|
||||
- Updated index bootstrap to use global "APP_PATH"
|
||||
- Refactored database migration system and Admin Tools functionality
|
||||
- Removed "admin-tools" page, all functionality is now in "admin" page
|
||||
|
||||
### Fixed
|
||||
- Database migration reliability issues
|
||||
- Validator rejecting "0" as a valid value
|
||||
- Collapsing sidebar layout issues
|
||||
- Profile avatar upload issues
|
||||
- Public pages incorrectly requiring authentication
|
||||
- Correct encoding of login redirect URL parameters
|
||||
|
||||
---
|
||||
|
||||
## 0.4.1 - 2025-11-13
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...v0.4.1
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...v0.4.1
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.4...v0.4.1
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...v0.4.1
|
||||
|
||||
### Added
|
||||
- Added the ability to have non-sanitized feedback messages
|
||||
- Added a notice for maintenance mode for superusers
|
||||
- Added initial support for maintenance mode
|
||||
- Added initial support for database upgrades and migrations
|
||||
- Added CSRF protection to the theme switcher functionality
|
||||
- Added a helper to manage all static assets of a theme
|
||||
- Added theme data to be passed correctly to the views
|
||||
- Added "modern" and "retro" theme screenshots
|
||||
- Added "alternative retro" theme
|
||||
- Added CSS and JS to the default theme
|
||||
- Added change theme menu entry
|
||||
|
||||
### Changed
|
||||
- Moved away from SQLite to MariaDB/MySQL for the main Jilo website
|
||||
- Integrated Highlight.js library into the SQL view modal for better code highlighting
|
||||
- Moved the migration flag to the database with a fallback to a file
|
||||
- Made the theme setting configuration per-user instead of global
|
||||
- Refactored the session class and added a random session name generator if not configured
|
||||
- Moved session variables to the configuration file
|
||||
|
||||
### Fixed
|
||||
- Fixed flash messages to show up only once per page
|
||||
- Fixed database migration functionality and associated feedback notices
|
||||
- Fixed theme folder structure, helpers, and display logic to work correctly with new asset management
|
||||
- Fixed index routing to work with the latest session and config changes
|
||||
- Fixed the router class and several bugs within the session class and theme switcher functionality
|
||||
|
||||
### Removed
|
||||
- Removed getScreenshotUrl function, using the generic getAssetUrl for all assets instead
|
||||
|
||||
---
|
||||
|
||||
## 0.4 - 2025-04-12
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.3...v0.4
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.3...v0.4
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.3...v0.4
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.3...v0.4
|
||||
|
||||
### Added
|
||||
- Added top-right menu with profile, admin, and docs sections
|
||||
- Added two-factor authentication
|
||||
- Added resetting of forgotten password
|
||||
- Added login credentials management
|
||||
- Added proper pagination
|
||||
- Added agents managemet pages
|
||||
- Added javascript-based feedback messages
|
||||
- Added description to each page
|
||||
- Added CSRF checks
|
||||
- Added validator class for all forms
|
||||
- Added rate limiting to all pages
|
||||
- Added authentication rate limiting to login and registration
|
||||
- Added unit tests
|
||||
- Added integration/feature tests
|
||||
- Added testing workflow for github
|
||||
|
||||
### Changed
|
||||
- Increased session to 2 hours w/out "remember me", 30 days with
|
||||
- Made the config editing in-place with AJAX
|
||||
- Redesigned the help page
|
||||
- Moved graphs and latest data to their own pages
|
||||
- Moved live config.js to its own page
|
||||
- Redesigned the messages system and renamed them to feedback messages
|
||||
|
||||
### Fixed
|
||||
- Bugfixes
|
||||
- Fixed config editing
|
||||
- Fixed logs search
|
||||
- Removed hardcoded messages, changed to feedback messages
|
||||
|
||||
---
|
||||
|
||||
## 0.3 - 2025-01-15
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.2.1...v0.3
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.2.1...v0.3
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.2.1...v0.3
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.2.1...v0.3
|
||||
|
||||
### Added
|
||||
- Added status page
|
||||
- Added latest data page
|
||||
- Added graphs page
|
||||
- Added Jilo agents status checks
|
||||
- Added periodic Jilo agents checks
|
||||
- Added Jilo Server check and notice on error
|
||||
- Added "jitsi platforms config" section in the sidebar
|
||||
- Added editing for platforms
|
||||
- Added editing for hosts
|
||||
- Added editing for the Jilo configuration file
|
||||
- Added phpdoc comments
|
||||
- Added rate limiting for login with blacklist and whitelist
|
||||
- Added a page for configuring the rate limiting
|
||||
|
||||
### Changed
|
||||
- Implemented a new messaging and notifications system
|
||||
- Moved all live checks pages to the "live data" sidebar section
|
||||
- Separated the config page to multiple pages
|
||||
- Moved the config pages to "jitsi platforms config" section
|
||||
|
||||
### Fixed
|
||||
- Fixed bugs in config editing pages and cleaned up the HTML
|
||||
|
||||
---
|
||||
|
||||
## 0.2.1 - 2024-10-17
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.2...v0.2.1
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.2...v0.2.1
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.2...v0.2.1
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.2...v0.2.1
|
||||
|
||||
### Added
|
||||
- Added support for managing Jilo Agents
|
||||
- Authenticating to Jilo Agents with JWT tokens with a shared secret key
|
||||
- Added Jilo Agent functionality to fetch data, cache it and manage the cache
|
||||
- Added more fields and avatar image to user profile
|
||||
- Added pagination (with ellipses) for the longer listings
|
||||
- Added initial support for application logs
|
||||
- Added help page
|
||||
- Added support for graphs by Chart.js
|
||||
- Added "graphs" section in sidebar with graphs and latest data pages
|
||||
|
||||
### Changed
|
||||
- Jitsi platforms config moved from file to SQLite database
|
||||
- Left sidebar menu items reordered
|
||||
|
||||
### Fixed
|
||||
- All output HTML sanitized
|
||||
- Sanitized input forms data
|
||||
- Fixed error in calculation of monthly total conferences on front page
|
||||
|
||||
---
|
||||
|
||||
## 0.2 - 2024-08-31
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.1.1...v0.2
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.1.1...v0.2
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.1.1...v0.2
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.1.1...v0.2
|
||||
|
||||
### Added
|
||||
- Added collapsible front page widgets
|
||||
- Added widgets to conferences, participants and components pages
|
||||
- Added front page widget for monthly conferences and participants number
|
||||
- Added login/registration control and messages
|
||||
- Added default config file locations
|
||||
- Added left collapsible sidebar
|
||||
- Added logo
|
||||
- Added database helper functions
|
||||
- Added support for multiple Jitsi platforms
|
||||
- Added app environments "production" and "development"
|
||||
- Added visualisation of config.js and interface_config.js per Jitsi platform
|
||||
|
||||
### Changed
|
||||
- MVC design - models(classes folder), views(templates folder) and controllers(pages folder)
|
||||
- Changed menus - categories on sidebar menu, jitsi platforms on top menu
|
||||
- Moved all the app code outside of the public web folder
|
||||
- Config file now can have nested arrays
|
||||
|
||||
### Fixed
|
||||
- Fixed SQL when conferences start and end time are not explicitly clear
|
||||
- Web design fixes
|
||||
- Fixed install script
|
||||
|
||||
---
|
||||
|
||||
## 0.1.1 - 2024-07-25
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.1...v0.1.1
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.1...v0.1.1
|
||||
- github: https://github.com/lindeas/jilo-web/compare/v0.1...v0.1.1
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.1...v0.1.1
|
||||
|
||||
### Added
|
||||
- Added duration calculation in conferences listing
|
||||
- Added manual install script
|
||||
- Added DEB and RPM build files
|
||||
- Added Bootstrap (licensed under MIT)
|
||||
|
||||
### Changed
|
||||
- Changed the layout with bootstrap CSS classes
|
||||
|
||||
---
|
||||
|
||||
## 0.1 - 2024-07-08
|
||||
|
||||
#### Links
|
||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/releases/tag/v0.1
|
||||
- codeberg: https://codeberg.org/lindeas/jilo-web/releases/tag/v0.1
|
||||
- github: https://github.com/lindeas/jilo-web/releases/tag/v0.1
|
||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/releases/v0.1
|
||||
|
||||
### Added
|
||||
- Initial version
|
||||
|
|
@ -23,9 +265,3 @@ All notable changes to this project will be documented in this file.
|
|||
- Added demo installation on https://work.lindeas.com/jilo-web-demo/
|
||||
- Added participant search page
|
||||
- Added component events search page
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
---
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -1,25 +1,80 @@
|
|||
# Jilo Web
|
||||
|
||||
Jilo Web is a PHP web interface to Jilo (JItsi Logs Observer).
|
||||
## overview
|
||||
|
||||
Jilo Web is a PHP web interface to **[Jilo](https://work.lindeas.com/redirect.php?url=jilo)** (JItsi Logs Observer).
|
||||
|
||||
To have a working installation, in addition to the code here you need a jilo database file, generated by Jilo.
|
||||
|
||||
The webpage for this project is https://lindeas.com/jilo. There you will find information about both Jilo and Jilo Web.
|
||||
|
||||
The main git repo of **Jilo Web** is:
|
||||
- https://code.lindeas.com/lindeas/jilo-web
|
||||
|
||||
It is mirrored at:
|
||||
- https://codeberg.org/lindeas/jilo-web
|
||||
- https://github.com/lindeas/jilo-web
|
||||
- https://gitlab.com/lindeas/jilo-web
|
||||
|
||||
You can use any of these git repos to get the program.
|
||||
|
||||
You are welcome to send feedback with issues, comments and pull requests to a git mirror you prefer.
|
||||
|
||||
## demo
|
||||
|
||||
To see a demo install, go to https://work.lindeas.com/jilo-web-demo/
|
||||
|
||||
## version
|
||||
|
||||
Current version: **0.4.1** released on **2025-11-13**
|
||||
|
||||
## license
|
||||
|
||||
This project is licensed under the GNU General Public License version 2 (GPL-2.0). See LICENSE file.
|
||||
|
||||
Bootstrap is used in this project and is licensed under the MIT License. See license-bootstrap file.
|
||||
|
||||
JQuery is used in this project and is licensed under the MIT License. See license-jquery file.
|
||||
|
||||
Chart.js is used in this project and is licensed under the MIT License. See license-chartjs file.
|
||||
|
||||
Highlight.js is used in this project and is licensed under the BSD 3-clause License. See license-highlightjs file.
|
||||
|
||||
## requirements
|
||||
|
||||
- web server (deb: apache | nginx)
|
||||
|
||||
- php support in the web server (deb: php-fpm | libapache2-mod-php)
|
||||
|
||||
- pdo and pdo_sqlite support in php (deb: php-db, php-sqlite3) uncomment in php.ini: ;extension=pdo_sqlite
|
||||
- php-curl module
|
||||
|
||||
## installation
|
||||
|
||||
You can install it in the following ways:
|
||||
|
||||
- download the latest release from the **"Releases"** section here
|
||||
```bash
|
||||
tar -xzf jilo-web_*.tgz
|
||||
cd jilo-web/doc/
|
||||
./install.sh
|
||||
```
|
||||
- clone the **git repo**:
|
||||
```bash
|
||||
git clone https://github.com/lindeas/jilo-web.git
|
||||
cd jilo-web
|
||||
./install.sh
|
||||
```
|
||||
- DEB and RPM packages are planned, but not yet available. There are experimental build scripts in "packaging" folder.
|
||||
|
||||
## config
|
||||
|
||||
- edit jilo-web.conf.php and set all the variables correctly
|
||||
|
||||
- "database" is the sqlite db file for jilo-web itself, create it with `cat jilo-web.schema | sqlite3 jilo-web.db`
|
||||
|
||||
- "sqlite_file" is the sqlite db file for jilo-web itself, it goes to `app/jilo-web.db`, create it with
|
||||
```bash
|
||||
cd app
|
||||
cat ../doc/jilo-web.schema | sqlite3 jilo-web.db
|
||||
```
|
||||
- "jilo_database" is the sqlite db file for jilo, with data from the Jitsi logs
|
||||
|
||||
## database
|
||||
|
||||
The database is an SQLite file. You also need the sqlite db from jilo (mysql/mariadb support is planned, but not yet available).
|
||||
|
|
|
|||
27
TODO.md
27
TODO.md
|
|
@ -1,27 +0,0 @@
|
|||
# Jilo Web
|
||||
|
||||
## TODO
|
||||
|
||||
- ~~jilo-web.db outside web root~~
|
||||
|
||||
- ~~jilo-web.db writable by web server user~~
|
||||
|
||||
- major refactoring after v0.1
|
||||
|
||||
- - add bootstrap template
|
||||
|
||||
- - clean up the code to follow model-view--controller style
|
||||
|
||||
- - no HTML inside PHP code
|
||||
|
||||
- - put all additional functions in files in a separate folder
|
||||
|
||||
- - reduce try/catch usage, use it only for critical errors
|
||||
|
||||
- - move all SQL code in the model classes, one query per method
|
||||
|
||||
- - add 'limit' to SQL and make pagination
|
||||
|
||||
- - pretty URLs routing (no htaccess, it needs to be in PHP code so that it can be used with all servers)
|
||||
|
||||
- add mysql/mariadb option
|
||||
|
|
@ -0,0 +1,647 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Agent
|
||||
*
|
||||
* Provides methods to interact with Jilo agents, including retrieving details, managing agents, generating JWT tokens,
|
||||
* and fetching data from agent APIs.
|
||||
*/
|
||||
class Agent {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Agent constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves details of agents for a specified host.
|
||||
*
|
||||
* @param int $host_id The host ID to filter agents by.
|
||||
* @param int $agent_id Optional agent ID to filter by.
|
||||
*
|
||||
* @return array The list of agent details.
|
||||
*/
|
||||
public function getAgentDetails($host_id, $agent_id = '') {
|
||||
$sql = 'SELECT
|
||||
ja.id,
|
||||
ja.host_id,
|
||||
ja.agent_type_id,
|
||||
ja.url,
|
||||
ja.secret_key,
|
||||
ja.check_period,
|
||||
jat.description AS agent_description,
|
||||
jat.endpoint AS agent_endpoint,
|
||||
h.platform_id
|
||||
FROM
|
||||
jilo_agent ja
|
||||
JOIN
|
||||
jilo_agent_type jat ON ja.agent_type_id = jat.id
|
||||
JOIN
|
||||
host h ON ja.host_id = h.id
|
||||
WHERE
|
||||
ja.host_id = :host_id';
|
||||
|
||||
if ($agent_id !== '') {
|
||||
$sql .= ' AND ja.id = :agent_id';
|
||||
}
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
|
||||
$query->bindParam(':host_id', $host_id);
|
||||
if ($agent_id !== '') {
|
||||
$query->bindParam(':agent_id', $agent_id);
|
||||
}
|
||||
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves details of a specified agent by its agent ID.
|
||||
*
|
||||
* @param int $agent_id The agent ID to filter by.
|
||||
*
|
||||
* @return array The agent details.
|
||||
*/
|
||||
public function getAgentIDDetails($agent_id) {
|
||||
$sql = 'SELECT
|
||||
ja.id,
|
||||
ja.host_id,
|
||||
ja.agent_type_id,
|
||||
ja.url,
|
||||
ja.secret_key,
|
||||
ja.check_period,
|
||||
jat.description AS agent_description,
|
||||
jat.endpoint AS agent_endpoint,
|
||||
h.platform_id
|
||||
FROM
|
||||
jilo_agent ja
|
||||
JOIN
|
||||
jilo_agent_type jat ON ja.agent_type_id = jat.id
|
||||
JOIN
|
||||
host h ON ja.host_id = h.id
|
||||
WHERE
|
||||
ja.id = :agent_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':agent_id', $agent_id);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves all agent types.
|
||||
*
|
||||
* @return array List of all agent types.
|
||||
*/
|
||||
public function getAgentTypes() {
|
||||
$sql = 'SELECT *
|
||||
FROM jilo_agent_type
|
||||
ORDER BY id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves agent types already configured for a specific host.
|
||||
*
|
||||
* @param int $host_id The host ID to filter agents by.
|
||||
*
|
||||
* @return array List of agent types configured for the host.
|
||||
*/
|
||||
public function getHostAgentTypes($host_id) {
|
||||
$sql = 'SELECT
|
||||
id,
|
||||
agent_type_id
|
||||
FROM
|
||||
jilo_agent
|
||||
WHERE
|
||||
host_id = :host_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':host_id', $host_id);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new agent to the database.
|
||||
*
|
||||
* @param int $host_id The host ID to add the agent to.
|
||||
* @param array $newAgent An associative array containing the details of the agent to be added.
|
||||
*
|
||||
* @return bool|string True if the agent was added successfully, otherwise error message.
|
||||
*/
|
||||
public function addAgent($host_id, $newAgent) {
|
||||
try {
|
||||
$sql = 'INSERT INTO jilo_agent
|
||||
(host_id, agent_type_id, url, secret_key, check_period)
|
||||
VALUES
|
||||
(:host_id, :agent_type_id, :url, :secret_key, :check_period)';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':host_id' => $host_id,
|
||||
':agent_type_id' => $newAgent['type_id'],
|
||||
':url' => $newAgent['url'],
|
||||
':secret_key' => $newAgent['secret_key'],
|
||||
':check_period' => $newAgent['check_period'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Edit an existing agent in the database.
|
||||
*
|
||||
* @param int $agent_id The ID of the agent to edit.
|
||||
* @param array $updatedAgent An associative array containing the updated details of the agent.
|
||||
*
|
||||
* @return bool|string True if the agent was updated successfully, otherwise error message.
|
||||
*/
|
||||
public function editAgent($agent_id, $updatedAgent) {
|
||||
try {
|
||||
$sql = 'UPDATE jilo_agent
|
||||
SET
|
||||
agent_type_id = :agent_type_id,
|
||||
url = :url,
|
||||
secret_key = :secret_key,
|
||||
check_period = :check_period
|
||||
WHERE
|
||||
id = :agent_id';
|
||||
|
||||
// Convert empty secret key to NULL
|
||||
$secretKey = !empty($updatedAgent['secret_key']) ? $updatedAgent['secret_key'] : null;
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':agent_id' => $agent_id,
|
||||
':agent_type_id' => $updatedAgent['agent_type_id'],
|
||||
':url' => $updatedAgent['url'],
|
||||
':secret_key' => $secretKey,
|
||||
':check_period' => $updatedAgent['check_period'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deletes an agent from the database.
|
||||
*
|
||||
* @param int $agent_id The agent ID to delete.
|
||||
*
|
||||
* @return bool|string Returns true on success or an error message on failure.
|
||||
*/
|
||||
public function deleteAgent($agent_id) {
|
||||
try {
|
||||
$sql = 'DELETE FROM jilo_agent
|
||||
WHERE
|
||||
id = :agent_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':agent_id', $agent_id);
|
||||
|
||||
$query->execute();
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the agent cache is still valid.
|
||||
*
|
||||
* @param int $agent_id The agent ID to check.
|
||||
*
|
||||
* @return bool Returns true if cache is valid, false otherwise.
|
||||
*/
|
||||
public function checkAgentCache($agent_id) {
|
||||
$agent_cache_name = 'agent' . $agent_id . '_cache';
|
||||
$agent_cache_time = 'agent' . $agent_id . '_time';
|
||||
return isset($_SESSION[$agent_cache_name]) && isset($_SESSION[$agent_cache_time]) && (time() - $_SESSION[$agent_cache_time] < 600);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Base64 URL encodes the input data. Used for encoding JWT tokens
|
||||
*
|
||||
* @param string $data The data to encode.
|
||||
*
|
||||
* @return string The base64 URL encoded string.
|
||||
*/
|
||||
private function base64UrlEncode($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates a JWT token for a Jilo agent.
|
||||
*
|
||||
* @param array $payload The payload data to include in the token.
|
||||
* @param string $secret_key The secret key used to sign the token.
|
||||
*
|
||||
* @return string The generated JWT token.
|
||||
*/
|
||||
public function generateAgentToken($payload, $secret_key) {
|
||||
|
||||
// header
|
||||
$header = json_encode([
|
||||
'typ' => 'JWT',
|
||||
'alg' => 'HS256'
|
||||
]);
|
||||
$base64Url_header = $this->base64UrlEncode($header);
|
||||
|
||||
// payload
|
||||
$payload = json_encode($payload);
|
||||
$base64Url_payload = $this->base64UrlEncode($payload);
|
||||
|
||||
// signature
|
||||
$signature = hash_hmac('sha256', $base64Url_header . "." . $base64Url_payload, $secret_key ?? '', true);
|
||||
$base64Url_signature = $this->base64UrlEncode($signature);
|
||||
|
||||
// build the JWT
|
||||
$jwt = $base64Url_header . "." . $base64Url_payload . "." . $base64Url_signature;
|
||||
|
||||
return $jwt;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches data from a Jilo agent's API, optionally forcing a refresh of the cache.
|
||||
*
|
||||
* @param int $agent_id The agent ID to fetch data for.
|
||||
* @param bool $force Whether to force-refresh the cache (default: false).
|
||||
*
|
||||
* @return string The API response, or an error message in JSON format.
|
||||
*/
|
||||
public function fetchAgent($agent_id, $force = false) {
|
||||
|
||||
// we need agent details for URL and JWT token
|
||||
$agentDetails = $this->getAgentIDDetails($agent_id);
|
||||
|
||||
// Safe exit in case the agent is not found
|
||||
if (empty($agentDetails)) {
|
||||
return json_encode(['error' => 'Agent not found']);
|
||||
}
|
||||
|
||||
$agent = $agentDetails[0];
|
||||
$agent_cache_name = 'agent' . $agent_id . '_cache';
|
||||
$agent_cache_time = 'agent' . $agent_id . '_time';
|
||||
|
||||
// check if the cache is still valid, unless force-refresh is requested
|
||||
if (!$force && $this->checkAgentCache($agent_id)) {
|
||||
return $_SESSION[$agent_cache_name];
|
||||
}
|
||||
|
||||
// generate the JWT token
|
||||
$payload = [
|
||||
'agent_id' => $agent_id,
|
||||
'timestamp' => time()
|
||||
];
|
||||
$jwt = $this->generateAgentToken($payload, $agent['secret_key']);
|
||||
|
||||
// Make the API request
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $agent['url'] . $agent['agent_endpoint']);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // timeout 10 seconds
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $jwt,
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curl_error = curl_error($ch);
|
||||
$curl_errno = curl_errno($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
// curl error
|
||||
if ($curl_errno) {
|
||||
return json_encode(['error' => 'curl error: ' . $curl_error]);
|
||||
}
|
||||
|
||||
// response is not 200 OK
|
||||
if ($http_code !== 200) {
|
||||
return json_encode(['error' => 'HTTP error: ' . $http_code]);
|
||||
}
|
||||
|
||||
// other custom error(s)
|
||||
if (strpos($response, 'Auth header not received') !== false) {
|
||||
return json_encode(['error' => 'Auth header not received']);
|
||||
}
|
||||
|
||||
// Cache the result and the timestamp if the response is successful
|
||||
// We decode it so that it's pure JSON and not escaped
|
||||
$_SESSION[$agent_cache_name] = json_decode($response, true);
|
||||
$_SESSION[$agent_cache_time] = time();
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clears the cached data for a specific agent.
|
||||
*
|
||||
* @param int $agent_id The agent ID for which the cache should be cleared.
|
||||
*/
|
||||
public function clearAgentCache($agent_id) {
|
||||
$_SESSION["agent{$agent_id}_cache"] = '';
|
||||
$_SESSION["agent{$agent_id}_cache_time"] = '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets a value from a nested array using dot notation
|
||||
* e.g. "bridge_selector.bridge_count" will get $array['bridge_selector']['bridge_count']
|
||||
*
|
||||
* @param array $array The array to search in
|
||||
* @param string $path The path in dot notation
|
||||
* @return mixed|null The value if found, null otherwise
|
||||
*/
|
||||
private function getNestedValue($array, $path) {
|
||||
$keys = explode('.', $path);
|
||||
$value = $array;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (!isset($value[$key])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$key];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the latest stored data for a specific host, agent type, and metric type.
|
||||
*
|
||||
* @param int $host_id The host ID.
|
||||
* @param string $agent_type The agent type.
|
||||
* @param string $metric_type The metric type to filter by.
|
||||
*
|
||||
* @return mixed The latest stored data.
|
||||
*/
|
||||
public function getLatestData($host_id, $agent_type, $metric_type) {
|
||||
$sql = 'SELECT
|
||||
jac.timestamp,
|
||||
jac.response_content,
|
||||
jac.agent_id,
|
||||
jat.description
|
||||
FROM
|
||||
jilo_agent_check jac
|
||||
JOIN
|
||||
jilo_agent ja ON jac.agent_id = ja.id
|
||||
JOIN
|
||||
jilo_agent_type jat ON ja.agent_type_id = jat.id
|
||||
JOIN
|
||||
host h ON ja.host_id = h.id
|
||||
WHERE
|
||||
h.id = :host_id
|
||||
AND jat.description = :agent_type
|
||||
AND jac.status_code = 200
|
||||
ORDER BY
|
||||
jac.timestamp DESC
|
||||
LIMIT 1';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':host_id' => $host_id,
|
||||
':agent_type' => $agent_type
|
||||
]);
|
||||
|
||||
$result = $query->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($result) {
|
||||
// Parse the JSON response content
|
||||
$data = json_decode($result['response_content'], true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the specific metric value from the response based on agent type
|
||||
if ($agent_type === 'jvb') {
|
||||
$value = $this->getNestedValue($data['jvb_api_data'], $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
|
||||
} elseif ($agent_type === 'jicofo') {
|
||||
$value = $this->getNestedValue($data['jicofo_api_data'], $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
|
||||
} elseif ($agent_type === 'jigasi') {
|
||||
$value = $this->getNestedValue($data['jigasi_api_data'], $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
|
||||
} elseif ($agent_type === 'prosody') {
|
||||
$value = $this->getNestedValue($data['prosody_api_data'], $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
|
||||
} elseif ($agent_type === 'nginx') {
|
||||
$value = $this->getNestedValue($data['nginx_api_data'], $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets historical data for a specific metric from agent checks
|
||||
*
|
||||
* @param int $host_id The host ID
|
||||
* @param string $agent_type The type of agent (e.g., 'jvb', 'jicofo')
|
||||
* @param string $metric_type The type of metric to retrieve
|
||||
* @param string $from_time Start time in Y-m-d format
|
||||
* @param string $until_time End time in Y-m-d format
|
||||
* @return array Array with the dataset from agent checks
|
||||
*/
|
||||
public function getHistoricalData($host_id, $agent_type, $metric_type, $from_time, $until_time) {
|
||||
// Get data from agent checks
|
||||
$sql = 'SELECT
|
||||
DATE(jac.timestamp) as date,
|
||||
jac.response_content,
|
||||
COUNT(*) as checks_count
|
||||
FROM
|
||||
jilo_agent_check jac
|
||||
JOIN
|
||||
jilo_agent ja ON jac.agent_id = ja.id
|
||||
JOIN
|
||||
jilo_agent_type jat ON ja.agent_type_id = jat.id
|
||||
JOIN
|
||||
host h ON ja.host_id = h.id
|
||||
WHERE
|
||||
h.id = :host_id
|
||||
AND jat.description = :agent_type
|
||||
AND jac.status_code = 200
|
||||
AND DATE(jac.timestamp) BETWEEN :from_time AND :until_time
|
||||
GROUP BY
|
||||
DATE(jac.timestamp)
|
||||
ORDER BY
|
||||
DATE(jac.timestamp)';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':host_id' => $host_id,
|
||||
':agent_type' => $agent_type,
|
||||
':from_time' => $from_time,
|
||||
':until_time' => $until_time
|
||||
]);
|
||||
|
||||
$results = $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$json_data = json_decode($row['response_content'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$api_data = [];
|
||||
if ($agent_type === 'jvb') {
|
||||
$api_data = $json_data['jvb_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'jicofo') {
|
||||
$api_data = $json_data['jicofo_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'jigasi') {
|
||||
$api_data = $json_data['jigasi_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'prosody') {
|
||||
$api_data = $json_data['prosody_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'nginx') {
|
||||
$api_data = $json_data['nginx_api_data'] ?? [];
|
||||
}
|
||||
|
||||
$value = $this->getNestedValue($api_data, $metric_type);
|
||||
if ($value !== null) {
|
||||
$data[] = [
|
||||
'date' => $row['date'],
|
||||
'value' => $value
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the previous record for a specific metric
|
||||
*
|
||||
* @param int $host_id The host ID
|
||||
* @param string $agent_type The type of agent (e.g., 'jvb', 'jicofo')
|
||||
* @param string $metric_type The type of metric to retrieve
|
||||
* @param string $current_timestamp Current record's timestamp to get data before this
|
||||
* @return array|null Previous record data or null if not found
|
||||
*/
|
||||
public function getPreviousRecord($host_id, $agent_type, $metric_type, $current_timestamp) {
|
||||
$sql = 'SELECT
|
||||
jac.timestamp,
|
||||
jac.response_content
|
||||
FROM
|
||||
jilo_agent_check jac
|
||||
JOIN
|
||||
jilo_agent ja ON jac.agent_id = ja.id
|
||||
JOIN
|
||||
jilo_agent_type jat ON ja.agent_type_id = jat.id
|
||||
JOIN
|
||||
host h ON ja.host_id = h.id
|
||||
WHERE
|
||||
h.id = :host_id
|
||||
AND jat.description = :agent_type
|
||||
AND jac.status_code = 200
|
||||
AND jac.timestamp < :current_timestamp
|
||||
ORDER BY
|
||||
jac.timestamp DESC
|
||||
LIMIT 1';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':host_id' => $host_id,
|
||||
':agent_type' => $agent_type,
|
||||
':current_timestamp' => $current_timestamp
|
||||
]);
|
||||
|
||||
$result = $query->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($result) {
|
||||
$json_data = json_decode($result['response_content'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$api_data = [];
|
||||
if ($agent_type === 'jvb') {
|
||||
$api_data = $json_data['jvb_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'jicofo') {
|
||||
$api_data = $json_data['jicofo_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'jigasi') {
|
||||
$api_data = $json_data['jigasi_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'prosody') {
|
||||
$api_data = $json_data['prosody_api_data'] ?? [];
|
||||
} elseif ($agent_type === 'nginx') {
|
||||
$api_data = $json_data['nginx_api_data'] ?? [];
|
||||
}
|
||||
|
||||
$value = $this->getNestedValue($api_data, $metric_type);
|
||||
if ($value !== null) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'timestamp' => $result['timestamp']
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* API Response Handler
|
||||
* Provides a consistent way to send JSON responses from controllers
|
||||
*/
|
||||
class ApiResponse {
|
||||
/**
|
||||
* Send a success response
|
||||
* @param mixed $data Optional data to include in response
|
||||
* @param string $message Optional success message
|
||||
* @param int $status HTTP status code
|
||||
*/
|
||||
public static function success($data = null, $message = '', $status = 200) {
|
||||
self::send([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'message' => $message
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
* @param string $message Error message
|
||||
* @param mixed $errors Optional error details
|
||||
* @param int $status HTTP status code
|
||||
*/
|
||||
public static function error($message, $errors = null, $status = 400) {
|
||||
self::send([
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'errors' => $errors
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the actual JSON response
|
||||
* @param array $data Response data
|
||||
* @param int $status HTTP status code
|
||||
*/
|
||||
private static function send($data, $status) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Component
|
||||
*
|
||||
* Provides methods to interact with Jitsi component events in the database.
|
||||
*/
|
||||
class Component {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Component constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves Jitsi component events based on various filters.
|
||||
*
|
||||
* @param string $jitsi_component The Jitsi component name.
|
||||
* @param int $component_id The component ID.
|
||||
* @param string $event_type The type of event to filter by.
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items to retrieve per page.
|
||||
*
|
||||
* @return array The list of Jitsi component events or an empty array if no results.
|
||||
*/
|
||||
public function jitsiComponents($jitsi_component, $component_id, $event_type, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
global $logObject;
|
||||
try {
|
||||
// Add time part to dates if not present
|
||||
if (strlen($from_time) <= 10) {
|
||||
$from_time .= ' 00:00:00';
|
||||
}
|
||||
if (strlen($until_time) <= 10) {
|
||||
$until_time .= ' 23:59:59';
|
||||
}
|
||||
|
||||
// list of jitsi component events
|
||||
$sql = "SELECT jitsi_component, loglevel, time, component_id, event_type, event_param
|
||||
FROM jitsi_components
|
||||
WHERE time >= :from_time
|
||||
AND time <= :until_time";
|
||||
|
||||
// Only add component and event filters if they're not the default values
|
||||
if ($jitsi_component !== 'jitsi_component') {
|
||||
$sql .= " AND LOWER(jitsi_component) = LOWER(:jitsi_component)";
|
||||
}
|
||||
if ($component_id !== 'component_id') {
|
||||
$sql .= " AND component_id = :component_id";
|
||||
}
|
||||
if ($event_type !== 'event_type') {
|
||||
$sql .= " AND event_type LIKE :event_type";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$sql .= ' LIMIT :offset, :items_per_page';
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
|
||||
// Bind parameters only if they're not default values
|
||||
if ($jitsi_component !== 'jitsi_component') {
|
||||
$stmt->bindValue(':jitsi_component', trim($jitsi_component, "'"));
|
||||
}
|
||||
if ($component_id !== 'component_id') {
|
||||
$stmt->bindValue(':component_id', trim($component_id, "'"));
|
||||
}
|
||||
if ($event_type !== 'event_type') {
|
||||
$stmt->bindValue(':event_type', '%' . trim($event_type, "'") . '%');
|
||||
}
|
||||
|
||||
$stmt->bindParam(':from_time', $from_time);
|
||||
$stmt->bindParam(':until_time', $until_time);
|
||||
|
||||
if ($items_per_page) {
|
||||
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->bindParam(':items_per_page', $items_per_page, PDO::PARAM_INT);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($result)) {
|
||||
$logObject->log('info', "Retrieved " . count($result) . " Jitsi component events", ['user_id' => $userId, 'scope' => 'system']);
|
||||
}
|
||||
return $result;
|
||||
} catch (PDOException $e) {
|
||||
$logObject->log('error', "Failed to retrieve Jitsi component events: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of components events matching the filter criteria
|
||||
*
|
||||
* @param string $jitsi_component The Jitsi component name.
|
||||
* @param int $component_id The component ID.
|
||||
* @param string $event_type The type of event to filter by.
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
*
|
||||
* @return int The total count of matching components
|
||||
*/
|
||||
public function getComponentEventsCount($jitsi_component, $component_id, $event_type, $from_time, $until_time) {
|
||||
global $logObject;
|
||||
try {
|
||||
// Add time part to dates if not present
|
||||
if (strlen($from_time) <= 10) {
|
||||
$from_time .= ' 00:00:00';
|
||||
}
|
||||
if (strlen($until_time) <= 10) {
|
||||
$until_time .= ' 23:59:59';
|
||||
}
|
||||
|
||||
// Build the query
|
||||
$sql = "SELECT COUNT(*) as total
|
||||
FROM jitsi_components
|
||||
WHERE time >= :from_time
|
||||
AND time <= :until_time";
|
||||
|
||||
// Only add component and event filters if they're not the default values
|
||||
if ($jitsi_component !== 'jitsi_component') {
|
||||
$sql .= " AND LOWER(jitsi_component) = LOWER(:jitsi_component)";
|
||||
}
|
||||
if ($component_id !== 'component_id') {
|
||||
$sql .= " AND component_id = :component_id";
|
||||
}
|
||||
if ($event_type !== 'event_type') {
|
||||
$sql .= " AND event_type LIKE :event_type";
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
|
||||
// Bind parameters only if they're not default values
|
||||
if ($jitsi_component !== 'jitsi_component') {
|
||||
$stmt->bindValue(':jitsi_component', trim($jitsi_component, "'"));
|
||||
}
|
||||
if ($component_id !== 'component_id') {
|
||||
$stmt->bindValue(':component_id', trim($component_id, "'"));
|
||||
}
|
||||
if ($event_type !== 'event_type') {
|
||||
$stmt->bindValue(':event_type', '%' . trim($event_type, "'") . '%');
|
||||
}
|
||||
|
||||
$stmt->bindParam(':from_time', $from_time);
|
||||
$stmt->bindParam(':until_time', $until_time);
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return (int)$result['total'];
|
||||
} catch (PDOException $e) {
|
||||
$logObject->log('error', "Failed to retrieve component events count: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Conference
|
||||
*
|
||||
* Provides methods for querying conference-related data from the database.
|
||||
*/
|
||||
class Conference {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Conference constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves conference data by conference ID within a specific time range.
|
||||
*
|
||||
* @param string $conference_id The conference ID.
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items to retrieve per page.
|
||||
*
|
||||
* @return array The list of conference events or an empty array if no results.
|
||||
*/
|
||||
public function conferenceById($conference_id, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// search for a conference by its ID for a time period (if given)
|
||||
$sql = "
|
||||
SELECT
|
||||
pe.time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
pe.loglevel,
|
||||
pe.event_type,
|
||||
p.endpoint_id AS participant_id,
|
||||
pe.event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
LEFT JOIN
|
||||
participants p ON c.conference_id = p.conference_id
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
c.conference_id = '%s'
|
||||
AND (pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
ce.time AS event_time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
ce.loglevel,
|
||||
ce.conference_event AS event_type,
|
||||
NULL AS participant_id,
|
||||
ce.conference_param AS event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE
|
||||
c.conference_id = '%s'
|
||||
AND (event_time >= '%s 00:00:00' AND event_time <= '%s 23:59:59')
|
||||
|
||||
ORDER BY
|
||||
pe.time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $conference_id, $from_time, $until_time, $conference_id, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves conference data by conference name within a specific time range.
|
||||
*
|
||||
* @param string $conference_name The conference name.
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items to retrieve per page.
|
||||
*
|
||||
* @return array The list of conference events or an empty array if no results.
|
||||
*/
|
||||
public function conferenceByName($conference_name, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// search for a conference by its name for a time period (if given)
|
||||
$sql = "
|
||||
SELECT
|
||||
pe.time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
pe.loglevel,
|
||||
pe.event_type,
|
||||
p.endpoint_id AS participant_id,
|
||||
pe.event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
LEFT JOIN
|
||||
participants p ON c.conference_id = p.conference_id
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
c.conference_name = '%s'
|
||||
AND (pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
ce.time AS event_time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
ce.loglevel,
|
||||
ce.conference_event AS event_type,
|
||||
NULL AS participant_id,
|
||||
ce.conference_param AS event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE
|
||||
c.conference_name = '%s'
|
||||
AND (event_time >= '%s 00:00:00' AND event_time <= '%s 23:59:59')
|
||||
|
||||
ORDER BY
|
||||
pe.time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $conference_name, $from_time, $until_time, $conference_name, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves all conferences within a specific time range, formatted.
|
||||
*
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items to retrieve per page.
|
||||
*
|
||||
* @return array The list of formatted conference data or an empty array if no results.
|
||||
*/
|
||||
public function conferencesAllFormatted($from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// list of conferences for time period (if given)
|
||||
// fields: component, duration, conference ID, conference name, number of participants, name count (the conf name is found), conference host
|
||||
$sql = "
|
||||
SELECT DISTINCT
|
||||
c.jitsi_component,
|
||||
(SELECT COALESCE
|
||||
(
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
ce.conference_event = 'conference created'
|
||||
),
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
ce.conference_event = 'bridge selected'
|
||||
)
|
||||
)
|
||||
)
|
||||
AS start,
|
||||
(SELECT COALESCE
|
||||
(
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
(ce.conference_event = 'conference expired' OR ce.conference_event = 'conference stopped')
|
||||
),
|
||||
(SELECT pe.time
|
||||
FROM participant_events pe
|
||||
WHERE
|
||||
pe.event_param = c.conference_id
|
||||
ORDER BY pe.time DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
)
|
||||
AS end,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
(SELECT COUNT(pe.participant_id)
|
||||
FROM participant_events pe
|
||||
WHERE
|
||||
pe.event_type = 'participant joining'
|
||||
AND
|
||||
pe.event_param = c.conference_id) AS participants,
|
||||
name_counts.name_count,
|
||||
c.conference_host
|
||||
FROM
|
||||
conferences c
|
||||
JOIN (
|
||||
SELECT
|
||||
conference_name,
|
||||
COUNT(*) AS name_count
|
||||
FROM
|
||||
conferences
|
||||
GROUP BY
|
||||
conference_name
|
||||
) AS name_counts ON c.conference_name = name_counts.conference_name
|
||||
JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE (ce.time >= '%s 00:00:00' AND ce.time <= '%s 23:59:59')
|
||||
ORDER BY
|
||||
c.id";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the number of conferences within a specific time range.
|
||||
*
|
||||
* @param string $from_time The start date in 'YYYY-MM-DD' format.
|
||||
* @param string $until_time The end date in 'YYYY-MM-DD' format.
|
||||
*
|
||||
* @return int The number of conferences found.
|
||||
*/
|
||||
public function conferenceNumber($from_time, $until_time) {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// number of conferences for time period (if given)
|
||||
// FIXME sometimes there is no start/end time, find a way around this
|
||||
$sql = "
|
||||
SELECT COUNT(*) AS conferences
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
(SELECT COALESCE
|
||||
(
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
ce.conference_event = 'conference created'
|
||||
),
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
ce.conference_event = 'bridge selected'
|
||||
)
|
||||
)
|
||||
) AS start,
|
||||
(SELECT COALESCE
|
||||
(
|
||||
(SELECT ce.time
|
||||
FROM conference_events ce
|
||||
WHERE
|
||||
ce.conference_id = c.conference_id
|
||||
AND
|
||||
(ce.conference_event = 'conference expired' OR ce.conference_event = 'conference stopped')
|
||||
),
|
||||
(SELECT pe.time
|
||||
FROM participant_events pe
|
||||
WHERE
|
||||
pe.event_param = c.conference_id
|
||||
ORDER BY pe.time DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
) AS end
|
||||
FROM conferences c
|
||||
JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE (start >= '%s 00:00:00' AND end <= '%s 23:59:59')
|
||||
) AS subquery";
|
||||
|
||||
$sql = sprintf($sql, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Config
|
||||
*
|
||||
* Handles editing and fetching of the config files.
|
||||
*/
|
||||
class Config {
|
||||
|
||||
/**
|
||||
* Edits a config file by updating specified options.
|
||||
*
|
||||
* @param array $updatedConfig Key-value pairs of config options to update.
|
||||
* @param string $config_file Path to the config file.
|
||||
*
|
||||
* @return array Returns an array with 'success' and 'updated' keys on success, or 'success' and 'error' keys on failure.
|
||||
*/
|
||||
public function editConfigFile($updatedConfig, $config_file) {
|
||||
global $logObject, $userId;
|
||||
$allLogs = [];
|
||||
$updated = [];
|
||||
|
||||
try {
|
||||
if (!is_array($updatedConfig)) {
|
||||
throw new Exception("Invalid config data: expected array");
|
||||
}
|
||||
|
||||
if (!file_exists($config_file) || !is_writable($config_file)) {
|
||||
throw new Exception("Config file does not exist or is not writable: $config_file");
|
||||
}
|
||||
|
||||
// First we get a fresh config file contents as text
|
||||
$config_contents = file_get_contents($config_file);
|
||||
if ($config_contents === false) {
|
||||
throw new Exception("Failed to read the config file: $config_file");
|
||||
}
|
||||
|
||||
$lines = explode("\n", $config_contents);
|
||||
|
||||
// We loop through the variables and update them
|
||||
foreach ($updatedConfig as $key => $newValue) {
|
||||
if (strpos($key, '[') !== false) {
|
||||
preg_match_all('/([^\[\]]+)/', $key, $matches);
|
||||
if (empty($matches[1])) continue;
|
||||
|
||||
$parts = $matches[1];
|
||||
$currentPath = [];
|
||||
$found = false;
|
||||
$inTargetArray = false;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$line = rtrim($line);
|
||||
|
||||
if (preg_match("/^\\s*\\]/", $line)) {
|
||||
if (!empty($currentPath)) {
|
||||
if ($inTargetArray && end($currentPath) === $parts[0]) {
|
||||
$inTargetArray = false;
|
||||
}
|
||||
array_pop($currentPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match("/^\\s*['\"]([^'\"]+)['\"]\\s*=>/", $line, $matches)) {
|
||||
$key = $matches[1];
|
||||
|
||||
if (strpos($line, '[') !== false) {
|
||||
$currentPath[] = $key;
|
||||
if ($key === $parts[0]) {
|
||||
$inTargetArray = true;
|
||||
}
|
||||
} else if ($key === end($parts) && $inTargetArray) {
|
||||
$pathMatches = true;
|
||||
$expectedPath = array_slice($parts, 0, -1);
|
||||
|
||||
if (count($currentPath) === count($expectedPath)) {
|
||||
for ($j = 0; $j < count($expectedPath); $j++) {
|
||||
if ($currentPath[$j] !== $expectedPath[$j]) {
|
||||
$pathMatches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pathMatches) {
|
||||
if ($newValue === 'true' || $newValue === '1') {
|
||||
$replacementValue = 'true';
|
||||
} elseif ($newValue === 'false' || $newValue === '0') {
|
||||
$replacementValue = 'false';
|
||||
} else {
|
||||
$replacementValue = var_export($newValue, true);
|
||||
}
|
||||
|
||||
if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
|
||||
$lines[$i] = $matches[1] . $replacementValue . $matches[2];
|
||||
$updated[] = implode('.', array_merge($currentPath, [$key]));
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
$allLogs[] = "Failed to update: $key";
|
||||
}
|
||||
} else {
|
||||
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
|
||||
throw new Exception("Invalid config key format: $key");
|
||||
}
|
||||
|
||||
if ($newValue === 'true' || $newValue === '1') {
|
||||
$replacementValue = 'true';
|
||||
} elseif ($newValue === 'false' || $newValue === '0') {
|
||||
$replacementValue = 'false';
|
||||
} else {
|
||||
$replacementValue = var_export($newValue, true);
|
||||
}
|
||||
|
||||
$found = false;
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
|
||||
$lines[$i] = $matches[1] . $replacementValue . $matches[2];
|
||||
$updated[] = $key;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
$allLogs[] = "Failed to update: $key";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We write the new config file
|
||||
$new_contents = implode("\n", $lines);
|
||||
if (file_put_contents($config_file, $new_contents) === false) {
|
||||
throw new Exception("Failed to write the config file: $config_file");
|
||||
}
|
||||
|
||||
if (!empty($allLogs)) {
|
||||
$logObject->log('info', implode("\n", $allLogs), ['user_id' => $userId, 'scope' => 'system']);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'updated' => $updated
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$logObject->log('error', "Config update error: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Database
|
||||
*
|
||||
* Manages database connections for SQLite and MySQL (or MariaDB).
|
||||
*/
|
||||
class Database {
|
||||
/**
|
||||
* @var PDO|null $pdo The database connection instance.
|
||||
*/
|
||||
private $pdo;
|
||||
|
||||
/**
|
||||
* Database constructor.
|
||||
* Initializes the database connection based on provided options.
|
||||
*
|
||||
* @param array $options An associative array with database connection options:
|
||||
* - type: The database type ('sqlite', 'mysql', or 'mariadb').
|
||||
* - dbFile: The path to the SQLite database file (required for SQLite).
|
||||
* - host: The database host (required for MySQL).
|
||||
* - port: The port for MySQL (optional, default: 3306).
|
||||
* - dbname: The name of the MySQL database (required for MySQL).
|
||||
* - user: The username for MySQL (required for MySQL).
|
||||
* - password: The password for MySQL (optional).
|
||||
*
|
||||
* @throws Exception If required extensions are not loaded or options are invalid.
|
||||
*/
|
||||
public function __construct($options) {
|
||||
// check if PDO extension is loaded
|
||||
if (!extension_loaded('pdo')) {
|
||||
throw new Exception('PDO extension not loaded.');
|
||||
}
|
||||
|
||||
// options check
|
||||
if (empty($options['type'])) {
|
||||
throw new Exception('Database type is not set.');
|
||||
}
|
||||
|
||||
// connect based on database type
|
||||
switch ($options['type']) {
|
||||
case 'sqlite':
|
||||
$this->connectSqlite($options);
|
||||
break;
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
$this->connectMysql($options);
|
||||
break;
|
||||
default:
|
||||
$this->pdo = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a connection to a SQLite database.
|
||||
*
|
||||
* @param array $options An associative array with SQLite connection options:
|
||||
* - dbFile: The path to the SQLite database file.
|
||||
*
|
||||
* @throws Exception If the SQLite PDO extension is not loaded or the database file is missing.
|
||||
*/
|
||||
private function connectSqlite($options) {
|
||||
// pdo_sqlite extension is needed
|
||||
if (!extension_loaded('pdo_sqlite')) {
|
||||
throw new Exception('PDO extension for SQLite not loaded.');
|
||||
}
|
||||
|
||||
// SQLite options
|
||||
if (empty($options['dbFile'])) {
|
||||
throw new Exception('SQLite database file path is missing.');
|
||||
}
|
||||
|
||||
// For in-memory database (especially for the tests), skip file check
|
||||
if ($options['dbFile'] !== ':memory:' && !file_exists($options['dbFile'])) {
|
||||
throw new Exception("SQLite database file \"{$options['dbFile']}\" not found.");
|
||||
}
|
||||
|
||||
// connect to SQLite
|
||||
try {
|
||||
$this->pdo = new PDO("sqlite:" . $options['dbFile']);
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
// enable foreign key constraints (not ON by default in SQLite3)
|
||||
$this->pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('SQLite connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a connection to a MySQL (or MariaDB) database.
|
||||
*
|
||||
* @param array $options An associative array with MySQL connection options:
|
||||
* - host: The database host.
|
||||
* - port: The database port (default: 3306).
|
||||
* - dbname: The name of the database.
|
||||
* - user: The database username.
|
||||
* - password: The database password (optional).
|
||||
*
|
||||
* @throws Exception If the MySQL PDO extension is not loaded or required options are missing.
|
||||
*/
|
||||
private function connectMysql($options) {
|
||||
// pdo_mysql extension is needed
|
||||
if (!extension_loaded('pdo_mysql')) {
|
||||
throw new Exception('PDO extension for MySQL not loaded.');
|
||||
}
|
||||
|
||||
// MySQL options
|
||||
if (empty($options['host']) || empty($options['dbname']) || empty($options['user'])) {
|
||||
throw new Exception('MySQL connection data is missing.');
|
||||
}
|
||||
|
||||
// Connect to MySQL
|
||||
try {
|
||||
$port = $options['port'] ?? 3306;
|
||||
$dsn = "mysql:host={$options['host']};port={$port};dbname={$options['dbname']};charset=utf8";
|
||||
$this->pdo = new PDO($dsn, $options['user'], $options['password'] ?? '');
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
} catch (PDOException $e) {
|
||||
$this->pdo = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current PDO connection instance.
|
||||
*
|
||||
* @return PDO|null The PDO instance or null if no connection is established.
|
||||
*/
|
||||
public function getConnection() {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an SQL query with optional parameters.
|
||||
*
|
||||
* @param string $query The SQL query to execute
|
||||
* @param array $params Optional parameters for the query
|
||||
* @return PDOStatement|false The result of the query execution
|
||||
* @throws Exception If the query fails
|
||||
*/
|
||||
public function execute($query, $params = []) {
|
||||
if (!$this->pdo) {
|
||||
throw new Exception('No database connection.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare($query);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('Query execution failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an SQL statement for execution.
|
||||
*
|
||||
* @param string $query The SQL query to prepare
|
||||
* @return PDOStatement The prepared statement
|
||||
* @throws Exception If the preparation fails
|
||||
*/
|
||||
public function prepare($query) {
|
||||
if (!$this->pdo) {
|
||||
throw new Exception('No database connection.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->pdo->prepare($query);
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('Statement preparation failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins a database transaction.
|
||||
*
|
||||
* @throws Exception If starting the transaction fails
|
||||
*/
|
||||
public function beginTransaction() {
|
||||
if (!$this->pdo) {
|
||||
throw new Exception('No database connection.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->pdo->beginTransaction();
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('Failed to start transaction: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the current database transaction.
|
||||
*
|
||||
* @throws Exception If committing the transaction fails
|
||||
*/
|
||||
public function commit() {
|
||||
if (!$this->pdo) {
|
||||
throw new Exception('No database connection.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->pdo->commit();
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('Failed to commit transaction: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back the current database transaction.
|
||||
*
|
||||
* @throws Exception If rolling back the transaction fails
|
||||
*/
|
||||
public function rollBack() {
|
||||
if (!$this->pdo) {
|
||||
throw new Exception('No database connection.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->pdo->rollBack();
|
||||
} catch (PDOException $e) {
|
||||
throw new Exception('Failed to rollback transaction: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
class Feedback {
|
||||
// Feedback types
|
||||
const TYPE_SUCCESS = 'success';
|
||||
const TYPE_ERROR = 'danger';
|
||||
const TYPE_INFO = 'info';
|
||||
const TYPE_WARNING = 'warning';
|
||||
|
||||
// Default feedback message configurations
|
||||
const NOTICE = [
|
||||
'DEFAULT' => [
|
||||
'type' => self::TYPE_INFO,
|
||||
'dismissible' => true
|
||||
]
|
||||
];
|
||||
|
||||
const ERROR = [
|
||||
'DEFAULT' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
]
|
||||
];
|
||||
|
||||
const LOGIN = [
|
||||
'LOGIN_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'LOGIN_FAILED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'LOGOUT_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'SESSION_TIMEOUT' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'IP_BLACKLISTED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'IP_NOT_WHITELISTED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'TOO_MANY_ATTEMPTS' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
]
|
||||
];
|
||||
|
||||
const REGISTER = [
|
||||
'SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'FAILED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'DISABLED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
];
|
||||
|
||||
const SECURITY = [
|
||||
'WHITELIST_ADD_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'WHITELIST_ADD_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'WHITELIST_REMOVE_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'WHITELIST_REMOVE_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'BLACKLIST_ADD_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'BLACKLIST_ADD_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'BLACKLIST_REMOVE_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'BLACKLIST_REMOVE_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
],
|
||||
'RATE_LIMIT_INFO' => [
|
||||
'type' => self::TYPE_INFO,
|
||||
'dismissible' => false
|
||||
],
|
||||
'PERMISSION_DENIED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'IP_REQUIRED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
]
|
||||
];
|
||||
|
||||
const THEME = [
|
||||
'THEME_CHANGE_SUCCESS' => [
|
||||
'type' => self::TYPE_SUCCESS,
|
||||
'dismissible' => true
|
||||
],
|
||||
'THEME_CHANGE_FAILED' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => true
|
||||
]
|
||||
];
|
||||
|
||||
const SYSTEM = [
|
||||
'DB_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'DB_CONNECT_ERROR' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'DB_UNKNOWN_TYPE' => [
|
||||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'MIGRATIONS_PENDING' => [
|
||||
'type' => self::TYPE_WARNING,
|
||||
'dismissible' => true
|
||||
],
|
||||
'MAINTENANCE_ON' => [
|
||||
'type' => self::TYPE_WARNING,
|
||||
'dismissible' => false
|
||||
],
|
||||
];
|
||||
|
||||
private static $strings = null;
|
||||
|
||||
/**
|
||||
* Get feedback message strings
|
||||
*/
|
||||
private static function getStrings() {
|
||||
if (self::$strings === null) {
|
||||
self::$strings = require __DIR__ . '/../includes/strings.php';
|
||||
}
|
||||
return self::$strings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback message configuration by key
|
||||
*/
|
||||
public static function get($category, $key) {
|
||||
$config = constant("self::$category")[$key] ?? null;
|
||||
if (!$config) return null;
|
||||
|
||||
$strings = self::getStrings();
|
||||
$message = $strings[$category][$key] ?? '';
|
||||
|
||||
return array_merge($config, ['message' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render feedback message HTML
|
||||
*/
|
||||
// Usage: echo Feedback::render('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]);
|
||||
public static function render($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) {
|
||||
$config = self::get($category, $key);
|
||||
if (!$config) return '';
|
||||
|
||||
$message = $customMessage ?? $config['message'];
|
||||
$isDismissible = $dismissible ?? $config['dismissible'] ?? false;
|
||||
$dismissClass = $isDismissible ? ' alert-dismissible fade show' : '';
|
||||
$dismissButton = $isDismissible ? '<button type="button" class="btn-close' . ($small ? ' btn-close-sm' : '') . '" data-bs-dismiss="alert" aria-label="Close"></button>' : '';
|
||||
$smallClass = $small ? ' alert-sm' : '';
|
||||
|
||||
return sprintf(
|
||||
'<div class="alert alert-%s%s%s" role="alert">%s%s</div>',
|
||||
$config['type'],
|
||||
$dismissClass,
|
||||
$smallClass,
|
||||
$sanitize ? htmlspecialchars($message) : $message,
|
||||
$dismissButton
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback message data for JavaScript
|
||||
*/
|
||||
public static function getMessageData($category, $key, $customMessage = null, $dismissible = null, $small = false) {
|
||||
$config = self::get($category, $key);
|
||||
if (!$config) return null;
|
||||
|
||||
return [
|
||||
'type' => $config['type'],
|
||||
'message' => $customMessage ?? $config['message'],
|
||||
'dismissible' => $dismissible ?? $config['dismissible'] ?? false,
|
||||
'small' => $small
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store feedback message in session for display after redirect
|
||||
*/
|
||||
// Usage: Feedback::flash('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]);
|
||||
public static function flash($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) {
|
||||
if (!isset($_SESSION['flash_messages'])) {
|
||||
$_SESSION['flash_messages'] = [];
|
||||
}
|
||||
|
||||
// Get the feedback message configuration
|
||||
$config = self::get($category, $key);
|
||||
$isDismissible = $dismissible ?? $config['dismissible'] ?? false;
|
||||
|
||||
$_SESSION['flash_messages'][] = [
|
||||
'category' => $category,
|
||||
'key' => $key,
|
||||
'custom_message' => $customMessage,
|
||||
'dismissible' => $isDismissible,
|
||||
'small' => $small,
|
||||
'sanitize' => $sanitize
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear all flash feedback messages
|
||||
*/
|
||||
public static function getFlash() {
|
||||
$system_messages = $_SESSION['flash_messages'] ?? [];
|
||||
unset($_SESSION['flash_messages']);
|
||||
return $system_messages;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Host
|
||||
*
|
||||
* Manages the hosts in the database, providing methods to retrieve, add, edit, and delete host entries.
|
||||
*/
|
||||
class Host {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Host constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get details of a specified host ID (or all hosts) in a specified platform ID.
|
||||
*
|
||||
* @param string $platform_id The platform ID to filter the hosts by (optional).
|
||||
* @param string $host_id The host ID to filter the details (optional).
|
||||
*
|
||||
* @return array The details of the host(s) in the form of an associative array.
|
||||
*/
|
||||
public function getHostDetails($platform_id = '', $host_id = '') {
|
||||
$sql = 'SELECT
|
||||
id,
|
||||
address,
|
||||
platform_id,
|
||||
name
|
||||
FROM
|
||||
host';
|
||||
|
||||
if ($platform_id !== '' && $host_id !== '') {
|
||||
$sql .= ' WHERE platform_id = :platform_id AND id = :host_id';
|
||||
} elseif ($platform_id !== '') {
|
||||
$sql .= ' WHERE platform_id = :platform_id';
|
||||
} elseif ($host_id !== '') {
|
||||
$sql .= ' WHERE id = :host_id';
|
||||
}
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
|
||||
if ($platform_id !== '') {
|
||||
$query->bindParam(':platform_id', $platform_id);
|
||||
}
|
||||
if ($host_id !== '') {
|
||||
$query->bindParam(':host_id', $host_id);
|
||||
}
|
||||
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new host to the database.
|
||||
*
|
||||
* @param array $newHost An associative array containing the details of the host to be added.
|
||||
*
|
||||
* @return bool True if the host was added successfully, otherwise false.
|
||||
*/
|
||||
public function addHost($newHost) {
|
||||
try {
|
||||
$sql = 'INSERT INTO host
|
||||
(address, platform_id, name)
|
||||
VALUES
|
||||
(:address, :platform_id, :name)';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':address' => $newHost['address'],
|
||||
':platform_id' => $newHost['platform_id'],
|
||||
':name' => $newHost['name'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Edit an existing host in the database.
|
||||
*
|
||||
* @param string $platform_id The platform ID to which the host belongs.
|
||||
* @param array $updatedHost An associative array containing the updated details of the host.
|
||||
*
|
||||
* @return bool|string True if the host was updated successfully, otherwise error message.
|
||||
*/
|
||||
public function editHost($platform_id, $updatedHost) {
|
||||
try {
|
||||
$sql = 'UPDATE host SET
|
||||
address = :address,
|
||||
name = :name
|
||||
WHERE
|
||||
id = :id AND platform_id = :platform_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':id' => $updatedHost['id'],
|
||||
':platform_id' => $platform_id,
|
||||
':address' => $updatedHost['address'],
|
||||
':name' => $updatedHost['name']
|
||||
]);
|
||||
|
||||
if ($query->rowCount() === 0) {
|
||||
return "No host found with ID {$updatedHost['id']} in platform $platform_id";
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete a host from the database.
|
||||
*
|
||||
* @param int $host_id The ID of the host to be deleted.
|
||||
*
|
||||
* @return bool True if the host was deleted successfully, otherwise false.
|
||||
*/
|
||||
public function deleteHost($host_id) {
|
||||
try {
|
||||
// Start transaction
|
||||
$this->db->beginTransaction();
|
||||
|
||||
// First delete all agents associated with this host
|
||||
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':host_id', $host_id);
|
||||
$query->execute();
|
||||
|
||||
// Then delete the host
|
||||
$sql = 'DELETE FROM host WHERE id = :host_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':host_id', $host_id);
|
||||
$query->execute();
|
||||
|
||||
// Commit transaction
|
||||
$this->db->commit();
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Rollback transaction on error
|
||||
$this->db->rollBack();
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Log wrapper that delegates to plugin Log or NullLogger fallback.
|
||||
* Used when code does require_once '../app/classes/log.php'.
|
||||
*/
|
||||
|
||||
use App\Core\NullLogger;
|
||||
|
||||
// If there is already a Log plugin loaded
|
||||
if (class_exists('Log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load fallback NullLogger
|
||||
require_once __DIR__ . '/../core/NullLogger.php';
|
||||
|
||||
class Log {
|
||||
private $logger;
|
||||
|
||||
/**
|
||||
* @param mixed $database Database or DatabaseConnector instance
|
||||
*/
|
||||
public function __construct($database) {
|
||||
global $logObject;
|
||||
if (isset($logObject) && method_exists($logObject, 'insertLog')) {
|
||||
$this->logger = $logObject;
|
||||
} else {
|
||||
$this->logger = new NullLogger();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PSR-3 compatible log method
|
||||
* @param string $level
|
||||
* @param string $message
|
||||
* @param array $context
|
||||
*/
|
||||
public function log(string $level, string $message, array $context = []): void {
|
||||
$this->logger->log($level, $message, $context);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Participant
|
||||
*
|
||||
* This class provides methods to retrieve information about participants and their related conference data.
|
||||
* It supports querying participant details by ID, name, or IP, as well as listing all participants and counting them within a specific time frame.
|
||||
*/
|
||||
class Participant {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve conferences by participant ID within a specified time period.
|
||||
*
|
||||
* @param string $participant_id The participant's ID (endpoint_id).
|
||||
* @param string $from_time The start date (format: 'YYYY-MM-DD'). Defaults to '0000-01-01' if empty.
|
||||
* @param string $until_time The end date (format: 'YYYY-MM-DD'). Defaults to '9999-12-31' if empty.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items per page for pagination.
|
||||
*
|
||||
* @return array List of conferences involving the specified participant ID.
|
||||
*/
|
||||
public function conferenceByParticipantId($participant_id, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// list conferences where participant ID (endpoint_id) is found
|
||||
$sql = "
|
||||
SELECT
|
||||
pe.time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
pe.loglevel,
|
||||
pe.event_type,
|
||||
p.endpoint_id AS participant_id,
|
||||
pe.event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
LEFT JOIN
|
||||
participants p ON c.conference_id = p.conference_id
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
p.endpoint_id = '%s'
|
||||
AND (pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
ce.time AS event_time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
ce.loglevel,
|
||||
ce.conference_event AS event_type,
|
||||
NULL AS participant_id,
|
||||
ce.conference_param AS event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE
|
||||
participant_id = '%s'
|
||||
AND (event_time >= '%s 00:00:00' AND event_time <= '%s 23:59:59')
|
||||
|
||||
ORDER BY
|
||||
pe.time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $participant_id, $from_time, $until_time, $participant_id, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve conferences by participant name within a specified time period.
|
||||
*
|
||||
* @param string $participant_name The participant's name (stats_id).
|
||||
* @param string $from_time The start date (format: 'YYYY-MM-DD'). Defaults to '0000-01-01' if empty.
|
||||
* @param string $until_time The end date (format: 'YYYY-MM-DD'). Defaults to '9999-12-31' if empty.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items per page for pagination.
|
||||
*
|
||||
* @return array List of conferences involving the specified participant name.
|
||||
*/
|
||||
public function conferenceByParticipantName($participant_name, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// list conferences where participant name (stats_id) is found
|
||||
$sql = "
|
||||
SELECT
|
||||
pe.time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
pe.loglevel,
|
||||
pe.event_type,
|
||||
p.endpoint_id AS participant_id,
|
||||
pe.event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
LEFT JOIN
|
||||
participants p ON c.conference_id = p.conference_id
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
pe.event_type = 'stats_id' AND pe.event_param LIKE '%%%s%%'
|
||||
AND (pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
ce.time AS event_time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
ce.loglevel,
|
||||
ce.conference_event AS event_type,
|
||||
NULL AS participant_id,
|
||||
ce.conference_param AS event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE
|
||||
event_type = 'stats_id' AND event_param LIKE '%%%s%%'
|
||||
AND (event_time >= '%s 00:00:00' AND event_time <= '%s 23:59:59')
|
||||
|
||||
ORDER BY
|
||||
pe.time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $participant_name, $from_time, $until_time, $participant_name, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve conferences by participant IP within a specified time period.
|
||||
*
|
||||
* @param string $participant_ip The participant's IP address.
|
||||
* @param string $from_time The start date (format: 'YYYY-MM-DD'). Defaults to '0000-01-01' if empty.
|
||||
* @param string $until_time The end date (format: 'YYYY-MM-DD'). Defaults to '9999-12-31' if empty.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items per page for pagination.
|
||||
*
|
||||
* @return array List of conferences involving the specified participant IP.
|
||||
*/
|
||||
public function conferenceByParticipantIP($participant_ip, $from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// list conferences where participant IP is found
|
||||
$sql = "
|
||||
SELECT
|
||||
pe.time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
pe.loglevel,
|
||||
pe.event_type,
|
||||
p.endpoint_id AS participant_id,
|
||||
pe.event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
LEFT JOIN
|
||||
participants p ON c.conference_id = p.conference_id
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
pe.event_type = 'pair selected' AND pe.event_param = '%s'
|
||||
AND (pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
ce.time AS event_time,
|
||||
c.conference_id,
|
||||
c.conference_name,
|
||||
c.conference_host,
|
||||
ce.loglevel,
|
||||
ce.conference_event AS event_type,
|
||||
NULL AS participant_id,
|
||||
ce.conference_param AS event_param
|
||||
FROM
|
||||
conferences c
|
||||
LEFT JOIN
|
||||
conference_events ce ON c.conference_id = ce.conference_id
|
||||
WHERE
|
||||
event_type = 'pair selected' AND event_param = '%s'
|
||||
AND (event_time >= '%s 00:00:00' AND event_time <= '%s 23:59:59')
|
||||
|
||||
ORDER BY
|
||||
pe.time";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $participant_ip, $from_time, $until_time, $participant_ip, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve a list of all participants within a specified time period.
|
||||
*
|
||||
* @param string $from_time The start date (format: 'YYYY-MM-DD'). Defaults to '0000-01-01' if empty.
|
||||
* @param string $until_time The end date (format: 'YYYY-MM-DD'). Defaults to '9999-12-31' if empty.
|
||||
* @param int $offset The offset for pagination.
|
||||
* @param int $items_per_page The number of items per page for pagination.
|
||||
*
|
||||
* @return array List of all participants.
|
||||
*/
|
||||
public function participantsAll($from_time, $until_time, $offset=0, $items_per_page='') {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// list all participants
|
||||
$sql = "
|
||||
SELECT DISTINCT
|
||||
p.jitsi_component, p.endpoint_id, p.conference_id
|
||||
FROM
|
||||
participants p
|
||||
JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59'
|
||||
ORDER BY p.id";
|
||||
|
||||
if ($items_per_page) {
|
||||
$items_per_page = (int)$items_per_page;
|
||||
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
|
||||
}
|
||||
|
||||
$sql = sprintf($sql, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Count the number of participants within a specified time period.
|
||||
*
|
||||
* @param string $from_time The start date (format: 'YYYY-MM-DD'). Defaults to '0000-01-01' if empty.
|
||||
* @param string $until_time The end date (format: 'YYYY-MM-DD'). Defaults to '9999-12-31' if empty.
|
||||
*
|
||||
* @return int The number of participants.
|
||||
*/
|
||||
public function participantNumber($from_time, $until_time) {
|
||||
|
||||
// time period drill-down
|
||||
// FIXME make it similar to the bash version
|
||||
if (empty($from_time)) {
|
||||
$from_time = '0000-01-01';
|
||||
}
|
||||
if (empty($until_time)) {
|
||||
$until_time = '9999-12-31';
|
||||
}
|
||||
|
||||
// this is needed for compatibility with the bash version, so we use '%s' placeholders
|
||||
$from_time = htmlspecialchars(strip_tags($from_time));
|
||||
$until_time = htmlspecialchars(strip_tags($until_time));
|
||||
|
||||
// number of participants for time period (if given)
|
||||
$sql = "
|
||||
SELECT COUNT(DISTINCT p.endpoint_id) as participants
|
||||
FROM
|
||||
participants p
|
||||
LEFT JOIN
|
||||
participant_events pe ON p.endpoint_id = pe.participant_id
|
||||
WHERE
|
||||
(pe.time >= '%s 00:00:00' AND pe.time <= '%s 23:59:59')
|
||||
AND pe.event_type = 'participant joining'";
|
||||
|
||||
$sql = sprintf($sql, $from_time, $until_time);
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Handles password reset functionality including token generation and validation
|
||||
*/
|
||||
class PasswordReset {
|
||||
private $db;
|
||||
private const TOKEN_LENGTH = 32;
|
||||
private const TOKEN_EXPIRY = 3600; // 1 hour
|
||||
|
||||
public function __construct($database) {
|
||||
if ($database instanceof PDO) {
|
||||
$this->db = $database;
|
||||
} else {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a password reset request and sends email to user
|
||||
*
|
||||
* @param string $email User's email address
|
||||
* @return array Status of the reset request
|
||||
*/
|
||||
public function requestReset($email) {
|
||||
// Check if email exists
|
||||
$query = $this->db->prepare("
|
||||
SELECT u.id, um.email
|
||||
FROM user u
|
||||
JOIN user_meta um ON u.id = um.user_id
|
||||
WHERE um.email = :email"
|
||||
);
|
||||
$query->bindParam(':email', $email);
|
||||
$query->execute();
|
||||
|
||||
$user = $query->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
|
||||
}
|
||||
|
||||
// Generate unique token
|
||||
$token = bin2hex(random_bytes(self::TOKEN_LENGTH / 2));
|
||||
$expires = time() + self::TOKEN_EXPIRY;
|
||||
|
||||
// Store token in database
|
||||
$query = $this->db->prepare("
|
||||
INSERT INTO user_password_reset (user_id, token, expires)
|
||||
VALUES (:user_id, :token, :expires)"
|
||||
);
|
||||
$query->bindParam(':user_id', $user['id']);
|
||||
$query->bindParam(':token', $token);
|
||||
$query->bindParam(':expires', $expires);
|
||||
|
||||
if (!$query->execute()) {
|
||||
return ['success' => false, 'message' => 'Failed to process reset request'];
|
||||
}
|
||||
|
||||
// We need the config for the email details
|
||||
global $config;
|
||||
|
||||
// Prepare the reset link
|
||||
$scheme = $_SERVER['REQUEST_SCHEME'];
|
||||
$domain = trim($config['domain'], '/');
|
||||
$folder = trim($config['folder'], '/');
|
||||
$folderPath = $folder !== '' ? "/$folder" : '';
|
||||
$resetLink = "{$scheme}://{$domain}{$folderPath}/index.php?page=login&action=reset&token=" . urlencode($token);
|
||||
|
||||
// Send email with reset link
|
||||
$to = $user['email'];
|
||||
// Load email helper
|
||||
require_once __DIR__ . '/../helpers/email_helper.php';
|
||||
|
||||
$subject = "{$config['site_name']} - Password reset request";
|
||||
|
||||
$variables = [
|
||||
'site_name' => $config['site_name'],
|
||||
'reset_link' => $resetLink,
|
||||
'site_slogan' => $config['site_slogan'] ?? ''
|
||||
];
|
||||
|
||||
$additionalHeaders = [
|
||||
'From' => "noreply@{$config['domain']}",
|
||||
'Reply-To' => "noreply@{$config['domain']}"
|
||||
];
|
||||
|
||||
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) {
|
||||
return ['success' => false, 'message' => 'Failed to send reset email'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a reset token and returns associated user ID if valid
|
||||
*
|
||||
* @param string $token Reset token
|
||||
* @return array Validation result with user ID if successful
|
||||
*/
|
||||
public function validateToken($token) {
|
||||
$now = time();
|
||||
|
||||
$query = $this->db->prepare("
|
||||
SELECT user_id
|
||||
FROM user_password_reset
|
||||
WHERE token = :token
|
||||
AND expires > :now
|
||||
AND used = 0
|
||||
");
|
||||
|
||||
$query->bindParam(':token', $token);
|
||||
$query->bindParam(':now', $now);
|
||||
$query->execute();
|
||||
|
||||
$result = $query->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$result) {
|
||||
return ['valid' => false];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'user_id' => $result['user_id']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the password reset process
|
||||
*
|
||||
* @param string $token Reset token
|
||||
* @param string $newPassword New password
|
||||
* @return bool Whether reset was successful
|
||||
*/
|
||||
public function resetPassword($token, $newPassword) {
|
||||
$validation = $this->validateToken($token);
|
||||
if (!$validation['valid']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
// Update password
|
||||
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$query = $this->db->prepare(
|
||||
"UPDATE user
|
||||
SET password = :password
|
||||
WHERE id = :user_id"
|
||||
);
|
||||
$query->bindParam(':password', $hashedPassword);
|
||||
$query->bindParam(':user_id', $validation['user_id']);
|
||||
$query->execute();
|
||||
|
||||
// Mark token as used
|
||||
$query = $this->db->prepare(
|
||||
"UPDATE user_password_reset
|
||||
SET used = 1
|
||||
WHERE token = :token"
|
||||
);
|
||||
$query->bindParam(':token', $token);
|
||||
$query->execute();
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollBack();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Platform
|
||||
*
|
||||
* Handles platform management in the database, including retrieving, adding, editing, and deleting platforms.
|
||||
*/
|
||||
class Platform {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Platform constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve details of a specific platform or all platforms.
|
||||
*
|
||||
* @param string $platform_id The ID of the platform to retrieve details for (optional).
|
||||
*
|
||||
* @return array An associative array containing platform details.
|
||||
*/
|
||||
public function getPlatformDetails($platform_id = '') {
|
||||
$sql = 'SELECT * FROM platform';
|
||||
if ($platform_id !== '') {
|
||||
$sql .= ' WHERE id = :platform_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':platform_id', $platform_id);
|
||||
} else {
|
||||
$query = $this->db->prepare($sql);
|
||||
}
|
||||
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new platform to the database.
|
||||
*
|
||||
* @param array $newPlatform An associative array containing the details of the new platform:
|
||||
* - `name` (string): The name of the platform.
|
||||
* - `jitsi_url` (string): The URL for the Jitsi integration.
|
||||
* - `jilo_database` (string): The database name for Jilo integration.
|
||||
*
|
||||
* @return bool|string True if the platform was added successfully, or an error message on failure.
|
||||
*/
|
||||
public function addPlatform($newPlatform) {
|
||||
try {
|
||||
$sql = 'INSERT INTO platform
|
||||
(name, jitsi_url, jilo_database)
|
||||
VALUES
|
||||
(:name, :jitsi_url, :jilo_database)';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':name' => $newPlatform['name'],
|
||||
':jitsi_url' => $newPlatform['jitsi_url'],
|
||||
':jilo_database' => $newPlatform['jilo_database'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Edit an existing platform in the database.
|
||||
*
|
||||
* @param int $platform_id The ID of the platform to update.
|
||||
* @param array $updatedPlatform An associative array containing the updated platform details:
|
||||
* - `name` (string): The updated name of the platform.
|
||||
* - `jitsi_url` (string): The updated Jitsi URL.
|
||||
* - `jilo_database` (string): The updated Jilo database name.
|
||||
*
|
||||
* @return bool|string True if the platform was updated successfully, or an error message on failure.
|
||||
*/
|
||||
public function editPlatform($platform_id, $updatedPlatform) {
|
||||
try {
|
||||
$sql = 'UPDATE platform SET
|
||||
name = :name,
|
||||
jitsi_url = :jitsi_url,
|
||||
jilo_database = :jilo_database
|
||||
WHERE
|
||||
id = :platform_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':name' => $updatedPlatform['name'],
|
||||
':jitsi_url' => $updatedPlatform['jitsi_url'],
|
||||
':jilo_database' => $updatedPlatform['jilo_database'],
|
||||
':platform_id' => $platform_id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete a platform from the database.
|
||||
*
|
||||
* @param int $platform_id The ID of the platform to delete.
|
||||
*
|
||||
* @return bool|string True if the platform was deleted successfully, or an error message on failure.
|
||||
*/
|
||||
public function deletePlatform($platform_id) {
|
||||
try {
|
||||
$this->db->beginTransaction();
|
||||
|
||||
// First, get all hosts in this platform
|
||||
$sql = 'SELECT id FROM host WHERE platform_id = :platform_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':platform_id', $platform_id);
|
||||
$query->execute();
|
||||
$hosts = $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Delete all agents for each host
|
||||
foreach ($hosts as $host) {
|
||||
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':host_id', $host['id']);
|
||||
$query->execute();
|
||||
}
|
||||
|
||||
// Delete all hosts in this platform
|
||||
$sql = 'DELETE FROM host WHERE platform_id = :platform_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':platform_id', $platform_id);
|
||||
$query->execute();
|
||||
|
||||
// Finally, delete the platform
|
||||
$sql = 'DELETE FROM platform WHERE id = :platform_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':platform_id', $platform_id);
|
||||
$query->execute();
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollBack();
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,669 @@
|
|||
<?php
|
||||
|
||||
use App\App;
|
||||
use App\Core\NullLogger;
|
||||
|
||||
class RateLimiter {
|
||||
public $db;
|
||||
/** @var mixed NullLogger (or PSR-3 logger) or plugin Log */
|
||||
private $logger;
|
||||
public $maxAttempts = 5; // Maximum login attempts
|
||||
public $decayMinutes = 15; // Time window in minutes
|
||||
public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist
|
||||
public $autoBlacklistDuration = 24; // Hours to blacklist for
|
||||
public $authRatelimitTable = 'security_rate_auth'; // For rate limiting username/password attempts
|
||||
public $pagesRatelimitTable = 'security_rate_page'; // For rate limiting page requests
|
||||
public $whitelistTable = 'security_ip_whitelist'; // For whitelisting IPs and network ranges
|
||||
public $blacklistTable = 'security_ip_blacklist'; // For blacklisting IPs and network ranges
|
||||
private $pageLimits = [
|
||||
// Default rate limits per minute
|
||||
'default' => 60,
|
||||
'admin' => 120,
|
||||
'message' => 20,
|
||||
'contact' => 30,
|
||||
'call' => 30,
|
||||
'register' => 5,
|
||||
'config' => 10
|
||||
];
|
||||
|
||||
/**
|
||||
* @param mixed $logger Optional NullLogger (or PSR-3 logger) or plugin Log
|
||||
*/
|
||||
public function __construct($logger = null) {
|
||||
$db = App::db();
|
||||
// Extract PDO connection from Database object
|
||||
$this->db = ($db instanceof PDO) ? $db : $db->getConnection();
|
||||
|
||||
// Initialize logger (plugin Log if present or NullLogger otherwise)
|
||||
if ($logger !== null) {
|
||||
$this->logger = $logger;
|
||||
} else {
|
||||
global $logObject;
|
||||
$this->logger = isset($logObject) && is_object($logObject) && method_exists($logObject, 'info')
|
||||
? $logObject
|
||||
: new NullLogger();
|
||||
}
|
||||
// Initialize database tables
|
||||
$this->createTablesIfNotExist();
|
||||
}
|
||||
|
||||
// Database preparation
|
||||
private function createTablesIfNotExist() {
|
||||
// Authentication attempts table
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$this->authRatelimitTable} (
|
||||
id int(11) PRIMARY KEY AUTO_INCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_ip_username (ip_address, username)
|
||||
)";
|
||||
$this->db->exec($sql);
|
||||
|
||||
// Pages rate limits table
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$this->pagesRatelimitTable} (
|
||||
id int(11) PRIMARY KEY AUTO_INCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
endpoint VARCHAR(255) NOT NULL,
|
||||
request_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_ip_endpoint (ip_address, endpoint),
|
||||
INDEX idx_request_time (request_time)
|
||||
)";
|
||||
$this->db->exec($sql);
|
||||
|
||||
// IP whitelist table
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} (
|
||||
id int(11) PRIMARY KEY AUTO_INCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
is_network BOOLEAN DEFAULT FALSE,
|
||||
description VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(255),
|
||||
UNIQUE KEY unique_ip (ip_address)
|
||||
)";
|
||||
$this->db->exec($sql);
|
||||
|
||||
// IP blacklist table
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} (
|
||||
id int(11) PRIMARY KEY AUTO_INCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
is_network BOOLEAN DEFAULT FALSE,
|
||||
reason VARCHAR(255),
|
||||
expiry_time TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(255),
|
||||
UNIQUE KEY unique_ip (ip_address)
|
||||
)";
|
||||
$this->db->exec($sql);
|
||||
|
||||
// Default IPs to whitelist (local interface and private networks IPs)
|
||||
$defaultIps = [
|
||||
['127.0.0.1', false, 'localhost IPv4'],
|
||||
['::1', false, 'localhost IPv6'],
|
||||
['10.0.0.0/8', true, 'Private network (Class A)'],
|
||||
['172.16.0.0/12', true, 'Private network (Class B)'],
|
||||
['192.168.0.0/16', true, 'Private network (Class C)']
|
||||
];
|
||||
|
||||
// Insert default whitelisted IPs if they don't exist
|
||||
$stmt = $this->db->prepare("INSERT IGNORE INTO {$this->whitelistTable}
|
||||
(ip_address, is_network, description, created_by)
|
||||
VALUES (?, ?, ?, 'system')");
|
||||
foreach ($defaultIps as $ip) {
|
||||
$stmt->execute([$ip[0], $ip[1], $ip[2]]);
|
||||
}
|
||||
|
||||
// Insert known malicious networks
|
||||
$defaultBlacklist = [
|
||||
['0.0.0.0/8', true, 'Reserved address space - RFC 1122'],
|
||||
['100.64.0.0/10', true, 'Carrier-grade NAT space - RFC 6598'],
|
||||
['192.0.2.0/24', true, 'TEST-NET-1 Documentation space - RFC 5737'],
|
||||
['198.51.100.0/24', true, 'TEST-NET-2 Documentation space - RFC 5737'],
|
||||
['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737']
|
||||
];
|
||||
|
||||
$stmt = $this->db->prepare("INSERT IGNORE INTO {$this->blacklistTable}
|
||||
(ip_address, is_network, reason, created_by)
|
||||
VALUES (?, ?, ?, 'system')");
|
||||
|
||||
foreach ($defaultBlacklist as $ip) {
|
||||
$stmt->execute([$ip[0], $ip[1], $ip[2]]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of recent login attempts for an IP
|
||||
*/
|
||||
public function getRecentAttempts($ip) {
|
||||
$stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->authRatelimitTable}
|
||||
WHERE ip_address = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL ? MINUTE)");
|
||||
$stmt->execute([$ip, $this->decayMinutes]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return intval($result['attempts']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is blacklisted
|
||||
*/
|
||||
public function isIpBlacklisted($ip) {
|
||||
// First check if IP is explicitly blacklisted or in a blacklisted range
|
||||
$stmt = $this->db->prepare("SELECT ip_address, is_network, expiry_time FROM {$this->blacklistTable} WHERE ip_address = ?");
|
||||
$stmt->execute([$ip]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
// Skip expired entries
|
||||
if ($row['expiry_time'] !== null && strtotime($row['expiry_time']) < time()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check network ranges
|
||||
$stmt = $this->db->prepare("SELECT ip_address, expiry_time FROM {$this->blacklistTable} WHERE is_network = 1");
|
||||
$stmt->execute();
|
||||
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
// Skip expired entries
|
||||
if ($row['expiry_time'] !== null && strtotime($row['expiry_time']) < time()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ipInRange($ip, $row['ip_address'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is whitelisted
|
||||
*/
|
||||
public function isIpWhitelisted($ip) {
|
||||
// Check exact IP match first
|
||||
$stmt = $this->db->prepare("SELECT ip_address FROM {$this->whitelistTable} WHERE ip_address = ?");
|
||||
$stmt->execute([$ip]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only check ranges for IPv4 addresses
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
// Check network ranges
|
||||
$stmt = $this->db->prepare("SELECT ip_address FROM {$this->whitelistTable} WHERE is_network = 1");
|
||||
$stmt->execute();
|
||||
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
if ($this->ipInRange($ip, $row['ip_address'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInRange($ip, $cidr) {
|
||||
// Only work with IPv4 addresses
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($subnet, $bits) = explode('/', $cidr);
|
||||
|
||||
// Make sure subnet is IPv4
|
||||
if (!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = ip2long($ip);
|
||||
$subnet = ip2long($subnet);
|
||||
$mask = -1 << (32 - $bits);
|
||||
$subnet &= $mask;
|
||||
|
||||
return ($ip & $mask) == $subnet;
|
||||
}
|
||||
|
||||
// Add to whitelist
|
||||
public function addToWhitelist($ip, $isNetwork = false, $description = '', $createdBy = 'system', $userId = null) {
|
||||
try {
|
||||
// Check if IP is blacklisted first
|
||||
if ($this->isIpBlacklisted($ip)) {
|
||||
$message = "Cannot whitelist {$ip} - IP is currently blacklisted";
|
||||
if ($userId) {
|
||||
$this->logger->log('info', "IP Whitelist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', $message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare("INSERT INTO {$this->whitelistTable}
|
||||
(ip_address, is_network, description, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_network = VALUES(is_network),
|
||||
description = VALUES(description),
|
||||
created_by = VALUES(created_by)");
|
||||
|
||||
$result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]);
|
||||
|
||||
if ($result) {
|
||||
$logMessage = sprintf(
|
||||
'IP Whitelist: Added %s "%s" by %s. Description: %s',
|
||||
$isNetwork ? 'network' : 'IP',
|
||||
$ip,
|
||||
$createdBy,
|
||||
$description
|
||||
);
|
||||
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
if ($userId) {
|
||||
$this->logger->log('error', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from whitelist
|
||||
public function removeFromWhitelist($ip, $removedBy = 'system', $userId = null) {
|
||||
try {
|
||||
// Get IP details before removal for logging
|
||||
$stmt = $this->db->prepare("SELECT * FROM {$this->whitelistTable} WHERE ip_address = ?");
|
||||
$stmt->execute([$ip]);
|
||||
$ipDetails = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Remove the IP
|
||||
$stmt = $this->db->prepare("DELETE FROM {$this->whitelistTable} WHERE ip_address = ?");
|
||||
|
||||
$result = $stmt->execute([$ip]);
|
||||
|
||||
if ($result && $ipDetails) {
|
||||
$logMessage = sprintf(
|
||||
'IP Whitelist: Removed %s "%s" by %s. Was added by: %s',
|
||||
$ipDetails['is_network'] ? 'network' : 'IP',
|
||||
$ip,
|
||||
$removedBy,
|
||||
$ipDetails['created_by']
|
||||
);
|
||||
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
if ($userId) {
|
||||
$this->logger->log('error', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function addToBlacklist($ip, $isNetwork = false, $reason = '', $createdBy = 'system', $userId = null, $expiryHours = null) {
|
||||
try {
|
||||
// Check if IP is whitelisted first
|
||||
if ($this->isIpWhitelisted($ip)) {
|
||||
$message = "Cannot blacklist {$ip} - IP is currently whitelisted";
|
||||
if ($userId) {
|
||||
$this->logger->log('info', "IP Blacklist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', $message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null;
|
||||
|
||||
$stmt = $this->db->prepare("INSERT INTO {$this->blacklistTable}
|
||||
(ip_address, is_network, reason, expiry_time, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_network = VALUES(is_network),
|
||||
reason = VALUES(reason),
|
||||
expiry_time = VALUES(expiry_time),
|
||||
created_by = VALUES(created_by)");
|
||||
|
||||
$result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]);
|
||||
|
||||
if ($result) {
|
||||
$logMessage = sprintf(
|
||||
'IP Blacklist: Added %s "%s" by %s. Reason: %s. Expires: %s',
|
||||
$isNetwork ? 'network' : 'IP',
|
||||
$ip,
|
||||
$createdBy,
|
||||
$reason,
|
||||
$expiryTime ?? 'never'
|
||||
);
|
||||
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Exception $e) {
|
||||
if ($userId) {
|
||||
$this->logger->log('error', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function removeFromBlacklist($ip, $removedBy = 'system', $userId = null) {
|
||||
try {
|
||||
// Get IP details before removal for logging
|
||||
$stmt = $this->db->prepare("SELECT * FROM {$this->blacklistTable} WHERE ip_address = ?");
|
||||
$stmt->execute([$ip]);
|
||||
$ipDetails = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Remove the IP
|
||||
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} WHERE ip_address = ?");
|
||||
|
||||
$result = $stmt->execute([$ip]);
|
||||
|
||||
if ($result && $ipDetails) {
|
||||
$logMessage = sprintf(
|
||||
'IP Blacklist: Removed %s "%s" by %s. Was added by: %s. Reason was: %s',
|
||||
$ipDetails['is_network'] ? 'network' : 'IP',
|
||||
$ip,
|
||||
$removedBy,
|
||||
$ipDetails['created_by'],
|
||||
$ipDetails['reason']
|
||||
);
|
||||
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Exception $e) {
|
||||
if ($userId) {
|
||||
$this->logger->log('error', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getWhitelistedIps() {
|
||||
$stmt = $this->db->prepare("SELECT * FROM {$this->whitelistTable} ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getBlacklistedIps() {
|
||||
$stmt = $this->db->prepare("SELECT * FROM {$this->blacklistTable} ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function cleanupExpiredEntries() {
|
||||
try {
|
||||
// Remove expired blacklist entries
|
||||
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable}
|
||||
WHERE expiry_time IS NOT NULL AND expiry_time < NOW()");
|
||||
$stmt->execute();
|
||||
|
||||
// Clean old login attempts
|
||||
$stmt = $this->db->prepare("DELETE FROM {$this->authRatelimitTable}
|
||||
WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)");
|
||||
$stmt->execute([':minutes' => $this->decayMinutes]);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->logger->log('error', "Failed to cleanup expired entries: " . $e->getMessage(), ['user_id' => $userId ?? null, 'scope' => 'system']);
|
||||
Feedback::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAllowed($username, $ipAddress) {
|
||||
// First check if IP is blacklisted
|
||||
if ($this->isIpBlacklisted($ipAddress)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check if IP is whitelisted
|
||||
if ($this->isIpWhitelisted($ipAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean old attempts
|
||||
$this->clearOldAttempts();
|
||||
|
||||
// Check if we've hit the rate limit
|
||||
if ($this->tooManyAttempts($username, $ipAddress)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check total attempts across all usernames from this IP
|
||||
$sql = "SELECT COUNT(*) as total_attempts
|
||||
FROM {$this->authRatelimitTable}
|
||||
WHERE ip_address = :ip
|
||||
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':minutes' => $this->decayMinutes
|
||||
]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Check if we would hit auto-blacklist threshold
|
||||
return $result['total_attempts'] < $this->autoBlacklistThreshold;
|
||||
}
|
||||
|
||||
public function attempt($username, $ipAddress, $failed = true) {
|
||||
// Only record failed attempts
|
||||
if (!$failed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Record this attempt
|
||||
$sql = "INSERT INTO {$this->authRatelimitTable} (ip_address, username) VALUES (:ip, :username)";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
try {
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':username' => $username
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function tooManyAttempts($username, $ipAddress) {
|
||||
$sql = "SELECT COUNT(*) as attempts
|
||||
FROM {$this->authRatelimitTable}
|
||||
WHERE ip_address = :ip
|
||||
AND username = :username
|
||||
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':username' => $username,
|
||||
':minutes' => $this->decayMinutes
|
||||
]);
|
||||
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Also check what's in the table
|
||||
$sql = "SELECT * FROM {$this->authRatelimitTable} WHERE ip_address = :ip";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':ip' => $ipAddress]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tooMany = $result['attempts'] >= $this->maxAttempts;
|
||||
|
||||
// Auto-blacklist if too many attempts
|
||||
if ($tooMany) {
|
||||
$this->addToBlacklist(
|
||||
$ipAddress,
|
||||
false,
|
||||
'Auto-blacklisted due to excessive login attempts',
|
||||
'system',
|
||||
null,
|
||||
$this->autoBlacklistDuration
|
||||
);
|
||||
}
|
||||
|
||||
return $tooMany;
|
||||
}
|
||||
|
||||
public function clearOldAttempts() {
|
||||
$sql = "DELETE FROM {$this->authRatelimitTable}
|
||||
WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':minutes' => $this->decayMinutes
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRemainingAttempts($username, $ipAddress) {
|
||||
$sql = "SELECT COUNT(*) as attempts
|
||||
FROM {$this->authRatelimitTable}
|
||||
WHERE ip_address = :ip
|
||||
AND username = :username
|
||||
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':username' => $username,
|
||||
':minutes' => $this->decayMinutes
|
||||
]);
|
||||
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return max(0, $this->maxAttempts - $result['attempts']);
|
||||
}
|
||||
|
||||
public function getDecayMinutes() {
|
||||
return $this->decayMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a page request is allowed
|
||||
*/
|
||||
public function isPageRequestAllowed($ipAddress, $endpoint, $userId = null) {
|
||||
// First check if IP is blacklisted
|
||||
if ($this->isIpBlacklisted($ipAddress)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check if IP is whitelisted
|
||||
if ($this->isIpWhitelisted($ipAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean old requests
|
||||
$this->cleanOldPageRequests();
|
||||
|
||||
// Get limit based on endpoint type and user role
|
||||
$limit = $this->getPageLimitForEndpoint($endpoint, $userId);
|
||||
|
||||
// Count recent requests, including this one
|
||||
$sql = "SELECT COUNT(*) as request_count
|
||||
FROM {$this->pagesRatelimitTable}
|
||||
WHERE ip_address = :ip
|
||||
AND endpoint = :endpoint
|
||||
AND request_time >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':endpoint' => $endpoint
|
||||
]);
|
||||
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result['request_count'] < $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a page request
|
||||
*/
|
||||
public function recordPageRequest($ipAddress, $endpoint) {
|
||||
$sql = "INSERT INTO {$this->pagesRatelimitTable} (ip_address, endpoint)
|
||||
VALUES (:ip, :endpoint)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
return $stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':endpoint' => $endpoint
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old page requests
|
||||
*/
|
||||
private function cleanOldPageRequests() {
|
||||
$sql = "DELETE FROM {$this->pagesRatelimitTable}
|
||||
WHERE request_time < DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page rate limit for endpoint
|
||||
*/
|
||||
private function getPageLimitForEndpoint($endpoint, $userId = null) {
|
||||
// Admin users get higher limits
|
||||
if ($userId) {
|
||||
// Check admin rights directly from database
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM `user_right` ur JOIN `right` r ON ur.right_id = r.id WHERE ur.user_id = ? AND r.name = ?');
|
||||
$stmt->execute([$userId, 'superuser']);
|
||||
if ($stmt->fetchColumn() > 0) {
|
||||
return $this->pageLimits['admin'];
|
||||
}
|
||||
}
|
||||
|
||||
// Get endpoint type from the endpoint path
|
||||
$endpointType = $this->getEndpointType($endpoint);
|
||||
|
||||
// Return specific limit if exists, otherwise default
|
||||
return isset($this->pageLimits[$endpointType])
|
||||
? $this->pageLimits[$endpointType]
|
||||
: $this->pageLimits['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get endpoint type from path
|
||||
*/
|
||||
private function getEndpointType($endpoint) {
|
||||
if (strpos($endpoint, 'message') !== false) return 'message';
|
||||
if (strpos($endpoint, 'contact') !== false) return 'contact';
|
||||
if (strpos($endpoint, 'call') !== false) return 'call';
|
||||
if (strpos($endpoint, 'register') !== false) return 'register';
|
||||
if (strpos($endpoint, 'config') !== false) return 'config';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining page requests
|
||||
*/
|
||||
public function getRemainingPageRequests($ipAddress, $endpoint, $userId = null) {
|
||||
$limit = $this->getPageLimitForEndpoint($endpoint, $userId);
|
||||
|
||||
$sql = "SELECT COUNT(*) as request_count
|
||||
FROM {$this->pagesRatelimitTable}
|
||||
WHERE ip_address = :ip
|
||||
AND endpoint = :endpoint
|
||||
AND request_time > DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([
|
||||
':ip' => $ipAddress,
|
||||
':endpoint' => $endpoint
|
||||
]);
|
||||
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return max(0, $limit - $result['request_count']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Router
|
||||
*
|
||||
* A simple Router class to manage URL-to-callback mapping and dispatch requests to the appropriate controllers and methods.
|
||||
* The class supports defining routes, matching URLs against patterns, and invoking callbacks for matched routes.
|
||||
*/
|
||||
class Router {
|
||||
/**
|
||||
* @var array $routes Associative array of route patterns and their corresponding callbacks.
|
||||
*/
|
||||
private $routes = [];
|
||||
|
||||
|
||||
/**
|
||||
* Adds a new route to the router.
|
||||
*
|
||||
* @param string $pattern The URL pattern to match (regular expression).
|
||||
* @param string $callback The callback for the route in the format "Controller@Method".
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($pattern, $callback) {
|
||||
$this->routes[$pattern] = $callback;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Dispatches a request to the appropriate route callback.
|
||||
*
|
||||
* @param string $url The URL to match against the defined routes.
|
||||
*
|
||||
* @return void Outputs the result of the invoked callback or a 404 error if no route matches.
|
||||
*/
|
||||
public function dispatch($url) {
|
||||
// remove query string variables from url
|
||||
$url = strtok($url, '?');
|
||||
|
||||
foreach ($this->routes as $pattern => $callback) {
|
||||
// check if the URL matches the current route pattern
|
||||
if (preg_match('#^' . $pattern . '$#', $url, $matches)) {
|
||||
// remove the exact match to extrat parameters
|
||||
array_shift($matches);
|
||||
return $this->invoke($callback, $matches);
|
||||
}
|
||||
}
|
||||
|
||||
// if there was no match at all, return 404
|
||||
http_response_code(404);
|
||||
echo '404 page not found';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Invokes the callback for a matched route.
|
||||
*
|
||||
* @param string $callback The callback for the route in the format "Controller@Method".
|
||||
* @param array $params Parameters extracted from the route pattern.
|
||||
*
|
||||
* @return void Executes the specified method on the specified controller with the provided parameters.
|
||||
*
|
||||
* @throws Exception If the controller class or method does not exist.
|
||||
*/
|
||||
private function invoke($callback, $params) {
|
||||
list($controllerName, $methodName) = explode('@', $callback);
|
||||
$controllerClass = "../pages/$controllerName";
|
||||
|
||||
// ensure the controller class exists
|
||||
if (!class_exists($controllerClass)) {
|
||||
throw new Exception("Controller '$controllerClass' not found.");
|
||||
}
|
||||
|
||||
$controller = new $controllerClass();
|
||||
|
||||
// ensure the method exists on the controller
|
||||
if (!method_exists($controller, $methodName)) {
|
||||
throw new Exception("Method '$methodName' not found in controller '$controllerClass'.");
|
||||
}
|
||||
|
||||
// call the controller's method with the parameters
|
||||
call_user_func_array([$controller, $methodName], $params);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Server
|
||||
*
|
||||
* Handles server-related operations, including retrieving server status.
|
||||
*/
|
||||
class Server {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Server constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks the status of a Jilo server by sending a GET request to its health endpoint.
|
||||
*
|
||||
* @param string $host The server hostname or IP address (default: '127.0.0.1').
|
||||
* @param int $port The port on which the server is running (default: 8080).
|
||||
* @param string $endpoint The health check endpoint path (default: '/health').
|
||||
*
|
||||
* @return bool True if the server returns a 200 OK status, otherwise false.
|
||||
*/
|
||||
public function getServerStatus($host = '127.0.0.1', $port = 8080, $endpoint = '/health') {
|
||||
$url = "http://$host:$port$endpoint";
|
||||
$options = [
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 3,
|
||||
],
|
||||
];
|
||||
$context = stream_context_create($options);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
// We check the response if it's 200 OK
|
||||
if ($response !== false && isset($http_response_header) && strpos($http_response_header[0], '200 OK') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's not 200 OK
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Session Class
|
||||
*
|
||||
* Core session management functionality for the application
|
||||
*/
|
||||
class Session {
|
||||
private static $initialized = false;
|
||||
private static $sessionName = ''; // Will be set from config, if not we'll have a random session name
|
||||
|
||||
/**
|
||||
* Generate a random session name
|
||||
*/
|
||||
private static function generateRandomSessionName(): string {
|
||||
return 'sess_' . bin2hex(random_bytes(8)); // 16-character random string
|
||||
}
|
||||
private static $sessionOptions = [
|
||||
'cookie_httponly' => 1,
|
||||
'cookie_secure' => 0,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'gc_maxlifetime' => 7200 // 2 hours
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize session configuration
|
||||
*/
|
||||
private static function initialize() {
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $config;
|
||||
|
||||
// Get session name from config or generate a random one
|
||||
self::$sessionName = $config['session']['name'] ?? self::generateRandomSessionName();
|
||||
|
||||
// Set session name before starting the session, only if headers not sent and no active session
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
session_name(self::$sessionName);
|
||||
}
|
||||
|
||||
// Set session cookie parameters only if headers not sent and no active session
|
||||
$thisPath = $config['folder'] ?? '/';
|
||||
$thisDomain = $config['domain'] ?? '';
|
||||
$isSecure = isset($_SERVER['HTTPS']);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0, // Session cookie (browser session)
|
||||
'path' => $thisPath,
|
||||
'domain' => $thisDomain,
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
}
|
||||
|
||||
// Align session start options dynamically with current transport
|
||||
self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0;
|
||||
self::$sessionOptions['cookie_samesite'] = 'Lax';
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session name from config or generate a random one
|
||||
*/
|
||||
private static function getSessionNameFromConfig($config) {
|
||||
if (isset($config['session']['name']) && !empty($config['session']['name'])) {
|
||||
return $config['session']['name'];
|
||||
}
|
||||
return self::generateRandomSessionName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume a session with secure options
|
||||
*/
|
||||
public static function startSession() {
|
||||
self::initialize();
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
if (!headers_sent()) {
|
||||
session_start(self::$sessionOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy current session and clean up
|
||||
*/
|
||||
public static function destroySession() {
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_unset();
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current username if set
|
||||
*/
|
||||
public static function getUsername() {
|
||||
return isset($_SESSION['username']) ? htmlspecialchars($_SESSION['username']) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID if set
|
||||
*/
|
||||
public static function getUserId() {
|
||||
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current session is valid
|
||||
*
|
||||
* @param bool $strict If true, will return false for new/unauthenticated sessions
|
||||
* @return bool True if session is valid, false otherwise
|
||||
*/
|
||||
public static function isValidSession($strict = true) {
|
||||
// Ensure a session is started (safe in CLI/tests)
|
||||
self::startSession();
|
||||
|
||||
// If there is no session data at all, it's not valid
|
||||
if (empty($_SESSION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In non-strict mode, consider empty session as valid (for login/logout)
|
||||
if (!$strict && !isset($_SESSION['user_id']) && !isset($_SESSION['username'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In strict mode, require user_id and username
|
||||
if ($strict && (!isset($_SESSION['user_id']) || !isset($_SESSION['username']))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
$session_timeout = isset($_SESSION['REMEMBER_ME']) ? (30 * 24 * 60 * 60) : 7200; // 30 days or 2 hours
|
||||
if (isset($_SESSION['LAST_ACTIVITY']) && (time() - $_SESSION['LAST_ACTIVITY'] > $session_timeout)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update last activity time
|
||||
$_SESSION['LAST_ACTIVITY'] = time();
|
||||
|
||||
// Regenerate session ID periodically (every 30 minutes)
|
||||
if (!isset($_SESSION['CREATED'])) {
|
||||
$_SESSION['CREATED'] = time();
|
||||
} else if (time() - $_SESSION['CREATED'] > 1800) {
|
||||
// Regenerate session ID and update creation time
|
||||
if (!headers_sent() && session_status() === PHP_SESSION_ACTIVE) {
|
||||
$oldData = $_SESSION;
|
||||
session_regenerate_id(true);
|
||||
$_SESSION = $oldData;
|
||||
$_SESSION['CREATED'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remember me option for extended session
|
||||
*/
|
||||
public static function setRememberMe($value = true) {
|
||||
$_SESSION['REMEMBER_ME'] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session data and cookies
|
||||
*/
|
||||
public static function cleanup($config) {
|
||||
self::destroySession();
|
||||
|
||||
// Clear cookies if headers not sent
|
||||
if (!headers_sent()) {
|
||||
setcookie('username', '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => $config['folder'],
|
||||
'domain' => $config['domain'],
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
}
|
||||
|
||||
// Start fresh session
|
||||
self::startSession();
|
||||
|
||||
// Reset session timeout flag
|
||||
unset($_SESSION['session_timeout_shown']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new authenticated session for a user
|
||||
*/
|
||||
public static function createAuthSession($userId, $username, $rememberMe, $config) {
|
||||
// Ensure session is started
|
||||
self::startSession();
|
||||
|
||||
// Set session variables
|
||||
$_SESSION['user_id'] = $userId;
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['LAST_ACTIVITY'] = time();
|
||||
$_SESSION['REMEMBER_ME'] = $rememberMe;
|
||||
|
||||
// Set cookie lifetime based on remember me
|
||||
$cookieLifetime = $rememberMe ? time() + (30 * 24 * 60 * 60) : 0;
|
||||
|
||||
// Update session cookie with remember me setting
|
||||
if (!headers_sent()) {
|
||||
setcookie(
|
||||
session_name(),
|
||||
session_id(),
|
||||
[
|
||||
'expires' => $cookieLifetime,
|
||||
'path' => $config['folder'] ?? '/',
|
||||
'domain' => $config['domain'] ?? '',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]
|
||||
);
|
||||
|
||||
// Set username cookie
|
||||
setcookie('username', $username, [
|
||||
'expires' => $cookieLifetime,
|
||||
'path' => $config['folder'] ?? '/',
|
||||
'domain' => $config['domain'] ?? '',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($rememberMe) {
|
||||
self::setRememberMe(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store 2FA pending information in session
|
||||
*/
|
||||
public static function store2FAPending($userId, $username, $rememberMe = false) {
|
||||
$_SESSION['2fa_pending_user_id'] = $userId;
|
||||
$_SESSION['2fa_pending_username'] = $username;
|
||||
if ($rememberMe) {
|
||||
$_SESSION['2fa_pending_remember'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear 2FA pending information from session
|
||||
*/
|
||||
public static function clear2FAPending() {
|
||||
unset($_SESSION['2fa_pending_user_id']);
|
||||
unset($_SESSION['2fa_pending_username']);
|
||||
unset($_SESSION['2fa_pending_remember']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 2FA pending information
|
||||
*/
|
||||
public static function get2FAPending() {
|
||||
if (!isset($_SESSION['2fa_pending_user_id']) || !isset($_SESSION['2fa_pending_username'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $_SESSION['2fa_pending_user_id'],
|
||||
'username' => $_SESSION['2fa_pending_username'],
|
||||
'remember_me' => isset($_SESSION['2fa_pending_remember'])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* class Settings
|
||||
*
|
||||
* Handles editing and fetching jilo configuration.
|
||||
*/
|
||||
class Settings {
|
||||
|
||||
/**
|
||||
* Loads javascript file the Jitsi server.
|
||||
*
|
||||
* @param string $jitsiUrl The base URL of the Jitsi server.
|
||||
* @param string $livejsFile The name of the remote js file to load.
|
||||
* @param bool $raw Whether to return the full file (true) or only uncommented values (false).
|
||||
*
|
||||
* @return string The content of the interface_config.js file or an error message.
|
||||
*/
|
||||
public function getPlatformJsFile($jitsiUrl, $livejsFile, $raw = false) {
|
||||
// constructing the URL
|
||||
$jsFile = $jitsiUrl . '/' . $livejsFile;
|
||||
|
||||
// default content, if we can't get the file contents
|
||||
$jsFileContent = "The file $livejsFile can't be loaded.";
|
||||
|
||||
// Check if URL is valid
|
||||
if (!filter_var($jsFile, FILTER_VALIDATE_URL)) {
|
||||
return "Invalid URL: $jsFile";
|
||||
}
|
||||
|
||||
// ssl options
|
||||
$contextOptions = [
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
];
|
||||
$context = stream_context_create($contextOptions);
|
||||
|
||||
// Try to get headers first to check if file exists and wasn't redirected
|
||||
$headers = @get_headers($jsFile, 1); // 1 to get headers as array
|
||||
if ($headers === false) {
|
||||
return "The file $livejsFile can't be loaded (connection error).";
|
||||
}
|
||||
|
||||
// Check for redirects
|
||||
$statusLine = $headers[0];
|
||||
if (strpos($statusLine, '301') !== false || strpos($statusLine, '302') !== false) {
|
||||
return "The file $livejsFile was redirected - this might indicate the file doesn't exist.";
|
||||
}
|
||||
|
||||
// Check if we got 200 OK
|
||||
if (strpos($statusLine, '200') === false) {
|
||||
return "The file $livejsFile can't be loaded (HTTP error: $statusLine).";
|
||||
}
|
||||
|
||||
// Check content type
|
||||
$contentType = isset($headers['Content-Type']) ? $headers['Content-Type'] : '';
|
||||
if (is_array($contentType)) {
|
||||
$contentType = end($contentType); // get last content-type in case of redirects
|
||||
}
|
||||
if (stripos($contentType, 'javascript') === false && stripos($contentType, 'text/plain') === false) {
|
||||
return "The file $livejsFile doesn't appear to be a JavaScript file (got $contentType).";
|
||||
}
|
||||
|
||||
// get the file
|
||||
$fileContent = @file_get_contents($jsFile, false, $context);
|
||||
|
||||
if ($fileContent !== false) {
|
||||
// Quick validation of content
|
||||
$firstLine = strtolower(trim(substr($fileContent, 0, 100)));
|
||||
if (strpos($firstLine, '<!doctype html>') !== false ||
|
||||
strpos($firstLine, '<html') !== false ||
|
||||
strpos($firstLine, '<?xml') !== false) {
|
||||
return "The file $livejsFile appears to be HTML/XML content instead of JavaScript.";
|
||||
}
|
||||
|
||||
// when we need only uncommented values
|
||||
if ($raw === false) {
|
||||
// remove block comments
|
||||
$jsFileContent = preg_replace('!/\*.*?\*/!s', '', $fileContent);
|
||||
// remove single-line comments
|
||||
$jsFileContent = preg_replace('/\/\/[^\n]*/', '', $jsFileContent);
|
||||
// remove empty lines
|
||||
$jsFileContent = preg_replace('/^\s*[\r\n]/m', '', $jsFileContent);
|
||||
|
||||
// when we need the full file as it is
|
||||
} else {
|
||||
$jsFileContent = $fileContent;
|
||||
}
|
||||
}
|
||||
|
||||
return $jsFileContent;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
<?php
|
||||
|
||||
use App\App;
|
||||
|
||||
// Already required in index.php, but we require it here,
|
||||
// because this class could be used standalone
|
||||
require_once __DIR__ . '/../helpers/logger_loader.php';
|
||||
|
||||
/**
|
||||
* Class TwoFactorAuthentication
|
||||
*
|
||||
* Handles two-factor authentication functionality using TOTP (Time-based One-Time Password).
|
||||
* Internal implementation without external dependencies.
|
||||
*/
|
||||
class TwoFactorAuthentication {
|
||||
private $db;
|
||||
private $secretLength = 20; // 160 bits for SHA1
|
||||
private $period = 30; // Time step in seconds (T0)
|
||||
private $digits = 6; // Number of digits in TOTP code
|
||||
private $algorithm = 'sha1'; // HMAC algorithm
|
||||
// Branding: populated from config so authenticator apps show the configured site name.
|
||||
private $issuer = 'Website';
|
||||
private $window = 1; // Time window of 1 step before/after
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param PDO $database Database connection
|
||||
*/
|
||||
public function __construct($database) {
|
||||
if ($database instanceof PDO) {
|
||||
$this->db = $database;
|
||||
} else {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
|
||||
$config = App::config();
|
||||
$this->issuer = (string)$config['site_name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable 2FA for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $secret Secret key (base32 encoded)
|
||||
* @param string $code Verification code
|
||||
* @return bool True if enabled successfully
|
||||
*/
|
||||
public function enable($userId, $secret = null, $code = null) {
|
||||
try {
|
||||
// Check if 2FA is already enabled
|
||||
$stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existing && $existing['enabled']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no secret provided, generate one and return setup data
|
||||
if ($secret === null) {
|
||||
// Generate secret key
|
||||
$secret = $this->generateSecret();
|
||||
|
||||
// Get user's username for the QR code
|
||||
$stmt = $this->db->prepare('SELECT username FROM user WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Generate backup codes
|
||||
$backupCodes = $this->generateBackupCodes();
|
||||
|
||||
// Store in database without enabling yet
|
||||
$this->db->beginTransaction();
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO user_2fa (user_id, secret_key, backup_codes, enabled, created_at)
|
||||
VALUES (?, ?, ?, 0, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
secret_key = VALUES(secret_key),
|
||||
backup_codes = VALUES(backup_codes),
|
||||
enabled = VALUES(enabled),
|
||||
created_at = VALUES(created_at)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$secret,
|
||||
json_encode($backupCodes)
|
||||
]);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
// Generate otpauth URL for QR code
|
||||
$otpauthUrl = $this->generateOtpauthUrl($user['username'], $secret);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'secret' => $secret,
|
||||
'otpauthUrl' => $otpauthUrl,
|
||||
'backupCodes' => $backupCodes
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// If secret and code provided, verify the code and enable 2FA
|
||||
if ($code !== null) {
|
||||
// Verify the setup code
|
||||
if (!$this->verify($userId, $code)) {
|
||||
app_log('warning', '2FA setup code verification failed', [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enable 2FA
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE user_2fa
|
||||
SET enabled = 1
|
||||
WHERE user_id = ? AND secret_key = ?
|
||||
');
|
||||
return $stmt->execute([$userId, $secret]);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
app_log('error', '2FA enable error: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 2FA code
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $code The verification code
|
||||
* @return bool True if verified, false otherwise
|
||||
*/
|
||||
public function verify($userId, $code) {
|
||||
try {
|
||||
// Get user's 2FA settings
|
||||
$settings = $this->getUserSettings($userId);
|
||||
if (!$settings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if code matches a backup code
|
||||
if ($this->verifyBackupCode($userId, $code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get current Unix timestamp
|
||||
$currentTime = time();
|
||||
|
||||
// Check time window
|
||||
for ($timeSlot = -$this->window; $timeSlot <= $this->window; $timeSlot++) {
|
||||
$checkTime = $currentTime + ($timeSlot * $this->period);
|
||||
$generatedCode = $this->generateCode($settings['secret_key'], $checkTime);
|
||||
if (hash_equals($generatedCode, $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
app_log('error', '2FA verification error: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random secret key
|
||||
*
|
||||
* @return string Base32 encoded secret
|
||||
*/
|
||||
private function generateSecret() {
|
||||
// Generate random bytes (160 bits for SHA1)
|
||||
$random = random_bytes($this->secretLength);
|
||||
return $this->base32Encode($random);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encode data
|
||||
*
|
||||
* @param string $data Data to encode
|
||||
* @return string Base32 encoded string
|
||||
*/
|
||||
private function base32Encode($data) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$binary = '';
|
||||
$encoded = '';
|
||||
|
||||
// Convert to binary
|
||||
for ($i = 0; $i < strlen($data); $i++) {
|
||||
$binary .= str_pad(decbin(ord($data[$i])), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// Process 5 bits at a time
|
||||
for ($i = 0; $i < strlen($binary); $i += 5) {
|
||||
$chunk = substr($binary, $i, 5);
|
||||
if (strlen($chunk) < 5) {
|
||||
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
|
||||
}
|
||||
$encoded .= $alphabet[bindec($chunk)];
|
||||
}
|
||||
|
||||
// Add padding
|
||||
$padding = strlen($encoded) % 8;
|
||||
if ($padding > 0) {
|
||||
$encoded .= str_repeat('=', 8 - $padding);
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 decode data
|
||||
*
|
||||
* @param string $data Base32 encoded string
|
||||
* @return string Decoded data
|
||||
*/
|
||||
private function base32Decode($data) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
// Remove padding and uppercase
|
||||
$data = rtrim(strtoupper($data), '=');
|
||||
|
||||
$binary = '';
|
||||
|
||||
// Convert to binary
|
||||
for ($i = 0; $i < strlen($data); $i++) {
|
||||
$position = strpos($alphabet, $data[$i]);
|
||||
if ($position === false) {
|
||||
continue;
|
||||
}
|
||||
$binary .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$decoded = '';
|
||||
// Process 8 bits at a time
|
||||
for ($i = 0; $i + 7 < strlen($binary); $i += 8) {
|
||||
$chunk = substr($binary, $i, 8);
|
||||
$decoded .= chr(bindec($chunk));
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP code for a given secret and time
|
||||
* RFC 6238 compliant implementation
|
||||
*/
|
||||
private function generateCode($secret, $time) {
|
||||
// Calculate number of time steps since Unix epoch
|
||||
$timeStep = (int)floor($time / $this->period);
|
||||
|
||||
// Pack time into 8 bytes (64-bit big-endian)
|
||||
$timeBin = pack('J', $timeStep);
|
||||
|
||||
// Clean secret of any padding
|
||||
$secret = rtrim($secret, '=');
|
||||
|
||||
// Get binary secret
|
||||
$secretBin = $this->base32Decode($secret);
|
||||
|
||||
// Calculate HMAC
|
||||
$hash = hash_hmac($this->algorithm, $timeBin, $secretBin, true);
|
||||
|
||||
// Get dynamic truncation offset
|
||||
$offset = ord($hash[strlen($hash) - 1]) & 0xF;
|
||||
|
||||
// Generate 31-bit number
|
||||
$code = (
|
||||
((ord($hash[$offset]) & 0x7F) << 24) |
|
||||
((ord($hash[$offset + 1]) & 0xFF) << 16) |
|
||||
((ord($hash[$offset + 2]) & 0xFF) << 8) |
|
||||
(ord($hash[$offset + 3]) & 0xFF)
|
||||
) % pow(10, $this->digits);
|
||||
|
||||
$code = str_pad($code, $this->digits, '0', STR_PAD_LEFT);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate otpauth URL for QR codes
|
||||
* Format: otpauth://totp/ISSUER:ACCOUNT?secret=SECRET&issuer=ISSUER&algorithm=ALGORITHM&digits=DIGITS&period=PERIOD
|
||||
*/
|
||||
private function generateOtpauthUrl($username, $secret) {
|
||||
$params = [
|
||||
'secret' => $secret,
|
||||
'issuer' => $this->issuer,
|
||||
'algorithm' => strtoupper($this->algorithm),
|
||||
'digits' => $this->digits,
|
||||
'period' => $this->period
|
||||
];
|
||||
|
||||
return sprintf(
|
||||
'otpauth://totp/%s:%s?%s',
|
||||
rawurlencode($this->issuer),
|
||||
rawurlencode($username),
|
||||
http_build_query($params)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes
|
||||
*
|
||||
* @param int $count Number of backup codes to generate
|
||||
* @return array Array of backup codes
|
||||
*/
|
||||
private function generateBackupCodes($count = 8) {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$codes[] = bin2hex(random_bytes(4));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a backup code
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $code The backup code to verify
|
||||
* @return bool True if verified, false otherwise
|
||||
*/
|
||||
private function verifyBackupCode($userId, $code) {
|
||||
try {
|
||||
$stmt = $this->db->prepare('SELECT backup_codes FROM user_2fa WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$backupCodes = json_decode($result['backup_codes'], true);
|
||||
|
||||
// Check if the code exists and hasn't been used
|
||||
$codeIndex = array_search($code, $backupCodes);
|
||||
if ($codeIndex !== false) {
|
||||
// Remove the used code
|
||||
unset($backupCodes[$codeIndex]);
|
||||
$backupCodes = array_values($backupCodes);
|
||||
|
||||
// Update backup codes in database
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE user_2fa
|
||||
SET backup_codes = ?
|
||||
WHERE user_id = ?
|
||||
');
|
||||
$stmt->execute([json_encode($backupCodes), $userId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
app_log('error', 'Backup code verification error: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2FA for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return bool True if disabled successfully
|
||||
*/
|
||||
public function disable($userId) {
|
||||
try {
|
||||
// First check if user has 2FA settings
|
||||
$settings = $this->getUserSettings($userId);
|
||||
if (!$settings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the 2FA settings entirely instead of just disabling
|
||||
$stmt = $this->db->prepare('
|
||||
DELETE FROM user_2fa
|
||||
WHERE user_id = ?
|
||||
');
|
||||
return $stmt->execute([$userId]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
app_log('error', '2FA disable error: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 2FA is enabled for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return bool True if enabled
|
||||
*/
|
||||
public function isEnabled($userId) {
|
||||
try {
|
||||
$stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result && $result['enabled'];
|
||||
|
||||
} catch (Exception $e) {
|
||||
app_log('error', '2FA status check error: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserSettings($userId) {
|
||||
try {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT secret_key, backup_codes, enabled
|
||||
FROM user_2fa
|
||||
WHERE user_id = ?
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (Exception $e) {
|
||||
app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [
|
||||
'scope' => 'security',
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
<?php
|
||||
|
||||
use App\App;
|
||||
|
||||
/**
|
||||
* class User
|
||||
*
|
||||
* Handles user-related functionalities such as login, rights management, and profile updates.
|
||||
*/
|
||||
class User {
|
||||
/**
|
||||
* @var PDO|null $db The database connection instance.
|
||||
*/
|
||||
private $db;
|
||||
private $rateLimiter;
|
||||
private $twoFactorAuth;
|
||||
/**
|
||||
* Cache for database schema checks
|
||||
* @var array<string,bool>
|
||||
*/
|
||||
private static $schemaCache = [];
|
||||
|
||||
/**
|
||||
* User constructor.
|
||||
* Initializes the database connection.
|
||||
*
|
||||
* @param object $database The database object to initialize the connection.
|
||||
*/
|
||||
public function __construct($database) {
|
||||
if ($database instanceof PDO) {
|
||||
$this->db = $database;
|
||||
} else {
|
||||
$this->db = $database->getConnection();
|
||||
}
|
||||
require_once __DIR__ . '/ratelimiter.php';
|
||||
require_once __DIR__ . '/twoFactorAuth.php';
|
||||
|
||||
$this->rateLimiter = new RateLimiter();
|
||||
$this->twoFactorAuth = new TwoFactorAuthentication($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a column exists in a given table. Results are cached per request.
|
||||
*
|
||||
* @param string $table
|
||||
* @param string $column
|
||||
* @return bool
|
||||
*/
|
||||
private function columnExists(string $table, string $column): bool {
|
||||
$cacheKey = $table . '.' . $column;
|
||||
if (isset(self::$schemaCache[$cacheKey])) {
|
||||
return self::$schemaCache[$cacheKey];
|
||||
}
|
||||
try {
|
||||
$stmt = $this->db->prepare("SHOW COLUMNS FROM `$table` LIKE :column");
|
||||
$stmt->execute([':column' => $column]);
|
||||
$exists = (bool)$stmt->fetch(PDO::FETCH_ASSOC);
|
||||
self::$schemaCache[$cacheKey] = $exists;
|
||||
return $exists;
|
||||
} catch (Exception $e) {
|
||||
// On error, assume column doesn't exist to be safe
|
||||
self::$schemaCache[$cacheKey] = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's preferred theme if stored in DB (user_meta.theme). Returns null if not set.
|
||||
*
|
||||
* @param int $userId
|
||||
* @return string|null
|
||||
*/
|
||||
public function getUserTheme(int $userId): ?string {
|
||||
if (!$this->columnExists('user_meta', 'theme')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$sql = 'SELECT theme FROM user_meta WHERE user_id = :user_id LIMIT 1';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':user_id' => $userId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$theme = $row['theme'] ?? null;
|
||||
return ($theme !== null && $theme !== '') ? $theme : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the user's preferred theme in DB (user_meta.theme) when the column exists.
|
||||
* Silently no-ops if the column is missing.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $theme
|
||||
* @return bool True when stored or safely skipped; false only on explicit DB error.
|
||||
*/
|
||||
public function setUserTheme(int $userId, string $theme): bool {
|
||||
if (!$this->columnExists('user_meta', 'theme')) {
|
||||
// Column not present; treat as success to avoid breaking UX
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$sql = 'UPDATE user_meta SET theme = :theme WHERE user_id = :user_id';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$ok = $stmt->execute([':theme' => $theme, ':user_id' => $userId]);
|
||||
return (bool)$ok;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logs in a user by verifying credentials.
|
||||
*
|
||||
* @param string $username The username of the user.
|
||||
* @param string $password The password of the user.
|
||||
* @param string $twoFactorCode Optional. The 2FA code if 2FA is enabled.
|
||||
*
|
||||
* @return array Login result with status and any necessary data
|
||||
*/
|
||||
public function login($username, $password, $twoFactorCode = null) {
|
||||
// Get user's IP address
|
||||
$ipAddress = getUserIP();
|
||||
|
||||
// Check rate limiting first
|
||||
if (!$this->rateLimiter->isAllowed($username, $ipAddress)) {
|
||||
$remainingTime = $this->rateLimiter->getDecayMinutes();
|
||||
throw new Exception("Too many login attempts. Please try again in {$remainingTime} minutes.");
|
||||
}
|
||||
|
||||
// Then check credentials
|
||||
$query = $this->db->prepare("SELECT * FROM user WHERE username = :username");
|
||||
$query->bindParam(':username', $username);
|
||||
$query->execute();
|
||||
|
||||
$user = $query->fetch(PDO::FETCH_ASSOC);
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
// Check if 2FA is enabled
|
||||
if ($this->twoFactorAuth->isEnabled($user['id'])) {
|
||||
if ($twoFactorCode === null) {
|
||||
return [
|
||||
'status' => 'requires_2fa',
|
||||
'user_id' => $user['id'],
|
||||
'username' => $user['username']
|
||||
];
|
||||
}
|
||||
|
||||
// Verify 2FA code
|
||||
if (!$this->twoFactorAuth->verify($user['id'], $twoFactorCode)) {
|
||||
return [
|
||||
'status' => 'invalid_2fa',
|
||||
'message' => 'Invalid 2FA code'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Login successful
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['CREATED'] = time();
|
||||
$_SESSION['LAST_ACTIVITY'] = time();
|
||||
return [
|
||||
'status' => 'success',
|
||||
'user_id' => $user['id'],
|
||||
'username' => $user['username']
|
||||
];
|
||||
}
|
||||
|
||||
// Get remaining attempts AFTER this failed attempt
|
||||
$remainingAttempts = $this->rateLimiter->getRemainingAttempts($username, $ipAddress);
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => "Invalid credentials. {$remainingAttempts} attempts remaining."
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves a user ID based on the username.
|
||||
*
|
||||
* @param string $username The username to look up.
|
||||
*
|
||||
* @return array|null User ID details or null if not found.
|
||||
*/
|
||||
// FIXME not used now?
|
||||
public function getUserId($username) {
|
||||
$sql = 'SELECT id FROM user WHERE username = :username';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->bindParam(':username', $username);
|
||||
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches user details by user ID.
|
||||
*
|
||||
* @param int $userId The user ID.
|
||||
*
|
||||
* @return array|null User details or null if not found.
|
||||
*/
|
||||
public function getUserDetails($userId) {
|
||||
$sql = 'SELECT
|
||||
um.*,
|
||||
u.username
|
||||
FROM
|
||||
user_meta um
|
||||
LEFT JOIN user u
|
||||
ON um.user_id = u.id
|
||||
WHERE
|
||||
u.id = :user_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
]);
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Grants a user a specific right.
|
||||
*
|
||||
* @param int $userId The user ID.
|
||||
* @param int $right_id The right ID to grant.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addUserRight($userId, $right_id) {
|
||||
$sql = 'INSERT INTO user_right
|
||||
(user_id, right_id)
|
||||
VALUES
|
||||
(:user_id, :right_id)';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
':right_id' => $right_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Revokes a specific right from a user.
|
||||
*
|
||||
* @param int $userId The user ID.
|
||||
* @param int $right_id The right ID to revoke.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function removeUserRight($userId, $right_id) {
|
||||
$sql = 'DELETE FROM user_right
|
||||
WHERE
|
||||
user_id = :user_id
|
||||
AND
|
||||
right_id = :right_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
':right_id' => $right_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves all rights in the system.
|
||||
*
|
||||
* @return array List of rights.
|
||||
*/
|
||||
public function getAllRights() {
|
||||
$sql = 'SELECT
|
||||
id AS right_id,
|
||||
name AS right_name
|
||||
FROM `right`
|
||||
ORDER BY id ASC';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute();
|
||||
|
||||
return $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the rights assigned to a specific user.
|
||||
*
|
||||
* @param int $userId The user ID.
|
||||
*
|
||||
* @return array List of user rights.
|
||||
*/
|
||||
public function getUserRights($userId) {
|
||||
$sql = 'SELECT
|
||||
u.id AS user_id,
|
||||
r.id AS right_id,
|
||||
r.name AS right_name
|
||||
FROM
|
||||
`user` u
|
||||
LEFT JOIN `user_right` ur
|
||||
ON u.id = ur.user_id
|
||||
LEFT JOIN `right` r
|
||||
ON ur.right_id = r.id
|
||||
WHERE
|
||||
u.id = :user_id';
|
||||
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
]);
|
||||
|
||||
$result = $query->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ensure specific entries are included in the result
|
||||
$specialEntries = [];
|
||||
|
||||
// user 1 is always superuser
|
||||
if ($userId == 1) {
|
||||
$specialEntries = [
|
||||
[
|
||||
'user_id' => 1,
|
||||
'right_id' => 1,
|
||||
'right_name' => 'superuser'
|
||||
]
|
||||
];
|
||||
|
||||
// user 2 is always demo
|
||||
} elseif ($userId == 2) {
|
||||
$specialEntries = [
|
||||
[
|
||||
'user_id' => 2,
|
||||
'right_id' => 100,
|
||||
'right_name' => 'demo user'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// merge the special entries with the existing results
|
||||
$result = array_merge($specialEntries, $result);
|
||||
// remove duplicates if necessary
|
||||
$result = array_unique($result, SORT_REGULAR);
|
||||
|
||||
// return the modified result
|
||||
return $result;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the user has a specific right.
|
||||
*
|
||||
* @param int $userId The user ID.
|
||||
* @param string $right_name The human-readable name of the user right.
|
||||
*
|
||||
* @return bool True if the user has the right, false otherwise.
|
||||
*/
|
||||
function hasRight($userId, $right_name) {
|
||||
$userRights = $this->getUserRights($userId);
|
||||
$userHasRight = false;
|
||||
|
||||
// superuser always has all the rights
|
||||
if ($userId === 1) {
|
||||
$userHasRight = true;
|
||||
}
|
||||
|
||||
foreach ($userRights as $right) {
|
||||
if ($right['right_name'] === $right_name) {
|
||||
$userHasRight = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $userHasRight;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates a user's metadata in the database.
|
||||
*
|
||||
* @param int $userId The ID of the user to update.
|
||||
* @param array $updatedUser An associative array containing updated user data:
|
||||
* - 'name' (string): The updated name of the user.
|
||||
* - 'email' (string): The updated email of the user.
|
||||
* - 'timezone' (string): The updated timezone of the user.
|
||||
* - 'bio' (string): The updated biography of the user.
|
||||
*
|
||||
* @return bool|string Returns true if the update is successful, or an error message if an exception occurs.
|
||||
*/
|
||||
public function editUser($userId, $updatedUser) {
|
||||
try {
|
||||
$sql = 'UPDATE user_meta SET
|
||||
name = :name,
|
||||
email = :email,
|
||||
timezone = :timezone,
|
||||
bio = :bio
|
||||
WHERE user_id = :user_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
':name' => $updatedUser['name'],
|
||||
':email' => $updatedUser['email'],
|
||||
':timezone' => $updatedUser['timezone'],
|
||||
':bio' => $updatedUser['bio']
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes a user's avatar from the database and deletes the associated file.
|
||||
*
|
||||
* @param int $userId The ID of the user whose avatar is being removed.
|
||||
* @param string $old_avatar Optional. The file path of the current avatar to delete. Default is an empty string.
|
||||
*
|
||||
* @return bool|string Returns true if the avatar is successfully removed, or an error message if an exception occurs.
|
||||
*/
|
||||
public function removeAvatar($userId, $old_avatar = '') {
|
||||
try {
|
||||
// remove from database
|
||||
$sql = 'UPDATE user_meta SET
|
||||
avatar = NULL
|
||||
WHERE user_id = :user_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':user_id' => $userId,
|
||||
]);
|
||||
|
||||
// delete the old avatar file
|
||||
if ($old_avatar && file_exists($old_avatar)) {
|
||||
unlink($old_avatar);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates a user's avatar by uploading a new file and saving its path in the database.
|
||||
*
|
||||
* @param int $userId The ID of the user whose avatar is being updated.
|
||||
* @param array $avatar_file The uploaded avatar file from the $_FILES array.
|
||||
* Should include 'tmp_name', 'name', 'error', etc.
|
||||
* @param string $avatars_path The directory path where avatar files should be saved.
|
||||
*
|
||||
* @return bool|string Returns true if the avatar is successfully updated, or an error message if an exception occurs.
|
||||
*/
|
||||
public function changeAvatar($userId, $avatar_file, $avatars_path) {
|
||||
try {
|
||||
// check if the file was uploaded
|
||||
if (isset($avatar_file) && $avatar_file['error'] === UPLOAD_ERR_OK) {
|
||||
$fileTmpPath = $avatar_file['tmp_name'];
|
||||
$fileName = $avatar_file['name'];
|
||||
$fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
|
||||
// validate file extension
|
||||
if (in_array($fileExtension, ['jpg', 'png', 'jpeg'])) {
|
||||
$newFileName = md5(time() . $fileName) . '.' . $fileExtension;
|
||||
$dest_path = $avatars_path . $newFileName;
|
||||
|
||||
// ensure avatars directory exists
|
||||
if (!is_dir($avatars_path)) {
|
||||
if (!mkdir($avatars_path, 0755, true)) {
|
||||
$_SESSION['error'] .= 'Unable to create avatars directory. ';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// check if directory is writable
|
||||
if (!is_writable($avatars_path)) {
|
||||
$_SESSION['error'] .= 'Avatars directory is not writable. ';
|
||||
return false;
|
||||
}
|
||||
|
||||
// move the file to avatars folder
|
||||
if (move_uploaded_file($fileTmpPath, $dest_path)) {
|
||||
try {
|
||||
// update user's avatar path in DB
|
||||
$sql = 'UPDATE user_meta SET
|
||||
avatar = :avatar
|
||||
WHERE user_id = :user_id';
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([
|
||||
':avatar' => $newFileName,
|
||||
':user_id' => $userId
|
||||
]);
|
||||
// all went OK
|
||||
$_SESSION['notice'] = 'Avatar updated successfully. ';
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$_SESSION['error'] .= 'Database error updating avatar. ';
|
||||
return $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. ';
|
||||
}
|
||||
} else {
|
||||
$_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. ';
|
||||
}
|
||||
} else {
|
||||
// Handle different upload errors
|
||||
switch ($avatar_file['error']) {
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
$_SESSION['error'] = 'Avatar file is too large. Maximum size is 500KB. ';
|
||||
break;
|
||||
case UPLOAD_ERR_PARTIAL:
|
||||
$_SESSION['error'] = 'Avatar file was only partially uploaded. ';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_FILE:
|
||||
$_SESSION['error'] = 'No avatar file was uploaded. ';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_TMP_DIR:
|
||||
$_SESSION['error'] = 'Missing temporary folder for file upload. ';
|
||||
break;
|
||||
case UPLOAD_ERR_CANT_WRITE:
|
||||
$_SESSION['error'] = 'Failed to write avatar file to disk. ';
|
||||
break;
|
||||
case UPLOAD_ERR_EXTENSION:
|
||||
$_SESSION['error'] = 'File upload stopped by extension. ';
|
||||
break;
|
||||
default:
|
||||
$_SESSION['error'] = 'Unknown upload error occurred. ';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$_SESSION['error'] = 'An error occurred while processing the avatar: ' . $e->getMessage();
|
||||
return $e->getMessage();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users for messaging
|
||||
*
|
||||
* @return array List of users with their IDs and usernames
|
||||
*/
|
||||
public function getUsers() {
|
||||
$sql = "SELECT id, username
|
||||
FROM `user`
|
||||
ORDER BY username ASC";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable two-factor authentication for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $secret Secret key to use
|
||||
* @param string $code Verification code to validate
|
||||
* @return bool True if enabled successfully
|
||||
*/
|
||||
public function enableTwoFactor($userId, $secret = null, $code = null) {
|
||||
return $this->twoFactorAuth->enable($userId, $secret, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable two-factor authentication for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return bool True if disabled successfully
|
||||
*/
|
||||
public function disableTwoFactor($userId) {
|
||||
return $this->twoFactorAuth->disable($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a two-factor authentication code
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $code The verification code
|
||||
* @return bool True if verified
|
||||
*/
|
||||
public function verifyTwoFactor($userId, $code) {
|
||||
return $this->twoFactorAuth->verify($userId, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two-factor authentication is enabled for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return bool True if enabled
|
||||
*/
|
||||
public function isTwoFactorEnabled($userId) {
|
||||
return $this->twoFactorAuth->isEnabled($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a user's password
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $currentPassword Current password for verification
|
||||
* @param string $newPassword New password to set
|
||||
* @return bool True if password was changed successfully
|
||||
*/
|
||||
public function changePassword($userId, $currentPassword, $newPassword) {
|
||||
try {
|
||||
// First verify the current password
|
||||
$sql = "SELECT password FROM user WHERE id = :user_id";
|
||||
$query = $this->db->prepare($sql);
|
||||
$query->execute([':user_id' => $userId]);
|
||||
$user = $query->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !password_verify($currentPassword, $user['password'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
|
||||
// Update the password
|
||||
$sql = "UPDATE user SET password = :password WHERE id = :user_id";
|
||||
$query = $this->db->prepare($sql);
|
||||
return $query->execute([
|
||||
':password' => $hashedPassword,
|
||||
':user_id' => $userId
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error changing password: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
class Validator {
|
||||
private $errors = [];
|
||||
private $data = [];
|
||||
|
||||
public function __construct(array $data) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function validate(array $rules) {
|
||||
foreach ($rules as $field => $fieldRules) {
|
||||
foreach ($fieldRules as $rule => $parameter) {
|
||||
$this->applyRule($field, $rule, $parameter);
|
||||
}
|
||||
}
|
||||
return empty($this->errors);
|
||||
}
|
||||
|
||||
private function applyRule($field, $rule, $parameter) {
|
||||
$value = $this->data[$field] ?? null;
|
||||
|
||||
switch ($rule) {
|
||||
// case for required fields that can be empty strings
|
||||
case 'required':
|
||||
if ($parameter && empty($value)) {
|
||||
$label = $this->formatFieldLabel($field);
|
||||
$this->addError($field, "$label is required");
|
||||
}
|
||||
break;
|
||||
// special case for required fields that can't be empty strings or null
|
||||
case 'required_strict':
|
||||
if ($parameter) {
|
||||
if ($value === null) {
|
||||
$label = $this->formatFieldLabel($field);
|
||||
$this->addError($field, "$label is required");
|
||||
} elseif (is_string($value)) {
|
||||
if (trim($value) === '') {
|
||||
$label = $this->formatFieldLabel($field);
|
||||
$this->addError($field, "$label is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'email':
|
||||
if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->addError($field, "Invalid email format");
|
||||
}
|
||||
break;
|
||||
case 'min':
|
||||
if (!empty($value) && strlen($value) < $parameter) {
|
||||
$this->addError($field, "Minimum length is $parameter characters");
|
||||
} | ||||