Compare commits
2417 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffbe93e0f | ||
|
|
0902d39970 | ||
|
|
8d5ba2af72 | ||
|
|
34948770ce | ||
|
|
8bafaed671 | ||
|
|
a604b3cd09 | ||
|
|
ad41bc01b9 | ||
|
|
4db70bf401 | ||
|
|
b88cc1366f | ||
|
|
feeed21b94 | ||
|
|
dfa4e5a7a3 | ||
|
|
28e5f44a30 | ||
|
|
b03b5b1adb | ||
|
|
b7c6c3f338 | ||
|
|
8568830cf6 | ||
|
|
1ac3bd1da8 | ||
|
|
9778f8be53 | ||
|
|
7e47287b23 | ||
|
|
7d8e45f64f | ||
|
|
8f77197cde | ||
|
|
1f24fe0843 | ||
|
|
c15e1eb042 | ||
|
|
1e6499d23a | ||
|
|
19257d4dc0 | ||
|
|
762562f837 | ||
|
|
75192abb3a | ||
|
|
6276dfb614 | ||
|
|
33860b84cf | ||
|
|
607065d94c | ||
|
|
ac02e2eb2e | ||
|
|
ce5ae3fe40 | ||
|
|
fb87b51a23 | ||
|
|
7070644842 | ||
|
|
ced9f320a4 | ||
|
|
fb8264bf7d | ||
|
|
cfa1bee2d8 | ||
|
|
fa11b0ffa8 | ||
|
|
c3889d64a3 | ||
|
|
8b69d552fd | ||
|
|
0a1ff05c39 | ||
|
|
2b33a89107 | ||
|
|
bde76b5b46 | ||
|
|
7c4e6aece8 | ||
|
|
e0e50a78ed | ||
|
|
930a460ef3 | ||
|
|
f7d81f9fba | ||
|
|
25998fdcc4 | ||
|
|
617e7f043b | ||
|
|
ced7abd27f | ||
|
|
f29a1be1eb | ||
|
|
0704ffb1a2 | ||
|
|
9f6c295484 | ||
|
|
8936bdca81 | ||
|
|
f86b1343b7 | ||
|
|
d6e39fb896 | ||
|
|
4004c16504 | ||
|
|
66b935037c | ||
|
|
4c78e098cf | ||
|
|
864add055b | ||
|
|
e44a5545a3 | ||
|
|
30aecb8565 | ||
|
|
79354453e5 | ||
|
|
d6e4abec73 | ||
|
|
be03243250 | ||
|
|
92f935c6de | ||
|
|
0a52e26cd0 | ||
|
|
6bf94e558a | ||
|
|
9ec215c3d4 | ||
|
|
704c6be1c4 | ||
|
|
0adf02bf99 | ||
|
|
8631425ff7 | ||
|
|
b8e25f8565 | ||
|
|
394b1fe33d | ||
|
|
038e65bfe3 | ||
|
|
6e2d8b1974 | ||
|
|
310f1baa09 | ||
|
|
acf9e16234 | ||
|
|
741bd23144 | ||
|
|
bdf1c2df6e | ||
|
|
8d1268bd19 | ||
|
|
8db72faf3c | ||
|
|
e4c061858e | ||
|
|
0653ed2183 | ||
|
|
a9f82b6e1b | ||
|
|
80d06d154a | ||
|
|
6265c0665b | ||
|
|
1c2a56e784 | ||
|
|
e03c8f04e1 | ||
|
|
bd23a99aec | ||
|
|
26272ce793 | ||
|
|
66f918c1bf | ||
|
|
a6fccc7c58 | ||
|
|
ae05c10e7c | ||
|
|
14d8ef6cb1 | ||
|
|
bc62c2b1be | ||
|
|
dedafe9c71 | ||
|
|
e754d8d80d | ||
|
|
cbb59e5e55 | ||
|
|
1928a2302c | ||
|
|
6b3a2a0fe7 | ||
|
|
52802f44f6 | ||
|
|
f01adc4cb4 | ||
|
|
c21dc5313d | ||
|
|
5243176d74 | ||
|
|
c495fcbd1a | ||
|
|
036a55e61e | ||
|
|
21ef5f9b84 | ||
|
|
2ddcbfb5ab | ||
|
|
c931c78b6a | ||
|
|
815d812297 | ||
|
|
8ed86b53ca | ||
|
|
98f27364f2 | ||
|
|
4e502f38aa | ||
|
|
3a468a9b85 | ||
|
|
a6009654c7 | ||
|
|
2d8660902d | ||
|
|
2e5ced23a8 | ||
|
|
4011cc56b6 | ||
|
|
1543965e5f | ||
|
|
c21a56585f | ||
|
|
cd53696831 | ||
|
|
8777d2bfd3 | ||
|
|
00cf79224d | ||
|
|
5b0d42173f | ||
|
|
6891ee8b19 | ||
|
|
5f2b2ffdf1 | ||
|
|
f0e67c93d6 | ||
|
|
e5fd908b54 | ||
|
|
3af7a19563 | ||
|
|
e1f0792dce | ||
|
|
2e91656245 | ||
|
|
8378095343 | ||
|
|
b6f67d4b0e | ||
|
|
ffe808c915 | ||
|
|
6169f4c0e4 | ||
|
|
8c5e542268 | ||
|
|
893b902314 | ||
|
|
2435e8e3d0 | ||
|
|
628d44460d | ||
|
|
fc1b7e500d | ||
|
|
7cfbbbe7e6 | ||
|
|
bea743b536 | ||
|
|
6ccc575cb4 | ||
|
|
9e4be57754 | ||
|
|
82b47628ae | ||
|
|
3bd62f4184 | ||
|
|
9a98c2f6c2 | ||
|
|
73c1706e6a | ||
|
|
649ee8bcd6 | ||
|
|
37e8f2dc28 | ||
|
|
4d9628a156 | ||
|
|
0ff9a3838e | ||
|
|
24eacb191d | ||
|
|
23e9ad6ef5 | ||
|
|
c7f26f706d | ||
|
|
699eabef80 | ||
|
|
73376194e0 | ||
|
|
aafa56df95 | ||
|
|
978c5d9704 | ||
|
|
c332e8f940 | ||
|
|
f116259f6a | ||
|
|
3f9902b001 | ||
|
|
a627276ab4 | ||
|
|
f72a66816a | ||
|
|
508f7d7e2b | ||
|
|
b86d372bd2 | ||
|
|
c75fd51626 | ||
|
|
6ad12288c3 | ||
|
|
af2543a99e | ||
|
|
2c61032d15 | ||
|
|
a54104aa82 | ||
|
|
dd2e73b40f | ||
|
|
a5ac4f38cf | ||
|
|
2ff11dec0a | ||
|
|
8e1547ca3b | ||
|
|
757e182d1b | ||
|
|
5562ca6039 | ||
|
|
74cf577245 | ||
|
|
79387309d8 | ||
|
|
e4b81feb5c | ||
|
|
58ac57e098 | ||
|
|
abc2941756 | ||
|
|
1432384b63 | ||
|
|
52df207088 | ||
|
|
7f834dbf8c | ||
|
|
9131b29b48 | ||
|
|
f621cd13d2 | ||
|
|
69904dbe81 | ||
|
|
d56430c0f0 | ||
|
|
f69bc8fc1e | ||
|
|
5a173ee18b | ||
|
|
6429b2e26d | ||
|
|
93f36adfea | ||
|
|
25547c9f38 | ||
|
|
6425149d20 | ||
|
|
4738e11ed0 | ||
|
|
ae67f6ce79 | ||
|
|
c23f004d35 | ||
|
|
8effec2cbb | ||
|
|
5afc429c25 | ||
|
|
d5552ad517 | ||
|
|
65a14ffdf1 | ||
|
|
837d0b5bc1 | ||
|
|
5b1417f432 | ||
|
|
53a004c41c | ||
|
|
ac34939c99 | ||
|
|
9aa8886309 | ||
|
|
1166cca357 | ||
|
|
395d3dd16b | ||
|
|
009dfd07de | ||
|
|
ff07ff5b7f | ||
|
|
2b32cfe6fa | ||
|
|
b31e9e71b6 | ||
|
|
94bf1973a0 | ||
|
|
357dfe76e8 | ||
|
|
5da955b3a5 | ||
|
|
7584a867eb | ||
|
|
9c77212f4a | ||
|
|
2b62224a5b | ||
|
|
bb561104f8 | ||
|
|
bdd9ab6a29 | ||
|
|
d450d6bae3 | ||
|
|
02cc464098 | ||
|
|
563345eddd | ||
|
|
e5e3230a16 | ||
|
|
ed4d4c67d9 | ||
|
|
42df276e73 | ||
|
|
bd17080f2a | ||
|
|
2fb7ddaaee | ||
|
|
ef868f776c | ||
|
|
06979345c7 | ||
|
|
e5de270e65 | ||
|
|
031c3c4684 | ||
|
|
a9d31590e8 | ||
|
|
4c4a55872a | ||
|
|
9387db5f8d | ||
|
|
6a5c7d44bf | ||
|
|
f1ace7fce8 | ||
|
|
20c946d127 | ||
|
|
bb2a1f3638 | ||
|
|
e603f29b47 | ||
|
|
9c2ecd1e22 | ||
|
|
80c3416ca7 | ||
|
|
e31433f320 | ||
|
|
1f29938186 | ||
|
|
a62f68ea5a | ||
|
|
25da8f5e21 | ||
|
|
f588dc6cff | ||
|
|
8605ee6fd8 | ||
|
|
211be30cf4 | ||
|
|
9bf91f17d6 | ||
|
|
576d9dd3e0 | ||
|
|
b31268c9f2 | ||
|
|
894ed19556 | ||
|
|
542e51e733 | ||
|
|
6a64f3f24d | ||
|
|
f81b7523d4 | ||
|
|
1006971271 | ||
|
|
d738e19ce9 | ||
|
|
3f62a41952 | ||
|
|
a38a650dc1 | ||
|
|
813db2b8f9 | ||
|
|
59fad153ae | ||
|
|
4302ba4bf2 | ||
|
|
ed69a871a5 | ||
|
|
56f45510dd | ||
|
|
22926a69bd | ||
|
|
9175f1b082 | ||
|
|
72829d6de6 | ||
|
|
73c3863c1a | ||
|
|
78a1c84d50 | ||
|
|
aedeef4e02 | ||
|
|
b741cc592f | ||
|
|
5f1cd080bf | ||
|
|
d25dc2c795 | ||
|
|
31d5eb7e56 | ||
|
|
19fa1d9b23 | ||
|
|
b4f9c99717 | ||
|
|
9062f49992 | ||
|
|
f8abcaa7c6 | ||
|
|
e43a7827c2 | ||
|
|
4ad8e3605b | ||
|
|
c21337b9ff | ||
|
|
a0da9ef61e | ||
|
|
22172da57e | ||
|
|
a134d21b79 | ||
|
|
d0fc83c00c | ||
|
|
64d8f30006 | ||
|
|
aecde93310 | ||
|
|
0c4698ec0d | ||
|
|
9a64ff7563 | ||
|
|
1fc7aeb1dd | ||
|
|
3f12a89764 | ||
|
|
232e3199fd | ||
|
|
c78c997817 | ||
|
|
f8c032458e | ||
|
|
4cb7cc37f9 | ||
|
|
23335f4d30 | ||
|
|
44e6f42b51 | ||
|
|
37ff435ff3 | ||
|
|
2f9a3f04b8 | ||
|
|
40edbc7a3b | ||
|
|
2d7845209f | ||
|
|
c06ca52f4c | ||
|
|
019cd66033 | ||
|
|
4147e5edc7 | ||
|
|
71e68a6056 | ||
|
|
8f453a8cdf | ||
|
|
04878fc9e0 | ||
|
|
29a6a762cb | ||
|
|
63ad6a2b9a | ||
|
|
da090fd3f5 | ||
|
|
d6e25f38a8 | ||
|
|
86ca3864a3 | ||
|
|
6b5230b0c1 | ||
|
|
80888f0675 | ||
|
|
b3c5824490 | ||
|
|
7a94b9361f | ||
|
|
09e06a159a | ||
|
|
ca961cb35f | ||
|
|
12545c69ac | ||
|
|
aeca6cbbdb | ||
|
|
211b130f98 | ||
|
|
2c8b751f98 | ||
|
|
e75f2f92e2 | ||
|
|
d5492cbb9b | ||
|
|
1a74471b68 | ||
|
|
042e811a40 | ||
|
|
7219c8b4a9 | ||
|
|
425420d663 | ||
|
|
b201176d3f | ||
|
|
8b6bd8d282 | ||
|
|
36644216b2 | ||
|
|
195008c69e | ||
|
|
8f8e68d3d3 | ||
|
|
f6a3f36f1a | ||
|
|
80499dbf6c | ||
|
|
2869876b67 | ||
|
|
5eb202941a | ||
|
|
663fb38d9f | ||
|
|
b6e7e09171 | ||
|
|
aabbb693b2 | ||
|
|
ba1523fc4b | ||
|
|
c3ece9f102 | ||
|
|
6883a079d4 | ||
|
|
24310c920d | ||
|
|
87c369998f | ||
|
|
dfad359290 | ||
|
|
e335797629 | ||
|
|
7cf1f40ff6 | ||
|
|
a99a8a4df3 | ||
|
|
94c26064cf | ||
|
|
3c096aac41 | ||
|
|
f0039774e4 | ||
|
|
eb9466f346 | ||
|
|
a356a50abb | ||
|
|
598c02eeff | ||
|
|
22b1008593 | ||
|
|
f6da62a606 | ||
|
|
1c85e12f9e | ||
|
|
5bd97598a8 | ||
|
|
ee83a7b5ce | ||
|
|
c731ab027a | ||
|
|
86ee4d9caa | ||
|
|
aadb98ed7c | ||
|
|
d2c5779301 | ||
|
|
7d00a5b969 | ||
|
|
804e131cb8 | ||
|
|
6a53f25665 | ||
|
|
380f009529 | ||
|
|
57ed2fc416 | ||
|
|
3b56ef7148 | ||
|
|
2653071886 | ||
|
|
5e122279ec | ||
|
|
4872ea9e6a | ||
|
|
bb39f268d3 | ||
|
|
bce06d45d0 | ||
|
|
54c50a815d | ||
|
|
6b04324ee5 | ||
|
|
8db31ebfa9 | ||
|
|
1eaa5d8767 | ||
|
|
522f12356a | ||
|
|
e344e28166 | ||
|
|
2d29fb1994 | ||
|
|
e1e77033fe | ||
|
|
1fad818f05 | ||
|
|
37bff46f33 | ||
|
|
9cb4d13d71 | ||
|
|
8815327257 | ||
|
|
a3371d538c | ||
|
|
8191e3fe63 | ||
|
|
b5cd169af8 | ||
|
|
37b50bf409 | ||
|
|
49a2ee5b82 | ||
|
|
14d1621db5 | ||
|
|
6e6fb20016 | ||
|
|
3278b1ce22 | ||
|
|
04b87a4e74 | ||
|
|
a920854796 | ||
|
|
6445f37847 | ||
|
|
6a72def6e9 | ||
|
|
b9303aa82d | ||
|
|
21b1f632c2 | ||
|
|
f8f228112d | ||
|
|
aae43d72a7 | ||
|
|
b6c2bcb77e | ||
|
|
45ce4cf469 | ||
|
|
64818f7247 | ||
|
|
c3fd773523 | ||
|
|
2dc5e080d2 | ||
|
|
64f7a9a7fc | ||
|
|
f6d3b4a4b6 | ||
|
|
b2e543a16a | ||
|
|
aaecfb1121 | ||
|
|
8e719e3503 | ||
|
|
4ac0016c0b | ||
|
|
faddf11f77 | ||
|
|
662c632f5d | ||
|
|
3d9fe80177 | ||
|
|
a2125acddd | ||
|
|
4bed2fc40c | ||
|
|
31b8ef5d87 | ||
|
|
7d18cdee95 | ||
|
|
3a794fecbf | ||
|
|
686d285731 | ||
|
|
f77ecd824c | ||
|
|
465370d3fc | ||
|
|
609354cd35 | ||
|
|
fc565eee92 | ||
|
|
64ba3f6e15 | ||
|
|
756aff4b5b | ||
|
|
5fdabdfc9b | ||
|
|
6280cd5947 | ||
|
|
bb81e1387a | ||
|
|
1b8c13914c | ||
|
|
3ee4b723c1 | ||
|
|
47b2d07e89 | ||
|
|
1be4155ab0 | ||
|
|
0f5a97b539 | ||
|
|
792488cce1 | ||
|
|
66f855cc61 | ||
|
|
f31bc34d5e | ||
|
|
1e782140d7 | ||
|
|
360e784c63 | ||
|
|
ebac0df7df | ||
|
|
15504bae53 | ||
|
|
722b0f7dc2 | ||
|
|
3496d08c13 | ||
|
|
b957c8bc58 | ||
|
|
8cad92436c | ||
|
|
21687c7558 | ||
|
|
8c59520317 | ||
|
|
eaea6ce9a3 | ||
|
|
f0a33927bd | ||
|
|
e82dac4403 | ||
|
|
c782e59531 | ||
|
|
e9193aefb8 | ||
|
|
64414a3731 | ||
|
|
f5dd77fcb3 | ||
|
|
a8d2cc0383 | ||
|
|
b33a7f79b1 | ||
|
|
311d07d454 | ||
|
|
43f4d4a7f4 | ||
|
|
b151f78ca6 | ||
|
|
af2bdef1bf | ||
|
|
434fd03fe8 | ||
|
|
2c0d90e797 | ||
|
|
f9048a8f49 | ||
|
|
6b9614314c | ||
|
|
0609176434 | ||
|
|
1f7955b392 | ||
|
|
4a671e7eef | ||
|
|
6dd26b00e3 | ||
|
|
ec2acad472 | ||
|
|
f1ec755618 | ||
|
|
0b76982d63 | ||
|
|
a79337cc31 | ||
|
|
47feb9edc4 | ||
|
|
1d1709d3d4 | ||
|
|
824d349c30 | ||
|
|
a7364990bd | ||
|
|
a94c398633 | ||
|
|
76638ad878 | ||
|
|
a83d3bdbe7 | ||
|
|
feb1812f54 | ||
|
|
070e9c454d | ||
|
|
166b5fd73a | ||
|
|
5e2d0f5680 | ||
|
|
0c98333bcb | ||
|
|
2851c8803c | ||
|
|
2867856d40 | ||
|
|
ba6b7d6dcf | ||
|
|
f9c75c2749 | ||
|
|
31a47018eb | ||
|
|
de1332c5e8 | ||
|
|
5983b5c420 | ||
|
|
3eae7efddd | ||
|
|
3ad97b79dd | ||
|
|
5223c2c47b | ||
|
|
7a108e1199 | ||
|
|
f6c761cadf | ||
|
|
dd6f36bd2b | ||
|
|
7c59c1c5b1 | ||
|
|
954a849ba6 | ||
|
|
1d5be80564 | ||
|
|
f10436b900 | ||
|
|
8762424c2d | ||
|
|
61e0904dc9 | ||
|
|
e9265a6c91 | ||
|
|
83b7a236fb | ||
|
|
955cc8746f | ||
|
|
9e72ed679a | ||
|
|
978c0ca2b5 | ||
|
|
a1a0a5e79f | ||
|
|
b4d8022fdf | ||
|
|
54991689b8 | ||
|
|
65e426811b | ||
|
|
ce1192260e | ||
|
|
c67214c3ca | ||
|
|
d0cf94fe51 | ||
|
|
07714438a2 | ||
|
|
09f8621acc | ||
|
|
760acbfca2 | ||
|
|
d37d275f10 | ||
|
|
e4776f9e93 | ||
|
|
c9a1251414 | ||
|
|
8f9f554749 | ||
|
|
028452c2ca | ||
|
|
ffdd0bbafd | ||
|
|
fe40a7c6d4 | ||
|
|
b1a9398ed1 | ||
|
|
6b34a91241 | ||
|
|
966023be17 | ||
|
|
40d572d645 | ||
|
|
3e6d6864b3 | ||
|
|
e86d9a8e88 | ||
|
|
2621e9f7d3 | ||
|
|
65dc8c0891 | ||
|
|
1b5791a358 | ||
|
|
9173d3c578 | ||
|
|
d252d687fc | ||
|
|
ab57b6aa2c | ||
|
|
9fd182c4fd | ||
|
|
9b36fb2c19 | ||
|
|
658d319eb0 | ||
|
|
550a12bdf0 | ||
|
|
59e8ca04d9 | ||
|
|
1656c79c1d | ||
|
|
e138eb9c72 | ||
|
|
357348c24e | ||
|
|
e25fcd61bc | ||
|
|
3f2960e7e6 | ||
|
|
8aa596b31a | ||
|
|
40f23af0bd | ||
|
|
142dfefb70 | ||
|
|
50b860233b | ||
|
|
4c5b506053 | ||
|
|
cbe232ca0c | ||
|
|
6bb6a7ae05 | ||
|
|
9ff7567cde | ||
|
|
406eb5d180 | ||
|
|
acaf674ec5 | ||
|
|
77e53b914d | ||
|
|
8eb3604caf | ||
|
|
8367fd14a8 | ||
|
|
2303e70a8e | ||
|
|
5a4238dabc | ||
|
|
610ed8fca5 | ||
|
|
69ba1c3fad | ||
|
|
0ffc402d67 | ||
|
|
bfe48924c7 | ||
|
|
7ce2ee1f5b | ||
|
|
376864db1b | ||
|
|
9e97a06f70 | ||
|
|
785c931890 | ||
|
|
ca3436be0c | ||
|
|
c565f0bb50 | ||
|
|
35701b1097 | ||
|
|
a9ae14af9a | ||
|
|
5213579a6b | ||
|
|
9d1888a651 | ||
|
|
11dc8514ab | ||
|
|
e887f93427 | ||
|
|
fc13f70813 | ||
|
|
41477980df | ||
|
|
0488b129fc | ||
|
|
531d6acce5 | ||
|
|
5f658f7a1e | ||
|
|
e5f5313156 | ||
|
|
15bde2461e | ||
|
|
44cf1623c5 | ||
|
|
d69331796b | ||
|
|
e8a879b7ce | ||
|
|
70869d4404 | ||
|
|
2bd556c00d | ||
|
|
28864740a0 | ||
|
|
9e6699c549 | ||
|
|
f946e8db21 | ||
|
|
4358882105 | ||
|
|
8606f596f3 | ||
|
|
e6bba7d8a2 | ||
|
|
4ef3aae193 | ||
|
|
8e312c4bcc | ||
|
|
e9911e85a2 | ||
|
|
0e5158704e | ||
|
|
c6a59701be | ||
|
|
a29345bd10 | ||
|
|
c7b215fcca | ||
|
|
cc6f561cfe | ||
|
|
36c63b4657 | ||
|
|
a1a03d6de4 | ||
|
|
b80ce88bc0 | ||
|
|
54a4eb2ac8 | ||
|
|
2b3f036f31 | ||
|
|
91ab321a53 | ||
|
|
c8c0500047 | ||
|
|
9b1ea7cf92 | ||
|
|
3cee1e72f9 | ||
|
|
ad15e1016b | ||
|
|
9847af13a0 | ||
|
|
938c548421 | ||
|
|
b1919669ce | ||
|
|
e551f6219c | ||
|
|
6cfece797d | ||
|
|
4fe405a125 | ||
|
|
b911c9c758 | ||
|
|
aa28f7415a | ||
|
|
615549b433 | ||
|
|
9ec6a57919 | ||
|
|
95f5599c9c | ||
|
|
deb2550db3 | ||
|
|
eaaf3d7b5a | ||
|
|
20dd384636 | ||
|
|
884e73e046 | ||
|
|
12664a4f41 | ||
|
|
2e8ddb8ca4 | ||
|
|
8619433b66 | ||
|
|
96c86cf070 | ||
|
|
588945d2dc | ||
|
|
b36e91044f | ||
|
|
9184f1bcc0 | ||
|
|
d2feddea1e | ||
|
|
739179a152 | ||
|
|
fa59113cd3 | ||
|
|
b4c508ebab | ||
|
|
c546eef6a9 | ||
|
|
4578cb157f | ||
|
|
5ce5684ca6 | ||
|
|
bd46943c63 | ||
|
|
9b0f84bac5 | ||
|
|
f74931633c | ||
|
|
d4b1344f6a | ||
|
|
3279e00aa4 | ||
|
|
c09f190712 | ||
|
|
047bf936b4 | ||
|
|
fa389273ab | ||
|
|
00f7dbb28d | ||
|
|
073dcf9517 | ||
|
|
8b03ca6c63 | ||
|
|
e0553d0a50 | ||
|
|
76f9f58175 | ||
|
|
540603ed7a | ||
|
|
bc38094a41 | ||
|
|
c4fac34e6a | ||
|
|
5ab6b84fe7 | ||
|
|
604fb010d2 | ||
|
|
72b608b158 | ||
|
|
a29715775e | ||
|
|
1627fa50f2 | ||
|
|
3855ca1361 | ||
|
|
9e39f5e155 | ||
|
|
d37ff4a55c | ||
|
|
1918e93421 | ||
|
|
1705130d64 | ||
|
|
38ea454585 | ||
|
|
f74ab6ed77 | ||
|
|
70b2d4fbcd | ||
|
|
97aba174a6 | ||
|
|
c7ef3e6810 | ||
|
|
179326973e | ||
|
|
2fbec35746 | ||
|
|
0bfb7c0509 | ||
|
|
a78e4e171e | ||
|
|
8998fe9241 | ||
|
|
898cd547b6 | ||
|
|
342ea117c8 | ||
|
|
4aeb694131 | ||
|
|
1bdc3bbb42 | ||
|
|
e402b52221 | ||
|
|
a050be734c | ||
|
|
a9533732f3 | ||
|
|
40a59a4d31 | ||
|
|
fc559814d4 | ||
|
|
4fc54f12bc | ||
|
|
0b7febae5d | ||
|
|
9db2fdd49a | ||
|
|
16f765d0af | ||
|
|
7c72912913 | ||
|
|
4f4e5f8e53 | ||
|
|
0ecf992f83 | ||
|
|
43af3a8e75 | ||
|
|
315337d552 | ||
|
|
bcebb72a66 | ||
|
|
83e7701eee | ||
|
|
886dec3ffd | ||
|
|
383f9a43ef | ||
|
|
8dfd5c407d | ||
|
|
459eb02112 | ||
|
|
30722020c8 | ||
|
|
d4ecaee5f2 | ||
|
|
b6995b1857 | ||
|
|
af3c4fe987 | ||
|
|
e94ff23bb9 | ||
|
|
566d557840 | ||
|
|
aa87bee014 | ||
|
|
379337ad60 | ||
|
|
18e8a11d00 | ||
|
|
9ec2b05e8d | ||
|
|
bef3c2f8f0 | ||
|
|
69b584d8b3 | ||
|
|
d5d3e70a48 | ||
|
|
90b6b970ec | ||
|
|
dbcbc6bedb | ||
|
|
4c0e181336 | ||
|
|
3f2c7094d9 | ||
|
|
df825a0a84 | ||
|
|
4d470ce230 | ||
|
|
da17fb63f3 | ||
|
|
416674e7ee | ||
|
|
8f52c67f0f | ||
|
|
798679ca44 | ||
|
|
c8a30a27dc | ||
|
|
2f458ba40e | ||
|
|
9eb03c6a57 | ||
|
|
98c1cbc769 | ||
|
|
05a597adeb | ||
|
|
0649e5cf13 | ||
|
|
210a0a10ae | ||
|
|
e99dbda126 | ||
|
|
9df80d212e | ||
|
|
70362ff7a6 | ||
|
|
5f1d0ed946 | ||
|
|
4df15d6ff2 | ||
|
|
954826f053 | ||
|
|
dca6e82594 | ||
|
|
2a9f2924da | ||
|
|
4433918d4c | ||
|
|
bb719ad844 | ||
|
|
1b5174621d | ||
|
|
ef18f255be | ||
|
|
aac583e33b | ||
|
|
e5e68826fb | ||
|
|
4bd53e4b1a | ||
|
|
a2ea6573bd | ||
|
|
c0655eb9e2 | ||
|
|
b410ab3bcc | ||
|
|
618a768f9a | ||
|
|
cea315048b | ||
|
|
c04cb14eec | ||
|
|
5b4f997f3d | ||
|
|
57ba3e8700 | ||
|
|
8d97c6b38e | ||
|
|
c1c272376f | ||
|
|
a28644548f | ||
|
|
9a08b53bf6 | ||
|
|
bba59bf96a | ||
|
|
caa104bbfb | ||
|
|
1b74c4c520 | ||
|
|
0dcb084a4f | ||
|
|
8b79340180 | ||
|
|
340edaa78a | ||
|
|
2b057a2d50 | ||
|
|
e47ea249e7 | ||
|
|
c1aa12dc8c | ||
|
|
b3847da28e | ||
|
|
2c52f06744 | ||
|
|
aaee730ba5 | ||
|
|
eb81674f06 | ||
|
|
ea2f1f4f6f | ||
|
|
f470357a42 | ||
|
|
2ad25f1aa9 | ||
|
|
af4f03d298 | ||
|
|
4a0653bcfd | ||
|
|
f7a5a1218f | ||
|
|
bf20177756 | ||
|
|
800cacb003 | ||
|
|
2454738eaa | ||
|
|
f1b2465e82 | ||
|
|
32a305c9d8 | ||
|
|
a1eac9959e | ||
|
|
7492c308ad | ||
|
|
d31162a1fa | ||
|
|
92a9a7268c | ||
|
|
76414cb5ba | ||
|
|
6d184e0320 | ||
|
|
30372b99c6 | ||
|
|
18ee0108e5 | ||
|
|
d374ce27cf | ||
|
|
c24013435c | ||
|
|
2007f3a095 | ||
|
|
a0c0cce2ab | ||
|
|
4a2d5c9066 | ||
|
|
9e446e7524 | ||
|
|
43e9641feb | ||
|
|
e446e9011a | ||
|
|
2494121615 | ||
|
|
a3e8dce871 | ||
|
|
ba0b4d518d | ||
|
|
1f2478fc1b | ||
|
|
1a8d28a2d8 | ||
|
|
96f9adb95f | ||
|
|
15162e7860 | ||
|
|
ec0a9296d8 | ||
|
|
86565a0384 | ||
|
|
870efc7fbe | ||
|
|
998db5d26d | ||
|
|
8593747712 | ||
|
|
19969abf63 | ||
|
|
6d7cfd1ca1 | ||
|
|
78f0d1f6c3 | ||
|
|
adbc2b0195 | ||
|
|
461d45b411 | ||
|
|
0d4164c5b1 | ||
|
|
43707a5416 | ||
|
|
c98ab982a2 | ||
|
|
dcce351ad2 | ||
|
|
edce45f71a | ||
|
|
3992b30cc2 | ||
|
|
c7ee42a4d5 | ||
|
|
bb41ea7dcc | ||
|
|
f2eee008f6 | ||
|
|
45ed12ddf0 | ||
|
|
16f93b3e13 | ||
|
|
0ddf498285 | ||
|
|
d808a5c822 | ||
|
|
f2cfb6c17d | ||
|
|
e8b14709e6 | ||
|
|
8585357942 | ||
|
|
e0b25054dc | ||
|
|
f3d21b79ab | ||
|
|
324d7ec1e1 | ||
|
|
e6f36113ce | ||
|
|
eabacbf421 | ||
|
|
375285162b | ||
|
|
cdb1bf0963 | ||
|
|
68ccf63486 | ||
|
|
a9094ea53d | ||
|
|
5736384377 | ||
|
|
fd56559cc8 | ||
|
|
3a70d6d188 | ||
|
|
c498818e8b | ||
|
|
cb352fad47 | ||
|
|
2596253535 | ||
|
|
81651aee97 | ||
|
|
80c42637df | ||
|
|
516361345e | ||
|
|
7f3b24a650 | ||
|
|
d443945b5c | ||
|
|
661d66cafb | ||
|
|
3d8fdd70b3 | ||
|
|
9fcd2b7908 | ||
|
|
7e47730c8b | ||
|
|
f368734241 | ||
|
|
28b3084186 | ||
|
|
3375276f0d | ||
|
|
0a77a0110d | ||
|
|
78b5986027 | ||
|
|
26abe9275c | ||
|
|
a0491216b9 | ||
|
|
e5f669ccb6 | ||
|
|
224fef683d | ||
|
|
14e01c9007 | ||
|
|
bb206da804 | ||
|
|
d01391325e | ||
|
|
7fa18e59e5 | ||
|
|
6c1a97be1b | ||
|
|
996d46cad7 | ||
|
|
8cde0cd852 | ||
|
|
287aae8bd2 | ||
|
|
ff23f981e2 | ||
|
|
05bfa11d71 | ||
|
|
81b9833a81 | ||
|
|
57b736b1df | ||
|
|
d0f6be6826 | ||
|
|
723994322b | ||
|
|
d8fa3342cf | ||
|
|
a5e258f7fa | ||
|
|
8178232911 | ||
|
|
1a173153c8 | ||
|
|
adbbaf93c6 | ||
|
|
fe64f15949 | ||
|
|
1a8b6a23dd | ||
|
|
286a598c77 | ||
|
|
0c1d1354cb | ||
|
|
4785ca1acc | ||
|
|
8b3b8dccf4 | ||
|
|
acd69b1d4d | ||
|
|
2b69f2e6ac | ||
|
|
ef8e3451d6 | ||
|
|
b4a59cf135 | ||
|
|
5ffc5fe341 | ||
|
|
b310cb3c80 | ||
|
|
08ff5199e3 | ||
|
|
70e46139cc | ||
|
|
e168da4f72 | ||
|
|
0658368aad | ||
|
|
1659802ff3 | ||
|
|
35afd50f3d | ||
|
|
dee9959fda | ||
|
|
ed8ce8c16c | ||
|
|
7df1b8cc0f | ||
|
|
e88ead392b | ||
|
|
f03746f5ad | ||
|
|
84d379d490 | ||
|
|
9738a8a826 | ||
|
|
19fa91d319 | ||
|
|
1fc4852e77 | ||
|
|
a2a38f1e14 | ||
|
|
fb329cd76e | ||
|
|
3ccb165522 | ||
|
|
a026e2c2bb | ||
|
|
d8ee237b1d | ||
|
|
50037f6cb7 | ||
|
|
05486e53e0 | ||
|
|
9fa42df385 | ||
|
|
c2ab4ac308 | ||
|
|
4c66b25e7c | ||
|
|
b1570d4632 | ||
|
|
0258bc7949 | ||
|
|
199dc6f59e | ||
|
|
726204763d | ||
|
|
10eb23d760 | ||
|
|
b785a66ae8 | ||
|
|
d16969837c | ||
|
|
a5ec46f14c | ||
|
|
660ef72532 | ||
|
|
3c1ba226c4 | ||
|
|
2a0545210b | ||
|
|
0a06e41497 | ||
|
|
dfe829d59e | ||
|
|
64280bd960 | ||
|
|
a97da15359 | ||
|
|
c9e8df7f5b | ||
|
|
1b1955f052 | ||
|
|
b1bd39c0fc | ||
|
|
1235bc14db | ||
|
|
5cbfe4cda5 | ||
|
|
4f38b77107 | ||
|
|
634fafc880 | ||
|
|
671b975a68 | ||
|
|
f51541ffe3 | ||
|
|
034a024cb2 | ||
|
|
766765b1f8 | ||
|
|
e7f5f7055d | ||
|
|
fd06d86062 | ||
|
|
a8ed6b5b44 | ||
|
|
096efb8fa4 | ||
|
|
ec6fc5236b | ||
|
|
fb13272e6c | ||
|
|
7eca06a097 | ||
|
|
32f353af8f | ||
|
|
739e0eb316 | ||
|
|
13624a7a97 | ||
|
|
a2760da676 | ||
|
|
9873e40076 | ||
|
|
a2c096a6e6 | ||
|
|
865eb0112d | ||
|
|
6ed7061bce | ||
|
|
7636748289 | ||
|
|
fdd9609557 | ||
|
|
40c3daf563 | ||
|
|
baf6a8e418 | ||
|
|
49551a8a70 | ||
|
|
4839703389 | ||
|
|
564eb4a85f | ||
|
|
6fe7df2233 | ||
|
|
027a6a79bc | ||
|
|
07f5d2e0d5 | ||
|
|
08054e4969 | ||
|
|
4e154644ee | ||
|
|
e0f9623670 | ||
|
|
37b200295c | ||
|
|
88022667ce | ||
|
|
c927a87db3 | ||
|
|
157f418855 | ||
|
|
42b9986fc8 | ||
|
|
605015f812 | ||
|
|
cc1eec93d5 | ||
|
|
32354483fc | ||
|
|
46d4ca6b0f | ||
|
|
ddac098704 | ||
|
|
6d50546eba | ||
|
|
14d29a54e1 | ||
|
|
be8de45714 | ||
|
|
0a6e3237b1 | ||
|
|
008e6ba2e6 | ||
|
|
46b804834a | ||
|
|
540e24e8f9 | ||
|
|
4c98063d6a | ||
|
|
72b4029ed3 | ||
|
|
13dcd373f2 | ||
|
|
65e8929689 | ||
|
|
31a748b0b3 | ||
|
|
38baea3dcf | ||
|
|
ad0e958736 | ||
|
|
c1600b90a6 | ||
|
|
72d999e759 | ||
|
|
a7bbb45fc2 | ||
|
|
34bbb281e0 | ||
|
|
4b61657602 | ||
|
|
db7278664f | ||
|
|
cd4fa81260 | ||
|
|
68fbbfa8d5 | ||
|
|
a5c0a48d2b | ||
|
|
89f08d4217 | ||
|
|
ba881ec2e1 | ||
|
|
717255bf50 | ||
|
|
090883cb61 | ||
|
|
b102c41008 | ||
|
|
a0d0eedbb3 | ||
|
|
16abb636c5 | ||
|
|
a7e6f19ae6 | ||
|
|
18f70738d0 | ||
|
|
d3bdf4cf03 | ||
|
|
5e425cd29c | ||
|
|
048b02db1d | ||
|
|
8930418d53 | ||
|
|
8ef737b16c | ||
|
|
01344256a9 | ||
|
|
5940919fae | ||
|
|
c4ccd82f63 | ||
|
|
a669131178 | ||
|
|
1a859cf341 | ||
|
|
067e0dba91 | ||
|
|
d5413cc751 | ||
|
|
cd7cdbcf72 | ||
|
|
ae15c5ebe6 | ||
|
|
a7b6778f64 | ||
|
|
7558346071 | ||
|
|
f986caf18b | ||
|
|
ba7ed40d6a | ||
|
|
4435679737 | ||
|
|
34aaa45c92 | ||
|
|
a81d289bc8 | ||
|
|
db8574ffe3 | ||
|
|
b72244398b | ||
|
|
01bc519b86 | ||
|
|
292b4f5483 | ||
|
|
6212b95a4d | ||
|
|
6916b0612f | ||
|
|
1f40a5bf8b | ||
|
|
b370b3bab2 | ||
|
|
9f375c6235 | ||
|
|
c9b5e3374c | ||
|
|
31aef061fb | ||
|
|
f7742d47ff | ||
|
|
724b80e91e | ||
|
|
82cd0aefdf | ||
|
|
e15a3c682f | ||
|
|
852e6ab5a0 | ||
|
|
20bf16abbf | ||
|
|
5fc603682d | ||
|
|
977fc1ce96 | ||
|
|
f547fd258d | ||
|
|
b0cece3f5f | ||
|
|
53601b77c8 | ||
|
|
0cb220acff | ||
|
|
aa2996f92e | ||
|
|
69662eeafc | ||
|
|
4387e71417 | ||
|
|
5c0480b39d | ||
|
|
e1f4787fb9 | ||
|
|
de70b21e55 | ||
|
|
d11d638144 | ||
|
|
02ef7e09e4 | ||
|
|
f010a12ded | ||
|
|
e50f7094f8 | ||
|
|
ac3047f124 | ||
|
|
294037ec70 | ||
|
|
5a506ef557 | ||
|
|
da3af2c22f | ||
|
|
1ef71b7a59 | ||
|
|
51d2b82acf | ||
|
|
5f42e35231 | ||
|
|
e2af9893ce | ||
|
|
cb443a2d15 | ||
|
|
a7f2cc5d2b | ||
|
|
905185812b | ||
|
|
5baa06d8c3 | ||
|
|
cbd430841c | ||
|
|
bfe32d8fc8 | ||
|
|
a2509df38b | ||
|
|
a0a4dc2cfa | ||
|
|
3fe4eae4c0 | ||
|
|
783611350c | ||
|
|
18daff762f | ||
|
|
662aed13ad | ||
|
|
55748a24b1 | ||
|
|
61128fd054 | ||
|
|
febe66089c | ||
|
|
aae512469f | ||
|
|
b75f321094 | ||
|
|
613394c342 | ||
|
|
ca1f935b18 | ||
|
|
5083dbf543 | ||
|
|
66bbb92c1f | ||
|
|
2ee720485f | ||
|
|
3f9d9c5d65 | ||
|
|
5b7a19ff2c | ||
|
|
3646a44f93 | ||
|
|
e7335b514a | ||
|
|
89dd4f8d08 | ||
|
|
e2fd2fe78b | ||
|
|
49aeede0f6 | ||
|
|
6511c358c8 | ||
|
|
4b5156f168 | ||
|
|
9b3ca4644a | ||
|
|
6edc4645d2 | ||
|
|
90710cc8b9 | ||
|
|
7bfb183578 | ||
|
|
906ec3885a | ||
|
|
f649fa57e6 | ||
|
|
b4208f2dda | ||
|
|
1d36f7d12b | ||
|
|
631ef2b10a | ||
|
|
bae1df2e8d | ||
|
|
0b92d43871 | ||
|
|
9b7f1e6e88 | ||
|
|
c0447cdcd2 | ||
|
|
3be937c503 | ||
|
|
bc4e83d76a | ||
|
|
20411e6f81 | ||
|
|
56298ed57f | ||
|
|
ec8dcf5960 | ||
|
|
294a968c9f | ||
|
|
78717b5eea | ||
|
|
e86eb6a4b8 | ||
|
|
a8de369edf | ||
|
|
3b140df508 | ||
|
|
cef8985d38 | ||
|
|
4fdfc49429 | ||
|
|
bfbab7ae9e | ||
|
|
e091bd6fb0 | ||
|
|
4a82172e07 | ||
|
|
d9e65f7c3a | ||
|
|
42841896d1 | ||
|
|
7f00b77db3 | ||
|
|
d6887d7b46 | ||
|
|
f22911b4a0 | ||
|
|
4adb209894 | ||
|
|
fa55f1d03b | ||
|
|
f511771fd4 | ||
|
|
5bd6ab7611 | ||
|
|
f5643173a8 | ||
|
|
8f3ebd182c | ||
|
|
8235d8390d | ||
|
|
878441406d | ||
|
|
9b0868c255 | ||
|
|
21615ad35c | ||
|
|
de38bc1557 | ||
|
|
2d1411e785 | ||
|
|
abe44d02fb | ||
|
|
b844c1f8d9 | ||
|
|
5952b2a34a | ||
|
|
b373cfed96 | ||
|
|
4e03636588 | ||
|
|
651174bcb8 | ||
|
|
b1f6f1ea99 | ||
|
|
e91860cadf | ||
|
|
6375cf7ae8 | ||
|
|
2a82c08d8b | ||
|
|
0a89849157 | ||
|
|
adaf44bc2b | ||
|
|
8b5d767d3c | ||
|
|
767bc9ef12 | ||
|
|
dfc0af21ee | ||
|
|
cfd67dce33 | ||
|
|
0241c51f6f | ||
|
|
958020b19b | ||
|
|
34d66a3d96 | ||
|
|
0689565ded | ||
|
|
8fcbdd0666 | ||
|
|
c7d251b206 | ||
|
|
f3ff44203c | ||
|
|
ee2311025c | ||
|
|
b8e40b166d | ||
|
|
d7dd0274fa | ||
|
|
b67e9a8130 | ||
|
|
794d113ce9 | ||
|
|
d8336989a8 | ||
|
|
64acf3047f | ||
|
|
f137dfa978 | ||
|
|
cb25e5e6d8 | ||
|
|
acdeaf19cf | ||
|
|
d30b907a8a | ||
|
|
76fbe00361 | ||
|
|
6a0c48e3d6 | ||
|
|
a0e016a9e5 | ||
|
|
15adae088c | ||
|
|
0e3ca147a2 | ||
|
|
89ca64a7a0 | ||
|
|
7f71996e02 | ||
|
|
530b5a1c00 | ||
|
|
2e2bf46553 | ||
|
|
195f5c12c4 | ||
|
|
f6be8e3546 | ||
|
|
c452c5b528 | ||
|
|
924cdc5d49 | ||
|
|
c77bceefa1 | ||
|
|
ab5c2bf384 | ||
|
|
28b08a7138 | ||
|
|
601a38aec2 | ||
|
|
2e5bf618dc | ||
|
|
c0fbf806de | ||
|
|
be73d1b48f | ||
|
|
6e98b55afb | ||
|
|
d180e05117 | ||
|
|
625e16d215 | ||
|
|
d2deb46110 | ||
|
|
75b8d191ff | ||
|
|
f87c292b74 | ||
|
|
a01cc55591 | ||
|
|
f32ba909b7 | ||
|
|
936852cafb | ||
|
|
d6005f9543 | ||
|
|
5418abd820 | ||
|
|
90bff5fd0b | ||
|
|
b68c9ff64f | ||
|
|
9b9234929b | ||
|
|
6d9f2e8b8c | ||
|
|
2a1672544f | ||
|
|
8f622ba5c9 | ||
|
|
e42f6b2cfa | ||
|
|
9200d7becd | ||
|
|
a70454cf1f | ||
|
|
07db1943fb | ||
|
|
3d35f6507a | ||
|
|
7a650eb1e4 | ||
|
|
d7c765c972 | ||
|
|
173261a69f | ||
|
|
5c5608680b | ||
|
|
09eea443cf | ||
|
|
d471720541 | ||
|
|
17270000eb | ||
|
|
154cc97603 | ||
|
|
3b6f243940 | ||
|
|
0a149ed440 | ||
|
|
595b86df6c | ||
|
|
770d17b42a | ||
|
|
8ad066409c | ||
|
|
4ac8949c3a | ||
|
|
83b2cf48d4 | ||
|
|
2bbb117eac | ||
|
|
f61112a8d7 | ||
|
|
3566b030c5 | ||
|
|
2d54fe4ed7 | ||
|
|
7fdd2cc7c9 | ||
|
|
81a85cbbe5 | ||
|
|
4902436b6b | ||
|
|
b82bcb0af9 | ||
|
|
eeea5d004a | ||
|
|
97ee0a9f85 | ||
|
|
958f92fd63 | ||
|
|
dfef268b05 | ||
|
|
e7d2f09eb4 | ||
|
|
5bb9012655 | ||
|
|
a291b2cd6f | ||
|
|
ead077fb92 | ||
|
|
1c9d6ac865 | ||
|
|
d098ee9dff | ||
|
|
b8d95dd222 | ||
|
|
7c93db95a3 | ||
|
|
d529634b7f | ||
|
|
765b5603c1 | ||
|
|
eec39a3fc5 | ||
|
|
72f66530aa | ||
|
|
99ee1cfc7e | ||
|
|
f8e82b63e3 | ||
|
|
afdf06b3f6 | ||
|
|
d21a86587f | ||
|
|
38071165d1 | ||
|
|
1cfc152d3b | ||
|
|
2db2f61992 | ||
|
|
4543f6ca39 | ||
|
|
f8d518300d | ||
|
|
347e214944 | ||
|
|
99b4d8e084 | ||
|
|
313cab6b2d | ||
|
|
494559cfd7 | ||
|
|
e3326aa0f1 | ||
|
|
bdd3ab4360 | ||
|
|
4f9ec2e8a4 | ||
|
|
14fd30c4f4 | ||
|
|
a7103b5b35 | ||
|
|
f6ce676e7e | ||
|
|
c2fbf7603a | ||
|
|
c3a4ea239c | ||
|
|
e2708933d3 | ||
|
|
cb2d9d4b07 | ||
|
|
1ba70226b8 | ||
|
|
d08710684d | ||
|
|
625e4cf9ee | ||
|
|
c8b310ebdb | ||
|
|
d971dd6700 | ||
|
|
e20863a7e1 | ||
|
|
8f2a87e5ed | ||
|
|
ae88360e20 | ||
|
|
7d97c2a27b | ||
|
|
02b7d55c2d | ||
|
|
55b5893cce | ||
|
|
1018e1c29c | ||
|
|
e5a4161e76 | ||
|
|
a3f437e482 | ||
|
|
9fcbbdc472 | ||
|
|
7aac597216 | ||
|
|
95b3c66366 | ||
|
|
3b354de2fc | ||
|
|
411392eb76 | ||
|
|
15c3e4edec | ||
|
|
fa0572ae44 | ||
|
|
ade75ace49 | ||
|
|
56539bb369 | ||
|
|
1c63bf0beb | ||
|
|
b10949d8cd | ||
|
|
853cc3ff6e | ||
|
|
a0cc6eb997 | ||
|
|
8b18e6f86d | ||
|
|
68e4d98bc5 | ||
|
|
390bf7a657 | ||
|
|
deb5c02ce6 | ||
|
|
004c5cd383 | ||
|
|
7b4254da58 | ||
|
|
d4903f04f1 | ||
|
|
f2b544ae68 | ||
|
|
ec91295677 | ||
|
|
4943fbd776 | ||
|
|
2478df8c0d | ||
|
|
85a178d90e | ||
|
|
a48c0fb2b4 | ||
|
|
3c944cbd72 | ||
|
|
727db52c19 | ||
|
|
80d534a53f | ||
|
|
fe2d08c395 | ||
|
|
97e2e1c16e | ||
|
|
a32b63f932 | ||
|
|
e0421c1e57 | ||
|
|
f457f7f5d7 | ||
|
|
3ac2d937d7 | ||
|
|
45eca10859 | ||
|
|
38aa8fa03a | ||
|
|
11036b113b | ||
|
|
f5893676eb | ||
|
|
d7b5b1eedb | ||
|
|
e44ec8720d | ||
|
|
6b592053f1 | ||
|
|
ef28fa026e | ||
|
|
e1a86f3be0 | ||
|
|
7f5656df08 | ||
|
|
a47e6e8998 | ||
|
|
b6fe0466ca | ||
|
|
9ea4ee3449 | ||
|
|
d9a6127c35 | ||
|
|
3ad003140f | ||
|
|
d7152485bb | ||
|
|
0f17dbc15d | ||
|
|
e1cc4bbdf0 | ||
|
|
a325d2c2cd | ||
|
|
da1ae4c270 | ||
|
|
9cc79d9fa5 | ||
|
|
a09f11d110 | ||
|
|
6e93e6d777 | ||
|
|
b05bd78e20 | ||
|
|
5a27e1a03b | ||
|
|
0f3628f2a4 | ||
|
|
b3fcf4d1c2 | ||
|
|
01a9afdd9d | ||
|
|
3ad1ebdb7b | ||
|
|
903d567e3c | ||
|
|
6a4bf7129d | ||
|
|
e02c014890 | ||
|
|
beb916d521 | ||
|
|
f3856b5db5 | ||
|
|
8af2942097 | ||
|
|
dcfdf299e3 | ||
|
|
ca139bab54 | ||
|
|
80b63d3d24 | ||
|
|
c550f2395f | ||
|
|
f7040ecc8f | ||
|
|
8bd0fe0662 | ||
|
|
e5cb738252 | ||
|
|
c016060553 | ||
|
|
9e59be7d65 | ||
|
|
5e2fc9155c | ||
|
|
543499560d | ||
|
|
cff7964831 | ||
|
|
9ad8a7f420 | ||
|
|
2fab6cd6ae | ||
|
|
ef192dcaee | ||
|
|
9d817c71e3 | ||
|
|
0bee59d7c3 | ||
|
|
76cd2a6786 | ||
|
|
04bba2e135 | ||
|
|
dab25f6789 | ||
|
|
c725451206 | ||
|
|
617c7900ff | ||
|
|
e8dea0d69d | ||
|
|
0fe71ec86f | ||
|
|
5ac69c5051 | ||
|
|
0518aa8650 | ||
|
|
7ffecbb318 | ||
|
|
e0a92c6455 | ||
|
|
3af5fccd61 | ||
|
|
fbadb05037 | ||
|
|
416daa868b | ||
|
|
34ccd76b0c | ||
|
|
db14b3f4ef | ||
|
|
ca0823c460 | ||
|
|
33d9ab4b86 | ||
|
|
ceed91b6d7 | ||
|
|
aa8409b0be | ||
|
|
5f45c31240 | ||
|
|
71b4a0416f | ||
|
|
00bb8a486d | ||
|
|
3a0a3c5325 | ||
|
|
1e839f731a | ||
|
|
97ae05b864 | ||
|
|
48a8a45140 | ||
|
|
88da170bb0 | ||
|
|
ec6f16c229 | ||
|
|
db4e3dabb7 | ||
|
|
b2a72da219 | ||
|
|
cf0a69a702 | ||
|
|
572d6bd9ea | ||
|
|
574339f935 | ||
|
|
baa8c871b0 | ||
|
|
b62bdb016a | ||
|
|
63c6ccfee9 | ||
|
|
db24385f40 | ||
|
|
c5a6ae3035 | ||
|
|
c8b0f9e6ce | ||
|
|
bd59fa8ef3 | ||
|
|
503ae701ae | ||
|
|
a7089b26e7 | ||
|
|
1b26acaaae | ||
|
|
dcd7e31738 | ||
|
|
c4dd380218 | ||
|
|
ad05ba1ee8 | ||
|
|
a175162186 | ||
|
|
b40bc8c20d | ||
|
|
44b02cfb4e | ||
|
|
9de4ad5cb3 | ||
|
|
482c9e5905 | ||
|
|
eba1626f2e | ||
|
|
f5f6671d48 | ||
|
|
868bbed290 | ||
|
|
10846d481c | ||
|
|
757c1f8c45 | ||
|
|
d8f164ffc1 | ||
|
|
324cbe9efc | ||
|
|
e4ea44aa5b | ||
|
|
122e1a4677 | ||
|
|
933d8ebfe7 | ||
|
|
8f4e214c52 | ||
|
|
e346587111 | ||
|
|
4dfb35a57b | ||
|
|
d16666c0f8 | ||
|
|
4d37f53a04 | ||
|
|
e3ed5fbc58 | ||
|
|
2e7d4277e1 | ||
|
|
5932ac3c7c | ||
|
|
5d32d7922f | ||
|
|
a800685947 | ||
|
|
7aca5a54dc | ||
|
|
e1cd2ceb1d | ||
|
|
c46cca519a | ||
|
|
da41fb5738 | ||
|
|
bd25a8d601 | ||
|
|
c13b13268b | ||
|
|
10cfbc6e45 | ||
|
|
6c99732673 | ||
|
|
3c4085eb0b | ||
|
|
443dd9f18f | ||
|
|
95c0fb8a70 | ||
|
|
a04b2542b5 | ||
|
|
49355f5db1 | ||
|
|
41e0e65a6b | ||
|
|
f714d809f8 | ||
|
|
cd39f7b2c6 | ||
|
|
c4c8390ead | ||
|
|
02311f190b | ||
|
|
085c99272e | ||
|
|
5fd1666a5d | ||
|
|
c0eb10521d | ||
|
|
bc371f1ef3 | ||
|
|
0486eb76c0 | ||
|
|
3b5c9950de | ||
|
|
dd352faa31 | ||
|
|
4f69dd8d32 | ||
|
|
d0741fde6e | ||
|
|
d485e686d9 | ||
|
|
ae37a551e1 | ||
|
|
afb2f9ec00 | ||
|
|
21d5d9d47e | ||
|
|
20c93925a8 | ||
|
|
e5ae41901c | ||
|
|
d8b68136ef | ||
|
|
5319ea8771 | ||
|
|
43fcf5ee3b | ||
|
|
46a38753a9 | ||
|
|
32372e8e86 | ||
|
|
c1edea4dc3 | ||
|
|
86e1f57198 | ||
|
|
fab814c46f | ||
|
|
4a1f654798 | ||
|
|
895a113478 | ||
|
|
37a7dd28d6 | ||
|
|
e5cc140d42 | ||
|
|
59a5cf2df5 | ||
|
|
d6b1adf613 | ||
|
|
562b0ceffe | ||
|
|
6bbe2307e9 | ||
|
|
aae546a08e | ||
|
|
2f2141f524 | ||
|
|
aee59626ee | ||
|
|
825801b867 | ||
|
|
447f3e2d5b | ||
|
|
ff846f4478 | ||
|
|
c794de680b | ||
|
|
034e5382ec | ||
|
|
e06ac1689c | ||
|
|
4de802c68d | ||
|
|
33aedb233d | ||
|
|
95bd1a50d9 | ||
|
|
76675ad76b | ||
|
|
ac9b2207bf | ||
|
|
d7c83f58b9 | ||
|
|
d17bd5580e | ||
|
|
94568c851a | ||
|
|
29bfc91683 | ||
|
|
cb5fa4d6e7 | ||
|
|
fc7739be2c | ||
|
|
5a12b9e6c4 | ||
|
|
4e83adc032 | ||
|
|
187202d363 | ||
|
|
545968a71f | ||
|
|
f5aee035b3 | ||
|
|
1389cf450c | ||
|
|
24e3b1505b | ||
|
|
347f8e5a22 | ||
|
|
0614e6b28b | ||
|
|
823c06d3ea | ||
|
|
3049d17f5e | ||
|
|
e7818d7fb4 | ||
|
|
7db6c6bba4 | ||
|
|
b87401a0c8 | ||
|
|
13b6ab04bb | ||
|
|
148ece162c | ||
|
|
ce2bb3abad | ||
|
|
cfddf0ada3 | ||
|
|
54304cf3e0 | ||
|
|
f1597622ea | ||
|
|
8c44b08682 | ||
|
|
6fa6203ce0 | ||
|
|
75c118c483 | ||
|
|
4238dbd412 | ||
|
|
9a54ada0ec | ||
|
|
ce8ae30311 | ||
|
|
2f77a84ec5 | ||
|
|
f83605c35f | ||
|
|
4c4bddeed6 | ||
|
|
4523849641 | ||
|
|
f49c60d7f6 | ||
|
|
2452fceeda | ||
|
|
fb2f71e1dc | ||
|
|
52437d4e2e | ||
|
|
72a95ecfca | ||
|
|
9e95b69c11 | ||
|
|
231c2a3a1e | ||
|
|
7dbea9f042 | ||
|
|
9dfb95a524 | ||
|
|
e9161610c4 | ||
|
|
f4792ac537 | ||
|
|
588b03cf34 | ||
|
|
94bf83c611 | ||
|
|
4bb35953b1 | ||
|
|
c6f3f61ff6 | ||
|
|
d64463235c | ||
|
|
dcc34570d5 | ||
|
|
464c85295a | ||
|
|
95bdababb3 | ||
|
|
a33a4bd894 | ||
|
|
c0719fdeaa | ||
|
|
3612c1747e | ||
|
|
a30b1bbf71 | ||
|
|
a0ace027d3 | ||
|
|
8dbd22f56c | ||
|
|
c2994a27fd | ||
|
|
9cb9f8a4f6 | ||
|
|
4d2833de88 | ||
|
|
adcbf7455e | ||
|
|
df8ef542dd | ||
|
|
c11e5c1f99 | ||
|
|
63cfb5eac0 | ||
|
|
6861524641 | ||
|
|
032d8bf67b | ||
|
|
47797f1fb1 | ||
|
|
92764465e0 | ||
|
|
04e108c31e | ||
|
|
aead579f0b | ||
|
|
f9089319d3 | ||
|
|
ff2f7caee1 | ||
|
|
da81df535a | ||
|
|
7078ed3ac3 | ||
|
|
da6b4b210f | ||
|
|
04f659bc2b | ||
|
|
8ef74deec1 | ||
|
|
7e20a09499 | ||
|
|
dea5a52c86 | ||
|
|
96b5b4ea5b | ||
|
|
aec346e2d4 | ||
|
|
b41e4b50d9 | ||
|
|
3c095544d0 | ||
|
|
77dcb85912 | ||
|
|
3ed73c4145 | ||
|
|
c37f589765 | ||
|
|
7ff92bc7c1 | ||
|
|
3839dfbf90 | ||
|
|
0ff4f40652 | ||
|
|
2797792322 | ||
|
|
3ce653ba74 | ||
|
|
0918b8b676 | ||
|
|
63204575eb | ||
|
|
fb3b0be50e | ||
|
|
0c08738a66 | ||
|
|
21cf5b57c1 | ||
|
|
b5f47b1b73 | ||
|
|
05c140da78 | ||
|
|
8225e4098b | ||
|
|
90aeb6e1a7 | ||
|
|
12e364969b | ||
|
|
ca58c70206 | ||
|
|
551996ca14 | ||
|
|
bb79d564a8 | ||
|
|
878872799e | ||
|
|
aa7b8a0fc0 | ||
|
|
14810b2cc5 | ||
|
|
5017a9ba7e | ||
|
|
a040c7dd2e | ||
|
|
912ebbc409 | ||
|
|
e1fe63ab19 | ||
|
|
509f03ce65 | ||
|
|
64a897b52f | ||
|
|
2f66db5989 | ||
|
|
033f40c263 | ||
|
|
a78fe8ceb9 | ||
|
|
c6a973f7e1 | ||
|
|
d7647520c8 | ||
|
|
70f491fd27 | ||
|
|
f07f2803f8 | ||
|
|
4364ce5d6f | ||
|
|
7c3d738756 | ||
|
|
ede010c25d | ||
|
|
db09b8eb84 | ||
|
|
a0cd155730 | ||
|
|
b7814d9541 | ||
|
|
912b917a47 | ||
|
|
c0112828eb | ||
|
|
b3237b0c49 | ||
|
|
b22ef5ae83 | ||
|
|
8d6661511a | ||
|
|
607c534174 | ||
|
|
3b213889ca | ||
|
|
36dc51ef4a | ||
|
|
663cbd91f5 | ||
|
|
82fe0e7bbf | ||
|
|
324815d58d | ||
|
|
a67e3af172 | ||
|
|
0cd23f7883 | ||
|
|
1b296fcae5 | ||
|
|
84d7030f7d | ||
|
|
2fddc276de | ||
|
|
a92ef0a8a1 | ||
|
|
99eee9c758 | ||
|
|
56ff354021 | ||
|
|
ac4d5c8c88 | ||
|
|
c5fa76dab0 | ||
|
|
33bf3304a1 | ||
|
|
53d2d18b89 | ||
|
|
fa23a00014 | ||
|
|
81b24c6cb3 | ||
|
|
60a33a6492 | ||
|
|
9acb7698ef | ||
|
|
9e6ded6544 | ||
|
|
ff5f98558d | ||
|
|
a088b1b0b5 | ||
|
|
29adccb6d1 | ||
|
|
c6d39fcba3 | ||
|
|
fe2acddb5b | ||
|
|
3dde8c05ad | ||
|
|
f49da74c3a | ||
|
|
53babc1113 | ||
|
|
09f8302e74 | ||
|
|
665bfd64d2 | ||
|
|
cf5360f6f6 | ||
|
|
f1edfcebc0 | ||
|
|
ef9860b6cc | ||
|
|
4f920f011f | ||
|
|
b613ac4b89 | ||
|
|
e8dca43f44 | ||
|
|
46b60f9d24 | ||
|
|
a02942b7e0 | ||
|
|
693cf4250a | ||
|
|
ee9f6454e0 | ||
|
|
c5d99e00d8 | ||
|
|
7be0616d38 | ||
|
|
ea527f9598 | ||
|
|
8fcea988ca | ||
|
|
6b8b98c15b | ||
|
|
17798df342 | ||
|
|
2f2b8dc983 | ||
|
|
6e763b8453 | ||
|
|
09a9219fcd | ||
|
|
c8406b45d4 | ||
|
|
14a67b99ba | ||
|
|
7461acdd1f | ||
|
|
88a8e85b12 | ||
|
|
5a1656b8d0 | ||
|
|
8fad3a15cd | ||
|
|
ce4c2142e2 | ||
|
|
6f9c01c375 | ||
|
|
5e255a07f6 | ||
|
|
5314fda342 | ||
|
|
dfc6f6fd6e | ||
|
|
05a08b4c05 | ||
|
|
07d7282383 | ||
|
|
01bed3e307 | ||
|
|
aabf70d458 | ||
|
|
6d2558a921 | ||
|
|
28995ffdd6 | ||
|
|
e4d0b57f3c | ||
|
|
0054f362a7 | ||
|
|
12bcdf2d47 | ||
|
|
e709fc9ce3 | ||
|
|
e0b490fdc0 | ||
|
|
7964f5979a | ||
|
|
6ebab36877 | ||
|
|
afb699f8d3 | ||
|
|
d772f157eb | ||
|
|
1b81ff4d3b | ||
|
|
8c5d997c6e | ||
|
|
c065519cca | ||
|
|
df79159e2e | ||
|
|
1064885a2c | ||
|
|
60362abef1 | ||
|
|
d7d9131de8 | ||
|
|
c44cc8082c | ||
|
|
7a4335b8bc | ||
|
|
8e3930d092 | ||
|
|
5cbdaae5b3 | ||
|
|
c7aecd32be | ||
|
|
4820d11ce3 | ||
|
|
fc8cd3cfb8 | ||
|
|
fc9b8c2a5a | ||
|
|
9ec91fc52d | ||
|
|
2ae4a2ed5a | ||
|
|
dfa5d0c5a7 | ||
|
|
fc3a481e6f | ||
|
|
5ab8c2f0f1 | ||
|
|
5a0aa636f3 | ||
|
|
fb1d33d27a | ||
|
|
8d8577a941 | ||
|
|
70ac8fa6ab | ||
|
|
7088ffd321 | ||
|
|
e175e489e8 | ||
|
|
7efdf5cfef | ||
|
|
5fb01f01bf | ||
|
|
333dd60b32 | ||
|
|
4433c32afc | ||
|
|
d5190b0d76 | ||
|
|
58e1b924ca | ||
|
|
ac7714b997 | ||
|
|
778a602aa6 | ||
|
|
fd0b203f1e | ||
|
|
b28732ee74 | ||
|
|
d8f33a4111 | ||
|
|
396a620cf4 | ||
|
|
f7b3f4573d | ||
|
|
9ead6c1481 | ||
|
|
55dc6460d2 | ||
|
|
3aa12be544 | ||
|
|
35e1659b77 | ||
|
|
2a9e52d36b | ||
|
|
3f48905331 | ||
|
|
cf307e25d0 | ||
|
|
4046c00a01 | ||
|
|
4226e945e6 | ||
|
|
f93a2d8717 | ||
|
|
2910fcc1a4 | ||
|
|
8ff61b4517 | ||
|
|
4944463f56 | ||
|
|
b3bd7ac615 | ||
|
|
64a180ba8f | ||
|
|
5a2ce15f96 | ||
|
|
f6f4fe4fc6 | ||
|
|
a17260a4ee | ||
|
|
4019e82f4a | ||
|
|
79230c1b0e | ||
|
|
da3175e7bd | ||
|
|
d654113204 | ||
|
|
6e3d32a9d5 | ||
|
|
e1d6c4f5f5 | ||
|
|
085f0b49c6 | ||
|
|
5fe3b0b459 | ||
|
|
3efda30b98 | ||
|
|
683b855584 | ||
|
|
9c10e190bc | ||
|
|
19308b645b | ||
|
|
c46430c663 | ||
|
|
d976269f1a | ||
|
|
c8e93a9f52 | ||
|
|
d32bb30071 | ||
|
|
d5263acdf8 | ||
|
|
8872ad33ad | ||
|
|
7e29a621c3 | ||
|
|
dfb216a8df | ||
|
|
f75bdec756 | ||
|
|
0082870864 | ||
|
|
d0e1a95d9c | ||
|
|
f69fb47d69 | ||
|
|
4f52f82a15 | ||
|
|
7c07ac22ad | ||
|
|
afb87c525d | ||
|
|
9b0ce41fd7 | ||
|
|
5f7c0a3b24 | ||
|
|
f7d90f2f53 | ||
|
|
43aab057c8 | ||
|
|
bfcdd642fd | ||
|
|
a8537659e2 | ||
|
|
9620ceb842 | ||
|
|
5ef15e91d4 | ||
|
|
2358ed1b24 | ||
|
|
af8d8c330d | ||
|
|
14f643592c | ||
|
|
8c5cdb630e | ||
|
|
b18903b59b | ||
|
|
42f96618e2 | ||
|
|
0c0d3e1715 | ||
|
|
2b06bca015 | ||
|
|
78630b3071 | ||
|
|
15063d92cd | ||
|
|
15821fe796 | ||
|
|
7d558ad7a2 | ||
|
|
4242898e5d | ||
|
|
d24f024cca | ||
|
|
ff93be7a89 | ||
|
|
a47d222a47 | ||
|
|
9f62c251f2 | ||
|
|
aff20f1a6d | ||
|
|
6841a295ff | ||
|
|
7a584e1a6e | ||
|
|
00be054135 | ||
|
|
6eb4a803fd | ||
|
|
6503a82094 | ||
|
|
31f52580c2 | ||
|
|
2aa0c3cc84 | ||
|
|
a3b3525b78 | ||
|
|
d76f10c312 | ||
|
|
a1e0e37223 | ||
|
|
9a1c1c56e6 | ||
|
|
3a5fe25e12 | ||
|
|
f56b6021d8 | ||
|
|
380c88b5a3 | ||
|
|
dd1288dc3c | ||
|
|
258a23cd9a | ||
|
|
92fb54556a | ||
|
|
e81eb9c8d5 | ||
|
|
8ec4006cc7 | ||
|
|
b3fdb991d6 | ||
|
|
5b086bb559 | ||
|
|
934d581737 | ||
|
|
e85d1755f0 | ||
|
|
1c4fe1b80c | ||
|
|
f6ff5cba82 | ||
|
|
193e4e39b1 | ||
|
|
ab7d5a3feb | ||
|
|
2279208b00 | ||
|
|
a8e1863341 | ||
|
|
506974a50d | ||
|
|
996ba82663 | ||
|
|
68524adadf | ||
|
|
b8ee612b45 | ||
|
|
5db633d911 | ||
|
|
2f208d9239 | ||
|
|
0c81d0ae2b | ||
|
|
6167bdc7f0 | ||
|
|
b50a306e66 | ||
|
|
0b06cfffba | ||
|
|
85551539f0 | ||
|
|
3914659718 | ||
|
|
8fd229b739 | ||
|
|
d69da8e3ea | ||
|
|
9a64809542 | ||
|
|
ce034fddd4 | ||
|
|
e931d6a88b | ||
|
|
a8a3067ac9 | ||
|
|
64dab0c4b6 | ||
|
|
dd7146205a | ||
|
|
68a132f271 | ||
|
|
c7b1dcec4f | ||
|
|
7d0a93483a | ||
|
|
836caf0fe0 | ||
|
|
980e1c9eb1 | ||
|
|
e2a9ea91cf | ||
|
|
2a7318eca2 | ||
|
|
b067fd2e77 | ||
|
|
6a674c3c79 | ||
|
|
0ac2827468 | ||
|
|
054dfa4cbd | ||
|
|
74371d3fcb | ||
|
|
9d3ba8991d | ||
|
|
0e4722ea98 | ||
|
|
208a47b41d | ||
|
|
7fb2f3170c | ||
|
|
9663e87838 | ||
|
|
8dd1cd9045 | ||
|
|
643380038b | ||
|
|
27dfbabe2f | ||
|
|
15bbc35e65 | ||
|
|
c9e4638b34 | ||
|
|
ff2cd6dc2f | ||
|
|
aa6892da82 | ||
|
|
3fbc5f7751 | ||
|
|
a57e06d09b | ||
|
|
bbc89bb2c2 | ||
|
|
ab58570a0c | ||
|
|
cd520a0251 | ||
|
|
8bcf12e1a7 | ||
|
|
ec087e4687 | ||
|
|
ae4352068e | ||
|
|
2faa0e4219 | ||
|
|
2e3a9035c4 | ||
|
|
2e6f99d09e | ||
|
|
f437850a50 | ||
|
|
820c968f73 | ||
|
|
9d1f098d8a | ||
|
|
d7ecf8041a | ||
|
|
a123f42291 | ||
|
|
e6a7df6144 | ||
|
|
4bd9411d87 | ||
|
|
284683e7e5 | ||
|
|
868ced76a8 | ||
|
|
729241c0fe | ||
|
|
8d48723158 | ||
|
|
2fb2f1ae49 | ||
|
|
d5b8dd8909 | ||
|
|
dfbcbbbb47 | ||
|
|
08f6bd8bef | ||
|
|
31b8a7931b | ||
|
|
a4dd4f0429 | ||
|
|
bf927c50f0 | ||
|
|
5f7be4b433 | ||
|
|
9bf20df941 | ||
|
|
adc31962c0 | ||
|
|
0e9b8a1a82 | ||
|
|
6150447c85 | ||
|
|
dd86fb0e14 | ||
|
|
b483d5413f | ||
|
|
c80ff2e709 | ||
|
|
2181e57e42 | ||
|
|
c490df7f50 | ||
|
|
b9e1be57e4 | ||
|
|
c3d96c7459 | ||
|
|
b9386d5a47 | ||
|
|
1d8abd8f4b | ||
|
|
0bf61dda08 | ||
|
|
660b813ff7 | ||
|
|
ba3b108239 | ||
|
|
42b08f9bcd | ||
|
|
849cdd257d | ||
|
|
16b174d882 | ||
|
|
61e2c8a1c0 | ||
|
|
c7a7609763 | ||
|
|
13130a217c | ||
|
|
daa2d2989e | ||
|
|
ee6de95a52 | ||
|
|
1daf59b7db | ||
|
|
94e91e33b8 | ||
|
|
d91f537bdd | ||
|
|
436a4cce2b | ||
|
|
71f9fe469a | ||
|
|
76b0c8446c | ||
|
|
069c7de78c | ||
|
|
3eeaf3be22 | ||
|
|
1989eabf86 | ||
|
|
491f9ed679 | ||
|
|
000259fc88 | ||
|
|
078765fe44 | ||
|
|
45877bb3a4 | ||
|
|
eb3d067e26 | ||
|
|
db80c441ec | ||
|
|
849b814034 | ||
|
|
37a4dbe66b | ||
|
|
75ab56cad1 | ||
|
|
25b481ac0a | ||
|
|
893507691b | ||
|
|
ac7adde4b1 | ||
|
|
d0aecd0ee5 | ||
|
|
307b8f8dde | ||
|
|
9d033acfff | ||
|
|
2617c53abf | ||
|
|
bbf1143090 | ||
|
|
2a37608cb0 | ||
|
|
3dd5e7445e | ||
|
|
8dcbcd8b62 | ||
|
|
d00428eb7e | ||
|
|
0e2ea27f54 | ||
|
|
b2809ed12e | ||
|
|
a72b9a174a | ||
|
|
ecb3d83c57 | ||
|
|
2cfb59d042 | ||
|
|
4c3063cadf | ||
|
|
66885fedaa | ||
|
|
064eb9df04 | ||
|
|
c3cef1eed6 | ||
|
|
ba8c4d3d24 | ||
|
|
c99a2a554b | ||
|
|
749e7c6cd0 | ||
|
|
4d29087431 | ||
|
|
183b769ee2 | ||
|
|
14cf3912f0 | ||
|
|
720457e876 | ||
|
|
27d004d299 | ||
|
|
7f650a619e | ||
|
|
d7977dec84 | ||
|
|
99a8f3d5d6 | ||
|
|
c1b4256d44 | ||
|
|
ed78a2e06f | ||
|
|
55a90e0464 | ||
|
|
fb78136870 | ||
|
|
b477556698 | ||
|
|
fc5cca9def | ||
|
|
dc455bcd87 | ||
|
|
bda82d2792 | ||
|
|
a36e233051 | ||
|
|
8484c0f0aa | ||
|
|
ffb5b49521 | ||
|
|
c15dd183a0 | ||
|
|
0eca2d49ba | ||
|
|
57e7cbfd09 | ||
|
|
e94bd9b845 | ||
|
|
05bf8e3b3d | ||
|
|
3992b19be3 | ||
|
|
a678a61c23 | ||
|
|
b5ce0a786a | ||
|
|
d58579d308 | ||
|
|
0620c3e00f | ||
|
|
a8374ec779 | ||
|
|
24090235d1 | ||
|
|
bbaa687aa7 | ||
|
|
dadfe72b48 | ||
|
|
9cc3eba009 | ||
|
|
54a636d79e | ||
|
|
0087c1ef9d | ||
|
|
39881e0d04 | ||
|
|
39a09c5d92 | ||
|
|
663a9ba07b | ||
|
|
144ae69f5c | ||
|
|
3e07bed51b | ||
|
|
9de219fd80 | ||
|
|
4a25435f7a | ||
|
|
b0f32affcb | ||
|
|
99548ea65f | ||
|
|
325ee02b49 | ||
|
|
a60786d32c | ||
|
|
2976afd5d1 | ||
|
|
744c52ba18 | ||
|
|
c31c1fd92a | ||
|
|
36615ef656 | ||
|
|
53a5dffb26 | ||
|
|
74f3a77a84 | ||
|
|
a15f1ac223 | ||
|
|
19a626e237 | ||
|
|
43c2ee6b7b | ||
|
|
b1555bfcd5 | ||
|
|
d5541791b6 | ||
|
|
62b1cae0ab | ||
|
|
933a23c9c7 | ||
|
|
f2799349ab | ||
|
|
1d223cc16f | ||
|
|
b7101a403b | ||
|
|
493917d8b1 | ||
|
|
e12aec4ccd | ||
|
|
d4936e18ee | ||
|
|
beb9c0e959 | ||
|
|
14faae3fd1 | ||
|
|
6f1472addb | ||
|
|
2fa2c3afec | ||
|
|
6e938ba74c | ||
|
|
53a63367dc | ||
|
|
ddf5c7f665 | ||
|
|
4e331c7f14 | ||
|
|
5e60cb83de | ||
|
|
595d6ea3b6 | ||
|
|
71fa62fd6a | ||
|
|
be5bb11fe3 | ||
|
|
981ae74e5c | ||
|
|
2b66193969 | ||
|
|
ed304f7687 | ||
|
|
7ac7af4774 | ||
|
|
5fa0a7866a | ||
|
|
f24148d431 | ||
|
|
980023a80c | ||
|
|
b68a1d7ab9 | ||
|
|
2ef90902aa | ||
|
|
e115b0678c | ||
|
|
0bda16de6d | ||
|
|
fd6ba459f9 | ||
|
|
d503908a65 | ||
|
|
215839c423 | ||
|
|
783bc86aaf | ||
|
|
6e626c0f89 | ||
|
|
facdd35b11 | ||
|
|
ec8a88a7a8 | ||
|
|
1b1c94ffa0 | ||
|
|
bcd003685e | ||
|
|
59039a14a5 | ||
|
|
0d6e217405 | ||
|
|
64e1805b53 | ||
|
|
22d02edbd8 | ||
|
|
5a496f6858 | ||
|
|
f4209d7a67 | ||
|
|
077bdeb01c | ||
|
|
095494f96f | ||
|
|
6f230ee4b2 | ||
|
|
311e0218af | ||
|
|
3fee369dc1 | ||
|
|
e57f2dfe7d | ||
|
|
dd5de1787f | ||
|
|
62f1aecfaf | ||
|
|
4ce388c8aa | ||
|
|
cb5451fe5d | ||
|
|
5466a2d64d | ||
|
|
77f8a79c51 | ||
|
|
33b2b38308 | ||
|
|
94426e97aa | ||
|
|
5b68e494db | ||
|
|
39d4cf362b | ||
|
|
b977a42738 | ||
|
|
ff2a74367f | ||
|
|
3f666d2302 | ||
|
|
a7d22973ff | ||
|
|
20583784f5 | ||
|
|
64f131ae27 | ||
|
|
015abe5a25 | ||
|
|
719a652235 | ||
|
|
50892ce9fc | ||
|
|
2e14836ed6 | ||
|
|
35e1aba4ad | ||
|
|
913537f96f | ||
|
|
b36a60d3a2 | ||
|
|
df247b021e | ||
|
|
9f678d8fde | ||
|
|
d89442438f | ||
|
|
08a9ae7b94 | ||
|
|
904e09f0dd | ||
|
|
038ef5b739 | ||
|
|
f8958ae1bc | ||
|
|
03eccbd56a | ||
|
|
fb31ea3c22 | ||
|
|
4082863b5a | ||
|
|
cc564af44e | ||
|
|
655ed2255a | ||
|
|
96b22744ec | ||
|
|
130d0bc7a0 | ||
|
|
1469e37c38 | ||
|
|
6ce495fcd3 | ||
|
|
776a3eff2a | ||
|
|
04e8ae5bdd | ||
|
|
18b9fb3876 | ||
|
|
1da86f27a7 | ||
|
|
85340a2fe9 | ||
|
|
c4a4d9c116 | ||
|
|
87a184595c | ||
|
|
b3b1e421f2 | ||
|
|
60483ef542 | ||
|
|
3c8a8b8988 | ||
|
|
2f8bdd8f0f | ||
|
|
e87db8b87f | ||
|
|
b36273a848 | ||
|
|
7b087158d7 | ||
|
|
2fbc44bd54 | ||
|
|
950512c2a7 | ||
|
|
f4010d498f | ||
|
|
f04d4ff3cd | ||
|
|
f8b290fc45 | ||
|
|
7e4eb29db7 | ||
|
|
93a74b7681 | ||
|
|
2677e088a8 | ||
|
|
0fd4984e5a | ||
|
|
896a65fd99 | ||
|
|
885209a614 | ||
|
|
4c109d6bd3 | ||
|
|
9c2c8c21f1 | ||
|
|
e40b247a97 | ||
|
|
a79cc758ed | ||
|
|
bafd426eaf | ||
|
|
36f9572cbb | ||
|
|
2586a11bcf | ||
|
|
d36138d5e1 | ||
|
|
7810bb54e0 | ||
|
|
2844773e4d | ||
|
|
23c406bff9 | ||
|
|
0f3adda592 | ||
|
|
441ed3beeb | ||
|
|
d1f5585fda | ||
|
|
0fd3ed8f6b | ||
|
|
0e5c1f83ff | ||
|
|
f112756b04 | ||
|
|
f822027ec5 | ||
|
|
034315d421 | ||
|
|
5cd8b35d1f | ||
|
|
84b996c489 | ||
|
|
d77403c0be | ||
|
|
e9fe936aa9 | ||
|
|
8afe17b984 | ||
|
|
2691105513 | ||
|
|
5f7efd4f31 | ||
|
|
7d52931a20 | ||
|
|
a45df0e173 | ||
|
|
0db49efe4a | ||
|
|
9639cf04f1 | ||
|
|
9866e43b4b | ||
|
|
014370ea06 | ||
|
|
fbf374ff5d | ||
|
|
a68ac9cb4d | ||
|
|
7943598528 | ||
|
|
4bc8b58af7 | ||
|
|
ec0e89c21d | ||
|
|
2975f94d9e | ||
|
|
a9a045eefd | ||
|
|
d09ede00fb | ||
|
|
515248eb8b | ||
|
|
66ee706a6c | ||
|
|
d44178cb0c | ||
|
|
c926a812d3 | ||
|
|
0b83d2f2b5 | ||
|
|
21960f2404 | ||
|
|
f94885a58f | ||
|
|
f7d4b4bf6d | ||
|
|
d04e060854 | ||
|
|
7801be3d39 | ||
|
|
b10660030a | ||
|
|
f5744f5188 | ||
|
|
272be09ba1 | ||
|
|
09150a4dbb | ||
|
|
c726f56b3e | ||
|
|
daded6d193 | ||
|
|
b0a5980833 | ||
|
|
1eaed55bc6 | ||
|
|
c2265313d8 | ||
|
|
49d5a123e5 | ||
|
|
c79c970171 | ||
|
|
fa0506f58a | ||
|
|
50889ccca5 | ||
|
|
b8ca5d24c5 | ||
|
|
63969529ad | ||
|
|
08434300d8 | ||
|
|
86566bcd39 | ||
|
|
a7fcce4448 | ||
|
|
366ed9913e | ||
|
|
79f4e16286 | ||
|
|
137a6928bc | ||
|
|
de9135f44f | ||
|
|
31f57e1f12 | ||
|
|
89cae279cd | ||
|
|
fd901726b0 | ||
|
|
5f40d68441 | ||
|
|
8eedbf64a4 | ||
|
|
c551201f79 | ||
|
|
a21a5c24d8 | ||
|
|
0a969e597b | ||
|
|
a1700b5f7e | ||
|
|
d61f77a805 | ||
|
|
f6384e2e15 | ||
|
|
09a201759b | ||
|
|
5dcff01436 | ||
|
|
f355721cdb | ||
|
|
a25f77ce3c | ||
|
|
692628653c | ||
|
|
35f798c862 | ||
|
|
3a0e0377f9 | ||
|
|
c6a26786ec | ||
|
|
e5cb7a3721 | ||
|
|
03a155c17b | ||
|
|
266d579e9d | ||
|
|
c97eefc7b2 | ||
|
|
9da6b45cc3 | ||
|
|
c9bf7a3245 | ||
|
|
dd368d87aa | ||
|
|
e5b279d013 | ||
|
|
8ca3437689 | ||
|
|
aeafb8247f | ||
|
|
75bab28d82 | ||
|
|
328d05bdf6 | ||
|
|
2229b32c90 | ||
|
|
ed409df323 | ||
|
|
b8decafd75 | ||
|
|
5aaee010c1 | ||
|
|
a01fe4043e | ||
|
|
e0ef0e018d | ||
|
|
0210a3e601 | ||
|
|
36000b1592 | ||
|
|
b296b9b299 | ||
|
|
dd6257a0a0 | ||
|
|
23b324cc9c | ||
|
|
f61f9e8654 | ||
|
|
286207ffa2 | ||
|
|
a3e82ad42f | ||
|
|
404200b8f0 | ||
|
|
dfecf470fa | ||
|
|
c737f58fc0 | ||
|
|
ab59b7f4ba | ||
|
|
514a24e2c4 | ||
|
|
742a327cbb | ||
|
|
864e067412 | ||
|
|
1c7a192854 | ||
|
|
c298f64295 | ||
|
|
e82166f87e | ||
|
|
909a2b4ce9 | ||
|
|
df8d05f09d | ||
|
|
8c3b1c8c95 | ||
|
|
ecdb755dd3 | ||
|
|
901e115a21 | ||
|
|
d4c2166019 | ||
|
|
cbc98ef624 | ||
|
|
794bc8a018 | ||
|
|
34900222dc | ||
|
|
f9a1d25c57 | ||
|
|
8fe7bcfb71 | ||
|
|
28ee65809e | ||
|
|
1b42f3310a | ||
|
|
8d2144895e | ||
|
|
13837ce88b | ||
|
|
73c65e3561 | ||
|
|
67a229b8a3 | ||
|
|
9dd3570a52 | ||
|
|
a6c8b12cdd | ||
|
|
7813c766ac | ||
|
|
9fc9826d30 | ||
|
|
19e1ed8b32 | ||
|
|
eb6b1d6375 | ||
|
|
8c6d352d07 | ||
|
|
cfa7654efc | ||
|
|
87af23248e | ||
|
|
ba08becd3a | ||
|
|
68b7a5e922 | ||
|
|
e8cc685f89 | ||
|
|
86dd137f75 | ||
|
|
b48f684c0a | ||
|
|
e0e6f3392d | ||
|
|
b1c349cc35 | ||
|
|
40aac38d43 | ||
|
|
051df7ab87 | ||
|
|
bb1f6702f6 | ||
|
|
c9542427b4 | ||
|
|
8601c5e075 | ||
|
|
3d97eca387 | ||
|
|
99b21f996c | ||
|
|
700cd7ce1f | ||
|
|
8d9da5a750 | ||
|
|
9a36bb7d72 | ||
|
|
e424dc57e7 | ||
|
|
7d60e2f671 | ||
|
|
8b2018852e | ||
|
|
0aeefa2387 | ||
|
|
4420f489ac | ||
|
|
aad4fd2a70 | ||
|
|
d2bda0fded | ||
|
|
b84727b187 | ||
|
|
6fd36dbfff | ||
|
|
8e134a7c85 | ||
|
|
389258a10c | ||
|
|
3657316fa2 | ||
|
|
a6f4249afb | ||
|
|
70afb94d3b | ||
|
|
8984adaa72 | ||
|
|
c523624696 | ||
|
|
072f189006 | ||
|
|
9967101d9f | ||
|
|
1ed09b646b | ||
|
|
f554bfc92b | ||
|
|
c80ea2c1b1 | ||
|
|
edd51b86d0 | ||
|
|
944b8a4eb0 | ||
|
|
a627893355 | ||
|
|
1600687449 | ||
|
|
fa2f17526f | ||
|
|
002e6828b6 | ||
|
|
a947472c67 | ||
|
|
e7acd7faa3 | ||
|
|
f755c7d429 | ||
|
|
b6652547fa | ||
|
|
be20146f25 | ||
|
|
df291db69b | ||
|
|
63a3b5e872 | ||
|
|
6353ac29e9 | ||
|
|
a4b583bac5 | ||
|
|
52fdc8c212 | ||
|
|
7e80adad56 | ||
|
|
bf5080aa18 | ||
|
|
89f95a22dc | ||
|
|
f1b21b73b2 | ||
|
|
6a13dca2d5 | ||
|
|
048b604a75 | ||
|
|
f7bb29c839 | ||
|
|
ba506cb16d | ||
|
|
179d0be933 |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
data*
|
||||
uploads
|
||||
*.pyc
|
||||
__pycache__
|
||||
env
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
liberapay: rubenwardy
|
||||
patreon: rubenwardy
|
||||
custom: [ "https://rubenwardy.com/donate/" ]
|
||||
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: Unconfirmed Bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Summary
|
||||
Describe your problem here
|
||||
|
||||
##### Steps to reproduce
|
||||
For bug reports or build issues, explain how the problem happened
|
||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: Feature
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
A clear and concise description of what the problem is.
|
||||
ie: Why is this needed?
|
||||
Ex. I'm always frustrated when [...]
|
||||
|
||||
## Solutions
|
||||
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Alternatives
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
7
.github/ISSUE_TEMPLATE/policy.md
vendored
Normal file
7
.github/ISSUE_TEMPLATE/policy.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Policy suggestion
|
||||
about: Suggest a change to the guidelines
|
||||
title: ''
|
||||
labels: Policy
|
||||
assignees: ''
|
||||
---
|
||||
16
.github/SECURITY.md
vendored
Normal file
16
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only support the latest production version, deployed to <https://content.luanti.org>.
|
||||
This is usually the latest `master` commit.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We ask that you report vulnerabilities privately, by contacting rubenwardy,
|
||||
to give us time to fix them. You can do that by using one of the methods outlined in the following link:
|
||||
|
||||
* https://rubenwardy.com/contact/
|
||||
|
||||
For more information on the justification of this policy, see
|
||||
[Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure).
|
||||
23
.github/workflows/test.yml
vendored
Normal file
23
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install docker-compose
|
||||
run: sudo apt-get install -y docker-compose
|
||||
- uses: actions/checkout@v4
|
||||
- name: Copy config
|
||||
run: cp utils/ci/* .
|
||||
- name: Build the Docker image
|
||||
run: docker-compose build
|
||||
- name: Start Docker
|
||||
run: docker-compose up -d
|
||||
- name: Run migrations
|
||||
run: ./utils/run_migrations.sh
|
||||
- name: Run tests
|
||||
run: ./utils/tests_cov.sh
|
||||
- name: Stop Docker
|
||||
run: docker-compose down
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -1,11 +1,17 @@
|
||||
config.cfg
|
||||
config.prod.cfg
|
||||
/config.cfg
|
||||
/*.env
|
||||
*.sqlite
|
||||
main.css
|
||||
.vscode
|
||||
custom.css
|
||||
tmp
|
||||
log.txt
|
||||
*.rdb
|
||||
uploads
|
||||
app/public/uploads
|
||||
app/public/thumbnails
|
||||
celerybeat-schedule
|
||||
/data
|
||||
.idea
|
||||
*.mo
|
||||
|
||||
# Created by https://www.gitignore.io/api/linux,macos,python,windows
|
||||
|
||||
@@ -100,10 +106,6 @@ coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM python:3.10.11-alpine
|
||||
|
||||
RUN addgroup --gid 5123 cdb && \
|
||||
adduser --uid 5123 -S cdb -G cdb
|
||||
|
||||
WORKDIR /home/cdb
|
||||
|
||||
RUN \
|
||||
apk add --no-cache postgresql-libs git bash unzip && \
|
||||
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev g++
|
||||
|
||||
RUN mkdir /var/cdb
|
||||
RUN chown -R cdb:cdb /var/cdb
|
||||
|
||||
COPY requirements.lock.txt requirements.lock.txt
|
||||
RUN pip install -r requirements.lock.txt && \
|
||||
pip install gunicorn
|
||||
|
||||
COPY utils utils
|
||||
COPY config.cfg config.cfg
|
||||
COPY migrations migrations
|
||||
COPY app app
|
||||
COPY translations translations
|
||||
|
||||
RUN pybabel compile -d translations
|
||||
RUN chown -R cdb:cdb /home/cdb
|
||||
|
||||
USER cdb
|
||||
660
LICENSE.md
Normal file
660
LICENSE.md
Normal file
@@ -0,0 +1,660 @@
|
||||
### GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
674
LICENSE.txt
674
LICENSE.txt
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
135
README.md
135
README.md
@@ -1,64 +1,101 @@
|
||||
# Content Database
|
||||
# ContentDB
|
||||

|
||||
|
||||
## Setup
|
||||
A content database for Luanti mods, games, and more.\
|
||||
Developed by rubenwardy, license AGPLv3.0+.
|
||||
|
||||
First create a Python virtual env:
|
||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||
|
||||
virtualenv env -ppython3
|
||||
source env/bin/activate
|
||||
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
|
||||
|
||||
then use pip:
|
||||
## Credits
|
||||
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
### Development
|
||||
|
||||
* Copy config.example.cfg to config.cfg
|
||||
* Fill SECRET_KEY and WTF_CSRF_SECRET_KEY in with a random string
|
||||
* Make a Github OAuth Client at <https://github.com/settings/developers>:
|
||||
* Homepage URL - `http://localhost:5000/`
|
||||
* Authorization callback URL - `http://localhost:5000/user/github/callback/`
|
||||
* Put client id and client secret in GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET
|
||||
* Setup the database: python3 setup.py
|
||||
|
||||
|
||||
## Running
|
||||
|
||||
### Development
|
||||
|
||||
You need to enter the virtual environment if you haven't yet in
|
||||
the current session:
|
||||
|
||||
source env/bin/activate
|
||||
|
||||
If you need to, reset the db like so:
|
||||
|
||||
python3 setup.py -t
|
||||
|
||||
Then run the server:
|
||||
|
||||
./rundebug.py
|
||||
|
||||
Then view in your web browser: http://localhost:5000/
|
||||
* `app/public/static/placeholder.png`: erlehmann, Warr1024. License: CC BY-SA 3.0
|
||||
|
||||
## How-tos
|
||||
|
||||
### Start celery worker
|
||||
|
||||
```sh
|
||||
FLASK_CONFIG=../config.cfg celery -A app.tasks.celery worker
|
||||
# Hot/live reload (only works with FLASK_DEBUG=1)
|
||||
./utils/reload.sh
|
||||
|
||||
# Cold update a running version of CDB with minimal downtime (production)
|
||||
./utils/update.sh
|
||||
|
||||
# Enter docker
|
||||
./utils/bash.sh
|
||||
|
||||
# Run migrations
|
||||
./utils/run_migrations.sh
|
||||
|
||||
# Create new migration
|
||||
./utils/create_migration.sh
|
||||
|
||||
# Delete database
|
||||
docker-compose down && sudo rm -rf data/db
|
||||
```
|
||||
|
||||
### Create migration
|
||||
|
||||
```sh
|
||||
# if sqlite
|
||||
python setup.py -t
|
||||
rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db stamp head
|
||||
### VSCode: Setting up Linting
|
||||
|
||||
# Create migration
|
||||
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
|
||||
* (optional) Install the [Docker extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
|
||||
* Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
|
||||
* Click no to installing pylint (we don't want it to be installed outside a virtual env)
|
||||
* Set up a virtual env
|
||||
* Replace `psycopg2` with `psycopg2_binary` in requirements.txt (because postgresql won't be installed on the system)
|
||||
* `python3 -m venv env`
|
||||
* Click yes to prompt to select virtual env for workspace
|
||||
* Click yes to any prompts about installing pylint
|
||||
* `source env/bin/activate`
|
||||
* `pip install -r requirements`
|
||||
* `pip install pylint` (if a prompt didn't appear)
|
||||
* Undo changes to requirements.txt
|
||||
|
||||
# Run migration
|
||||
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
|
||||
### VSCode: Material Icon Folder Designations
|
||||
|
||||
```json
|
||||
"material-icon-theme.folders.associations": {
|
||||
"packages": "",
|
||||
"tasks": "",
|
||||
"api": "",
|
||||
"meta": "",
|
||||
"blueprints": "routes",
|
||||
"scss": "sass",
|
||||
"flatpages": "markdown",
|
||||
"data": "temp",
|
||||
"migrations": "archive",
|
||||
"textures": "images",
|
||||
"sounds": "audio"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
|
||||
User "1" --> "*" Package
|
||||
User --> UserEmailVerification
|
||||
User "1" --> "*" Notification
|
||||
Package "1" --> "*" Release
|
||||
Package "1" --> "*" Dependency
|
||||
Package "1" --> "*" Tag
|
||||
Package "1" --> "*" MetaPackage : provides
|
||||
Release --> LuantiVersion
|
||||
Package --> License
|
||||
Dependency --> Package
|
||||
Dependency --> MetaPackage
|
||||
MetaPackage "1" --> "*" Package
|
||||
Package "1" --> "*" Screenshot
|
||||
Package "1" --> "*" Thread
|
||||
Thread "1" --> "*" Reply
|
||||
Thread "1" --> "*" User : watchers
|
||||
User "1" --> "*" Thread
|
||||
User "1" --> "*" Reply
|
||||
User "1" --> "*" ForumTopic
|
||||
|
||||
User --> "0..1" EmailPreferences
|
||||
User "1" --> "*" APIToken
|
||||
APIToken --> Package
|
||||
```
|
||||
|
||||
242
app/__init__.py
242
app/__init__.py
@@ -1,41 +1,245 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
import flask_menu as menu
|
||||
from flask_mail import Mail
|
||||
from flask.ext import markdown
|
||||
from flask_github import GitHub
|
||||
from flask_wtf.csrf import CsrfProtect
|
||||
from flask_flatpages import FlatPages
|
||||
import datetime
|
||||
import os
|
||||
import redis
|
||||
|
||||
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response, render_template_string
|
||||
from flask_babel import Babel, gettext
|
||||
from flask_flatpages import FlatPages
|
||||
from flask_github import GitHub
|
||||
from flask_login import logout_user, current_user, LoginManager
|
||||
from flask_mail import Mail
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from app.markdown import init_markdown, render_markdown
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
|
||||
|
||||
if os.getenv("SENTRY_DSN"):
|
||||
def before_send(event, hint):
|
||||
from app.tasks import TaskError
|
||||
if "exc_info" in hint:
|
||||
exc_type, exc_value, tb = hint["exc_info"]
|
||||
if isinstance(exc_value, TaskError):
|
||||
return None
|
||||
return event
|
||||
|
||||
environment = os.getenv("SENTRY_ENVIRONMENT")
|
||||
assert environment is not None
|
||||
sentry_sdk.init(
|
||||
dsn=os.getenv("SENTRY_DSN"),
|
||||
environment=environment,
|
||||
|
||||
integrations=[FlaskIntegration()],
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
# of transactions for performance monitoring.
|
||||
traces_sample_rate=0.1,
|
||||
# Set profiles_sample_rate to 1.0 to profile 100%
|
||||
# of sampled transactions.
|
||||
# We recommend adjusting this value in production.
|
||||
profiles_sample_rate=0.1,
|
||||
|
||||
before_send=before_send,
|
||||
)
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder="public/static")
|
||||
|
||||
|
||||
def my_flatpage_renderer(text):
|
||||
# Render with jinja first
|
||||
prerendered_body = render_template_string(text)
|
||||
return render_markdown(prerendered_body, clean=False)
|
||||
|
||||
|
||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||
app.config["FLATPAGES_HTML_RENDERER"] = my_flatpage_renderer
|
||||
app.config["WTF_CSRF_TIME_LIMIT"] = None
|
||||
|
||||
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
|
||||
app.config["LANGUAGES"] = {
|
||||
"en": "English",
|
||||
"cs": "čeština",
|
||||
"de": "Deutsch",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"it": "Italiano",
|
||||
"ms": "Bahasa Melayu",
|
||||
"pl": "Język Polski",
|
||||
"ru": "русский язык",
|
||||
"sk": "Slovenčina",
|
||||
"sv": "Svenska",
|
||||
"ta": "தமிழ்",
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
"vi": "tiếng Việt",
|
||||
"zh_CN": "汉语",
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
|
||||
menu.Menu(app=app)
|
||||
markdown.Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
|
||||
if not app.config["ADMIN_CONTACT_URL"]:
|
||||
raise Exception("Missing config property: ADMIN_CONTACT_URL")
|
||||
|
||||
redis_client = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||
|
||||
github = GitHub(app)
|
||||
csrf = CsrfProtect(app)
|
||||
csrf = CSRFProtect(app)
|
||||
mail = Mail(app)
|
||||
pages = FlatPages(app)
|
||||
babel = Babel()
|
||||
init_markdown(app)
|
||||
|
||||
from . import models, tasks
|
||||
from .views import *
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "users.login"
|
||||
|
||||
|
||||
from .sass import init_app as sass
|
||||
sass(app)
|
||||
|
||||
|
||||
from . import models, template_filters
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return models.User.query.filter_by(username=user_id).first()
|
||||
|
||||
|
||||
from .blueprints import create_blueprints
|
||||
create_blueprints(app)
|
||||
|
||||
|
||||
@app.route("/uploads/<path:path>")
|
||||
def send_upload(path):
|
||||
return send_from_directory(app.config["UPLOAD_DIR"], path)
|
||||
|
||||
|
||||
@app.route("/<path:path>/")
|
||||
def flatpage(path):
|
||||
page = pages.get_or_404(path)
|
||||
template = page.meta.get("template", "flatpage.html")
|
||||
return render_template(template, page=page)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_for_ban():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.ban and current_user.ban.has_expired:
|
||||
models.db.session.delete(current_user.ban)
|
||||
if current_user.rank == models.UserRank.BANNED:
|
||||
current_user.rank = models.UserRank.MEMBER
|
||||
models.db.session.commit()
|
||||
elif current_user.is_banned:
|
||||
if current_user.ban:
|
||||
flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
|
||||
else:
|
||||
flash(gettext("You have been banned."), "danger")
|
||||
logout_user()
|
||||
return redirect(url_for("users.login"))
|
||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||
current_user.rank = models.UserRank.NEW_MEMBER
|
||||
models.db.session.commit()
|
||||
|
||||
|
||||
from .utils import clear_notifications, is_safe_url, create_session
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_for_notifications():
|
||||
clear_notifications(request.path)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return render_template("500.html"), 500
|
||||
|
||||
|
||||
def get_locale():
|
||||
if not request:
|
||||
return None
|
||||
|
||||
locales = app.config["LANGUAGES"].keys()
|
||||
|
||||
if current_user.is_authenticated and current_user.locale in locales:
|
||||
return current_user.locale
|
||||
|
||||
locale = request.cookies.get("locale")
|
||||
if locale not in locales:
|
||||
locale = request.accept_languages.best_match(locales)
|
||||
|
||||
if locale and current_user.is_authenticated:
|
||||
with create_session() as new_session:
|
||||
new_session.query(models.User) \
|
||||
.filter(models.User.username == current_user.username) \
|
||||
.update({"locale": locale})
|
||||
new_session.commit()
|
||||
|
||||
return locale
|
||||
|
||||
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
|
||||
@app.route("/set-locale/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def set_locale():
|
||||
locale = request.form.get("locale")
|
||||
if locale not in app.config["LANGUAGES"].keys():
|
||||
flash("Unknown locale {}".format(locale), "danger")
|
||||
locale = None
|
||||
|
||||
next_url = request.form.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
resp = make_response(redirect(next_url))
|
||||
else:
|
||||
resp = make_response(redirect(url_for("homepage.home")))
|
||||
|
||||
if locale:
|
||||
expire_date = datetime.datetime.now()
|
||||
expire_date = expire_date + datetime.timedelta(days=5*365)
|
||||
resp.set_cookie("locale", locale, expires=expire_date, secure=True, samesite="Lax")
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.locale = locale
|
||||
models.db.session.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/set-nonfree/", methods=["POST"])
|
||||
def set_nonfree():
|
||||
resp = redirect(url_for("homepage.home"))
|
||||
if request.cookies.get("hide_nonfree") == "1":
|
||||
resp.set_cookie("hide_nonfree", "0", expires=0, secure=True, samesite="Lax")
|
||||
else:
|
||||
expire_date = datetime.datetime.now()
|
||||
expire_date = expire_date + datetime.timedelta(days=5*365)
|
||||
resp.set_cookie("hide_nonfree", "1", expires=expire_date, secure=True, samesite="Lax")
|
||||
|
||||
return resp
|
||||
|
||||
252
app/_translations.py
Normal file
252
app/_translations.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# THIS FILE IS AUTOGENERATED: utils/extract_translations.py
|
||||
|
||||
from flask_babel import pgettext
|
||||
|
||||
# NOTE: tags: title for 128px
|
||||
pgettext("tags", "128px+")
|
||||
# NOTE: tags: description for 128px
|
||||
pgettext("tags", "For 128px or higher texture packs")
|
||||
# NOTE: tags: title for 16px
|
||||
pgettext("tags", "16px")
|
||||
# NOTE: tags: description for 16px
|
||||
pgettext("tags", "For 16px texture packs")
|
||||
# NOTE: tags: title for 32px
|
||||
pgettext("tags", "32px")
|
||||
# NOTE: tags: description for 32px
|
||||
pgettext("tags", "For 32px texture packs")
|
||||
# NOTE: tags: title for 64px
|
||||
pgettext("tags", "64px")
|
||||
# NOTE: tags: description for 64px
|
||||
pgettext("tags", "For 64px texture packs")
|
||||
# NOTE: tags: title for adventure__rpg
|
||||
pgettext("tags", "Adventure / RPG")
|
||||
# NOTE: tags: title for april_fools
|
||||
pgettext("tags", "Joke")
|
||||
# NOTE: tags: description for april_fools
|
||||
pgettext("tags", "For humorous content, meant as a novelty or joke, not to be taken seriously, and that is not meant to be used seriously or long-term.")
|
||||
# NOTE: tags: title for building
|
||||
pgettext("tags", "Building")
|
||||
# NOTE: tags: description for building
|
||||
pgettext("tags", "Focuses on building, such as adding new materials or nodes")
|
||||
# NOTE: tags: title for building_mechanics
|
||||
pgettext("tags", "Building Mechanics and Tools")
|
||||
# NOTE: tags: description for building_mechanics
|
||||
pgettext("tags", "Adds game mechanics or tools that change how players build.")
|
||||
# NOTE: tags: title for chat
|
||||
pgettext("tags", "Chat / Commands")
|
||||
# NOTE: tags: description for chat
|
||||
pgettext("tags", "Focus on player chat/communication or console interaction.")
|
||||
# NOTE: tags: title for commerce
|
||||
pgettext("tags", "Commerce / Economy")
|
||||
# NOTE: tags: description for commerce
|
||||
pgettext("tags", "Related to economies, money, and trading")
|
||||
# NOTE: tags: title for complex_installation
|
||||
pgettext("tags", "Complex installation")
|
||||
# NOTE: tags: description for complex_installation
|
||||
pgettext("tags", "Requires futher installation steps, such as installing LuaRocks or editing the trusted mod setting")
|
||||
# NOTE: tags: title for crafting
|
||||
pgettext("tags", "Crafting")
|
||||
# NOTE: tags: description for crafting
|
||||
pgettext("tags", "Big changes to crafting gameplay")
|
||||
# NOTE: tags: title for creative
|
||||
pgettext("tags", "Creative")
|
||||
# NOTE: tags: description for creative
|
||||
pgettext("tags", "Written specifically or exclusively for use in creative mode. Adds content only available through a creative inventory, or provides tools that facilitate ingame creation and doesn't add difficulty or scarcity")
|
||||
# NOTE: tags: title for custom_mapgen
|
||||
pgettext("tags", "Custom mapgen")
|
||||
# NOTE: tags: description for custom_mapgen
|
||||
pgettext("tags", "Contains a completely custom mapgen implemented in Lua, usually requires worlds to be set to the 'singlenode' mapgen.")
|
||||
# NOTE: tags: title for decorative
|
||||
pgettext("tags", "Decorative")
|
||||
# NOTE: tags: description for decorative
|
||||
pgettext("tags", "Adds nodes with no other purpose than for use in building")
|
||||
# NOTE: tags: title for developer_tools
|
||||
pgettext("tags", "Developer Tools")
|
||||
# NOTE: tags: description for developer_tools
|
||||
pgettext("tags", "Tools for game and mod developers")
|
||||
# NOTE: tags: title for education
|
||||
pgettext("tags", "Education")
|
||||
# NOTE: tags: description for education
|
||||
pgettext("tags", "Either has educational value, or is a tool to help teachers ")
|
||||
# NOTE: tags: title for environment
|
||||
pgettext("tags", "Environment / Weather")
|
||||
# NOTE: tags: description for environment
|
||||
pgettext("tags", "Improves the world, adding weather, ambient sounds, or other environment mechanics")
|
||||
# NOTE: tags: title for food
|
||||
pgettext("tags", "Food / Drinks")
|
||||
# NOTE: tags: title for gui
|
||||
pgettext("tags", "GUI")
|
||||
# NOTE: tags: description for gui
|
||||
pgettext("tags", "For content whose main utility or features are provided within a GUI, on-screen menu, or similar")
|
||||
# NOTE: tags: title for hud
|
||||
pgettext("tags", "HUD")
|
||||
# NOTE: tags: description for hud
|
||||
pgettext("tags", "For mods that grant the player extra information in the HUD")
|
||||
# NOTE: tags: title for inventory
|
||||
pgettext("tags", "Inventory")
|
||||
# NOTE: tags: description for inventory
|
||||
pgettext("tags", "Changes the inventory GUI")
|
||||
# NOTE: tags: title for jam_combat_mod
|
||||
pgettext("tags", "Jam / Combat 2020")
|
||||
# NOTE: tags: description for jam_combat_mod
|
||||
pgettext("tags", "For mods created for the Discord \"Combat\" modding event in 2020")
|
||||
# NOTE: tags: title for jam_game_2021
|
||||
pgettext("tags", "Jam / Game 2021")
|
||||
# NOTE: tags: description for jam_game_2021
|
||||
pgettext("tags", "Entries to the 2021 Minetest Game Jam")
|
||||
# NOTE: tags: title for jam_game_2022
|
||||
pgettext("tags", " Jam / Game 2022")
|
||||
# NOTE: tags: description for jam_game_2022
|
||||
pgettext("tags", "Entries to the 2022 Minetest Game Jam ")
|
||||
# NOTE: tags: title for jam_game_2023
|
||||
pgettext("tags", "Jam / Game 2023")
|
||||
# NOTE: tags: description for jam_game_2023
|
||||
pgettext("tags", "Entries to the 2023 Minetest Game Jam ")
|
||||
# NOTE: tags: title for jam_game_2024
|
||||
pgettext("tags", "Jam / Game 2024")
|
||||
# NOTE: tags: description for jam_game_2024
|
||||
pgettext("tags", "Entries to the 2024 Luanti Game Jam")
|
||||
# NOTE: tags: title for jam_weekly_2021
|
||||
pgettext("tags", "Jam / Weekly Challenges 2021")
|
||||
# NOTE: tags: description for jam_weekly_2021
|
||||
pgettext("tags", "For mods created for the Discord \"Weekly Challenges\" modding event in 2021")
|
||||
# NOTE: tags: title for less_than_px
|
||||
pgettext("tags", "<16px")
|
||||
# NOTE: tags: description for less_than_px
|
||||
pgettext("tags", "For less than 16px texture packs ")
|
||||
# NOTE: tags: title for library
|
||||
pgettext("tags", "API / Library")
|
||||
# NOTE: tags: description for library
|
||||
pgettext("tags", "Primarily adds an API for other mods to use")
|
||||
# NOTE: tags: title for magic
|
||||
pgettext("tags", "Magic / Enchanting")
|
||||
# NOTE: tags: title for mapgen
|
||||
pgettext("tags", "Mapgen / Biomes / Decoration")
|
||||
# NOTE: tags: description for mapgen
|
||||
pgettext("tags", "New mapgen or changes mapgen")
|
||||
# NOTE: tags: title for mini-game
|
||||
pgettext("tags", "Mini-game")
|
||||
# NOTE: tags: description for mini-game
|
||||
pgettext("tags", "Adds a mini-game to be played within Luanti")
|
||||
# NOTE: tags: title for mobs
|
||||
pgettext("tags", "Mobs / Animals / NPCs")
|
||||
# NOTE: tags: description for mobs
|
||||
pgettext("tags", "Adds mobs, animals, and non-player characters")
|
||||
# NOTE: tags: title for mtg
|
||||
pgettext("tags", "Minetest Game improved")
|
||||
# NOTE: tags: description for mtg
|
||||
pgettext("tags", "Forks of Minetest Game")
|
||||
# NOTE: tags: title for multiplayer
|
||||
pgettext("tags", "Multiplayer-focused")
|
||||
# NOTE: tags: description for multiplayer
|
||||
pgettext("tags", "Can/should only be used in multiplayer")
|
||||
# NOTE: tags: title for oneofakind__original
|
||||
pgettext("tags", "One-of-a-kind / Original")
|
||||
# NOTE: tags: description for oneofakind__original
|
||||
pgettext("tags", "For games and such that are of their own kind, distinct and original in nature to others of the same category.")
|
||||
# NOTE: tags: title for plants_and_farming
|
||||
pgettext("tags", "Plants and Farming")
|
||||
# NOTE: tags: description for plants_and_farming
|
||||
pgettext("tags", "Adds new plants or other farmable resources.")
|
||||
# NOTE: tags: title for player_effects
|
||||
pgettext("tags", "Player Effects / Power Ups")
|
||||
# NOTE: tags: description for player_effects
|
||||
pgettext("tags", "For content that changes player effects, including physics, for example: speed, jump height or gravity.")
|
||||
# NOTE: tags: title for puzzle
|
||||
pgettext("tags", "Puzzle")
|
||||
# NOTE: tags: description for puzzle
|
||||
pgettext("tags", "Focus on puzzle solving instead of combat")
|
||||
# NOTE: tags: title for pve
|
||||
pgettext("tags", "Player vs Environment (PvE)")
|
||||
# NOTE: tags: description for pve
|
||||
pgettext("tags", "For content designed for one or more players that focus on combat against the world, mobs, or NPCs.")
|
||||
# NOTE: tags: title for pvp
|
||||
pgettext("tags", "Player vs Player (PvP)")
|
||||
# NOTE: tags: description for pvp
|
||||
pgettext("tags", "Designed to be played competitively against other players")
|
||||
# NOTE: tags: title for seasonal
|
||||
pgettext("tags", "Seasonal")
|
||||
# NOTE: tags: description for seasonal
|
||||
pgettext("tags", "For content generally themed around a certain season or holiday")
|
||||
# NOTE: tags: title for server_tools
|
||||
pgettext("tags", "Server Moderation and Tools")
|
||||
# NOTE: tags: description for server_tools
|
||||
pgettext("tags", "Helps with server maintenance and moderation")
|
||||
# NOTE: tags: title for shooter
|
||||
pgettext("tags", "Shooter")
|
||||
# NOTE: tags: description for shooter
|
||||
pgettext("tags", "First person shooters (FPS) and more")
|
||||
# NOTE: tags: title for simulation
|
||||
pgettext("tags", "Sims")
|
||||
# NOTE: tags: description for simulation
|
||||
pgettext("tags", "Mods and games that aim to simulate real life activity. Similar to SimCity/The Sims/OpenTTD/etc.")
|
||||
# NOTE: tags: title for singleplayer
|
||||
pgettext("tags", "Singleplayer-focused")
|
||||
# NOTE: tags: description for singleplayer
|
||||
pgettext("tags", "Content that can be played alone")
|
||||
# NOTE: tags: title for skins
|
||||
pgettext("tags", "Player customization / Skins")
|
||||
# NOTE: tags: description for skins
|
||||
pgettext("tags", "Allows the player to customize their character by changing the texture or adding accessories.")
|
||||
# NOTE: tags: title for sound_music
|
||||
pgettext("tags", "Sounds / Music")
|
||||
# NOTE: tags: description for sound_music
|
||||
pgettext("tags", "Focuses on or adds new sounds or musical things")
|
||||
# NOTE: tags: title for sports
|
||||
pgettext("tags", "Sports")
|
||||
# NOTE: tags: title for storage
|
||||
pgettext("tags", "Storage")
|
||||
# NOTE: tags: description for storage
|
||||
pgettext("tags", "Adds or improves item storage mechanics")
|
||||
# NOTE: tags: title for strategy_rts
|
||||
pgettext("tags", "Strategy / RTS")
|
||||
# NOTE: tags: description for strategy_rts
|
||||
pgettext("tags", "Games and mods with a heavy strategy component, whether real-time or turn-based")
|
||||
# NOTE: tags: title for survival
|
||||
pgettext("tags", "Survival")
|
||||
# NOTE: tags: description for survival
|
||||
pgettext("tags", "Written specifically for survival gameplay with a focus on game-balance, difficulty level, or resources available through crafting, mining, ...")
|
||||
# NOTE: tags: title for technology
|
||||
pgettext("tags", "Machines / Electronics")
|
||||
# NOTE: tags: description for technology
|
||||
pgettext("tags", "Adds machines useful in automation, tubes, or power.")
|
||||
# NOTE: tags: title for tools
|
||||
pgettext("tags", "Tools / Weapons / Armor")
|
||||
# NOTE: tags: description for tools
|
||||
pgettext("tags", "Adds or changes tools, weapons, and armor")
|
||||
# NOTE: tags: title for transport
|
||||
pgettext("tags", "Transport")
|
||||
# NOTE: tags: description for transport
|
||||
pgettext("tags", "Adds or changes transportation methods. Includes teleportation, vehicles, ridable mobs, transport infrastructure and thematic content")
|
||||
# NOTE: tags: title for world_tools
|
||||
pgettext("tags", "World Maintenance and Tools")
|
||||
# NOTE: tags: description for world_tools
|
||||
pgettext("tags", "Tools to manage the world")
|
||||
# NOTE: content_warnings: title for alcohol_tobacco
|
||||
pgettext("content_warnings", "Alcohol / Tobacco")
|
||||
# NOTE: content_warnings: description for alcohol_tobacco
|
||||
pgettext("content_warnings", "Contains alcohol and/or tobacco")
|
||||
# NOTE: content_warnings: title for bad_language
|
||||
pgettext("content_warnings", "Bad Language")
|
||||
# NOTE: content_warnings: description for bad_language
|
||||
pgettext("content_warnings", "Contains swearing")
|
||||
# NOTE: content_warnings: title for drugs
|
||||
pgettext("content_warnings", "Drugs")
|
||||
# NOTE: content_warnings: description for drugs
|
||||
pgettext("content_warnings", "Contains recreational drugs other than alcohol or tobacco")
|
||||
# NOTE: content_warnings: title for gambling
|
||||
pgettext("content_warnings", "Gambling")
|
||||
# NOTE: content_warnings: description for gambling
|
||||
pgettext("content_warnings", "Games of chance, gambling games, etc")
|
||||
# NOTE: content_warnings: title for gore
|
||||
pgettext("content_warnings", "Gore")
|
||||
# NOTE: content_warnings: description for gore
|
||||
pgettext("content_warnings", "Blood, etc")
|
||||
# NOTE: content_warnings: title for horror
|
||||
pgettext("content_warnings", "Fear / Horror")
|
||||
# NOTE: content_warnings: description for horror
|
||||
pgettext("content_warnings", "Shocking and scary content. May scare young children")
|
||||
# NOTE: content_warnings: title for violence
|
||||
pgettext("content_warnings", "Violence")
|
||||
# NOTE: content_warnings: description for violence
|
||||
pgettext("content_warnings", "Non-cartoon violence. May be towards fantasy or human-like characters")
|
||||
28
app/blueprints/__init__.py
Normal file
28
app/blueprints/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
|
||||
def create_blueprints(app):
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
modules = next(os.walk(dir))[1]
|
||||
|
||||
for modname in modules:
|
||||
if all(c.islower() for c in modname):
|
||||
module = importlib.import_module("." + modname, __name__)
|
||||
app.register_blueprint(module.bp)
|
||||
22
app/blueprints/admin/__init__.py
Normal file
22
app/blueprints/admin/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("admin", __name__)
|
||||
|
||||
from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, languageseditor, email, approval_stats
|
||||
434
app/blueprints/admin/actions.py
Normal file
434
app/blueprints/admin/actions.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import datetime
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group, uuid
|
||||
from flask import redirect, url_for, flash, current_app
|
||||
from sqlalchemy import or_, and_, not_, func
|
||||
|
||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, Thread, AuditLogEntry, ReportAttachment
|
||||
from app.tasks.emails import send_pending_digests
|
||||
from app.tasks.forumtasks import import_topic_list, check_all_forum_accounts
|
||||
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, check_for_updates, update_all_game_support, \
|
||||
import_languages, check_all_zip_files
|
||||
from app.tasks.usertasks import import_github_user_ids, do_delete_likely_spammers
|
||||
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links, update_file_size_bytes
|
||||
from app.utils import add_notification, get_system_user
|
||||
|
||||
actions = {}
|
||||
|
||||
|
||||
def action(title: str):
|
||||
def func(f):
|
||||
name = f.__name__
|
||||
actions[name] = {
|
||||
"title": title,
|
||||
"func": f,
|
||||
}
|
||||
|
||||
return f
|
||||
|
||||
return func
|
||||
|
||||
|
||||
@action("Delete stuck releases")
|
||||
def del_stuck_releases():
|
||||
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Delete unused uploads")
|
||||
def clean_uploads():
|
||||
upload_dir = current_app.config['UPLOAD_DIR']
|
||||
|
||||
(_, _, filenames) = next(os.walk(upload_dir))
|
||||
existing_uploads = set(filenames)
|
||||
|
||||
if len(existing_uploads) != 0:
|
||||
def get_filenames_from_column(column):
|
||||
results = db.session.query(column).filter(column.isnot(None), column != "").all()
|
||||
return set([os.path.basename(x[0]) for x in results])
|
||||
|
||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
||||
attachment_urls = get_filenames_from_column(ReportAttachment.url)
|
||||
pp_urls = get_filenames_from_column(User.profile_pic)
|
||||
|
||||
db_urls = release_urls.union(screenshot_urls).union(pp_urls).union(attachment_urls)
|
||||
unreachable = existing_uploads.difference(db_urls)
|
||||
|
||||
import sys
|
||||
print("On Disk: ", existing_uploads, file=sys.stderr)
|
||||
print("In DB: ", db_urls, file=sys.stderr)
|
||||
print("Unreachable: ", unreachable, file=sys.stderr)
|
||||
|
||||
for filename in unreachable:
|
||||
os.remove(os.path.join(upload_dir, filename))
|
||||
|
||||
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
|
||||
else:
|
||||
flash("No downloads to create", "danger")
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete unused mod names")
|
||||
def del_mod_names():
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
query.delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted " + str(count) + " unused mod names", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Recalc package scores")
|
||||
def recalc_scores():
|
||||
for package in Package.query.all():
|
||||
package.recalculate_score()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Recalculated package scores", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Import forum topic list")
|
||||
def do_import_topic_list():
|
||||
task = import_topic_list.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Check all forum accounts")
|
||||
def check_all_forum_accounts():
|
||||
task = check_all_forum_accounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Run update configs")
|
||||
def run_update_config():
|
||||
check_for_updates.delay()
|
||||
|
||||
flash("Started update configs", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
def _package_list(packages: List[str]):
|
||||
# Who needs translations?
|
||||
if len(packages) >= 3:
|
||||
packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
|
||||
return ", ".join(packages)
|
||||
else:
|
||||
return " and ".join(packages)
|
||||
|
||||
|
||||
@action("Send WIP package notification")
|
||||
def remind_wip():
|
||||
users = User.query.filter(User.packages.any(or_(
|
||||
Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = Package.query.filter(
|
||||
Package.author_id == user.id,
|
||||
or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.all()
|
||||
|
||||
packages = [pkg.title for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
havent = "haven't" if len(packages) > 1 else "hasn't"
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"Did you forget? {packages_list} {havent} been submitted for review yet",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send outdated package notification")
|
||||
def remind_outdated():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = Package.query.filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.all()
|
||||
|
||||
packages = [pkg.title for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"The following packages may be outdated: {packages_list}",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Import licenses from SPDX")
|
||||
def import_licenses():
|
||||
renames = {
|
||||
"GPLv2": "GPL-2.0-only",
|
||||
"GPLv3": "GPL-3.0-only",
|
||||
"AGPLv2": "AGPL-2.0-only",
|
||||
"AGPLv3": "AGPL-3.0-only",
|
||||
"LGPLv2.1": "LGPL-2.1-only",
|
||||
"LGPLv3": "LGPL-3.0-only",
|
||||
"Apache 2.0": "Apache-2.0",
|
||||
"BSD 2-Clause / FreeBSD": "BSD-2-Clause-FreeBSD",
|
||||
"BSD 3-Clause": "BSD-3-Clause",
|
||||
"CC0": "CC0-1.0",
|
||||
"CC BY 3.0": "CC-BY-3.0",
|
||||
"CC BY 4.0": "CC-BY-4.0",
|
||||
"CC BY-NC-SA 3.0": "CC-BY-NC-SA-3.0",
|
||||
"CC BY-SA 3.0": "CC-BY-SA-3.0",
|
||||
"CC BY-SA 4.0": "CC-BY-SA-4.0",
|
||||
"NPOSLv3": "NPOSL-3.0",
|
||||
"MPL 2.0": "MPL-2.0",
|
||||
"EUPLv1.2": "EUPL-1.2",
|
||||
"SIL Open Font License v1.1": "OFL-1.1",
|
||||
}
|
||||
|
||||
for old_name, new_name in renames.items():
|
||||
License.query.filter_by(name=old_name).update({ "name": new_name })
|
||||
|
||||
r = requests.get(
|
||||
"https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json")
|
||||
licenses = r.json()["licenses"]
|
||||
|
||||
existing_licenses = {}
|
||||
for license_data in License.query.all():
|
||||
assert license_data.name not in renames.keys()
|
||||
existing_licenses[license_data.name.lower()] = license_data
|
||||
|
||||
for license_data in licenses:
|
||||
obj = existing_licenses.get(license_data["licenseId"].lower())
|
||||
if obj:
|
||||
obj.url = license_data["reference"]
|
||||
elif license_data.get("isOsiApproved") and license_data.get("isFsfLibre") and not license_data["isDeprecatedLicenseId"]:
|
||||
obj = License(license_data["licenseId"], True, license_data["reference"])
|
||||
db.session.add(obj)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Delete inactive users")
|
||||
def delete_inactive_users():
|
||||
users = User.query.filter(User.is_active == False, ~User.packages.any(), ~User.forum_topics.any(),
|
||||
User.rank == UserRank.NOT_JOINED).all()
|
||||
for user in users:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send Video URL notification")
|
||||
def remind_video_url():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = Package.query.filter(
|
||||
or_(Package.author == user, Package.maintainers.contains(user)),
|
||||
Package.video_url == None,
|
||||
Package.type == PackageType.GAME,
|
||||
Package.state == PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
package_names = [pkg.title for pkg in packages]
|
||||
packages_list = _package_list(package_names)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You should add a video to {packages_list}",
|
||||
url_for('users.profile', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send missing game support notifications")
|
||||
def remind_missing_game_support():
|
||||
users = User.query.filter(
|
||||
User.maintained_packages.any(and_(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False))).all()
|
||||
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = Package.query.filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.all()
|
||||
|
||||
packages = [pkg.title for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You need to confirm whether the following packages support all games: {packages_list}",
|
||||
url_for('todo.all_game_support', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Detect game support")
|
||||
def detect_game_support():
|
||||
task_id = uuid()
|
||||
update_all_game_support.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Send pending notif digests")
|
||||
def do_send_pending_digests():
|
||||
send_pending_digests.delay()
|
||||
|
||||
|
||||
@action("Import user ids from GitHub")
|
||||
def do_import_github_user_ids():
|
||||
task_id = uuid()
|
||||
import_github_user_ids.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Notify about links to git/forums instead of CDB")
|
||||
def do_notify_git_forums_links():
|
||||
task_id = uuid()
|
||||
notify_about_git_forum_links.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Check all zip files")
|
||||
def do_check_all_zip_files():
|
||||
task_id = uuid()
|
||||
check_all_zip_files.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Update file_size_bytes")
|
||||
def do_update_file_size_bytes():
|
||||
task_id = uuid()
|
||||
update_file_size_bytes.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("DANGER: Delete less popular removed packages")
|
||||
def del_less_popular_removed_packages():
|
||||
task_id = uuid()
|
||||
clear_removed_packages.apply_async((False, ), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("DANGER: Delete all removed packages")
|
||||
def del_removed_packages():
|
||||
task_id = uuid()
|
||||
clear_removed_packages.apply_async((True, ), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("DANGER: Check all releases (postReleaseCheckUpdate)")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
tasks.append(check_zip_release.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state == PackageState.APPROVED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(check_zip_release.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("DANGER: Import translations")
|
||||
def reimport_translations():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state == PackageState.APPROVED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(import_languages.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("DANGER: Import screenshots from Git")
|
||||
def import_screenshots():
|
||||
packages = Package.query \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id == None) \
|
||||
.all()
|
||||
for package in packages:
|
||||
import_repo_screenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("DANGER: Delete empty threads")
|
||||
def delete_empty_threads():
|
||||
query = Thread.query.filter(~Thread.replies.any())
|
||||
count = query.count()
|
||||
for thread in query.all():
|
||||
thread.watchers.clear()
|
||||
db.session.delete(thread)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Deleted {count} threads", "success")
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("DANGER: Check for broken links in all packages")
|
||||
def check_for_broken_links():
|
||||
for package in Package.query.filter_by(state=PackageState.APPROVED).all():
|
||||
check_package_for_broken_links.delay(package.id)
|
||||
|
||||
|
||||
@action("DANGER: Delete likely spammers")
|
||||
def delete_likely_spammers():
|
||||
task_id = uuid()
|
||||
do_delete_likely_spammers.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
223
app/blueprints/admin/admin.py
Normal file
223
app/blueprints/admin/admin.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import redirect, render_template, url_for, request, flash
|
||||
from flask_login import current_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, BooleanField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
from app.utils import rank_required, add_audit_log, add_notification, get_system_user, nonempty_or_none, \
|
||||
get_int_or_abort
|
||||
from sqlalchemy import func
|
||||
from . import bp
|
||||
from .actions import actions
|
||||
from app.models import UserRank, Package, db, PackageState, PackageRelease, PackageScreenshot, User, AuditSeverity, NotificationType, PackageAlias
|
||||
from ...querybuilder import QueryBuilder
|
||||
|
||||
|
||||
@bp.route("/admin/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def admin_page():
|
||||
if request.method == "POST" and current_user.rank.at_least(UserRank.ADMIN):
|
||||
action = request.form["action"]
|
||||
if action in actions:
|
||||
ret = actions[action]["func"]()
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
else:
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
return render_template("admin/list.html", actions=actions)
|
||||
|
||||
|
||||
class SwitchUserForm(FlaskForm):
|
||||
username = StringField("Username")
|
||||
submit = SubmitField("Switch")
|
||||
|
||||
|
||||
@bp.route("/admin/switchuser/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def switch_user():
|
||||
form = SwitchUserForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
if user is None:
|
||||
flash("Unable to find user", "danger")
|
||||
elif login_user(user):
|
||||
return redirect(url_for("users.profile", username=current_user.username))
|
||||
else:
|
||||
flash("Unable to login as user", "danger")
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("admin/switch_user.html", form=form)
|
||||
|
||||
|
||||
class SendNotificationForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
||||
submit = SubmitField("Send")
|
||||
|
||||
|
||||
@bp.route("/admin/send-notification/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def send_bulk_notification():
|
||||
form = SendNotificationForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
|
||||
add_notification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
return render_template("admin/send_bulk_notification.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/admin/restore/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def restore():
|
||||
if request.method == "POST":
|
||||
target = request.form["submit"]
|
||||
if "Review" in target:
|
||||
target = PackageState.READY_FOR_REVIEW
|
||||
elif "Changes" in target:
|
||||
target = PackageState.CHANGES_NEEDED
|
||||
else:
|
||||
target = PackageState.WIP
|
||||
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "danger")
|
||||
else:
|
||||
package.state = target
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
||||
package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
deleted_packages = Package.query \
|
||||
.filter(Package.state == PackageState.DELETED) \
|
||||
.join(Package.author) \
|
||||
.order_by(db.asc(User.username), db.asc(Package.name)) \
|
||||
.all()
|
||||
|
||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
||||
|
||||
|
||||
class TransferPackageForm(FlaskForm):
|
||||
old_username = StringField("Old Username", [InputRequired()])
|
||||
new_username = StringField("New Username", [InputRequired()])
|
||||
package = StringField("Package", [Optional()])
|
||||
remove_maintainer = BooleanField("Remove current owner from maintainers")
|
||||
submit = SubmitField("Transfer")
|
||||
|
||||
|
||||
def perform_transfer(form: TransferPackageForm):
|
||||
query = Package.query.filter(Package.author.has(username=form.old_username.data))
|
||||
if nonempty_or_none(form.package.data):
|
||||
query = query.filter_by(name=form.package.data)
|
||||
|
||||
packages = query.all()
|
||||
if len(packages) == 0:
|
||||
flash("Unable to find package(s)", "danger")
|
||||
return
|
||||
|
||||
new_user = User.query.filter_by(username=form.new_username.data).first()
|
||||
if new_user is None:
|
||||
flash("Unable to find new user", "danger")
|
||||
return
|
||||
|
||||
names = [x.name for x in packages]
|
||||
already_existing = Package.query.filter(Package.author_id == new_user.id, Package.name.in_(names)).all()
|
||||
if len(already_existing) > 0:
|
||||
existing_names = [x.name for x in already_existing]
|
||||
flash("Unable to transfer packages as names exist at destination: " + ", ".join(existing_names), "danger")
|
||||
return
|
||||
|
||||
for package in packages:
|
||||
if form.remove_maintainer.data:
|
||||
package.maintainers.remove(package.author)
|
||||
package.author = new_user
|
||||
package.maintainers.append(new_user)
|
||||
package.aliases.append(PackageAlias(form.old_username.data, package.name))
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
f"Transferred {form.old_username.data}/{package.name} to {form.new_username.data}",
|
||||
package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Transferred " + ", ".join([x.name for x in packages]), "success")
|
||||
|
||||
return redirect(url_for("admin.transfer"))
|
||||
|
||||
|
||||
@bp.route("/admin/transfer/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def transfer():
|
||||
form = TransferPackageForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
ret = perform_transfer(form)
|
||||
if ret is not None:
|
||||
return ret
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("admin/transfer.html", form=form)
|
||||
|
||||
|
||||
def sum_file_sizes(clazz):
|
||||
ret = {}
|
||||
for entry in (db.session
|
||||
.query(clazz.package_id, func.sum(clazz.file_size_bytes))
|
||||
.select_from(clazz)
|
||||
.group_by(clazz.package_id)
|
||||
.all()):
|
||||
ret[entry[0]] = entry[1]
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route("/admin/storage/")
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def storage():
|
||||
qb = QueryBuilder(request.args, cookies=True)
|
||||
qb.only_approved = False
|
||||
packages = qb.build_package_query().all()
|
||||
|
||||
show_all = len(packages) < 100
|
||||
min_size = get_int_or_abort(request.args.get("min_size"), 0 if show_all else 50)
|
||||
|
||||
package_size_releases = sum_file_sizes(PackageRelease)
|
||||
package_size_screenshots = sum_file_sizes(PackageScreenshot)
|
||||
|
||||
data = []
|
||||
for package in packages:
|
||||
size_releases = package_size_releases.get(package.id, 0)
|
||||
size_screenshots = package_size_screenshots.get(package.id, 0)
|
||||
size_total = size_releases + size_screenshots
|
||||
if size_total < min_size * 1024 * 1024:
|
||||
continue
|
||||
|
||||
latest_release = package.releases.first()
|
||||
size_latest = latest_release.file_size_bytes if latest_release else 0
|
||||
data.append([package, size_total, size_releases, size_screenshots, size_latest])
|
||||
|
||||
data.sort(key=lambda x: x[1], reverse=True)
|
||||
return render_template("admin/storage.html", data=data)
|
||||
77
app/blueprints/admin/approval_stats.py
Normal file
77
app/blueprints/admin/approval_stats.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
from flask import render_template, request, abort, redirect, url_for, jsonify
|
||||
|
||||
from . import bp
|
||||
from app.logic.approval_stats import get_approval_statistics
|
||||
from app.models import UserRank
|
||||
from app.utils import rank_required
|
||||
|
||||
|
||||
@bp.route("/admin/approval_stats/")
|
||||
@rank_required(UserRank.APPROVER)
|
||||
def approval_stats():
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
if start and end:
|
||||
try:
|
||||
start = datetime.datetime.fromisoformat(start)
|
||||
end = datetime.datetime.fromisoformat(end)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
elif start:
|
||||
return redirect(url_for("admin.approval_stats", start=start, end=datetime.datetime.utcnow().date().isoformat()))
|
||||
elif end:
|
||||
return redirect(url_for("admin.approval_stats", start="2020-07-01", end=end))
|
||||
else:
|
||||
end = datetime.datetime.utcnow()
|
||||
start = end - datetime.timedelta(days=365)
|
||||
|
||||
stats = get_approval_statistics(start, end)
|
||||
return render_template("admin/approval_stats.html", stats=stats, start=start, end=end)
|
||||
|
||||
|
||||
@bp.route("/admin/approval_stats.json")
|
||||
@rank_required(UserRank.APPROVER)
|
||||
def approval_stats_json():
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
if start and end:
|
||||
try:
|
||||
start = datetime.datetime.fromisoformat(start)
|
||||
end = datetime.datetime.fromisoformat(end)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
else:
|
||||
end = datetime.datetime.utcnow()
|
||||
start = end - datetime.timedelta(days=365)
|
||||
|
||||
stats = get_approval_statistics(start, end)
|
||||
for key, value in stats.packages_info.items():
|
||||
stats.packages_info[key] = value.__dict__()
|
||||
|
||||
return jsonify({
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"editor_approvals": stats.editor_approvals,
|
||||
"packages_info": stats.packages_info,
|
||||
"turnaround_time": {
|
||||
"avg": stats.avg_turnaround_time,
|
||||
"max": stats.max_turnaround_time,
|
||||
},
|
||||
})
|
||||
73
app/blueprints/admin/audit.py
Normal file
73
app/blueprints/admin/audit.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import Optional, Length
|
||||
|
||||
from app.models import db, AuditLogEntry, UserRank, User, Permission
|
||||
from app.utils import rank_required, get_int_or_abort
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
class AuditForm(FlaskForm):
|
||||
username = StringField(lazy_gettext("Username"), [Optional(), Length(0, 25)])
|
||||
q = StringField(lazy_gettext("Query"), [Optional(), Length(0, 300)])
|
||||
url = StringField(lazy_gettext("URL"), [Optional(), Length(0, 300)])
|
||||
submit = SubmitField(lazy_gettext("Search"), name=None)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/")
|
||||
@rank_required(UserRank.APPROVER)
|
||||
def audit():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
|
||||
|
||||
form = AuditForm(request.args)
|
||||
username = form.username.data
|
||||
q = form.q.data
|
||||
url = form.url.data
|
||||
if username:
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
query = query.filter_by(causer=user)
|
||||
|
||||
if q:
|
||||
query = query.filter(AuditLogEntry.title.ilike(f"%{q}%"))
|
||||
|
||||
if url:
|
||||
query = query.filter(AuditLogEntry.url.ilike(f"%{url}%"))
|
||||
|
||||
if not current_user.rank.at_least(UserRank.MODERATOR):
|
||||
query = query.filter(AuditLogEntry.package)
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination, form=form)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/<int:id_>/")
|
||||
@login_required
|
||||
def audit_view(id_):
|
||||
entry: AuditLogEntry = AuditLogEntry.query.get_or_404(id_)
|
||||
if not entry.check_perm(current_user, Permission.VIEW_AUDIT_DESCRIPTION):
|
||||
abort(403)
|
||||
|
||||
return render_template("admin/audit_view.html", entry=entry)
|
||||
77
app/blueprints/admin/email.py
Normal file
77
app/blueprints/admin/email.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import request, abort, url_for, redirect, render_template, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import TextAreaField, SubmitField, StringField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.markdown import render_markdown
|
||||
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
|
||||
from app.utils import rank_required, add_audit_log, normalize_line_endings
|
||||
from . import bp
|
||||
from app.models import UserRank, User, AuditSeverity
|
||||
|
||||
|
||||
class SendEmailForm(FlaskForm):
|
||||
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
|
||||
text = TextAreaField("Message", [InputRequired()], filters=[normalize_line_endings])
|
||||
submit = SubmitField("Send")
|
||||
|
||||
|
||||
@bp.route("/admin/send-email/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def send_single_email():
|
||||
username = request.args["username"]
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
next_url = url_for("users.profile", username=user.username)
|
||||
|
||||
if user.email is None:
|
||||
flash("User has no email address!", "danger")
|
||||
return redirect(next_url)
|
||||
|
||||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task = send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||
|
||||
return render_template("admin/send_email.html", form=form, user=user)
|
||||
|
||||
|
||||
@bp.route("/admin/send-bulk-email/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def send_bulk_email():
|
||||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task_send_bulk.delay(form.subject.data, text, html)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
return render_template("admin/send_bulk_email.html", form=form)
|
||||
73
app/blueprints/admin/languageseditor.py
Normal file
73
app/blueprints/admin/languageseditor.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-24 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.models import db, AuditSeverity, UserRank, Language, Package, PackageState, PackageTranslation
|
||||
from app.utils import add_audit_log, rank_required, normalize_line_endings
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/admin/languages/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def language_list():
|
||||
at_least_one_count = db.session.query(PackageTranslation.package_id).group_by(PackageTranslation.package_id).count()
|
||||
total_package_count = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
return render_template("admin/languages/list.html",
|
||||
languages=Language.query.all(), total_package_count=total_package_count,
|
||||
at_least_one_count=at_least_one_count)
|
||||
|
||||
|
||||
class LanguageForm(FlaskForm):
|
||||
id = StringField("Id", [InputRequired(), Length(2, 10)])
|
||||
title = TextAreaField("Title", [Optional(), Length(2, 100)], filters=[normalize_line_endings])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/admin/languages/new/", methods=["GET", "POST"])
|
||||
@bp.route("/admin/languages/<id_>/edit/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def create_edit_language(id_=None):
|
||||
language = None
|
||||
if id_ is not None:
|
||||
language = Language.query.filter_by(id=id_).first()
|
||||
if language is None:
|
||||
abort(404)
|
||||
|
||||
form = LanguageForm(obj=language)
|
||||
if form.validate_on_submit():
|
||||
if language is None:
|
||||
language = Language()
|
||||
db.session.add(language)
|
||||
form.populate_obj(language)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created language {language.id}",
|
||||
url_for("admin.create_edit_language", id_=language.id))
|
||||
else:
|
||||
form.populate_obj(language)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited language {language.id}",
|
||||
url_for("admin.create_edit_language", id_=language.id))
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.create_edit_language", id_=language.id))
|
||||
|
||||
return render_template("admin/languages/edit.html", language=language, form=form)
|
||||
73
app/blueprints/admin/licenseseditor.py
Normal file
73
app/blueprints/admin/licenseseditor.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.utils import rank_required, nonempty_or_none, add_audit_log
|
||||
from . import bp
|
||||
from app.models import UserRank, License, db, AuditSeverity
|
||||
|
||||
|
||||
@bp.route("/licenses/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def license_list():
|
||||
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
|
||||
|
||||
|
||||
class LicenseForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
url = URLField("URL", [Optional()], filters=[nonempty_or_none])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/licenses/new/", methods=["GET", "POST"])
|
||||
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def create_edit_license(name=None):
|
||||
license = None
|
||||
if name is not None:
|
||||
license = License.query.filter_by(name=name).first()
|
||||
if license is None:
|
||||
abort(404)
|
||||
|
||||
form = LicenseForm(formdata=request.form, obj=license)
|
||||
if request.method == "GET" and license is None:
|
||||
form.is_foss.data = True
|
||||
elif form.validate_on_submit():
|
||||
if license is None:
|
||||
license = License(form.name.data)
|
||||
db.session.add(license)
|
||||
flash("Created license " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created license {license.name}",
|
||||
url_for("admin.license_list"))
|
||||
else:
|
||||
flash("Updated license " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited license {license.name}",
|
||||
url_for("admin.license_list"))
|
||||
|
||||
form.populate_obj(license)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.license_list"))
|
||||
|
||||
return render_template("admin/licenses/edit.html", license=license, form=form)
|
||||
88
app/blueprints/admin/tagseditor.py
Normal file
88
app/blueprints/admin/tagseditor.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from . import bp
|
||||
from app.models import Permission, Tag, db, AuditSeverity
|
||||
from app.utils import add_audit_log, normalize_line_endings
|
||||
|
||||
|
||||
@bp.route("/tags/")
|
||||
@login_required
|
||||
def tag_list():
|
||||
if not Permission.EDIT_TAGS.check(current_user):
|
||||
abort(403)
|
||||
|
||||
query = Tag.query
|
||||
|
||||
if request.args.get("sort") == "views":
|
||||
query = query.order_by(db.desc(Tag.views))
|
||||
else:
|
||||
query = query.order_by(db.asc(Tag.title))
|
||||
|
||||
return render_template("admin/tags/list.html", tags=query.all())
|
||||
|
||||
|
||||
class TagForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)], filters=[normalize_line_endings])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
|
||||
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/tags/new/", methods=["GET", "POST"])
|
||||
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_tag(name=None):
|
||||
tag = None
|
||||
if name is not None:
|
||||
tag = Tag.query.filter_by(name=name).first()
|
||||
if tag is None:
|
||||
abort(404)
|
||||
|
||||
if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
|
||||
abort(403)
|
||||
|
||||
form = TagForm(obj=tag)
|
||||
if form.validate_on_submit():
|
||||
if tag is None:
|
||||
tag = Tag(form.title.data)
|
||||
tag.description = form.description.data
|
||||
db.session.add(tag)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created tag {tag.name}",
|
||||
url_for("admin.create_edit_tag", name=tag.name))
|
||||
else:
|
||||
form.populate_obj(tag)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited tag {tag.name}",
|
||||
url_for("admin.create_edit_tag", name=tag.name))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if Permission.EDIT_TAGS.check(current_user):
|
||||
return redirect(url_for("admin.create_edit_tag", name=tag.name))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
return render_template("admin/tags/edit.html", tag=tag, form=form)
|
||||
71
app/blueprints/admin/versioneditor.py
Normal file
71
app/blueprints/admin/versioneditor.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.utils import rank_required, add_audit_log
|
||||
from . import bp
|
||||
from app.models import UserRank, LuantiRelease, db, AuditSeverity
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def version_list():
|
||||
return render_template("admin/versions/list.html",
|
||||
versions=LuantiRelease.query.order_by(db.asc(LuantiRelease.id)).all())
|
||||
|
||||
|
||||
class VersionForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
protocol = IntegerField("Protocol")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/versions/new/", methods=["GET", "POST"])
|
||||
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def create_edit_version(name=None):
|
||||
version = None
|
||||
if name is not None:
|
||||
version = LuantiRelease.query.filter_by(name=name).first()
|
||||
if version is None:
|
||||
abort(404)
|
||||
|
||||
form = VersionForm(formdata=request.form, obj=version)
|
||||
if form.validate_on_submit():
|
||||
if version is None:
|
||||
version = LuantiRelease(form.name.data)
|
||||
db.session.add(version)
|
||||
flash("Created version " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created version {version.name}",
|
||||
url_for("admin.license_list"))
|
||||
else:
|
||||
flash("Updated version " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited version {version.name}",
|
||||
url_for("admin.version_list"))
|
||||
|
||||
form.populate_obj(version)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.version_list"))
|
||||
|
||||
return render_template("admin/versions/edit.html", version=version, form=form)
|
||||
63
app/blueprints/admin/warningseditor.py
Normal file
63
app/blueprints/admin/warningseditor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.utils import rank_required, normalize_line_endings
|
||||
from . import bp
|
||||
from app.models import UserRank, ContentWarning, db
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def warning_list():
|
||||
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
|
||||
|
||||
|
||||
class WarningForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)], filters=[normalize_line_endings])
|
||||
name = StringField("Name", [Optional(), Length(1, 20),
|
||||
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
|
||||
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def create_edit_warning(name=None):
|
||||
warning = None
|
||||
if name is not None:
|
||||
warning = ContentWarning.query.filter_by(name=name).first()
|
||||
if warning is None:
|
||||
abort(404)
|
||||
|
||||
form = WarningForm(formdata=request.form, obj=warning)
|
||||
if form.validate_on_submit():
|
||||
if warning is None:
|
||||
warning = ContentWarning(form.title.data, form.description.data)
|
||||
db.session.add(warning)
|
||||
else:
|
||||
form.populate_obj(warning)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("admin.warning_list"))
|
||||
|
||||
return render_template("admin/warnings/edit.html", warning=warning, form=form)
|
||||
49
app/blueprints/api/__init__.py
Normal file
49
app/blueprints/api/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from flask import Blueprint
|
||||
|
||||
from .support import error
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
from . import tokens, endpoints
|
||||
|
||||
|
||||
@bp.errorhandler(400)
|
||||
@bp.errorhandler(401)
|
||||
@bp.errorhandler(403)
|
||||
@bp.errorhandler(404)
|
||||
def handle_exception(e):
|
||||
"""Return JSON instead of HTML for HTTP errors."""
|
||||
# start with the correct headers and status code from the error
|
||||
response = e.get_response()
|
||||
# replace the body with JSON
|
||||
response.data = json.dumps({
|
||||
"success": False,
|
||||
"code": e.code,
|
||||
"name": e.name,
|
||||
"description": e.description,
|
||||
})
|
||||
response.content_type = "application/json"
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/api/<path:path>")
|
||||
def page_not_found(path):
|
||||
error(404, "Endpoint or method not found")
|
||||
46
app/blueprints/api/auth.py
Normal file
46
app/blueprints/api/auth.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2019 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, abort
|
||||
|
||||
from app.models import APIToken
|
||||
from .support import error
|
||||
|
||||
|
||||
def is_api_authd(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = None
|
||||
|
||||
value = request.headers.get("authorization")
|
||||
if value is None:
|
||||
pass
|
||||
elif value[0:7].lower() == "bearer ":
|
||||
access_token = value[7:]
|
||||
if len(access_token) < 10:
|
||||
error(400, "API token is too short")
|
||||
|
||||
token = APIToken.query.filter_by(access_token=access_token).first()
|
||||
if token is None:
|
||||
error(403, "Unknown API token")
|
||||
else:
|
||||
error(403, "Unsupported authentication method")
|
||||
|
||||
return f(token=token, *args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
940
app/blueprints/api/endpoints.py
Normal file
940
app/blueprints/api/endpoints.py
Normal file
@@ -0,0 +1,940 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import flask_sqlalchemy
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_babel import gettext
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
|
||||
from app.markdown import render_markdown
|
||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, \
|
||||
LuantiRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
|
||||
PackageAlias, Language
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date, cached, \
|
||||
cors_allowed
|
||||
from app.utils.luanti_hypertext import html_to_luanti, package_info_as_hypertext, package_reviews_as_hypertext
|
||||
from . import bp
|
||||
from .auth import is_api_authd
|
||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
|
||||
api_order_screenshots, api_edit_package, api_set_cover_image
|
||||
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def packages():
|
||||
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()])
|
||||
lang = request.accept_languages.best_match(allowed_languages)
|
||||
|
||||
qb = QueryBuilder(request.args, lang=lang)
|
||||
query = qb.build_package_query()
|
||||
|
||||
fmt = request.args.get("fmt")
|
||||
if fmt == "keys":
|
||||
return jsonify([pkg.as_key_dict() for pkg in query.all()])
|
||||
|
||||
include_vcs = fmt == "vcs"
|
||||
pkgs = qb.convert_to_dictionary(query.all(), include_vcs)
|
||||
if "engine_version" in request.args or "protocol_version" in request.args:
|
||||
pkgs = [pkg for pkg in pkgs if pkg.get("release")]
|
||||
|
||||
# Promote featured packages
|
||||
if "sort" not in request.args and \
|
||||
"order" not in request.args and \
|
||||
"q" not in request.args and \
|
||||
"limit" not in request.args:
|
||||
featured_lut = set()
|
||||
featured = qb.convert_to_dictionary(query.filter(
|
||||
Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all(),
|
||||
include_vcs)
|
||||
for pkg in featured:
|
||||
featured_lut.add(f"{pkg['author']}/{pkg['name']}")
|
||||
pkg["short_description"] = gettext("Featured") + ". " + pkg["short_description"]
|
||||
pkg["featured"] = True
|
||||
|
||||
not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut]
|
||||
pkgs = featured + not_featured
|
||||
|
||||
resp = jsonify(pkgs)
|
||||
resp.vary = "Accept-Language"
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_view(package):
|
||||
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()])
|
||||
lang = request.accept_languages.best_match(allowed_languages)
|
||||
|
||||
data = package.as_dict(current_app.config["BASE_URL"], lang=lang)
|
||||
resp = jsonify(data)
|
||||
resp.vary = "Accept-Language"
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/for-client/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_view_client(package: Package):
|
||||
protocol_version = request.args.get("protocol_version")
|
||||
engine_version = request.args.get("engine_version")
|
||||
if protocol_version or engine_version:
|
||||
version = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
|
||||
else:
|
||||
version = None
|
||||
|
||||
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()])
|
||||
lang = request.accept_languages.best_match(allowed_languages)
|
||||
|
||||
data = package.as_dict(current_app.config["BASE_URL"], version, lang=lang, screenshots_dict=True)
|
||||
|
||||
formspec_version = get_int_or_abort(request.args["formspec_version"])
|
||||
include_images = is_yes(request.args.get("include_images", "true"))
|
||||
page_url = package.get_url("packages.view", absolute=True)
|
||||
if data["long_description"] is not None:
|
||||
html = render_markdown(data["long_description"])
|
||||
data["long_description"] = html_to_luanti(html, page_url, formspec_version, include_images)
|
||||
|
||||
data["info_hypertext"] = package_info_as_hypertext(package, formspec_version)
|
||||
|
||||
data["download_size"] = package.get_download_release(version).file_size
|
||||
|
||||
data["reviews"] = {
|
||||
"positive": package.reviews.filter(PackageReview.rating > 3).count(),
|
||||
"neutral": package.reviews.filter(PackageReview.rating == 3).count(),
|
||||
"negative": package.reviews.filter(PackageReview.rating < 3).count(),
|
||||
}
|
||||
|
||||
resp = jsonify(data)
|
||||
resp.vary = "Accept-Language"
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/for-client/reviews/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_view_client_reviews(package: Package):
|
||||
formspec_version = get_int_or_abort(request.args["formspec_version"])
|
||||
data = package_reviews_as_hypertext(package, formspec_version)
|
||||
|
||||
resp = jsonify(data)
|
||||
resp.vary = "Accept-Language"
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/hypertext/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_hypertext(package):
|
||||
formspec_version = get_int_or_abort(request.args["formspec_version"])
|
||||
include_images = is_yes(request.args.get("include_images", "true"))
|
||||
html = render_markdown(package.desc if package.desc else "")
|
||||
page_url = package.get_url("packages.view", absolute=True)
|
||||
return jsonify(html_to_luanti(html, page_url, formspec_version, include_images))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def edit_package(token, package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
return api_edit_package(token, package, request.json)
|
||||
|
||||
|
||||
def resolve_package_deps(out, package, only_hard, depth=1):
|
||||
id_ = package.get_id()
|
||||
if id_ in out:
|
||||
return
|
||||
|
||||
ret = []
|
||||
out[id_] = ret
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
return
|
||||
|
||||
for dep in package.dependencies:
|
||||
if only_hard and dep.optional:
|
||||
continue
|
||||
|
||||
if dep.package:
|
||||
name = dep.package.name
|
||||
fulfilled_by = [ dep.package.get_id() ]
|
||||
resolve_package_deps(out, dep.package, only_hard, depth)
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.get_id() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED]
|
||||
|
||||
if depth == 1 and not dep.optional:
|
||||
most_likely = next((pkg for pkg in dep.meta_package.packages \
|
||||
if pkg.type == PackageType.MOD and pkg.state == PackageState.APPROVED), None)
|
||||
if most_likely:
|
||||
resolve_package_deps(out, most_likely, only_hard, depth + 1)
|
||||
|
||||
else:
|
||||
raise Exception("Malformed dependency")
|
||||
|
||||
ret.append({
|
||||
"name": name,
|
||||
"is_optional": dep.optional,
|
||||
"packages": fulfilled_by
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def package_dependencies(package):
|
||||
only_hard = request.args.get("only_hard")
|
||||
|
||||
out = {}
|
||||
resolve_package_deps(out, package, only_hard)
|
||||
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@bp.route("/api/topics/")
|
||||
@cors_allowed
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_topic_query(show_added=True)
|
||||
return jsonify([t.as_dict() for t in query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/whoami/")
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def whoami(token):
|
||||
if token is None:
|
||||
return jsonify({ "is_authenticated": False, "username": None })
|
||||
else:
|
||||
return jsonify({ "is_authenticated": True, "username": token.owner.username })
|
||||
|
||||
|
||||
@bp.route("/api/delete-token/", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def api_delete_token(token):
|
||||
if token is None:
|
||||
error(404, "Token not found")
|
||||
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@bp.route("/api/markdown/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def markdown():
|
||||
return render_markdown(request.data.decode("utf-8"))
|
||||
|
||||
|
||||
@bp.route("/api/releases/")
|
||||
@cors_allowed
|
||||
def list_all_releases():
|
||||
query = PackageRelease.query.filter_by(approved=True) \
|
||||
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
|
||||
.order_by(db.desc(PackageRelease.created_at))
|
||||
|
||||
if "author" in request.args:
|
||||
author = User.query.filter_by(username=request.args["author"]).first()
|
||||
if author is None:
|
||||
error(404, "Author not found")
|
||||
query = query.filter(PackageRelease.package.has(author=author))
|
||||
|
||||
if "maintainer" in request.args:
|
||||
maintainer = User.query.filter_by(username=request.args["maintainer"]).first()
|
||||
if maintainer is None:
|
||||
error(404, "Maintainer not found")
|
||||
query = query.join(Package)
|
||||
query = query.filter(Package.maintainers.contains(maintainer))
|
||||
|
||||
return jsonify([ rel.as_long_dict() for rel in query.limit(30).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_releases(package):
|
||||
return jsonify([ rel.as_dict() for rel in package.releases.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def create_release(token, package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
||||
error(403, "You do not have the permission to approve releases")
|
||||
|
||||
if request.headers.get("Content-Type") == "application/json":
|
||||
data = request.json
|
||||
else:
|
||||
data = request.form
|
||||
|
||||
if not ("title" in data or "name" in data):
|
||||
error(400, "name is required in the POST data")
|
||||
|
||||
name = data.get("name")
|
||||
title = data.get("title") or name
|
||||
name = name or title
|
||||
|
||||
if data.get("method") == "git":
|
||||
for option in ["method", "ref"]:
|
||||
if option not in data:
|
||||
error(400, option + " is required in the POST data")
|
||||
|
||||
return api_create_vcs_release(token, package, name, title, data.get("release_notes"), data["ref"])
|
||||
|
||||
elif request.files:
|
||||
file = request.files.get("file")
|
||||
if file is None:
|
||||
error(400, "Missing 'file' in multipart body")
|
||||
|
||||
commit_hash = data.get("commit")
|
||||
|
||||
return api_create_zip_release(token, package, name, title, data.get("release_notes"), file, None, None, "API", commit_hash)
|
||||
|
||||
else:
|
||||
error(400, "Unknown release-creation method. Specify the method or provide a file.")
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def release_view(package: Package, id: int):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
error(404, "Release not found")
|
||||
|
||||
return jsonify(release.as_dict())
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def delete_release(token: APIToken, package: Package, id: int):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
error(404, "Release not found")
|
||||
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
if not release.check_perm(token.owner, Permission.DELETE_RELEASE):
|
||||
error(403, "Unable to delete the release, make sure there's a newer release available")
|
||||
|
||||
db.session.delete(release)
|
||||
db.session.commit()
|
||||
|
||||
if release.file_path and os.path.isfile(release.file_path):
|
||||
os.remove(release.file_path)
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_screenshots(package):
|
||||
screenshots = package.screenshots.all()
|
||||
return jsonify([ss.as_dict(current_app.config["BASE_URL"]) for ss in screenshots])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def create_screenshot(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to create screenshots")
|
||||
|
||||
data = request.form
|
||||
if "title" not in data:
|
||||
error(400, "Title is required in the POST data")
|
||||
|
||||
file = request.files.get("file")
|
||||
if file is None:
|
||||
error(400, "Missing 'file' in multipart body")
|
||||
|
||||
return api_create_screenshot(token, package, data["title"], file, is_yes(data.get("is_cover_image")))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def screenshot(package, id):
|
||||
ss = PackageScreenshot.query.get(id)
|
||||
if ss is None or ss.package != package:
|
||||
error(404, "Screenshot not found")
|
||||
|
||||
return jsonify(ss.as_dict(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def delete_screenshot(token: APIToken, package: Package, id: int):
|
||||
ss = PackageScreenshot.query.get(id)
|
||||
if ss is None or ss.package != package:
|
||||
error(404, "Screenshot not found")
|
||||
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to delete screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
if package.cover_image == ss:
|
||||
package.cover_image = None
|
||||
db.session.merge(package)
|
||||
|
||||
db.session.delete(ss)
|
||||
db.session.commit()
|
||||
|
||||
os.remove(ss.file_path)
|
||||
|
||||
return jsonify({ "success": True })
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/order/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def order_screenshots(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
if json is None or not isinstance(json, list):
|
||||
error(400, "Expected order body to be array")
|
||||
|
||||
return api_order_screenshots(token, package, request.json)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def set_cover_image(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
if json is None or not isinstance(json, dict) or "cover_image" not in json:
|
||||
error(400, "Expected body to be an object with cover_image as a key")
|
||||
|
||||
return api_set_cover_image(token, package, request.json["cover_image"])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/reviews/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_reviews(package):
|
||||
reviews = package.reviews
|
||||
return jsonify([review.as_dict() for review in reviews])
|
||||
|
||||
|
||||
@bp.route("/api/reviews/")
|
||||
@cors_allowed
|
||||
def list_all_reviews():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 200)
|
||||
|
||||
query = PackageReview.query
|
||||
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
|
||||
|
||||
if "for_user" in request.args:
|
||||
query = query.filter(PackageReview.package.has(Package.author.has(username=request.args["for_user"])))
|
||||
|
||||
if "author" in request.args:
|
||||
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
|
||||
|
||||
if "is_positive" in request.args:
|
||||
if is_yes(request.args.get("is_positive")):
|
||||
query = query.filter(PackageReview.rating > 3)
|
||||
else:
|
||||
query = query.filter(PackageReview.rating <= 3)
|
||||
|
||||
q = request.args.get("q")
|
||||
if q:
|
||||
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
|
||||
|
||||
query = query.order_by(db.desc(PackageReview.created_at))
|
||||
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
||||
"total": pagination.total,
|
||||
"urls": {
|
||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
||||
},
|
||||
"items": [review.as_dict(True) for review in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/stats/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def package_stats(package: Package):
|
||||
start = get_request_date("start")
|
||||
end = get_request_date("end")
|
||||
return jsonify(get_package_stats(package, start, end))
|
||||
|
||||
|
||||
@bp.route("/api/package_stats/")
|
||||
@cors_allowed
|
||||
@cached(900)
|
||||
def all_package_stats():
|
||||
return jsonify(get_all_package_stats())
|
||||
|
||||
|
||||
@bp.route("/api/scores/")
|
||||
@cors_allowed
|
||||
@cached(900)
|
||||
def package_scores():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
|
||||
pkgs = [package.as_score_dict() for package in query.all()]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/tags/")
|
||||
@cors_allowed
|
||||
@cached(60*60)
|
||||
def tags():
|
||||
return jsonify([tag.as_dict() for tag in Tag.query.order_by(db.asc(Tag.name)).all()])
|
||||
|
||||
|
||||
@bp.route("/api/content_warnings/")
|
||||
@cors_allowed
|
||||
@cached(60*60)
|
||||
def content_warnings():
|
||||
return jsonify([warning.as_dict() for warning in ContentWarning.query.order_by(db.asc(ContentWarning.name)).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/licenses/")
|
||||
@cors_allowed
|
||||
@cached(60*60)
|
||||
def licenses():
|
||||
all_licenses = License.query.order_by(db.asc(License.name)).all()
|
||||
return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses])
|
||||
|
||||
|
||||
@bp.route("/api/homepage/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def homepage():
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
spotlight = query.filter(
|
||||
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
|
||||
.order_by(func.random()).limit(6).all()
|
||||
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
|
||||
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
|
||||
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
|
||||
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
|
||||
high_reviewed = query.order_by(db.desc(Package.score - Package.score_downloads)) \
|
||||
.filter(Package.reviews.any()).limit(4).all()
|
||||
|
||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
||||
.filter_by(state=PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.created_at)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
def map_packages(packages: List[Package]):
|
||||
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"count": count,
|
||||
"downloads": downloads,
|
||||
"spotlight": map_packages(spotlight),
|
||||
"new": map_packages(new),
|
||||
"updated": map_packages(updated),
|
||||
"pop_mod": map_packages(pop_mod),
|
||||
"pop_txp": map_packages(pop_txp),
|
||||
"pop_game": map_packages(pop_gam),
|
||||
"high_reviewed": map_packages(high_reviewed)
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/minetest_versions/")
|
||||
@cors_allowed
|
||||
def versions():
|
||||
protocol_version = request.args.get("protocol_version")
|
||||
engine_version = request.args.get("engine_version")
|
||||
if protocol_version or engine_version:
|
||||
rel = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
|
||||
if rel is None:
|
||||
error(404, "No releases found")
|
||||
|
||||
return jsonify(rel.as_dict())
|
||||
|
||||
return jsonify([rel.as_dict() \
|
||||
for rel in LuantiRelease.query.all() if rel.get_actual() is not None])
|
||||
|
||||
|
||||
@bp.route("/api/languages/")
|
||||
@cors_allowed
|
||||
def languages():
|
||||
return jsonify([x.as_dict() for x in Language.query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/dependencies/")
|
||||
@cors_allowed
|
||||
def all_deps():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
|
||||
def format_pkg(pkg: Package):
|
||||
return {
|
||||
"type": pkg.type.to_name(),
|
||||
"author": pkg.author.username,
|
||||
"name": pkg.name,
|
||||
"provides": [x.name for x in pkg.provides],
|
||||
"depends": [str(x) for x in pkg.dependencies if not x.optional],
|
||||
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
|
||||
}
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
||||
"total": pagination.total,
|
||||
"urls": {
|
||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
||||
},
|
||||
"items": [format_pkg(pkg) for pkg in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/users/<username>/")
|
||||
@cors_allowed
|
||||
def user_view(username: str):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
error(404, "User not found")
|
||||
|
||||
return jsonify(user.get_dict())
|
||||
|
||||
|
||||
@bp.route("/api/users/<username>/stats/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def user_stats(username: str):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
error(404, "User not found")
|
||||
|
||||
start = get_request_date("start")
|
||||
end = get_request_date("end")
|
||||
return jsonify(get_package_stats_for_user(user, start, end))
|
||||
|
||||
|
||||
@bp.route("/api/cdb_schema/")
|
||||
@cors_allowed
|
||||
@cached(60*60)
|
||||
def json_schema():
|
||||
tags = Tag.query.all()
|
||||
warnings = ContentWarning.query.all()
|
||||
licenses = License.query.order_by(db.asc(License.name)).all()
|
||||
return jsonify({
|
||||
"title": "CDB Config",
|
||||
"description": "Package Configuration",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"license": {
|
||||
"enum": [license.name for license in licenses],
|
||||
"enumDescriptions": [license.is_foss and "FOSS" or "NON-FOSS" for license in licenses]
|
||||
},
|
||||
},
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "Package Type",
|
||||
"enum": ["MOD", "GAME", "TXP"],
|
||||
"enumDescriptions": ["Mod", "Game", "Texture Pack"]
|
||||
},
|
||||
"title": {
|
||||
"description": "Human-readable title",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Technical name (needs permission if already approved).",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z_]+$"
|
||||
},
|
||||
"short_description": {
|
||||
"description": "Package Short Description",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"dev_state": {
|
||||
"description": "Development State",
|
||||
"enum": [
|
||||
"WIP",
|
||||
"BETA",
|
||||
"ACTIVELY_DEVELOPED",
|
||||
"MAINTENANCE_ONLY",
|
||||
"AS_IS",
|
||||
"DEPRECATED",
|
||||
"LOOKING_FOR_MAINTAINER"
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"description": "Package Tags",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [tag.name for tag in tags],
|
||||
"enumDescriptions": [tag.title for tag in tags]
|
||||
},
|
||||
"uniqueItems": True,
|
||||
},
|
||||
"content_warnings": {
|
||||
"description": "Package Content Warnings",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [warning.name for warning in warnings],
|
||||
"enumDescriptions": [warning.title for warning in warnings]
|
||||
},
|
||||
"uniqueItems": True,
|
||||
},
|
||||
"license": {
|
||||
"description": "Package License",
|
||||
"$ref": "#/$defs/license"
|
||||
},
|
||||
"media_license": {
|
||||
"description": "Package Media License",
|
||||
"$ref": "#/$defs/license"
|
||||
},
|
||||
"long_description": {
|
||||
"description": "Package Long Description",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"repo": {
|
||||
"description": "Git Repository URL",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"website": {
|
||||
"description": "Website URL",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"issue_tracker": {
|
||||
"description": "Issue Tracker URL",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"forums": {
|
||||
"description": "Forum Topic ID",
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"video_url": {
|
||||
"description": "URL to a Video",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"donate_url": {
|
||||
"description": "URL to a donation page",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"translation_url": {
|
||||
"description": "URL to send users interested in translating your package",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/hypertext/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@cors_allowed
|
||||
def hypertext():
|
||||
formspec_version = get_int_or_abort(request.args["formspec_version"])
|
||||
include_images = is_yes(request.args.get("include_images", "true"))
|
||||
|
||||
html = request.data.decode("utf-8")
|
||||
if request.content_type == "text/markdown":
|
||||
html = render_markdown(html)
|
||||
|
||||
return jsonify(html_to_luanti(html, "", formspec_version, include_images))
|
||||
|
||||
|
||||
@bp.route("/api/collections/")
|
||||
@cors_allowed
|
||||
def collection_list():
|
||||
if "author" in request.args:
|
||||
user = User.query.filter_by(username=request.args["author"]).one_or_404()
|
||||
query = user.collections
|
||||
else:
|
||||
query = Collection.query.order_by(db.asc(Collection.title))
|
||||
|
||||
if "package" in request.args:
|
||||
id_ = request.args["package"]
|
||||
package = Package.get_by_key(id_)
|
||||
if package is None:
|
||||
error(404, f"Package {id_} not found")
|
||||
|
||||
query = query.filter(Collection.packages.contains(package))
|
||||
|
||||
collections = [x.as_short_dict() for x in query.all() if not x.private]
|
||||
return jsonify(collections)
|
||||
|
||||
|
||||
@bp.route("/api/collections/<author>/<name>/")
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def collection_view(token, author, name):
|
||||
user = token.owner if token else None
|
||||
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
if not collection.check_perm(user, Permission.VIEW_COLLECTION):
|
||||
error(404, "Collection not found")
|
||||
|
||||
items = collection.items
|
||||
if not collection.check_perm(user, Permission.EDIT_COLLECTION):
|
||||
items = [x for x in items if x.package.check_perm(user, Permission.VIEW_PACKAGE)]
|
||||
|
||||
ret = collection.as_dict()
|
||||
ret["items"] = [x.as_dict() for x in items]
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@bp.route("/api/updates/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def updates():
|
||||
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
|
||||
engine_version = request.args.get("engine_version")
|
||||
if protocol_version or engine_version:
|
||||
version = LuantiRelease.get(engine_version, protocol_version)
|
||||
else:
|
||||
version = None
|
||||
|
||||
# Subquery to get the latest release for each package
|
||||
latest_release_query = (db.session.query(
|
||||
PackageRelease.package_id,
|
||||
func.max(PackageRelease.id).label('max_release_id'))
|
||||
.select_from(PackageRelease)
|
||||
.filter(PackageRelease.approved == True))
|
||||
|
||||
if version:
|
||||
latest_release_query = (latest_release_query
|
||||
.filter(or_(PackageRelease.min_rel_id == None,
|
||||
PackageRelease.min_rel_id <= version.id))
|
||||
.filter(or_(PackageRelease.max_rel_id == None,
|
||||
PackageRelease.max_rel_id >= version.id)))
|
||||
|
||||
latest_release_subquery = (
|
||||
latest_release_query
|
||||
.group_by(PackageRelease.package_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Get package id and latest release
|
||||
query = (db.session.query(User.username, Package.name, latest_release_subquery.c.max_release_id)
|
||||
.select_from(Package)
|
||||
.join(User, Package.author)
|
||||
.join(latest_release_subquery, Package.id == latest_release_subquery.c.package_id)
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.all())
|
||||
|
||||
ret = {}
|
||||
for author_username, package_name, release_id in query:
|
||||
ret[f"{author_username}/{package_name}"] = release_id
|
||||
|
||||
# Get aliases
|
||||
aliases = (db.session.query(PackageAlias.author, PackageAlias.name, User.username, Package.name)
|
||||
.select_from(PackageAlias)
|
||||
.join(Package, PackageAlias.package)
|
||||
.join(User, Package.author)
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.all())
|
||||
|
||||
for old_author, old_name, new_author, new_name in aliases:
|
||||
new_release = ret.get(f"{new_author}/{new_name}")
|
||||
if new_release is not None:
|
||||
ret[f"{old_author}/{old_name}"] = new_release
|
||||
|
||||
return jsonify(ret)
|
||||
120
app/blueprints/api/support.py
Normal file
120
app/blueprints/api/support.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Optional
|
||||
from flask import jsonify, abort, make_response, url_for, current_app
|
||||
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
||||
from app.models import APIToken, Package, LuantiRelease, PackageScreenshot
|
||||
|
||||
|
||||
def error(code: int, msg: str):
|
||||
abort(make_response(jsonify({ "success": False, "error": msg }), code))
|
||||
|
||||
|
||||
# Catches LogicErrors and aborts with JSON error
|
||||
def guard(f):
|
||||
def ret(*args, **kwargs):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except LogicError as e:
|
||||
error(e.code, e.message)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def api_create_vcs_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason="API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
rel = guard(do_create_vcs_release)(token.owner, package, name, title, release_notes, ref, min_v, max_v, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.as_dict()
|
||||
})
|
||||
|
||||
|
||||
def api_create_zip_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason="API", commit_hash: str = None):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
rel = guard(do_create_zip_release)(token.owner, package, name, title, release_notes, file, min_v, max_v, reason, commit_hash)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.as_dict()
|
||||
})
|
||||
|
||||
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"screenshot": ss.as_dict()
|
||||
})
|
||||
|
||||
|
||||
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
guard(do_order_screenshots)(token.owner, package, order)
|
||||
|
||||
return jsonify({
|
||||
"success": True
|
||||
})
|
||||
|
||||
|
||||
def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
guard(do_set_cover_image)(token.owner, package, cover_image)
|
||||
|
||||
return jsonify({
|
||||
"success": True
|
||||
})
|
||||
|
||||
|
||||
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
was_modified = guard(do_edit_package)(token.owner, package, False, False, data, reason)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"package": package.as_dict(current_app.config["BASE_URL"]),
|
||||
"was_modified": was_modified,
|
||||
})
|
||||
144
app/blueprints/api/tokens.py
Normal file
144
app/blueprints/api/tokens.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import render_template, redirect, request, session, url_for, abort
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from app.models import db, User, APIToken, Permission
|
||||
from app.utils import random_string
|
||||
from . import bp
|
||||
from ..users.settings import get_setting_tabs
|
||||
|
||||
|
||||
class CreateAPIToken(FlaskForm):
|
||||
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
|
||||
package = QuerySelectField(lazy_gettext("Limit to package"), allow_blank=True,
|
||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/user/tokens/")
|
||||
@login_required
|
||||
def list_tokens_redirect():
|
||||
return redirect(url_for("api.list_tokens", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/")
|
||||
@login_required
|
||||
def list_tokens(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
|
||||
@bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_token(username, id=None):
|
||||
user = User.query.filter_by(username=username).one_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
token = None
|
||||
access_token = None
|
||||
if not is_new:
|
||||
token = APIToken.query.get(id)
|
||||
if token is None or token.owner != user:
|
||||
abort(404)
|
||||
|
||||
access_token = session.pop("token_" + str(token.id), None)
|
||||
|
||||
form = CreateAPIToken(formdata=request.form, obj=token)
|
||||
form.package.query_factory = lambda: user.maintained_packages.all()
|
||||
|
||||
if form.validate_on_submit():
|
||||
if is_new:
|
||||
token = APIToken()
|
||||
db.session.add(token)
|
||||
token.owner = user
|
||||
token.access_token = random_string(32)
|
||||
|
||||
form.populate_obj(token)
|
||||
db.session.commit()
|
||||
|
||||
if is_new:
|
||||
# Store token so it can be shown in the edit page
|
||||
session["token_" + str(token.id)] = token.access_token
|
||||
|
||||
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
|
||||
|
||||
return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/<int:id>/reset/", methods=["POST"])
|
||||
@login_required
|
||||
def reset_token(username, id):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
token.access_token = random_string(32)
|
||||
|
||||
db.session.commit() # save
|
||||
|
||||
# Store token so it can be shown in the edit page
|
||||
session["token_" + str(token.id)] = token.access_token
|
||||
|
||||
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/<int:id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
def delete_token(username, id):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("api.list_tokens", username=username))
|
||||
384
app/blueprints/collections/__init__.py
Normal file
384
app/blueprints/collections/__init__.py
Normal file
@@ -0,0 +1,384 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import typing
|
||||
|
||||
from flask import Blueprint, request, redirect, render_template, flash, abort, url_for, jsonify
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, FieldList, HiddenField, TextAreaField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity
|
||||
from app.utils import nonempty_or_none, normalize_line_endings, should_return_json
|
||||
from app.utils.models import is_package_page, add_audit_log, create_session
|
||||
|
||||
bp = Blueprint("collections", __name__)
|
||||
|
||||
|
||||
regex_invalid_chars = re.compile("[^a-z0-9_]")
|
||||
|
||||
|
||||
@bp.route("/collections/")
|
||||
@bp.route("/collections/<author>/")
|
||||
def list_all(author=None):
|
||||
if author:
|
||||
user = User.query.filter_by(username=author).one_or_404()
|
||||
query = user.collections
|
||||
else:
|
||||
user = None
|
||||
query = Collection.query.filter(Collection.items.any()).order_by(db.asc(Collection.title))
|
||||
|
||||
if "package" in request.args:
|
||||
package = Package.get_by_key(request.args["package"])
|
||||
if package is None:
|
||||
abort(404)
|
||||
|
||||
query = query.filter(Collection.packages.contains(package))
|
||||
|
||||
collections = [x for x in query.all() if x.check_perm(current_user, Permission.VIEW_COLLECTION)]
|
||||
return render_template("collections/list.html",
|
||||
user=user, collections=collections,
|
||||
noindex=len(collections) == 0)
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/")
|
||||
def view(author, name):
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
|
||||
abort(404)
|
||||
|
||||
items = collection.items
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
|
||||
|
||||
if should_return_json():
|
||||
return jsonify([ item.package.as_key_dict() for item in items ])
|
||||
else:
|
||||
return render_template("collections/view.html", collection=collection, items=items)
|
||||
|
||||
|
||||
class CollectionForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
|
||||
name = StringField("URL", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
|
||||
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)])
|
||||
long_description = TextAreaField(lazy_gettext("Page Content"), [Optional()], filters=[nonempty_or_none, normalize_line_endings])
|
||||
private = BooleanField(lazy_gettext("Private"))
|
||||
pinned = BooleanField(lazy_gettext("Pinned to my profile"))
|
||||
descriptions = FieldList(
|
||||
StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]),
|
||||
min_entries=0)
|
||||
package_ids = FieldList(HiddenField(), min_entries=0)
|
||||
package_removed = FieldList(HiddenField(), min_entries=0)
|
||||
order = HiddenField()
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/collections/new/", methods=["GET", "POST"])
|
||||
@bp.route("/collections/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit(author=None, name=None):
|
||||
collection: typing.Optional[Collection] = None
|
||||
if author is not None and name is not None:
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
elif "author" in request.args:
|
||||
author = request.args["author"]
|
||||
if author != current_user.username and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
if author is None:
|
||||
author = current_user
|
||||
else:
|
||||
author = User.query.filter_by(username=author).one()
|
||||
|
||||
form = CollectionForm(formdata=request.form, obj=collection)
|
||||
|
||||
initial_packages = []
|
||||
if "package" in request.args:
|
||||
for package_id in request.args.getlist("package"):
|
||||
package = Package.get_by_key(package_id)
|
||||
if package:
|
||||
initial_packages.append(package)
|
||||
|
||||
if request.method == "GET":
|
||||
# HACK: fix bug in wtforms
|
||||
form.private.data = collection.private if collection else False
|
||||
form.pinned.data = collection.pinned if collection else False
|
||||
if collection:
|
||||
for item in collection.items:
|
||||
form.descriptions.append_entry(item.description)
|
||||
form.package_ids.append_entry(item.package.get_id())
|
||||
form.package_removed.append_entry("0")
|
||||
else:
|
||||
form.name = None
|
||||
form.pinned = None
|
||||
|
||||
if form.validate_on_submit():
|
||||
ret = handle_create_edit(collection, form, initial_packages, author)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("collections/create_edit.html",
|
||||
collection=collection, form=form)
|
||||
|
||||
|
||||
def handle_create_edit(collection: Collection, form: CollectionForm,
|
||||
initial_packages: typing.List[Package], author: User):
|
||||
|
||||
severity = AuditSeverity.NORMAL if author == current_user else AuditSeverity.EDITOR
|
||||
name = form.name.data if collection else regex_invalid_chars.sub("", form.title.data.lower().replace(" ", "_"))
|
||||
|
||||
if collection is None or name != collection.name:
|
||||
if Collection.query \
|
||||
.filter(Collection.name == name, Collection.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("A collection with a similar title already exists"), "danger")
|
||||
return
|
||||
|
||||
if Package.query \
|
||||
.filter(Package.name == name, Package.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("Unable to create collection as a package with that name already exists"), "danger")
|
||||
return
|
||||
|
||||
if collection is None:
|
||||
collection = Collection()
|
||||
collection.author = author
|
||||
form.populate_obj(collection)
|
||||
collection.name = name
|
||||
db.session.add(collection)
|
||||
|
||||
for package in initial_packages:
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.order = len(collection.items)
|
||||
db.session.add(link)
|
||||
|
||||
add_audit_log(severity, current_user,
|
||||
f"Created collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
else:
|
||||
form.populate_obj(collection)
|
||||
collection.name = name
|
||||
|
||||
link_lookup = {}
|
||||
for link in collection.items:
|
||||
link_lookup[link.package.get_id()] = link
|
||||
|
||||
for i, package_id in enumerate(form.package_ids):
|
||||
link = link_lookup.get(package_id.data)
|
||||
to_delete = form.package_removed[i].data == "1"
|
||||
if link is None:
|
||||
if to_delete:
|
||||
continue
|
||||
|
||||
package = Package.get_by_key(package_id.data)
|
||||
if package is None:
|
||||
abort(400)
|
||||
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.description = form.descriptions[i].data
|
||||
link_lookup[link.package.get_id()] = link
|
||||
db.session.add(link)
|
||||
elif to_delete:
|
||||
db.session.delete(link)
|
||||
else:
|
||||
link.description = form.descriptions[i].data
|
||||
|
||||
for i, package_id in enumerate(form.order.data.split(",")):
|
||||
if package_id != "":
|
||||
link_lookup[package_id].order = i + 1
|
||||
|
||||
add_audit_log(severity, current_user,
|
||||
f"Edited collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(collection.get_url("collections.view"))
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/delete/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def delete(author, name):
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Deleted collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.delete(collection)
|
||||
db.session.commit()
|
||||
return redirect(url_for("collections.list_all", author=author))
|
||||
|
||||
return render_template("collections/delete.html", collection=collection)
|
||||
|
||||
|
||||
def toggle_package(collection: Collection, package: Package):
|
||||
severity = AuditSeverity.NORMAL if collection.author == current_user else AuditSeverity.EDITOR
|
||||
|
||||
author = User.query.get(collection.author_id) if collection.author is None else collection.author
|
||||
|
||||
if package in collection.packages:
|
||||
CollectionPackage.query \
|
||||
.filter(CollectionPackage.collection == collection, CollectionPackage.package == package) \
|
||||
.delete(synchronize_session=False)
|
||||
add_audit_log(severity, current_user,
|
||||
f"Removed {package.get_id()} from collection {author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
db.session.commit()
|
||||
return False
|
||||
else:
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.order = len(collection.items)
|
||||
db.session.add(link)
|
||||
add_audit_log(severity, current_user,
|
||||
f"Added {package.get_id()} to collection {author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def get_or_create_favorites(session):
|
||||
collection = Collection.query.filter(Collection.name == "favorites", Collection.author == current_user).first()
|
||||
if collection is None:
|
||||
is_new = True
|
||||
collection = Collection()
|
||||
collection.title = "Favorites"
|
||||
collection.name = "favorites"
|
||||
collection.short_description = "My favorites"
|
||||
collection.author_id = current_user.id
|
||||
session.add(collection)
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
return collection, is_new
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/add-to/", methods=["GET", "POST"])
|
||||
@is_package_page
|
||||
@login_required
|
||||
def package_add(package):
|
||||
with create_session() as new_session:
|
||||
collection, is_new = get_or_create_favorites(new_session)
|
||||
if is_new:
|
||||
new_session.commit()
|
||||
|
||||
if request.method == "POST":
|
||||
collection_id = request.form["collection"]
|
||||
collection = Collection.query.get(collection_id)
|
||||
if collection is None:
|
||||
abort(404)
|
||||
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
|
||||
if toggle_package(collection, package):
|
||||
flash(gettext("Added package to collection"), "success")
|
||||
else:
|
||||
flash(gettext("Removed package from collection"), "success")
|
||||
|
||||
return redirect(package.get_url("collections.package_add"))
|
||||
|
||||
collections = current_user.collections.all()
|
||||
if current_user.rank.at_least(UserRank.EDITOR) and current_user.username != "ContentDB":
|
||||
collections.extend(Collection.query.filter(Collection.author.has(username="ContentDB")).all())
|
||||
|
||||
return render_template("collections/package_add_to.html", package=package, collections=collections)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/favorite/", methods=["POST"])
|
||||
@is_package_page
|
||||
@login_required
|
||||
def package_toggle_favorite(package):
|
||||
collection, _is_new = get_or_create_favorites(db.session)
|
||||
collection.author = current_user
|
||||
|
||||
if toggle_package(collection, package):
|
||||
msg = gettext("Added package to favorites collection")
|
||||
if not collection.private:
|
||||
msg += " " + gettext("(Public, change from Profile > My Collections)")
|
||||
|
||||
flash(msg, "success")
|
||||
else:
|
||||
flash(gettext("Removed package from favorites collection"), "success")
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/clone/", methods=["POST"])
|
||||
@login_required
|
||||
def clone(author, name):
|
||||
old_collection: typing.Optional[Collection] = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
index = 0
|
||||
new_name = name
|
||||
new_title = old_collection.title
|
||||
while True:
|
||||
if Collection.query \
|
||||
.filter(Collection.name == new_name, Collection.author == current_user) \
|
||||
.count() == 0:
|
||||
break
|
||||
|
||||
index += 1
|
||||
new_name = f"{name}_{index}"
|
||||
new_title = f"{old_collection.title} ({index})"
|
||||
|
||||
collection = Collection()
|
||||
collection.title = new_title
|
||||
collection.author = current_user
|
||||
collection.short_description = old_collection.short_description
|
||||
collection.name = new_name
|
||||
collection.private = True
|
||||
db.session.add(collection)
|
||||
|
||||
for item in old_collection.items:
|
||||
new_item = CollectionPackage()
|
||||
new_item.package = item.package
|
||||
new_item.collection = collection
|
||||
new_item.description = item.description
|
||||
new_item.order = item.order
|
||||
db.session.add(new_item)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Created collection {collection.name} from {old_collection.author.username}/{old_collection.name} ",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(collection.get_url("collections.view"))
|
||||
49
app/blueprints/donate/__init__.py
Normal file
49
app/blueprints/donate/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import User, Package, PackageState, db, License, PackageReview, Collection
|
||||
|
||||
bp = Blueprint("donate", __name__)
|
||||
|
||||
|
||||
@bp.route("/donate/")
|
||||
def donate():
|
||||
reviewed_packages = None
|
||||
if current_user.is_authenticated:
|
||||
reviewed_packages = Package.query.filter(
|
||||
Package.state == PackageState.APPROVED,
|
||||
or_(Package.reviews.any(and_(PackageReview.author_id == current_user.id, PackageReview.rating >= 3)),
|
||||
Package.collections.any(and_(Collection.author_id == current_user.id, Collection.name == "favorites"))),
|
||||
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
||||
).order_by(db.asc(Package.title)).all()
|
||||
|
||||
query = Package.query.filter(
|
||||
Package.license.has(License.is_foss == True),
|
||||
Package.media_license.has(License.is_foss == True),
|
||||
Package.state == PackageState.APPROVED,
|
||||
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
||||
).order_by(db.desc(Package.score))
|
||||
|
||||
packages_count = query.count()
|
||||
top_packages = query.limit(40).all()
|
||||
|
||||
return render_template("donate/index.html",
|
||||
reviewed_packages=reviewed_packages, top_packages=top_packages, packages_count=packages_count)
|
||||
179
app/blueprints/feeds/__init__.py
Normal file
179
app/blueprints/feeds/__init__.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, make_response
|
||||
from flask_babel import gettext
|
||||
|
||||
from app.markdown import render_markdown
|
||||
from app.models import Package, PackageState, db, PackageRelease
|
||||
from app.utils import is_package_page, abs_url_for, cached, cors_allowed
|
||||
|
||||
bp = Blueprint("feeds", __name__)
|
||||
|
||||
|
||||
def _make_feed(title: str, feed_url: str, items: list):
|
||||
return {
|
||||
"version": "https://jsonfeed.org/version/1",
|
||||
"title": title,
|
||||
"description": gettext("Welcome to the best place to find Luanti mods, games, and texture packs"),
|
||||
"home_page_url": "https://content.luanti.org/",
|
||||
"feed_url": feed_url,
|
||||
"icon": "https://content.luanti.org/favicon-128.png",
|
||||
"expired": False,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _render_link(url: str):
|
||||
return f"<p><a href='{url}'>Read more</a></p>"
|
||||
|
||||
|
||||
def _get_new_packages_feed(feed_url: str) -> dict:
|
||||
packages = (Package.query
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.order_by(db.desc(Package.approved_at))
|
||||
.limit(100)
|
||||
.all())
|
||||
|
||||
items = [{
|
||||
"id": package.get_url("packages.view", absolute=True),
|
||||
"language": "en",
|
||||
"title": f"New: {package.title}",
|
||||
"content_html": render_markdown(package.desc) \
|
||||
if package.desc else _render_link(package.get_url("packages.view", absolute=True)),
|
||||
"author": {
|
||||
"name": package.author.display_name,
|
||||
"avatar": package.author.get_profile_pic_url(absolute=True),
|
||||
"url": abs_url_for("users.profile", username=package.author.username),
|
||||
},
|
||||
"image": package.get_thumb_url(level=4, abs=True, format="png"),
|
||||
"url": package.get_url("packages.view", absolute=True),
|
||||
"summary": package.short_desc,
|
||||
"date_published": package.approved_at.isoformat(timespec="seconds") + "Z",
|
||||
"tags": ["new_package"],
|
||||
} for package in packages]
|
||||
|
||||
return _make_feed(gettext("ContentDB new packages"), feed_url, items)
|
||||
|
||||
|
||||
def _get_releases_feed(query, feed_url: str):
|
||||
releases = (query
|
||||
.filter(PackageRelease.package.has(state=PackageState.APPROVED), PackageRelease.approved==True)
|
||||
.order_by(db.desc(PackageRelease.created_at))
|
||||
.limit(250)
|
||||
.all())
|
||||
|
||||
items = [{
|
||||
"id": release.package.get_url("packages.view_release", id=release.id, absolute=True),
|
||||
"language": "en",
|
||||
"title": f"\"{release.package.title}\" updated: {release.title}",
|
||||
"content_html": render_markdown(release.release_notes) \
|
||||
if release.release_notes else _render_link(release.package.get_url("packages.view_release", id=release.id, absolute=True)),
|
||||
"author": {
|
||||
"name": release.package.author.display_name,
|
||||
"avatar": release.package.author.get_profile_pic_url(absolute=True),
|
||||
"url": abs_url_for("users.profile", username=release.package.author.username),
|
||||
},
|
||||
"url": release.package.get_url("packages.view_release", id=release.id, absolute=True),
|
||||
"image": release.package.get_thumb_url(level=4, abs=True, format="png"),
|
||||
"summary": release.summary,
|
||||
"date_published": release.created_at.isoformat(timespec="seconds") + "Z",
|
||||
"tags": ["release"],
|
||||
} for release in releases]
|
||||
|
||||
return _make_feed(gettext("ContentDB package updates"), feed_url, items)
|
||||
|
||||
|
||||
def _get_all_feed(feed_url: str):
|
||||
releases = _get_releases_feed(PackageRelease.query, "")["items"]
|
||||
packages = _get_new_packages_feed("")["items"]
|
||||
items = releases + packages
|
||||
items.sort(reverse=True, key=lambda x: x["date_published"])
|
||||
|
||||
return _make_feed(gettext("ContentDB all"), feed_url, items)
|
||||
|
||||
|
||||
def _atomify(feed):
|
||||
resp = make_response(render_template("feeds/json_to_atom.xml", feed=feed))
|
||||
resp.headers["Content-type"] = "application/atom+xml; charset=utf-8"
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/feeds/all.json")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def all_json():
|
||||
feed = _get_all_feed(abs_url_for("feeds.all_json"))
|
||||
return jsonify(feed)
|
||||
|
||||
|
||||
@bp.route("/feeds/all.atom")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def all_atom():
|
||||
feed = _get_all_feed(abs_url_for("feeds.all_atom"))
|
||||
return _atomify(feed)
|
||||
|
||||
|
||||
@bp.route("/feeds/packages.json")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def packages_all_json():
|
||||
feed = _get_new_packages_feed(abs_url_for("feeds.packages_all_json"))
|
||||
return jsonify(feed)
|
||||
|
||||
|
||||
@bp.route("/feeds/packages.atom")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def packages_all_atom():
|
||||
feed = _get_new_packages_feed(abs_url_for("feeds.packages_all_atom"))
|
||||
return _atomify(feed)
|
||||
|
||||
|
||||
@bp.route("/feeds/releases.json")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def releases_all_json():
|
||||
feed = _get_releases_feed(PackageRelease.query, abs_url_for("feeds.releases_all_json"))
|
||||
return jsonify(feed)
|
||||
|
||||
|
||||
@bp.route("/feeds/releases.atom")
|
||||
@cors_allowed
|
||||
@cached(1800)
|
||||
def releases_all_atom():
|
||||
feed = _get_releases_feed(PackageRelease.query, abs_url_for("feeds.releases_all_atom"))
|
||||
return _atomify(feed)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases_feed.json")
|
||||
@cors_allowed
|
||||
@is_package_page
|
||||
@cached(1800)
|
||||
def releases_package_json(package: Package):
|
||||
feed = _get_releases_feed(package.releases, package.get_url("feeds.releases_package_json", absolute=True))
|
||||
return jsonify(feed)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases_feed.atom")
|
||||
@cors_allowed
|
||||
@is_package_page
|
||||
@cached(1800)
|
||||
def releases_package_atom(package: Package):
|
||||
feed = _get_releases_feed(package.releases, package.get_url("feeds.releases_package_atom", absolute=True))
|
||||
return _atomify(feed)
|
||||
136
app/blueprints/homepage/__init__.py
Normal file
136
app/blueprints/homepage/__init__.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, render_template, redirect
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag, \
|
||||
Collection, License, Language
|
||||
|
||||
bp = Blueprint("homepage", __name__)
|
||||
|
||||
from sqlalchemy.orm import joinedload, subqueryload, load_only, noload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
|
||||
PKGS_PER_ROW = 4
|
||||
|
||||
# GAMEJAM_BANNER = "https://jam.luanti.org/img/banner.png"
|
||||
#
|
||||
# class GameJam:
|
||||
# cover_image = type("", (), dict(url=GAMEJAM_BANNER))()
|
||||
# tags = []
|
||||
#
|
||||
# def get_cover_image_url(self):
|
||||
# return GAMEJAM_BANNER
|
||||
#
|
||||
# def get_url(self, _name):
|
||||
# return "/gamejam/"
|
||||
#
|
||||
# title = "Luanti Game Jam 2023: \"Unexpected\""
|
||||
# author = None
|
||||
#
|
||||
# short_desc = "The game jam has finished! It's now up to the community to play and rate the games."
|
||||
# type = type("", (), dict(value="Competition"))()
|
||||
# content_warnings = []
|
||||
# reviews = []
|
||||
|
||||
|
||||
@bp.route("/gamejam/")
|
||||
def gamejam():
|
||||
return redirect("https://jam.luanti.org/")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def home():
|
||||
def package_load(query):
|
||||
return query.options(
|
||||
load_only(Package.name, Package.title, Package.short_desc, Package.state, raiseload=True),
|
||||
subqueryload(Package.main_screenshot),
|
||||
joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True),
|
||||
joinedload(Package.license).load_only(License.name, License.is_foss, raiseload=True),
|
||||
joinedload(Package.media_license).load_only(License.name, License.is_foss, raiseload=True))
|
||||
|
||||
def package_spotlight_load(query):
|
||||
return query.options(
|
||||
load_only(Package.name, Package.title, Package.type, Package.short_desc, Package.state, Package.cover_image_id, raiseload=True),
|
||||
subqueryload(Package.main_screenshot),
|
||||
joinedload(Package.tags),
|
||||
joinedload(Package.content_warnings),
|
||||
joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True),
|
||||
subqueryload(Package.cover_image),
|
||||
joinedload(Package.license).load_only(License.name, License.is_foss, raiseload=True),
|
||||
joinedload(Package.media_license).load_only(License.name, License.is_foss, raiseload=True))
|
||||
|
||||
def review_load(query):
|
||||
return query.options(
|
||||
load_only(PackageReview.id, PackageReview.rating, PackageReview.created_at, PackageReview.language_id, raiseload=True),
|
||||
joinedload(PackageReview.author).load_only(User.username, User.rank, User.email, User.display_name, User.profile_pic, User.is_active, raiseload=True),
|
||||
joinedload(PackageReview.votes),
|
||||
joinedload(PackageReview.language).load_only(Language.title, raiseload=True),
|
||||
joinedload(PackageReview.thread).load_only(Thread.title, Thread.replies_count, raiseload=True).subqueryload(Thread.first_reply),
|
||||
joinedload(PackageReview.package)
|
||||
.load_only(Package.title, Package.name, raiseload=True)
|
||||
.joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True))
|
||||
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = db.session.query(Package.id).filter(Package.state == PackageState.APPROVED).count()
|
||||
|
||||
spotlight_pkgs = package_spotlight_load(query.filter(
|
||||
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB"))))
|
||||
.order_by(func.random())).limit(6).all()
|
||||
# spotlight_pkgs.insert(0, GameJam())
|
||||
|
||||
new = package_load(query).order_by(db.desc(Package.approved_at)).limit(PKGS_PER_ROW).all() # 0.06
|
||||
pop_mod = package_load(query).filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all()
|
||||
pop_gam = package_load(query).filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all()
|
||||
pop_txp = package_load(query).filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all()
|
||||
|
||||
high_reviewed = package_load(query.order_by(db.desc(Package.score - Package.score_downloads))
|
||||
.filter(Package.reviews.any()).limit(PKGS_PER_ROW)).all()
|
||||
|
||||
recent_releases_query = (
|
||||
db.session.query(
|
||||
Package.id,
|
||||
func.max(PackageRelease.created_at).label("max_created_at")
|
||||
)
|
||||
.join(PackageRelease, Package.releases)
|
||||
.group_by(Package.id)
|
||||
.order_by(db.desc("max_created_at"))
|
||||
.limit(3*PKGS_PER_ROW)
|
||||
.subquery())
|
||||
|
||||
updated = (
|
||||
package_load(db.session.query(Package)
|
||||
.select_from(recent_releases_query)
|
||||
.join(Package, Package.id == recent_releases_query.c.id)
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.limit(PKGS_PER_ROW))
|
||||
.all())
|
||||
|
||||
reviews = review_load(PackageReview.query.filter(PackageReview.rating > 3)
|
||||
.order_by(db.desc(PackageReview.created_at))).limit(5).all()
|
||||
|
||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).outerjoin(Tags).join(Package).filter(Package.state == PackageState.APPROVED)\
|
||||
.group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, spotlight_pkgs=spotlight_pkgs,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed,
|
||||
reviews=reviews)
|
||||
126
app/blueprints/metrics/__init__.py
Normal file
126
app/blueprints/metrics/__init__.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import Blueprint, make_response
|
||||
from sqlalchemy import or_, and_
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app.models import Package, db, User, UserRank, PackageState, PackageReview, ThreadReply, Collection, AuditLogEntry, \
|
||||
PackageTranslation, Language
|
||||
from app.rediscache import get_key
|
||||
|
||||
bp = Blueprint("metrics", __name__)
|
||||
|
||||
|
||||
def generate_metrics():
|
||||
def write_single_stat(name, help, type, value):
|
||||
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
|
||||
|
||||
return fmt.format(name=name, help=help, type=type, value=value)
|
||||
|
||||
def gen_labels(labels):
|
||||
pieces = [f"{key}=\"{val}\"" for key, val in labels.items()]
|
||||
return ",".join(pieces)
|
||||
|
||||
def write_array_stat(name, help, type, data):
|
||||
result = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
|
||||
.format(name=name, help=help, type=type)
|
||||
|
||||
for entry in data:
|
||||
assert(len(entry) == 2)
|
||||
result += "{name}{{{labels}}} {value}\n" \
|
||||
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
|
||||
|
||||
return result + "\n"
|
||||
|
||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
users = User.query.filter(User.rank > UserRank.NOT_JOINED, User.rank != UserRank.BOT, User.is_active).count()
|
||||
authors = User.query.filter(User.packages.any(state=PackageState.APPROVED)).count()
|
||||
|
||||
one_day_ago = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
one_week_ago = datetime.datetime.now() - datetime.timedelta(weeks=1)
|
||||
one_month_ago = datetime.datetime.now() - datetime.timedelta(weeks=4)
|
||||
|
||||
active_users_day = User.query.filter(and_(User.rank != UserRank.BOT, or_(
|
||||
User.audit_log_entries.any(AuditLogEntry.created_at > one_day_ago),
|
||||
User.replies.any(ThreadReply.created_at > one_day_ago)))).count()
|
||||
active_users_week = User.query.filter(and_(User.rank != UserRank.BOT, or_(
|
||||
User.audit_log_entries.any(AuditLogEntry.created_at > one_week_ago),
|
||||
User.replies.any(ThreadReply.created_at > one_week_ago)))).count()
|
||||
active_users_month = User.query.filter(and_(User.rank != UserRank.BOT, or_(
|
||||
User.audit_log_entries.any(AuditLogEntry.created_at > one_month_ago),
|
||||
User.replies.any(ThreadReply.created_at > one_month_ago)))).count()
|
||||
|
||||
reviews = PackageReview.query.count()
|
||||
comments = ThreadReply.query.count()
|
||||
collections = Collection.query.count()
|
||||
|
||||
score_result = db.session.query(func.sum(Package.score)).one_or_none()
|
||||
score = 0 if not score_result or not score_result[0] else score_result[0]
|
||||
|
||||
packages_with_translations = (db.session.query(PackageTranslation.package_id)
|
||||
.filter(PackageTranslation.language_id != "en")
|
||||
.group_by(PackageTranslation.package_id).count())
|
||||
packages_with_translations_meta = (db.session.query(PackageTranslation.package_id)
|
||||
.filter(PackageTranslation.short_desc.is_not(None), PackageTranslation.language_id != "en")
|
||||
.group_by(PackageTranslation.package_id).count())
|
||||
languages_packages = (db.session.query(PackageTranslation.language_id, func.count(Package.id))
|
||||
.select_from(PackageTranslation).outerjoin(Package)
|
||||
.order_by(db.asc(PackageTranslation.language_id))
|
||||
.group_by(PackageTranslation.language_id).all())
|
||||
languages_packages_meta = (db.session.query(PackageTranslation.language_id, func.count(Package.id))
|
||||
.select_from(PackageTranslation).outerjoin(Package)
|
||||
.filter(PackageTranslation.short_desc.is_not(None))
|
||||
.order_by(db.asc(PackageTranslation.language_id))
|
||||
.group_by(PackageTranslation.language_id).all())
|
||||
|
||||
ret = ""
|
||||
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
|
||||
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
|
||||
ret += write_single_stat("contentdb_authors", "Number of users with packages", "gauge", authors)
|
||||
ret += write_single_stat("contentdb_users_active_1d", "Number of daily active registered users", "gauge", active_users_day)
|
||||
ret += write_single_stat("contentdb_users_active_1w", "Number of weekly active registered users", "gauge", active_users_week)
|
||||
ret += write_single_stat("contentdb_users_active_1m", "Number of monthly active registered users", "gauge", active_users_month)
|
||||
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
|
||||
ret += write_single_stat("contentdb_emails", "Number of emails sent", "counter", int(get_key("emails_sent", "0")))
|
||||
ret += write_single_stat("contentdb_reviews", "Number of reviews", "gauge", reviews)
|
||||
ret += write_single_stat("contentdb_comments", "Number of comments", "gauge", comments)
|
||||
ret += write_single_stat("contentdb_collections", "Number of collections", "gauge", collections)
|
||||
ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
|
||||
ret += write_single_stat("contentdb_packages_with_translations", "Number of packages with translations", "gauge",
|
||||
packages_with_translations)
|
||||
ret += write_single_stat("contentdb_packages_with_translations_meta", "Number of packages with translated meta",
|
||||
"gauge", packages_with_translations_meta)
|
||||
ret += write_array_stat("contentdb_languages_translated",
|
||||
"Number of packages per language", "gauge",
|
||||
[({"language": x[0]}, x[1]) for x in languages_packages])
|
||||
ret += write_array_stat("contentdb_languages_translated_meta",
|
||||
"Number of packages with translated short desc per language", "gauge",
|
||||
[({"language": x[0]}, x[1]) for x in languages_packages_meta])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route("/metrics")
|
||||
def metrics():
|
||||
response = make_response(generate_metrics(), 200)
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
||||
68
app/blueprints/modnames/__init__.py
Normal file
68
app/blueprints/modnames/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, redirect, render_template, abort
|
||||
from sqlalchemy import func
|
||||
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
|
||||
|
||||
bp = Blueprint("modnames", __name__)
|
||||
|
||||
|
||||
@bp.route("/metapackages/<path:path>")
|
||||
def mp_redirect(path):
|
||||
return redirect("/modnames/" + path)
|
||||
|
||||
|
||||
@bp.route("/modnames/")
|
||||
def list_all():
|
||||
modnames = db.session.query(MetaPackage, func.count(Package.id)) \
|
||||
.select_from(MetaPackage).outerjoin(MetaPackage.packages) \
|
||||
.order_by(db.asc(MetaPackage.name)) \
|
||||
.group_by(MetaPackage.id).all()
|
||||
return render_template("modnames/list.html", modnames=modnames)
|
||||
|
||||
|
||||
@bp.route("/modnames/<name>/")
|
||||
def view(name):
|
||||
modname = MetaPackage.query.filter_by(name=name).first()
|
||||
if modname is None:
|
||||
abort(404)
|
||||
|
||||
dependers = db.session.query(Package) \
|
||||
.select_from(MetaPackage) \
|
||||
.filter(MetaPackage.name==name) \
|
||||
.join(MetaPackage.dependencies) \
|
||||
.join(Dependency.depender) \
|
||||
.filter(Dependency.optional==False, Package.state==PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
optional_dependers = db.session.query(Package) \
|
||||
.select_from(MetaPackage) \
|
||||
.filter(MetaPackage.name==name) \
|
||||
.join(MetaPackage.dependencies) \
|
||||
.join(Dependency.depender) \
|
||||
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=name) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("modnames/view.html", modname=modname,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
similar_topics=similar_topics)
|
||||
48
app/blueprints/notifications/__init__.py
Normal file
48
app/blueprints/notifications/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, desc
|
||||
|
||||
from app.models import db, Notification, NotificationType
|
||||
|
||||
bp = Blueprint("notifications", __name__)
|
||||
|
||||
|
||||
@bp.route("/notifications/")
|
||||
@login_required
|
||||
def list_all():
|
||||
notifications = Notification.query.filter(Notification.user == current_user,
|
||||
Notification.type != NotificationType.EDITOR_ALERT, Notification.type != NotificationType.EDITOR_MISC) \
|
||||
.order_by(desc(Notification.created_at)) \
|
||||
.all()
|
||||
|
||||
editor_notifications = Notification.query.filter(Notification.user == current_user,
|
||||
or_(Notification.type == NotificationType.EDITOR_ALERT, Notification.type == NotificationType.EDITOR_MISC)) \
|
||||
.order_by(desc(Notification.created_at)) \
|
||||
.all()
|
||||
|
||||
return render_template("notifications/list.html",
|
||||
notifications=notifications, editor_notifications=editor_notifications)
|
||||
|
||||
|
||||
@bp.route("/notifications/clear/", methods=["POST"])
|
||||
@login_required
|
||||
def clear():
|
||||
Notification.query.filter_by(user=current_user).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("notifications.list_all"))
|
||||
264
app/blueprints/oauth/__init__.py
Normal file
264
app/blueprints/oauth/__init__.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import urllib.parse as urlparse
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import typing
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response, flash
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, URLField, SelectField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app import csrf
|
||||
from app.blueprints.users.settings import get_setting_tabs
|
||||
from app.models import db, OAuthClient, User, Permission, APIToken, AuditSeverity, UserRank
|
||||
from app.utils import random_string, add_audit_log
|
||||
|
||||
bp = Blueprint("oauth", __name__)
|
||||
|
||||
|
||||
def build_redirect_url(url: str, code: str, state: typing.Optional[str]):
|
||||
params = {"code": code}
|
||||
if state is not None:
|
||||
params["state"] = state
|
||||
url_parts = list(urlparse.urlparse(url))
|
||||
query = dict(urlparse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
url_parts[4] = urlencode(query)
|
||||
return urlparse.urlunparse(url_parts)
|
||||
|
||||
|
||||
@bp.route("/oauth/authorize/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def oauth_start():
|
||||
response_type = request.args.get("response_type", "code")
|
||||
if response_type != "code":
|
||||
return "Unsupported response_type, only code is supported", 400
|
||||
|
||||
client_id = request.args.get("client_id", "")
|
||||
if client_id == "":
|
||||
return "Missing client_id", 400
|
||||
|
||||
redirect_uri = request.args.get("redirect_uri", "")
|
||||
if redirect_uri == "":
|
||||
return "Missing redirect_uri", 400
|
||||
|
||||
client = OAuthClient.query.get_or_404(client_id)
|
||||
if client.redirect_url != redirect_uri:
|
||||
return "redirect_uri does not match client", 400
|
||||
|
||||
if not client.approved and client.owner != current_user:
|
||||
abort(404)
|
||||
|
||||
scope = request.args.get("scope", "public")
|
||||
if scope != "public":
|
||||
return "Unsupported scope, only public is supported", 400
|
||||
|
||||
state = request.args.get("state")
|
||||
|
||||
token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first()
|
||||
if token:
|
||||
token.access_token = random_string(32)
|
||||
token.auth_code = random_string(32)
|
||||
db.session.commit()
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
if action == "cancel":
|
||||
return redirect(client.redirect_url)
|
||||
|
||||
elif action == "authorize":
|
||||
token = APIToken()
|
||||
token.access_token = random_string(32)
|
||||
token.name = f"Token for {client.title} by {client.owner.username}"
|
||||
token.owner = current_user
|
||||
token.client = client
|
||||
assert client is not None
|
||||
token.auth_code = random_string(32)
|
||||
db.session.add(token)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, current_user,
|
||||
f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ",
|
||||
url_for("users.profile", username=current_user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
return render_template("oauth/authorize.html", client=client)
|
||||
|
||||
|
||||
def error(code: int, msg: str):
|
||||
abort(make_response(jsonify({"success": False, "error": msg}), code))
|
||||
|
||||
|
||||
@bp.route("/oauth/token/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def oauth_grant():
|
||||
form = request.form
|
||||
|
||||
grant_type = request.args.get("grant_type", "authorization_code")
|
||||
if grant_type != "authorization_code":
|
||||
error(400, "Unsupported grant_type, only authorization_code is supported")
|
||||
|
||||
client_id = form.get("client_id", "")
|
||||
if client_id == "":
|
||||
error(400, "Missing client_id")
|
||||
|
||||
client_secret = form.get("client_secret", "")
|
||||
if client_secret == "":
|
||||
error(400, "Missing client_secret")
|
||||
|
||||
code = form.get("code", "")
|
||||
if code == "":
|
||||
error(400, "Missing code")
|
||||
|
||||
client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first()
|
||||
if client is None:
|
||||
error(400, "client_id and/or client_secret is incorrect")
|
||||
|
||||
token = APIToken.query.filter_by(auth_code=code).first()
|
||||
if token is None or token.client != client:
|
||||
error(400, "Incorrect code. It may have already been redeemed")
|
||||
|
||||
token.auth_code = None
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"access_token": token.access_token,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/user/apps/")
|
||||
@login_required
|
||||
def list_clients_redirect():
|
||||
return redirect(url_for("oauth.list_clients", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/")
|
||||
@login_required
|
||||
def list_clients(username):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
return render_template("oauth/list_clients.html", user=user, tabs=get_setting_tabs(user), current_tab="oauth_clients")
|
||||
|
||||
|
||||
class OAuthClientForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)])
|
||||
description = StringField(lazy_gettext("Description"), [Optional()])
|
||||
redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)])
|
||||
app_type = SelectField(lazy_gettext("App Type"), [InputRequired()], choices=[
|
||||
("server", "Server-side (client_secret is kept safe)"),
|
||||
("client", "Client-side (client_secret is visible to all users)"),
|
||||
], coerce=lambda x: x)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/new/", methods=["GET", "POST"])
|
||||
@bp.route("/users/<username>/apps/<id_>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_client(username, id_=None):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
is_new = id_ is None
|
||||
client = None
|
||||
if id_ is not None:
|
||||
client = OAuthClient.query.get_or_404(id_)
|
||||
if client.owner != user:
|
||||
abort(404)
|
||||
|
||||
form = OAuthClientForm(formdata=request.form, obj=client)
|
||||
|
||||
if form.validate_on_submit():
|
||||
if is_new:
|
||||
if OAuthClient.query.filter(OAuthClient.title.ilike(form.title.data.strip())).count() > 0:
|
||||
flash(gettext("An OAuth client with that title already exists. Please choose a new title."), "danger")
|
||||
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
|
||||
|
||||
client = OAuthClient()
|
||||
db.session.add(client)
|
||||
client.owner = user
|
||||
client.id = random_string(24)
|
||||
client.secret = random_string(32)
|
||||
client.approved = current_user.rank.at_least(UserRank.EDITOR)
|
||||
|
||||
|
||||
form.populate_obj(client)
|
||||
|
||||
verb = "Created" if is_new else "Edited"
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"{verb} OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.create_edit_client", username=username, id_=client.id))
|
||||
|
||||
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/<id_>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
def delete_client(username, id_):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
client = OAuthClient.query.get(id_)
|
||||
if client is None or client.owner != user:
|
||||
abort(404)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Deleted OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("users.profile", username=current_user.username))
|
||||
|
||||
db.session.delete(client)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.list_clients", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/<id_>/revoke-all/", methods=["POST"])
|
||||
@login_required
|
||||
def revoke_all(username, id_):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
client = OAuthClient.query.get(id_)
|
||||
if client is None or client.owner != user:
|
||||
abort(404)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Revoked all user tokens for OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
|
||||
client.tokens = []
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Revoked all user tokens"), "success")
|
||||
|
||||
return redirect(url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
87
app/blueprints/packages/__init__.py
Normal file
87
app/blueprints/packages/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_babel import gettext
|
||||
|
||||
from app.models import User, Package, Permission, PackageType
|
||||
|
||||
bp = Blueprint("packages", __name__)
|
||||
|
||||
|
||||
def get_package_tabs(user: User, package: Package):
|
||||
if package is None or not package.check_perm(user, Permission.EDIT_PACKAGE):
|
||||
return []
|
||||
|
||||
retval = [
|
||||
{
|
||||
"id": "edit",
|
||||
"title": gettext("Edit Details"),
|
||||
"url": package.get_url("packages.create_edit")
|
||||
},
|
||||
{
|
||||
"id": "translation",
|
||||
"title": gettext("Translation"),
|
||||
"url": package.get_url("packages.translation")
|
||||
},
|
||||
{
|
||||
"id": "releases",
|
||||
"title": gettext("Releases"),
|
||||
"url": package.get_url("packages.list_releases")
|
||||
},
|
||||
{
|
||||
"id": "screenshots",
|
||||
"title": gettext("Screenshots"),
|
||||
"url": package.get_url("packages.screenshots")
|
||||
},
|
||||
{
|
||||
"id": "maintainers",
|
||||
"title": gettext("Maintainers"),
|
||||
"url": package.get_url("packages.edit_maintainers")
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"title": gettext("Audit Log"),
|
||||
"url": package.get_url("packages.audit")
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"title": gettext("Statistics"),
|
||||
"url": package.get_url("packages.statistics")
|
||||
},
|
||||
{
|
||||
"id": "share",
|
||||
"title": gettext("Share and Badges"),
|
||||
"url": package.get_url("packages.share")
|
||||
},
|
||||
{
|
||||
"id": "remove",
|
||||
"title": gettext("Remove / Unpublish"),
|
||||
"url": package.get_url("packages.remove")
|
||||
}
|
||||
]
|
||||
|
||||
if package.type == PackageType.MOD or package.type == PackageType.TXP:
|
||||
retval.insert(2, {
|
||||
"id": "game_support",
|
||||
"title": gettext("Supported Games"),
|
||||
"url": package.get_url("packages.game_support")
|
||||
})
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
from . import packages, advanced_search, screenshots, releases, reviews, game_hub
|
||||
103
app/blueprints/packages/advanced_search.py
Normal file
103
app/blueprints/packages/advanced_search.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms.fields.choices import SelectField, SelectMultipleField
|
||||
from wtforms.fields.simple import StringField, BooleanField
|
||||
from wtforms.validators import Optional
|
||||
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
|
||||
|
||||
from . import bp
|
||||
from ...models import PackageType, Tag, db, ContentWarning, License, Language, LuantiRelease, Package, PackageState
|
||||
|
||||
|
||||
def make_label(obj: Tag | ContentWarning):
|
||||
translated = obj.get_translated()
|
||||
if translated["description"]:
|
||||
return "{}: {}".format(translated["title"], translated["description"])
|
||||
else:
|
||||
return translated["title"]
|
||||
|
||||
|
||||
def get_hide_choices():
|
||||
ret = [
|
||||
("android_default", gettext("Android Default")),
|
||||
("desktop_default", gettext("Desktop Default")),
|
||||
("nonfree", gettext("Non-free")),
|
||||
("wip", gettext("Work in Progress")),
|
||||
("deprecated", gettext("Deprecated")),
|
||||
("*", gettext("All content warnings")),
|
||||
]
|
||||
content_warnings = ContentWarning.query.order_by(db.asc(ContentWarning.name)).all()
|
||||
tags = Tag.query.order_by(db.asc(Tag.name)).all()
|
||||
ret += [(x.name, make_label(x)) for x in content_warnings + tags]
|
||||
return ret
|
||||
|
||||
|
||||
class AdvancedSearchForm(FlaskForm):
|
||||
q = StringField(lazy_gettext("Query"), [Optional()])
|
||||
type = SelectMultipleField(lazy_gettext("Type"), [Optional()],
|
||||
choices=PackageType.choices(), coerce=PackageType.coerce)
|
||||
author = StringField(lazy_gettext("Author"), [Optional()])
|
||||
tag = QuerySelectMultipleField(lazy_gettext('Tags'),
|
||||
query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)),
|
||||
get_pk=lambda a: a.name, get_label=make_label)
|
||||
flag = QuerySelectMultipleField(lazy_gettext('Content Warnings'),
|
||||
query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)),
|
||||
get_pk=lambda a: a.name, get_label=make_label)
|
||||
license = QuerySelectMultipleField(lazy_gettext("License"), [Optional()],
|
||||
query_factory=lambda: License.query.order_by(db.asc(License.name)),
|
||||
allow_blank=True, blank_value="",
|
||||
get_pk=lambda a: a.name, get_label=lambda a: a.name)
|
||||
game = QuerySelectField(lazy_gettext("Supports Game"), [Optional()],
|
||||
query_factory=lambda: Package.query.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED).order_by(db.asc(Package.name)),
|
||||
allow_blank=True, blank_value="",
|
||||
get_pk=lambda a: f"{a.author.username}/{a.name}",
|
||||
get_label=lambda a: lazy_gettext("%(title)s by %(author)s", title=a.title, author=a.author.display_name))
|
||||
lang = QuerySelectField(lazy_gettext("Language"),
|
||||
query_factory=lambda: Language.query.order_by(db.asc(Language.title)),
|
||||
allow_blank=True, blank_value="",
|
||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
|
||||
engine_version = QuerySelectField(lazy_gettext("Luanti Version"),
|
||||
query_factory=lambda: LuantiRelease.query.order_by(db.asc(LuantiRelease.id)),
|
||||
allow_blank=True, blank_value="",
|
||||
get_pk=lambda a: a.value, get_label=lambda a: a.name)
|
||||
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[
|
||||
("", ""),
|
||||
("name", lazy_gettext("Name")),
|
||||
("title", lazy_gettext("Title")),
|
||||
("score", lazy_gettext("Package score")),
|
||||
("reviews", lazy_gettext("Reviews")),
|
||||
("downloads", lazy_gettext("Downloads")),
|
||||
("created_at", lazy_gettext("Created At")),
|
||||
("approved_at", lazy_gettext("Approved At")),
|
||||
("last_release", lazy_gettext("Last Release")),
|
||||
])
|
||||
order = SelectField(lazy_gettext("Order"), [Optional()], choices=[
|
||||
("desc", lazy_gettext("Descending")),
|
||||
("asc", lazy_gettext("Ascending")),
|
||||
])
|
||||
random = BooleanField(lazy_gettext("Random order"))
|
||||
|
||||
|
||||
@bp.route("/packages/advanced-search/")
|
||||
def advanced_search():
|
||||
form = AdvancedSearchForm()
|
||||
form.hide.choices = get_hide_choices()
|
||||
return render_template("packages/advanced_search.html", form=form)
|
||||
53
app/blueprints/packages/game_hub.py
Normal file
53
app/blueprints/packages/game_hub.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template, abort
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from . import bp
|
||||
from app.utils import is_package_page
|
||||
from app.models import Package, PackageType, PackageState, db, PackageRelease
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/hub/")
|
||||
@is_package_page
|
||||
def game_hub(package: Package):
|
||||
if package.type != PackageType.GAME:
|
||||
abort(404)
|
||||
|
||||
def join(query):
|
||||
return query.options(
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
query = Package.query.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
|
||||
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
||||
.filter(Package.reviews.any()).limit(4).all()
|
||||
|
||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
||||
.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.created_at)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
return render_template("packages/game_hub.html", package=package, count=count,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp,
|
||||
high_reviewed=high_reviewed)
|
||||
871
app/blueprints/packages/packages.py
Normal file
871
app/blueprints/packages/packages.py
Normal file
@@ -0,0 +1,871 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
from urllib.parse import quote as urlescape
|
||||
|
||||
from celery import uuid
|
||||
from flask import render_template, make_response, request, redirect, flash, url_for, abort
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from jinja2.utils import markupsafe
|
||||
from sqlalchemy import func, or_, and_
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField
|
||||
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.rediscache import has_key, set_temp_key
|
||||
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, remove_package_game_support, \
|
||||
update_package_game_support
|
||||
from app.tasks.pkgtasks import check_package_on_submit
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
from . import bp, get_package_tabs
|
||||
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
|
||||
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
|
||||
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
|
||||
PackageDailyStats, Collection
|
||||
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
|
||||
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options, \
|
||||
post_to_approval_thread, normalize_line_endings
|
||||
from app.logic.package_approval import validate_package_for_approval, can_move_to_state
|
||||
from app.logic.game_support import game_support_set
|
||||
|
||||
|
||||
@bp.route("/packages/")
|
||||
def list_all():
|
||||
qb = QueryBuilder(request.args, cookies=True)
|
||||
query = qb.build_package_query()
|
||||
title = qb.title
|
||||
|
||||
query = query.options(
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license),
|
||||
subqueryload(Package.tags))
|
||||
|
||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
if ip is not None and not is_user_bot():
|
||||
edited = False
|
||||
for tag in qb.tags:
|
||||
edited = True
|
||||
key = "tag/{}/{}".format(ip, tag.name)
|
||||
if not has_key(key):
|
||||
set_temp_key(key, "true")
|
||||
Tag.query.filter_by(id=tag.id).update({
|
||||
"views": Tag.views + 1
|
||||
})
|
||||
|
||||
if edited:
|
||||
db.session.commit()
|
||||
|
||||
if qb.lucky:
|
||||
package = query.first()
|
||||
if package:
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
topic = qb.build_topic_query().first()
|
||||
if qb.search and topic:
|
||||
return redirect(topic.url)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
query = query.paginate(page=page, per_page=num)
|
||||
|
||||
search = request.args.get("q")
|
||||
type_name = request.args.get("type")
|
||||
|
||||
authors = []
|
||||
if search:
|
||||
authors = User.query \
|
||||
.filter(or_(*[func.lower(User.username) == name.lower().strip() for name in search.split(" ")])) \
|
||||
.all()
|
||||
|
||||
authors = [(author.username, search.lower().replace(author.username.lower(), "")) for author in authors]
|
||||
|
||||
topics = None
|
||||
if qb.search and not query.has_next:
|
||||
topics = qb.build_topic_query().all()
|
||||
|
||||
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).join(Tags).join(Package).filter(Package.state==PackageState.APPROVED) \
|
||||
.group_by(Tag.id).order_by(db.asc(Tag.title))
|
||||
tags = qb.filter_package_query(tags_query).all()
|
||||
|
||||
selected_tags = set(qb.tags)
|
||||
|
||||
return render_template("packages/list.html",
|
||||
query_hint=qb.query_hint, packages=query.items, pagination=query,
|
||||
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
|
||||
authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex)
|
||||
|
||||
|
||||
def get_releases(package):
|
||||
if package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
return package.releases.limit(5)
|
||||
else:
|
||||
return package.releases.filter_by(approved=True).limit(5)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/")
|
||||
def user_redirect(author):
|
||||
return redirect(url_for("users.profile", username=author))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
if not package.check_perm(current_user, Permission.VIEW_PACKAGE):
|
||||
return render_template("packages/gone.html", package=package), 403
|
||||
|
||||
packages_uses = None
|
||||
if package.type == PackageType.MOD:
|
||||
packages_uses = Package.query.filter(
|
||||
Package.type == PackageType.MOD,
|
||||
Package.id != package.id,
|
||||
Package.state == PackageState.APPROVED,
|
||||
Package.dependencies.any(
|
||||
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
|
||||
.order_by(db.desc(Package.score)).limit(6).all()
|
||||
|
||||
releases = get_releases(package)
|
||||
|
||||
review_thread = package.review_thread
|
||||
if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
review_thread = None
|
||||
|
||||
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
|
||||
if not current_user.is_authenticated:
|
||||
threads = threads.filter_by(private=False)
|
||||
elif not current_user.rank.at_least(UserRank.APPROVER) and not current_user == package.author:
|
||||
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
|
||||
|
||||
has_review = current_user.is_authenticated and \
|
||||
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||
|
||||
validation = None
|
||||
if package.state != PackageState.APPROVED:
|
||||
validation = validate_package_for_approval(package)
|
||||
|
||||
favorites_count = Collection.query.filter(
|
||||
Collection.packages.contains(package),
|
||||
Collection.name == "favorites").count()
|
||||
|
||||
public_collection_count = Collection.query.filter(
|
||||
Collection.packages.contains(package),
|
||||
Collection.private == False).count()
|
||||
|
||||
is_favorited = current_user.is_authenticated and \
|
||||
Collection.query.filter(
|
||||
Collection.author == current_user,
|
||||
Collection.packages.contains(package),
|
||||
Collection.name == "favorites").count() > 0
|
||||
|
||||
return render_template("packages/view.html",
|
||||
package=package, releases=releases, packages_uses=packages_uses,
|
||||
review_thread=review_thread, threads=threads.all(), validation=validation,
|
||||
has_review=has_review, favorites_count=favorites_count, is_favorited=is_favorited,
|
||||
public_collection_count=public_collection_count)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/shields/<type>/")
|
||||
@is_package_page
|
||||
def shield(package, type):
|
||||
if type == "title":
|
||||
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
|
||||
.format(urlescape(package.title), urlescape("#375a7f"))
|
||||
elif type == "downloads":
|
||||
api_url = abs_url_for("api.package_view", author=package.author.username, name=package.name)
|
||||
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
|
||||
.format(urlescape("#375a7f"), urlescape(api_url))
|
||||
else:
|
||||
from flask import abort
|
||||
abort(404)
|
||||
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/download/")
|
||||
@is_package_page
|
||||
def download(package):
|
||||
release = package.get_download_release()
|
||||
|
||||
if release is None:
|
||||
if "application/zip" in request.accept_mimetypes and \
|
||||
"text/html" not in request.accept_mimetypes:
|
||||
return "", 204
|
||||
else:
|
||||
flash(gettext("No download available."), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
else:
|
||||
return redirect(release.get_download_url())
|
||||
|
||||
|
||||
def make_label(obj: Tag | ContentWarning):
|
||||
translated = obj.get_translated()
|
||||
if translated["description"]:
|
||||
return "{}: {}".format(translated["title"], translated["description"])
|
||||
else:
|
||||
return translated["title"]
|
||||
|
||||
|
||||
class PackageForm(FlaskForm):
|
||||
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
|
||||
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
|
||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
||||
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
|
||||
|
||||
dev_state = SelectField(lazy_gettext("Maintenance State"), [DataRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
|
||||
|
||||
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=make_label)
|
||||
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=make_label)
|
||||
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
|
||||
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)], filters=[normalize_line_endings])
|
||||
|
||||
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)])
|
||||
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None])
|
||||
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
|
||||
translation_url = StringField(lazy_gettext("Translation URL"), [Optional(), URL()], filters=[lambda x: x or None])
|
||||
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
def validate_name(self, field):
|
||||
if field.data == "_game":
|
||||
raise ValidationError(lazy_gettext("_game is not an allowed name"))
|
||||
|
||||
|
||||
def handle_create_edit(package: typing.Optional[Package], form: PackageForm, author: User):
|
||||
wasNew = False
|
||||
if package is None:
|
||||
package = Package.query.filter_by(name=form.name.data, author_id=author.id).first()
|
||||
if package is not None:
|
||||
if package.state == PackageState.DELETED:
|
||||
flash(
|
||||
gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"),
|
||||
"danger")
|
||||
return redirect(url_for("report.report", url=package.get_url("packages.view")))
|
||||
else:
|
||||
flash(markupsafe.Markup(
|
||||
f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" +
|
||||
gettext("Package already exists")), "danger")
|
||||
return None
|
||||
|
||||
if Collection.query \
|
||||
.filter(Collection.name == form.name.data, Collection.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("A collection with a similar name already exists"), "danger")
|
||||
return
|
||||
|
||||
package = Package()
|
||||
db.session.add(package)
|
||||
package.author = author
|
||||
package.maintainers.append(author)
|
||||
wasNew = True
|
||||
|
||||
try:
|
||||
do_edit_package(current_user, package, wasNew, True, {
|
||||
"type": form.type.data,
|
||||
"title": form.title.data,
|
||||
"name": form.name.data,
|
||||
"short_desc": form.short_desc.data,
|
||||
"dev_state": form.dev_state.data,
|
||||
"tags": form.tags.raw_data,
|
||||
"content_warnings": form.content_warnings.raw_data,
|
||||
"license": form.license.data,
|
||||
"media_license": form.media_license.data,
|
||||
"desc": form.desc.data,
|
||||
"repo": form.repo.data,
|
||||
"website": form.website.data,
|
||||
"issueTracker": form.issueTracker.data,
|
||||
"forums": form.forums.data,
|
||||
"video_url": form.video_url.data,
|
||||
"donate_url": form.donate_url.data,
|
||||
"translation_url": form.translation_url.data,
|
||||
})
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
import_repo_screenshot.delay(package.id)
|
||||
|
||||
next_url = package.get_url("packages.view")
|
||||
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
||||
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
||||
elif wasNew:
|
||||
next_url = package.get_url("packages.setup_releases")
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
@bp.route("/packages/new/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit(author=None, name=None):
|
||||
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
|
||||
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
||||
return redirect(url_for("users.email_notifications"))
|
||||
|
||||
package = None
|
||||
if author is None:
|
||||
form = PackageForm(formdata=request.form)
|
||||
form.submit.label.text = lazy_gettext("Save draft")
|
||||
|
||||
author = request.args.get("author")
|
||||
if author is None or author == current_user.username:
|
||||
author = current_user
|
||||
else:
|
||||
author = User.query.filter_by(username=author).first()
|
||||
if author is None:
|
||||
flash(gettext("Unable to find that user"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
if not author.check_perm(current_user, Permission.CHANGE_AUTHOR):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
else:
|
||||
package = get_package_by_info(author, name)
|
||||
if package is None:
|
||||
abort(404)
|
||||
if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
|
||||
abort(403)
|
||||
|
||||
author = package.author
|
||||
|
||||
form = PackageForm(formdata=request.form, obj=package)
|
||||
|
||||
# Initial form class from post data and default data
|
||||
if request.method == "GET":
|
||||
if package is None:
|
||||
form.name.data = request.args.get("bname")
|
||||
form.title.data = request.args.get("title")
|
||||
form.repo.data = request.args.get("repo")
|
||||
form.forums.data = request.args.get("forums")
|
||||
form.license.data = None
|
||||
form.media_license.data = None
|
||||
else:
|
||||
form.tags.data = package.tags
|
||||
form.content_warnings.data = package.content_warnings
|
||||
|
||||
if request.method == "POST" and form.type.data == PackageType.TXP:
|
||||
form.license.data = form.media_license.data
|
||||
|
||||
if form.validate_on_submit():
|
||||
ret = handle_create_edit(package, form, author)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
package_query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
if package is not None:
|
||||
package_query = package_query.filter(Package.id != package.id)
|
||||
|
||||
enableWizard = name is None and request.method != "POST"
|
||||
return render_template("packages/create_edit.html", package=package,
|
||||
form=form, author=author, enable_wizard=enableWizard,
|
||||
packages=package_query.all(),
|
||||
modnames=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
|
||||
tabs=get_package_tabs(current_user, package), current_tab="edit")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/state/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def move_to_state(package):
|
||||
state = PackageState.get(request.args.get("state"))
|
||||
if state is None:
|
||||
abort(400)
|
||||
|
||||
if package.state == state:
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
if not can_move_to_state(package, current_user, state):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
old_state = package.state
|
||||
package.state = state
|
||||
msg = "Marked {} as {}".format(package.title, state.value)
|
||||
|
||||
if state == PackageState.APPROVED:
|
||||
if not package.approved_at:
|
||||
post_discord_webhook.delay(package.author.display_name,
|
||||
"New package {}".format(package.get_url("packages.view", absolute=True)), False,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True, "png"))
|
||||
package.approved_at = datetime.datetime.now()
|
||||
|
||||
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
|
||||
for s in screenshots:
|
||||
s.approved = True
|
||||
|
||||
msg = "Approved {}".format(package.title)
|
||||
update_package_game_support.delay(package.id)
|
||||
elif state == PackageState.READY_FOR_REVIEW:
|
||||
post_discord_webhook.delay(package.author.display_name,
|
||||
"Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True, "png"))
|
||||
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
|
||||
post_to_approval_thread(package, current_user, msg, True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
check_package_on_submit.delay(package.id)
|
||||
|
||||
if package.state == PackageState.CHANGES_NEEDED:
|
||||
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
|
||||
if package.review_thread:
|
||||
return redirect(package.review_thread.get_view_url())
|
||||
else:
|
||||
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
|
||||
elif (package.review_thread and
|
||||
old_state == PackageState.CHANGES_NEEDED and package.state == PackageState.READY_FOR_REVIEW):
|
||||
flash(gettext("Please comment in the approval thread so editors know what you have changed"), "warning")
|
||||
return redirect(package.review_thread.get_view_url())
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/translation/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def translation(package):
|
||||
return render_template("packages/translation.html", package=package,
|
||||
has_content_translations=any([x.title or x.short_desc for x in package.translations.all()]),
|
||||
tabs=get_package_tabs(current_user, package), current_tab="translation")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def remove(package):
|
||||
if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
|
||||
abort(403)
|
||||
|
||||
states = [PackageDevState.AS_IS, PackageDevState.DEPRECATED, PackageDevState.LOOKING_FOR_MAINTAINER]
|
||||
|
||||
if request.method == "GET":
|
||||
# Find packages that will having missing hard deps after this action
|
||||
broken_meta = MetaPackage.query.filter(MetaPackage.packages.contains(package),
|
||||
~MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED)))
|
||||
hard_deps = Package.query.filter(
|
||||
Package.state == PackageState.APPROVED,
|
||||
Package.dependencies.any(
|
||||
and_(Dependency.meta_package_id.in_([x.id for x in broken_meta]), Dependency.optional == False))).all()
|
||||
|
||||
return render_template("packages/remove.html",
|
||||
package=package, hard_deps=hard_deps, states=states,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="remove")
|
||||
|
||||
for state in states:
|
||||
if state.name in request.form:
|
||||
flash(gettext("Set state to %(state)s", state=state.title), "success")
|
||||
package.dev_state = state
|
||||
msg = "Set dev state of {} to {}".format(package.title, state.title)
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
reason = request.form.get("reason") or "?"
|
||||
if len(reason) > 500:
|
||||
abort(400)
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.check_perm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
package.state = PackageState.DELETED
|
||||
|
||||
url = url_for("users.profile", username=package.author.username)
|
||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, msg, url, package)
|
||||
db.session.commit()
|
||||
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
f"Deleted package {package.author.username}/{package.name} with reason '{reason}'",
|
||||
True, package.title, package.short_desc, package.get_thumb_url(2, True, "png"))
|
||||
|
||||
remove_package_game_support.delay(package.id)
|
||||
|
||||
flash(gettext("Deleted package"), "success")
|
||||
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.check_perm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
package.state = PackageState.WIP
|
||||
|
||||
msg = "Unapproved {}, reason={}".format(package.title, reason)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, msg, package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True, "png"))
|
||||
|
||||
remove_package_game_support.delay(package.id)
|
||||
|
||||
flash(gettext("Unapproved package"), "success")
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
|
||||
|
||||
class PackageMaintainersForm(FlaskForm):
|
||||
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_maintainers(package):
|
||||
if not package.check_perm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
flash(gettext("You don't have permission to edit maintainers"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
form = PackageMaintainersForm(formdata=request.form)
|
||||
if request.method == "GET":
|
||||
form.maintainers_str.data = ", ".join([ x.username for x in package.maintainers if x != package.author ])
|
||||
|
||||
if form.validate_on_submit():
|
||||
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
|
||||
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
|
||||
|
||||
thread = package.threads.filter_by(author=get_system_user()).first()
|
||||
|
||||
for user in users:
|
||||
if not user in package.maintainers:
|
||||
if thread and user not in thread.watchers:
|
||||
thread.watchers.append(user)
|
||||
add_notification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
|
||||
for user in package.maintainers:
|
||||
if user != package.author and not user in users:
|
||||
add_notification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Removed you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
|
||||
package.maintainers.clear()
|
||||
package.maintainers.extend(users)
|
||||
if package.author not in package.maintainers:
|
||||
package.maintainers.append(package.author)
|
||||
|
||||
msg = "Edited {} maintainers".format(package.title)
|
||||
add_notification(package.author, current_user, NotificationType.MAINTAINER, msg, package.get_url("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
|
||||
|
||||
return render_template("packages/edit_maintainers.html", package=package, form=form,
|
||||
users=users, tabs=get_package_tabs(current_user, package), current_tab="maintainers")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def remove_self_maintainers(package):
|
||||
if not current_user in package.maintainers:
|
||||
flash(gettext("You are not a maintainer"), "danger")
|
||||
|
||||
elif current_user == package.author:
|
||||
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger")
|
||||
|
||||
else:
|
||||
package.maintainers.remove(current_user)
|
||||
|
||||
add_notification(package.author, current_user, NotificationType.MAINTAINER,
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/audit/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def audit(package):
|
||||
if not (package.check_perm(current_user, Permission.EDIT_PACKAGE) or
|
||||
package.check_perm(current_user, Permission.APPROVE_NEW)):
|
||||
abort(403)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
|
||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
|
||||
|
||||
|
||||
class PackageAliasForm(FlaskForm):
|
||||
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
|
||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
|
||||
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@is_package_page
|
||||
def alias_list(package: Package):
|
||||
return render_template("packages/alias_list.html", package=package)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@is_package_page
|
||||
def alias_create_edit(package: Package, alias_id: int = None):
|
||||
alias = None
|
||||
if alias_id:
|
||||
alias = PackageAlias.query.get(alias_id)
|
||||
if alias is None or alias.package != package:
|
||||
abort(404)
|
||||
|
||||
form = PackageAliasForm(request.form, obj=alias)
|
||||
if form.validate_on_submit():
|
||||
if alias is None:
|
||||
alias = PackageAlias()
|
||||
alias.package = package
|
||||
db.session.add(alias)
|
||||
|
||||
form.populate_obj(alias)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.alias_list"))
|
||||
|
||||
return render_template("packages/alias_create_edit.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/share/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def share(package):
|
||||
return render_template("packages/share.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="share")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/similar/")
|
||||
@is_package_page
|
||||
def similar(package):
|
||||
packages_modnames = {}
|
||||
for mname in package.provides:
|
||||
packages_modnames[mname] = Package.query.filter(Package.id != package.id,
|
||||
Package.state != PackageState.DELETED) \
|
||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == mname.id)) \
|
||||
.order_by(db.desc(Package.score)) \
|
||||
.all()
|
||||
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=package.name) \
|
||||
.filter(ForumTopic.topic_id != package.forums) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("packages/similar.html", package=package,
|
||||
packages_modnames=packages_modnames, similar_topics=similar_topics)
|
||||
|
||||
|
||||
def csv_games_check(_form, field):
|
||||
game_names = [name.strip() for name in field.data.split(",")]
|
||||
if len(game_names) == 0 or (len(game_names) == 1 and game_names[0] == ""):
|
||||
return
|
||||
|
||||
missing = set()
|
||||
for game_name in game_names:
|
||||
if game_name.endswith("_game"):
|
||||
game_name = game_name[:-5]
|
||||
if Package.query.filter(and_(Package.state==PackageState.APPROVED, Package.type==PackageType.GAME,
|
||||
or_(Package.name==game_name, Package.name==game_name + "_game"))).count() == 0:
|
||||
missing.add(game_name)
|
||||
|
||||
if len(missing) > 0:
|
||||
raise ValidationError(f"Unable to find game {','.join(missing)}")
|
||||
|
||||
|
||||
class GameSupportForm(FlaskForm):
|
||||
enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()])
|
||||
supported = StringField(lazy_gettext("Supported games"), [Optional(), csv_games_check])
|
||||
unsupported = StringField(lazy_gettext("Unsupported games"), [Optional(), csv_games_check])
|
||||
supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/support/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def game_support(package):
|
||||
if package.type != PackageType.MOD and package.type != PackageType.TXP:
|
||||
abort(404)
|
||||
|
||||
can_edit = package.check_perm(current_user, Permission.EDIT_PACKAGE)
|
||||
if not (can_edit or package.check_perm(current_user, Permission.APPROVE_NEW)):
|
||||
abort(403)
|
||||
|
||||
if package.releases.count() == 0:
|
||||
flash(gettext("You need at least one release before you can edit game support"), "danger")
|
||||
return redirect(package.get_url('packages.create_release' if package.update_config else 'packages.setup_releases'))
|
||||
|
||||
if package.type == PackageType.MOD and len(package.provides) == 0:
|
||||
flash(gettext("Mod(pack) needs to contain at least one mod. Please create a new release"), "danger")
|
||||
return redirect(package.get_url('packages.list_releases'))
|
||||
|
||||
force_game_detection = package.supported_games.filter(and_(
|
||||
PackageGameSupport.confidence > 1, PackageGameSupport.supports == True)).count() == 0
|
||||
|
||||
can_support_all_games = package.type != PackageType.TXP and \
|
||||
package.supported_games.filter(and_(
|
||||
PackageGameSupport.confidence == 1, PackageGameSupport.supports == True)).count() == 0
|
||||
|
||||
can_override = can_edit
|
||||
|
||||
form = GameSupportForm() if can_edit else None
|
||||
if form and request.method == "GET":
|
||||
form.enable_support_detection.data = package.enable_game_support_detection
|
||||
form.supports_all_games.data = package.supports_all_games and can_support_all_games
|
||||
|
||||
if can_override:
|
||||
manual_supported_games = package.supported_games.filter_by(confidence=11).all()
|
||||
form.supported.data = ", ".join([x.game.name for x in manual_supported_games if x.supports])
|
||||
form.unsupported.data = ", ".join([x.game.name for x in manual_supported_games if not x.supports])
|
||||
else:
|
||||
form.supported = None
|
||||
form.unsupported = None
|
||||
|
||||
if form and form.validate_on_submit():
|
||||
detect_update_needed = False
|
||||
|
||||
if can_override:
|
||||
try:
|
||||
game_is_supported = {}
|
||||
for game in get_games_from_csv(db.session, form.supported.data or ""):
|
||||
game_is_supported[game.id] = True
|
||||
for game in get_games_from_csv(db.session, form.unsupported.data or ""):
|
||||
game_is_supported[game.id] = False
|
||||
game_support_set(db.session, package, game_is_supported, 11)
|
||||
detect_update_needed = True
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
next_url = package.get_url("packages.game_support")
|
||||
|
||||
enable_support_detection = form.enable_support_detection.data or force_game_detection
|
||||
if enable_support_detection != package.enable_game_support_detection:
|
||||
package.enable_game_support_detection = enable_support_detection
|
||||
if package.enable_game_support_detection:
|
||||
detect_update_needed = True
|
||||
else:
|
||||
package.supported_games.filter_by(confidence=1).delete()
|
||||
|
||||
if can_support_all_games:
|
||||
package.supports_all_games = form.supports_all_games.data
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, "Edited game support", package.get_url("packages.game_support"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if detect_update_needed:
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
task_id = uuid()
|
||||
check_zip_release.apply_async((release.id, release.file_path), task_id=task_id)
|
||||
next_url = url_for("tasks.check", id=task_id, r=next_url)
|
||||
|
||||
return redirect(next_url)
|
||||
|
||||
all_game_support = package.supported_games.all()
|
||||
all_game_support.sort(key=lambda x: -x.game.score)
|
||||
supported_games_list: typing.List[str] = [x.game.name for x in all_game_support if x.supports]
|
||||
if package.supports_all_games:
|
||||
supported_games_list.insert(0, "*")
|
||||
supported_games = ", ".join(supported_games_list)
|
||||
unsupported_games = ", ".join([x.game.name for x in all_game_support if not x.supports])
|
||||
|
||||
mod_conf_lines = ""
|
||||
if supported_games:
|
||||
mod_conf_lines += f"supported_games = {supported_games}"
|
||||
if unsupported_games:
|
||||
mod_conf_lines += f"\nunsupported_games = {unsupported_games}"
|
||||
|
||||
return render_template("packages/game_support.html", package=package, form=form,
|
||||
mod_conf_lines=mod_conf_lines, force_game_detection=force_game_detection,
|
||||
can_support_all_games=can_support_all_games, tabs=get_package_tabs(current_user, package),
|
||||
current_tab="game_support")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/stats/")
|
||||
@is_package_page
|
||||
def statistics(package):
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
|
||||
return render_template("packages/stats.html",
|
||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats",
|
||||
start=start, end=end, options=get_daterange_options(), noindex=start or end)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/stats.csv")
|
||||
@is_package_page
|
||||
def stats_csv(package):
|
||||
stats: typing.List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
|
||||
|
||||
columns = ["platform_minetest", "platform_other", "reason_new",
|
||||
"reason_dependency", "reason_update"]
|
||||
|
||||
result = "Date, " + ", ".join(columns) + "\n"
|
||||
|
||||
for stat in stats:
|
||||
stat: PackageDailyStats
|
||||
result += stat.date.isoformat()
|
||||
for i, key in enumerate(columns):
|
||||
result += ", " + str(getattr(stat, key))
|
||||
result += "\n"
|
||||
|
||||
date = datetime.datetime.utcnow().date()
|
||||
|
||||
res = make_response(result, 200)
|
||||
res.headers["Content-Disposition"] = f"attachment; filename={package.author.username}_{package.name}_stats_{date.isoformat()}.csv"
|
||||
res.headers["Content-type"] = "text/csv"
|
||||
return res
|
||||
395
app/blueprints/packages/releases.py
Normal file
395
app/blueprints/packages/releases.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
|
||||
from flask import render_template, request, redirect, flash, url_for, abort
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
|
||||
from wtforms.fields.simple import TextAreaField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, LuantiRelease, \
|
||||
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
|
||||
from app.rediscache import has_key, set_temp_key, make_download_key
|
||||
from app.tasks.importtasks import check_update_config
|
||||
from app.utils import is_user_bot, is_package_page, nonempty_or_none, normalize_line_endings
|
||||
from . import bp, get_package_tabs
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
||||
@is_package_page
|
||||
def list_releases(package):
|
||||
return render_template("packages/releases_list.html",
|
||||
package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="releases")
|
||||
|
||||
|
||||
def get_mt_releases(is_max):
|
||||
query = LuantiRelease.query.order_by(db.asc(LuantiRelease.id))
|
||||
if is_max:
|
||||
query = query.limit(query.count() - 1)
|
||||
else:
|
||||
query = query.filter(LuantiRelease.name != "0.4.17")
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
|
||||
title = StringField(lazy_gettext("Title"), [Optional(), Length(1, 100)], filters=[nonempty_or_none])
|
||||
release_notes = TextAreaField(lazy_gettext("Release Notes"), [Optional(), Length(1, 5000)],
|
||||
filters=[nonempty_or_none, normalize_line_endings])
|
||||
upload_mode = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
|
||||
vcs_label = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
|
||||
file_upload = FileField(lazy_gettext("File Upload"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class EditPackageReleaseForm(FlaskForm):
|
||||
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)], filters=[nonempty_or_none])
|
||||
release_notes = TextAreaField(lazy_gettext("Release Notes"), [Optional(), Length(1, 5000)],
|
||||
filters=[nonempty_or_none, normalize_line_endings])
|
||||
url = StringField(lazy_gettext("URL"), [Optional()])
|
||||
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
|
||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def create_release(package):
|
||||
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
|
||||
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
||||
return redirect(url_for("users.email_notifications"))
|
||||
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreatePackageReleaseForm()
|
||||
if package.repo is not None:
|
||||
form.upload_mode.choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
|
||||
if request.method == "GET":
|
||||
form.upload_mode.data = "vcs"
|
||||
form.vcs_label.data = request.args.get("ref")
|
||||
|
||||
if request.method == "GET":
|
||||
form.title.data = request.args.get("title")
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
if form.upload_mode.data == "vcs":
|
||||
rel = do_create_vcs_release(current_user, package, form.name.data, form.title.data, form.release_notes.data,
|
||||
form.vcs_label.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
|
||||
else:
|
||||
rel = do_create_zip_release(current_user, package, form.name.data, form.title.data, form.release_notes.data,
|
||||
form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
|
||||
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url()))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
return render_template("packages/release_new.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/download/")
|
||||
@is_package_page
|
||||
def download_release(package, id):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
if ip is not None and not is_user_bot():
|
||||
user_agent = request.headers.get("User-Agent") or ""
|
||||
is_luanti = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
|
||||
reason = request.args.get("reason")
|
||||
PackageDailyStats.update(package, is_luanti, reason)
|
||||
|
||||
key = make_download_key(ip, release.package)
|
||||
if not has_key(key):
|
||||
set_temp_key(key, "true")
|
||||
|
||||
bonus = 0
|
||||
if reason == "new":
|
||||
bonus = 1
|
||||
elif reason == "dependency" or reason == "update":
|
||||
bonus = 0.5
|
||||
|
||||
PackageRelease.query.filter_by(id=release.id).update({
|
||||
"downloads": PackageRelease.downloads + 1
|
||||
})
|
||||
|
||||
Package.query.filter_by(id=package.id).update({
|
||||
"downloads": Package.downloads + 1,
|
||||
"score_downloads": Package.score_downloads + bonus,
|
||||
"score": Package.score + bonus
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(release.url)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/")
|
||||
@is_package_page
|
||||
def view_release(package, id):
|
||||
release: PackageRelease = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
return render_template("packages/release_view.html", package=package, release=release)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_release(package, id):
|
||||
release: PackageRelease = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
canEdit = package.check_perm(current_user, Permission.MAKE_RELEASE)
|
||||
canApprove = release.check_perm(current_user, Permission.APPROVE_RELEASE)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditPackageReleaseForm(formdata=request.form, obj=release)
|
||||
|
||||
if request.method == "GET":
|
||||
# HACK: fix bug in wtforms
|
||||
form.approved.data = release.approved
|
||||
|
||||
if form.validate_on_submit():
|
||||
if canEdit:
|
||||
release.name = form.name.data
|
||||
release.title = form.title.data
|
||||
release.release_notes = form.release_notes.data
|
||||
release.min_rel = form.min_rel.data.get_actual()
|
||||
release.max_rel = form.max_rel.data.get_actual()
|
||||
|
||||
if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL):
|
||||
release.url = form.url.data
|
||||
release.task_id = form.task_id.data
|
||||
if release.task_id is not None:
|
||||
release.task_id = None
|
||||
|
||||
if form.approved.data:
|
||||
release.approve(current_user)
|
||||
elif canApprove:
|
||||
release.approved = False
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
|
||||
return render_template("packages/release_edit.html", package=package, release=release, form=form)
|
||||
|
||||
|
||||
|
||||
class BulkReleaseForm(FlaskForm):
|
||||
set_min = BooleanField(lazy_gettext("Set Min"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
set_max = BooleanField(lazy_gettext("Set Max"))
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
|
||||
submit = SubmitField(lazy_gettext("Update"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def bulk_change_release(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = BulkReleaseForm()
|
||||
|
||||
if request.method == "GET":
|
||||
form.only_change_none.data = True
|
||||
elif form.validate_on_submit():
|
||||
only_change_none = form.only_change_none.data
|
||||
|
||||
for release in package.releases.all():
|
||||
if form.set_min.data and (not only_change_none or release.min_rel is None):
|
||||
release.min_rel = form.min_rel.data.get_actual()
|
||||
if form.set_max.data and (not only_change_none or release.max_rel is None):
|
||||
release.max_rel = form.max_rel.data.get_actual()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
|
||||
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_release(package, id):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
if not release.check_perm(current_user, Permission.DELETE_RELEASE):
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
|
||||
db.session.delete(release)
|
||||
db.session.commit()
|
||||
|
||||
if release.file_path and os.path.isfile(release.file_path):
|
||||
os.remove(release.file_path)
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
|
||||
class PackageUpdateConfigFrom(FlaskForm):
|
||||
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()],
|
||||
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")),
|
||||
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))],
|
||||
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG)
|
||||
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None)
|
||||
action = RadioField(lazy_gettext("Action"), [InputRequired()],
|
||||
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
|
||||
default="make_release")
|
||||
submit = SubmitField(lazy_gettext("Save Settings"))
|
||||
disable = SubmitField(lazy_gettext("Disable Automation"))
|
||||
|
||||
|
||||
def set_update_config(package, form):
|
||||
if package.update_config is None:
|
||||
package.update_config = PackageUpdateConfig()
|
||||
db.session.add(package.update_config)
|
||||
|
||||
form.populate_obj(package.update_config)
|
||||
package.update_config.ref = nonempty_or_none(form.ref.data)
|
||||
package.update_config.make_release = form.action.data == "make_release"
|
||||
|
||||
if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
|
||||
if package.update_config.last_commit is None:
|
||||
last_release = package.releases.first()
|
||||
if last_release and last_release.commit_hash:
|
||||
package.update_config.last_commit = last_release.commit_hash
|
||||
elif package.update_config.trigger == PackageUpdateTrigger.TAG:
|
||||
# Only create releases for tags created after this
|
||||
package.update_config.last_commit = None
|
||||
package.update_config.last_tag = None
|
||||
|
||||
package.update_config.outdated_at = None
|
||||
package.update_config.auto_created = False
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if package.update_config.last_commit is None:
|
||||
check_update_config.delay(package.id)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/update-config/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def update_config(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
abort(403)
|
||||
|
||||
if not package.repo:
|
||||
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
|
||||
return redirect(package.get_url("packages.create_edit"))
|
||||
|
||||
form = PackageUpdateConfigFrom(obj=package.update_config)
|
||||
if request.method == "GET":
|
||||
if package.update_config:
|
||||
form.action.data = "make_release" if package.update_config.make_release else "notification"
|
||||
elif request.args.get("action") == "notification":
|
||||
form.trigger.data = PackageUpdateTrigger.COMMIT
|
||||
form.action.data = "notification"
|
||||
|
||||
if "trigger" in request.args:
|
||||
form.trigger.data = PackageUpdateTrigger.get(request.args["trigger"])
|
||||
|
||||
if form.validate_on_submit():
|
||||
if form.disable.data:
|
||||
flash(gettext("Deleted update configuration"), "success")
|
||||
if package.update_config:
|
||||
db.session.delete(package.update_config)
|
||||
db.session.commit()
|
||||
else:
|
||||
set_update_config(package, form)
|
||||
|
||||
if not form.disable.data and package.releases.count() == 0:
|
||||
flash(gettext("Now, please create an initial release"), "success")
|
||||
return redirect(package.get_url("packages.create_release"))
|
||||
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
|
||||
return render_template("packages/update_config.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/setup-releases/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def setup_releases(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
abort(403)
|
||||
|
||||
if package.update_config:
|
||||
return redirect(package.get_url("packages.update_config"))
|
||||
|
||||
return render_template("packages/release_wizard.html", package=package)
|
||||
|
||||
|
||||
@bp.route("/user/update-configs/")
|
||||
@bp.route("/users/<username>/update-configs/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def bulk_update_config(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("packages.bulk_update_config", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
form = PackageUpdateConfigFrom()
|
||||
if form.validate_on_submit():
|
||||
for package in user.packages.filter(Package.state != PackageState.DELETED, Package.repo.isnot(None)).all():
|
||||
set_update_config(package, form)
|
||||
|
||||
return redirect(url_for("packages.bulk_update_config", username=username))
|
||||
|
||||
confs = user.packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has()) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
return render_template("packages/bulk_update_conf.html", user=user, confs=confs, form=form)
|
||||
276
app/blueprints/packages/reviews.py
Normal file
276
app/blueprints/packages/reviews.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
import typing
|
||||
from flask import render_template, request, redirect, flash, url_for, abort, jsonify
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField, RadioField
|
||||
from wtforms.validators import InputRequired, Length, DataRequired
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
||||
Permission, AuditSeverity, PackageState, Language
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \
|
||||
add_audit_log, has_blocked_domains, should_return_json, normalize_line_endings
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/reviews/")
|
||||
def list_reviews():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page=page, per_page=num)
|
||||
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
|
||||
|
||||
|
||||
def get_default_language():
|
||||
locale = get_locale()
|
||||
if locale:
|
||||
return Language.query.filter_by(id=locale.language).first()
|
||||
|
||||
return None
|
||||
|
||||
class ReviewForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
|
||||
language = QuerySelectField(lazy_gettext("Language"), [DataRequired()],
|
||||
allow_blank=True,
|
||||
query_factory=lambda: Language.query.order_by(db.asc(Language.title)),
|
||||
get_pk=lambda a: a.id,
|
||||
get_label=lambda a: a.title,
|
||||
default=get_default_language)
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)], filters=[normalize_line_endings])
|
||||
rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
|
||||
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))])
|
||||
btn_submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def review(package):
|
||||
if current_user in package.maintainers:
|
||||
flash(gettext("You can't review your own package!"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
if package.state != PackageState.APPROVED:
|
||||
abort(404)
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
can_review = review is not None or current_user.can_review_ratelimit()
|
||||
|
||||
if not can_review:
|
||||
flash(gettext("You've reviewed too many packages recently. Please wait before trying again, and consider making your reviews more detailed"), "danger")
|
||||
|
||||
form = ReviewForm(formdata=request.form, obj=review)
|
||||
|
||||
# Set default values
|
||||
if request.method == "GET" and review:
|
||||
form.title.data = review.thread.title
|
||||
form.rating.data = str(review.rating)
|
||||
form.comment.data = review.thread.first_reply.comment
|
||||
|
||||
# Validate and submit
|
||||
elif can_review and form.validate_on_submit():
|
||||
if has_blocked_domains(form.comment.data, current_user.username, f"review of {package.get_id()}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
else:
|
||||
was_new = False
|
||||
if not review:
|
||||
was_new = True
|
||||
review = PackageReview()
|
||||
review.package = package
|
||||
review.author = current_user
|
||||
db.session.add(review)
|
||||
|
||||
review.rating = int(form.rating.data)
|
||||
review.language = form.language.data
|
||||
|
||||
thread = review.thread
|
||||
if not thread:
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.private = False
|
||||
thread.package = package
|
||||
thread.review = review
|
||||
db.session.add(thread)
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
else:
|
||||
reply = thread.first_reply
|
||||
reply.comment = form.comment.data
|
||||
|
||||
thread.title = form.title.data
|
||||
|
||||
db.session.commit()
|
||||
|
||||
package.recalculate_score()
|
||||
|
||||
if was_new:
|
||||
notif_msg = "New review '{}'".format(form.title.data)
|
||||
type = NotificationType.NEW_REVIEW
|
||||
else:
|
||||
notif_msg = "Updated review '{}'".format(form.title.data)
|
||||
type = NotificationType.OTHER
|
||||
|
||||
add_notification(package.maintainers, current_user, type, notif_msg,
|
||||
url_for("threads.view", id=thread.id), package)
|
||||
|
||||
if was_new:
|
||||
msg = f"Reviewed {package.title} ({review.language.title}): {thread.get_view_url(absolute=True)}"
|
||||
post_discord_webhook.delay(thread.author.display_name, msg, False)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
return render_template("packages/review_create_edit.html",
|
||||
form=form, package=package, review=review)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_review(package, reviewer):
|
||||
review = PackageReview.query \
|
||||
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
|
||||
.first()
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if not review.check_perm(current_user, Permission.DELETE_REVIEW):
|
||||
abort(403)
|
||||
|
||||
thread = review.thread
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = "_converted review into a thread_"
|
||||
reply.is_status_update = True
|
||||
db.session.add(reply)
|
||||
|
||||
thread.review = None
|
||||
|
||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
||||
add_audit_log(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
||||
current_user, msg, thread.get_view_url(), thread.package)
|
||||
|
||||
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
||||
add_notification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
|
||||
db.session.delete(review)
|
||||
|
||||
package.recalculate_score()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
|
||||
def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]:
|
||||
if current_user in package.maintainers:
|
||||
return gettext("You can't vote on the reviews on your own package!")
|
||||
|
||||
review: PackageReview = PackageReview.query.get(review_id)
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if review.author == current_user:
|
||||
return gettext("You can't vote on your own reviews!")
|
||||
|
||||
is_positive = is_yes(request.form["is_positive"])
|
||||
|
||||
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
|
||||
if vote is None:
|
||||
vote = PackageReviewVote()
|
||||
vote.review = review
|
||||
vote.user = current_user
|
||||
vote.is_positive = is_positive
|
||||
db.session.add(vote)
|
||||
elif vote.is_positive == is_positive:
|
||||
db.session.delete(vote)
|
||||
else:
|
||||
vote.is_positive = is_positive
|
||||
|
||||
review.update_score()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def review_vote(package, review_id):
|
||||
msg = handle_review_vote(package, review_id)
|
||||
if should_return_json():
|
||||
if msg:
|
||||
return jsonify({"success": False, "error": msg}), 403
|
||||
else:
|
||||
return jsonify({"success": True})
|
||||
|
||||
if msg:
|
||||
flash(msg, "danger")
|
||||
|
||||
next_url = request.args.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect(review.thread.get_view_url())
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review-votes/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@is_package_page
|
||||
def review_votes(package):
|
||||
user_biases = {}
|
||||
for review in package.reviews:
|
||||
review_sign = review.as_weight()
|
||||
for vote in review.votes:
|
||||
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
|
||||
vote_sign = 1 if vote.is_positive else -1
|
||||
vote_bias = review_sign * vote_sign
|
||||
if vote_bias == 1:
|
||||
user_biases[vote.user.username][0] += 1
|
||||
else:
|
||||
user_biases[vote.user.username][1] += 1
|
||||
|
||||
reviews = package.reviews.all()
|
||||
|
||||
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
|
||||
user_biases_info = []
|
||||
for username, bias in user_biases.items():
|
||||
total_votes = bias[0] + bias[1]
|
||||
balance = bias[0] - bias[1]
|
||||
perc_with = round((100 * bias[0]) / total_votes)
|
||||
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(reviews) - total_votes, perc_with))
|
||||
|
||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
||||
|
||||
return render_template("packages/review_votes.html", package=package, reviews=reviews,
|
||||
user_biases=user_biases_info)
|
||||
150
app/blueprints/packages/screenshots.py
Normal file
150
app/blueprints/packages/screenshots.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
|
||||
from flask import render_template, request, redirect, flash, url_for, abort
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileRequired
|
||||
from wtforms import StringField, SubmitField, BooleanField, FileField
|
||||
from wtforms.validators import Length, DataRequired, Optional
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
||||
from . import bp, get_package_tabs
|
||||
from app.models import Permission, db, PackageScreenshot
|
||||
from app.utils import is_package_page
|
||||
|
||||
|
||||
class CreateScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
file_upload = FileField(lazy_gettext("File Upload"), [FileRequired()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class EditScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class EditPackageScreenshotsForm(FlaskForm):
|
||||
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def screenshots(package):
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
form = EditPackageScreenshotsForm(obj=package)
|
||||
form.cover_image.query = package.screenshots
|
||||
|
||||
if request.method == "POST":
|
||||
order = request.form.get("order")
|
||||
if order:
|
||||
try:
|
||||
do_order_screenshots(current_user, package, order.split(","))
|
||||
return redirect(package.get_url("packages.view"))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(package)
|
||||
db.session.commit()
|
||||
|
||||
return render_template("packages/screenshots.html", package=package, form=form,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="screenshots")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def create_screenshot(package):
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreateScreenshotForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
do_create_screenshot(current_user, package, form.title.data, form.file_upload.data, False)
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
return render_template("packages/screenshot_new.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_screenshot(package, id):
|
||||
screenshot = PackageScreenshot.query.get(id)
|
||||
if screenshot is None or screenshot.package != package:
|
||||
abort(404)
|
||||
|
||||
can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
|
||||
can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||
if not (can_edit or can_approve):
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditScreenshotForm(obj=screenshot)
|
||||
if form.validate_on_submit():
|
||||
was_approved = screenshot.approved
|
||||
|
||||
if can_edit:
|
||||
screenshot.title = form.title.data or "Untitled"
|
||||
|
||||
if can_approve:
|
||||
screenshot.approved = form.approved.data
|
||||
else:
|
||||
screenshot.approved = was_approved
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
|
||||
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_screenshot(package, id):
|
||||
screenshot = PackageScreenshot.query.get(id)
|
||||
if screenshot is None or screenshot.package != package:
|
||||
abort(404)
|
||||
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
if package.cover_image == screenshot:
|
||||
package.cover_image = None
|
||||
db.session.merge(package)
|
||||
|
||||
db.session.delete(screenshot)
|
||||
db.session.commit()
|
||||
|
||||
os.remove(screenshot.file_path)
|
||||
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
182
app/blueprints/report/__init__.py
Normal file
182
app/blueprints/report/__init__.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, request, render_template, url_for, abort, flash
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from werkzeug.utils import redirect
|
||||
from wtforms import TextAreaField, SubmitField, URLField, StringField, SelectField, FileField
|
||||
from wtforms.validators import InputRequired, Length, Optional, DataRequired
|
||||
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import User, UserRank, Report, db, AuditSeverity, ReportCategory, Thread, Permission, ReportAttachment
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.utils import (is_no, abs_url_samesite, normalize_line_endings, rank_required, add_audit_log, abs_url_for,
|
||||
random_string, add_replies)
|
||||
|
||||
bp = Blueprint("report", __name__)
|
||||
|
||||
|
||||
class ReportForm(FlaskForm):
|
||||
category = SelectField(lazy_gettext("Category"), [DataRequired()], choices=ReportCategory.choices(with_none=True), coerce=ReportCategory.coerce)
|
||||
|
||||
url = URLField(lazy_gettext("URL"), [Optional()])
|
||||
title = StringField(lazy_gettext("Subject / Title"), [InputRequired(), Length(10, 300)])
|
||||
message = TextAreaField(lazy_gettext("Message"), [Optional(), Length(0, 10000)], filters=[normalize_line_endings])
|
||||
file_upload = FileField(lazy_gettext("Image Upload"), [Optional()])
|
||||
submit = SubmitField(lazy_gettext("Report"))
|
||||
|
||||
|
||||
@bp.route("/report/", methods=["GET", "POST"])
|
||||
def report():
|
||||
is_anon = not current_user.is_authenticated or not is_no(request.args.get("anon"))
|
||||
|
||||
url = request.args.get("url")
|
||||
if url:
|
||||
if url.startswith("/report/"):
|
||||
abort(404)
|
||||
|
||||
url = abs_url_samesite(url)
|
||||
|
||||
form = ReportForm() if current_user.is_authenticated else None
|
||||
if form and request.method == "GET":
|
||||
try:
|
||||
form.category.data = ReportCategory.coerce(request.args.get("category"))
|
||||
except KeyError:
|
||||
pass
|
||||
form.url.data = url
|
||||
form.title.data = request.args.get("title", "")
|
||||
|
||||
if form and form.validate_on_submit():
|
||||
report = Report()
|
||||
report.id = random_string(8)
|
||||
report.user = current_user if current_user.is_authenticated else None
|
||||
form.populate_obj(report)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
thread = Thread()
|
||||
thread.title = f"Report: {form.title.data}"
|
||||
thread.author = current_user
|
||||
thread.private = True
|
||||
thread.watchers.extend(User.query.filter(User.rank >= UserRank.MODERATOR).all())
|
||||
db.session.add(thread)
|
||||
db.session.flush()
|
||||
|
||||
report.thread = thread
|
||||
|
||||
add_replies(thread, current_user, f"**{report.category.title} report created**\n\n{form.message.data}")
|
||||
else:
|
||||
ip_addr = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
report.message = ip_addr + "\n\n" + report.message
|
||||
|
||||
db.session.add(report)
|
||||
db.session.flush()
|
||||
|
||||
if form.file_upload.data:
|
||||
atmt = ReportAttachment()
|
||||
report.attachments.add(atmt)
|
||||
uploaded_url, _ = upload_file(form.file_upload.data, "image", lazy_gettext("a PNG, JPEG, or WebP image file"))
|
||||
atmt.url = uploaded_url
|
||||
db.session.add(atmt)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
add_audit_log(AuditSeverity.USER, current_user, f"New report: {report.title}",
|
||||
url_for("report.view", rid=report.id))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
abs_url = abs_url_for("report.view", rid=report.id)
|
||||
msg = f"**New Report**\nReport on `{report.url}`\n\n{report.title}\n\nView: {abs_url}"
|
||||
post_discord_webhook.delay(None if is_anon else current_user.username, msg, True)
|
||||
|
||||
return redirect(url_for("report.report_received", rid=report.id))
|
||||
|
||||
return render_template("report/report.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
|
||||
|
||||
|
||||
@bp.route("/report/received/")
|
||||
def report_received():
|
||||
rid = request.args.get("rid")
|
||||
report = Report.query.get_or_404(rid)
|
||||
return render_template("report/report_received.html", report=report)
|
||||
|
||||
|
||||
@bp.route("/admin/reports/")
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def list_all():
|
||||
reports = Report.query.order_by(db.asc(Report.is_resolved), db.asc(Report.created_at)).all()
|
||||
return render_template("report/list.html", reports=reports)
|
||||
|
||||
|
||||
@bp.route("/admin/reports/<rid>/", methods=["GET", "POST"])
|
||||
def view(rid: str):
|
||||
report = Report.query.get_or_404(rid)
|
||||
if not report.check_perm(current_user, Permission.SEE_REPORT):
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
if report.is_resolved:
|
||||
if "reopen" in request.form:
|
||||
report.is_resolved = False
|
||||
url = url_for("report.view", rid=report.id)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Reopened report \"{report.title}\"", url)
|
||||
|
||||
if report.thread:
|
||||
add_replies(report.thread, current_user, f"Reopened report", is_status_update=True)
|
||||
|
||||
db.session.commit()
|
||||
else:
|
||||
if "completed" in request.form:
|
||||
outcome = "as completed"
|
||||
elif "removed" in request.form:
|
||||
outcome = "as content removed"
|
||||
elif "invalid" in request.form:
|
||||
outcome = "without action"
|
||||
if report.thread:
|
||||
flash("Make sure to comment why the report is invalid in the thread", "warning")
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
report.is_resolved = True
|
||||
url = url_for("report.view", rid=report.id)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Report closed {outcome} \"{report.title}\"", url)
|
||||
|
||||
if report.thread:
|
||||
add_replies(report.thread, current_user, f"Closed report {outcome}", is_status_update=True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return render_template("report/view.html", report=report)
|
||||
|
||||
|
||||
@bp.route("/admin/reports/<rid>/edit/", methods=["GET", "POST"])
|
||||
def edit(rid: str):
|
||||
report = Report.query.get_or_404(rid)
|
||||
if not report.check_perm(current_user, Permission.SEE_REPORT):
|
||||
abort(404)
|
||||
|
||||
form = ReportForm(request.form, obj=report)
|
||||
form.submit.label.text = lazy_gettext("Save")
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(report)
|
||||
url = url_for("report.view", rid=report.id)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited report \"{report.title}\"", url)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("report.view", rid=report.id))
|
||||
|
||||
return render_template("report/edit.html", report=report, form=form)
|
||||
@@ -1,61 +1,64 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, jsonify, url_for, request, redirect, render_template
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
from flask.ext import menu
|
||||
from app import app, csrf
|
||||
from app.models import *
|
||||
from app.tasks import celery, TaskError
|
||||
from app.tasks.importtasks import getMeta
|
||||
from app.utils import shouldReturnJson
|
||||
# from celery.result import AsyncResult
|
||||
from app import csrf
|
||||
from app.models import UserRank
|
||||
from app.tasks import celery
|
||||
from app.tasks.importtasks import get_meta
|
||||
from app.utils import should_return_json
|
||||
|
||||
bp = Blueprint("tasks", __name__)
|
||||
|
||||
from app.utils import *
|
||||
|
||||
@csrf.exempt
|
||||
@app.route("/tasks/getmeta/new/", methods=["POST"])
|
||||
@bp.route("/tasks/getmeta/new/", methods=["POST"])
|
||||
@login_required
|
||||
def new_getmeta_page():
|
||||
def start_getmeta():
|
||||
from flask import request
|
||||
author = request.args.get("author")
|
||||
author = current_user.forums_username if author is None else author
|
||||
aresult = getMeta.delay(request.args.get("url"), author)
|
||||
aresult = get_meta.delay(request.args.get("url"), author)
|
||||
return jsonify({
|
||||
"poll_url": url_for("check_task", id=aresult.id),
|
||||
"poll_url": url_for("tasks.check", id=aresult.id),
|
||||
})
|
||||
|
||||
@app.route("/tasks/<id>/")
|
||||
def check_task(id):
|
||||
|
||||
@bp.route("/tasks/<id>/")
|
||||
def check(id):
|
||||
result = celery.AsyncResult(id)
|
||||
status = result.status
|
||||
traceback = result.traceback
|
||||
result = result.result
|
||||
|
||||
info = None
|
||||
if isinstance(result, Exception):
|
||||
info = {
|
||||
'id': id,
|
||||
'status': status,
|
||||
}
|
||||
|
||||
if current_user.is_authenticated and current_user.rank.atLeast(UserRank.ADMIN):
|
||||
if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN):
|
||||
info["error"] = str(traceback)
|
||||
elif str(result)[1:12] == "TaskError: ":
|
||||
info["error"] = str(result)[12:-1]
|
||||
if hasattr(result, "value"):
|
||||
info["error"] = result.value
|
||||
else:
|
||||
info["error"] = str(result)
|
||||
else:
|
||||
info["error"] = "Unknown server error"
|
||||
else:
|
||||
@@ -65,7 +68,7 @@ def check_task(id):
|
||||
'result': result,
|
||||
}
|
||||
|
||||
if shouldReturnJson():
|
||||
if should_return_json():
|
||||
return jsonify(info)
|
||||
else:
|
||||
r = request.args.get("r")
|
||||
407
app/blueprints/threads/__init__.py
Normal file
407
app/blueprints/threads/__init__.py
Normal file
@@ -0,0 +1,407 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, request, render_template, abort, flash, redirect, url_for
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.markdown import get_user_mentions, render_markdown
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
bp = Blueprint("threads", __name__)
|
||||
|
||||
from flask_login import current_user, login_required
|
||||
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
|
||||
NotificationType, ThreadReply
|
||||
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains, \
|
||||
normalize_line_endings
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.utils import get_int_or_abort
|
||||
|
||||
|
||||
@bp.route("/threads/")
|
||||
def list_all():
|
||||
query = Thread.query
|
||||
if not Permission.SEE_THREAD.check(current_user):
|
||||
query = query.filter_by(private=False)
|
||||
|
||||
package = None
|
||||
pid = request.args.get("pid")
|
||||
if pid:
|
||||
pid = get_int_or_abort(pid)
|
||||
package = Package.query.get_or_404(pid)
|
||||
query = query.filter_by(package=package)
|
||||
|
||||
query = query.filter_by(review_id=None)
|
||||
|
||||
query = query.order_by(db.desc(Thread.created_at))
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items,
|
||||
package=package, noindex=pid)
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
|
||||
@login_required
|
||||
def subscribe(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
flash(gettext("Already subscribed!"), "success")
|
||||
else:
|
||||
flash(gettext("Subscribed to thread"), "success")
|
||||
thread.watchers.append(current_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
|
||||
@login_required
|
||||
def unsubscribe(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
flash(gettext("Unsubscribed!"), "success")
|
||||
thread.watchers.remove(current_user)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash(gettext("Already not subscribed!"), "success")
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
|
||||
@login_required
|
||||
def set_lock(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
|
||||
abort(404)
|
||||
|
||||
thread.locked = is_yes(request.args.get("lock"))
|
||||
if thread.locked is None:
|
||||
abort(400)
|
||||
|
||||
if thread.locked:
|
||||
msg = "Locked thread '{}'".format(thread.title)
|
||||
flash(gettext("Locked thread"), "success")
|
||||
else:
|
||||
msg = "Unlocked thread '{}'".format(thread.title)
|
||||
flash(gettext("Unlocked thread"), "success")
|
||||
|
||||
add_notification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def delete_thread(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("threads/delete_thread.html", thread=thread)
|
||||
|
||||
summary = "\n\n".join([("<{}> {}".format(reply.author.display_name, reply.comment)) for reply in thread.replies])
|
||||
|
||||
msg = "Deleted thread {} by {}".format(thread.title, thread.author.display_name)
|
||||
|
||||
db.session.delete(thread)
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def delete_reply(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None:
|
||||
abort(404)
|
||||
|
||||
reply_id = request.args.get("reply")
|
||||
if reply_id is None:
|
||||
abort(404)
|
||||
|
||||
reply = ThreadReply.query.get(reply_id)
|
||||
if reply is None or reply.thread != thread:
|
||||
abort(404)
|
||||
|
||||
if thread.first_reply == reply:
|
||||
flash(gettext("Cannot delete thread opening post!"), "danger")
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
if not reply.check_perm(current_user, Permission.DELETE_REPLY):
|
||||
abort(403)
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("threads/delete_reply.html", thread=thread, reply=reply)
|
||||
|
||||
msg = "Deleted reply by {}".format(reply.author.display_name)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
|
||||
|
||||
db.session.delete(reply)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)], filters=[normalize_line_endings])
|
||||
btn_submit = SubmitField(lazy_gettext("Comment"))
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_reply(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None:
|
||||
abort(404)
|
||||
|
||||
reply_id = request.args.get("reply")
|
||||
if reply_id is None:
|
||||
abort(404)
|
||||
|
||||
reply: ThreadReply = ThreadReply.query.get(reply_id)
|
||||
if reply is None or reply.thread != thread:
|
||||
abort(404)
|
||||
|
||||
if not reply.check_perm(current_user, Permission.EDIT_REPLY):
|
||||
abort(403)
|
||||
|
||||
form = CommentForm(formdata=request.form, obj=reply)
|
||||
if form.validate_on_submit():
|
||||
comment = form.comment.data
|
||||
if has_blocked_domains(comment, current_user.username, f"edit to reply {reply.get_url(True)}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
else:
|
||||
msg = "Edited reply by {}".format(reply.author.display_name)
|
||||
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
||||
add_notification(reply.author, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
|
||||
add_audit_log(severity, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
|
||||
|
||||
reply.comment = comment
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
|
||||
def view(id):
|
||||
thread: Thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
form = CommentForm(formdata=request.form) if thread.check_perm(current_user, Permission.COMMENT_THREAD) else None
|
||||
|
||||
# Check that title is none to load comments into textarea if redirected from new thread page
|
||||
if form and form.validate_on_submit() and request.form.get("title") is None:
|
||||
comment = form.comment.data
|
||||
|
||||
if not current_user.can_comment_ratelimit():
|
||||
flash(gettext("Please wait before commenting again"), "danger")
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
if has_blocked_domains(comment, current_user.username, f"reply to {thread.get_view_url(True)}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
return render_template("threads/view.html", thread=thread, form=form)
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.author = current_user
|
||||
reply.comment = comment
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
if current_user not in thread.watchers:
|
||||
thread.watchers.append(current_user)
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(comment)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
if not thread.check_perm(mentioned, Permission.SEE_THREAD):
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.get_view_url(), thread.package)
|
||||
|
||||
if mentioned not in thread.watchers:
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
|
||||
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.get_view_url(), thread.package)
|
||||
post_discord_webhook.delay(current_user.display_name,
|
||||
"Replied to bot messages: {}".format(thread.get_view_url(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
return render_template("threads/view.html", thread=thread, form=form)
|
||||
|
||||
|
||||
class ThreadForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)], filters=[normalize_line_endings])
|
||||
btn_submit = SubmitField(lazy_gettext("Open Thread"))
|
||||
|
||||
|
||||
@bp.route("/threads/new/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def new():
|
||||
form = ThreadForm(formdata=request.form)
|
||||
|
||||
package = None
|
||||
if "pid" in request.args:
|
||||
package = Package.query.get(int(request.args.get("pid")))
|
||||
if package is None:
|
||||
abort(404)
|
||||
|
||||
if package is None and not current_user.rank.at_least(UserRank.APPROVER):
|
||||
abort(404)
|
||||
|
||||
is_review_thread = package and not package.approved
|
||||
is_private_thread = is_review_thread
|
||||
|
||||
# Check that user can make the thread
|
||||
if package and not package.check_perm(current_user, Permission.CREATE_THREAD):
|
||||
flash(gettext("Unable to create thread!"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Only allow creating one thread when not approved
|
||||
elif is_review_thread and package.review_thread is not None:
|
||||
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply
|
||||
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger")
|
||||
return redirect(package.review_thread.get_view_url(), code=307)
|
||||
|
||||
elif not current_user.can_open_thread_ratelimit():
|
||||
flash(gettext("Please wait before opening another thread"), "danger")
|
||||
|
||||
if package:
|
||||
return redirect(package.get_url("packages.view"))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Set default values
|
||||
elif request.method == "GET":
|
||||
form.title.data = request.args.get("title") or ""
|
||||
|
||||
# Validate and submit
|
||||
elif form.validate_on_submit():
|
||||
if has_blocked_domains(form.comment.data, current_user.username, f"new thread"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
else:
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.title = form.title.data
|
||||
thread.private = is_private_thread
|
||||
thread.package = package
|
||||
db.session.add(thread)
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
if package and package.author != current_user:
|
||||
thread.watchers.append(package.author)
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if is_review_thread:
|
||||
package.review_thread = thread
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
||||
add_notification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.get_view_url(), thread.package)
|
||||
|
||||
if mentioned not in thread.watchers:
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
notif_msg = "New thread '{}'".format(thread.title)
|
||||
if package is not None:
|
||||
add_notification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.get_view_url(), package)
|
||||
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.get_view_url(), package)
|
||||
|
||||
if is_review_thread:
|
||||
post_discord_webhook.delay(current_user.display_name,
|
||||
"Opened approval thread: {}".format(thread.get_view_url(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
return render_template("threads/new.html", form=form, package=package)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/comments/")
|
||||
def user_comments(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 40))
|
||||
|
||||
# Filter replies the current user can see
|
||||
query = ThreadReply.query.options(selectinload(ThreadReply.thread)).filter_by(author=user)
|
||||
only_public = False
|
||||
if current_user != user and not (current_user.is_authenticated and current_user.rank.at_least(UserRank.APPROVER)):
|
||||
query = query.filter(ThreadReply.thread.has(private=False))
|
||||
only_public = True
|
||||
|
||||
pagination = query.order_by(db.desc(ThreadReply.created_at)).paginate(page=page, per_page=num)
|
||||
|
||||
return render_template("threads/user_comments.html", user=user, pagination=pagination, only_public=only_public)
|
||||
145
app/blueprints/thumbnails/__init__.py
Normal file
145
app/blueprints/thumbnails/__init__.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import requests
|
||||
from flask import abort, send_file, Blueprint, current_app, request
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
|
||||
bp = Blueprint("thumbnails", __name__)
|
||||
|
||||
|
||||
ALLOWED_RESOLUTIONS = [(100, 67), (270, 180), (350, 233), (1100, 520)]
|
||||
ALLOWED_MIMETYPES = {
|
||||
"png": "image/png",
|
||||
"webp": "image/webp",
|
||||
"jpg": "image/jpeg",
|
||||
}
|
||||
|
||||
|
||||
def mkdir(path):
|
||||
assert path != "" and path is not None
|
||||
try:
|
||||
if not os.path.isdir(path):
|
||||
os.mkdir(path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
|
||||
def resize_and_crop(img_path, modified_path, size):
|
||||
with Image.open(img_path) as img:
|
||||
# Get current and desired ratio for the images
|
||||
img_ratio = img.size[0] / float(img.size[1])
|
||||
desired_ratio = size[0] / float(size[1])
|
||||
|
||||
# Is more portrait than target, scale and crop
|
||||
if desired_ratio > img_ratio:
|
||||
img = img.resize((int(size[0]), int(size[0] * img.size[1] / img.size[0])),
|
||||
Image.BICUBIC)
|
||||
box = (0, (img.size[1] - size[1]) / 2, img.size[0], (img.size[1] + size[1]) / 2)
|
||||
img = img.crop(box)
|
||||
|
||||
# Is more landscape than target, scale and crop
|
||||
elif desired_ratio < img_ratio:
|
||||
img = img.resize((int(size[1] * img.size[0] / img.size[1]), int(size[1])),
|
||||
Image.BICUBIC)
|
||||
box = ((img.size[0] - size[0]) / 2, 0, (img.size[0] + size[0]) / 2, img.size[1])
|
||||
img = img.crop(box)
|
||||
|
||||
# Is exactly the same ratio as target
|
||||
else:
|
||||
img = img.resize(size, Image.BICUBIC)
|
||||
|
||||
if modified_path.endswith(".jpg") and img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
img.save(modified_path, lossless=True)
|
||||
|
||||
|
||||
def find_source_file(img):
|
||||
upload_dir = current_app.config["UPLOAD_DIR"]
|
||||
source_filepath = os.path.join(upload_dir, img)
|
||||
if os.path.isfile(source_filepath):
|
||||
return source_filepath
|
||||
|
||||
period = source_filepath.rfind(".")
|
||||
start = source_filepath[:period]
|
||||
ext = source_filepath[period + 1:]
|
||||
if ext not in ALLOWED_MIMETYPES:
|
||||
abort(404)
|
||||
|
||||
for other_ext in ALLOWED_MIMETYPES.keys():
|
||||
other_path = f"{start}.{other_ext}"
|
||||
if ext != other_ext and os.path.isfile(other_path):
|
||||
return other_path
|
||||
|
||||
abort(404)
|
||||
|
||||
|
||||
def get_mimetype(cache_filepath: str) -> str:
|
||||
period = cache_filepath.rfind(".")
|
||||
ext = cache_filepath[period + 1:]
|
||||
mimetype = ALLOWED_MIMETYPES.get(ext)
|
||||
if mimetype is None:
|
||||
abort(404)
|
||||
return mimetype
|
||||
|
||||
|
||||
@bp.route("/thumbnails/<int:level>/<img>")
|
||||
def make_thumbnail(img, level):
|
||||
if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
|
||||
abort(403)
|
||||
w, h = ALLOWED_RESOLUTIONS[level - 1]
|
||||
|
||||
thumbnail_dir = current_app.config["THUMBNAIL_DIR"]
|
||||
mkdir(thumbnail_dir)
|
||||
|
||||
output_dir = os.path.join(thumbnail_dir, str(level))
|
||||
mkdir(output_dir)
|
||||
|
||||
cache_filepath = os.path.join(output_dir, img)
|
||||
if not os.path.isfile(cache_filepath):
|
||||
source_filepath = find_source_file(img)
|
||||
resize_and_crop(source_filepath, cache_filepath, (w, h))
|
||||
|
||||
res = send_file(cache_filepath, mimetype=get_mimetype(cache_filepath))
|
||||
res.headers["Cache-Control"] = "max-age=604800" # 1 week
|
||||
return res
|
||||
|
||||
|
||||
@bp.route("/thumbnails/youtube/<id_>.jpg")
|
||||
def youtube(id_: str):
|
||||
if not re.match(r"^[A-Za-z0-9\-_]+$", id_):
|
||||
abort(400)
|
||||
|
||||
cache_dir = os.path.join(current_app.config["THUMBNAIL_DIR"], "youtube")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
cache_filepath = os.path.join(cache_dir, id_ + ".jpg")
|
||||
|
||||
url = f"https://img.youtube.com/vi/{id_}/default.jpg"
|
||||
|
||||
response = requests.get(url, stream=True)
|
||||
if response.status_code != 200:
|
||||
abort(response.status_code)
|
||||
|
||||
with open(cache_filepath, "wb") as file:
|
||||
file.write(response.content)
|
||||
|
||||
res = send_file(cache_filepath)
|
||||
res.headers["Cache-Control"] = "max-age=604800" # 1 week
|
||||
return res
|
||||
22
app/blueprints/todo/__init__.py
Normal file
22
app/blueprints/todo/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("todo", __name__)
|
||||
|
||||
from . import editor, user
|
||||
217
app/blueprints/todo/editor.py
Normal file
217
app/blueprints/todo/editor.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, url_for, abort, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
||||
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, LuantiRelease, Report
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import get_int_or_abort, is_yes, rank_required
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/todo/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def view_editor():
|
||||
can_approve_new = Permission.APPROVE_NEW.check(current_user)
|
||||
can_approve_rel = Permission.APPROVE_RELEASE.check(current_user)
|
||||
can_approve_scn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
||||
|
||||
packages = None
|
||||
wip_packages = None
|
||||
if can_approve_new:
|
||||
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
wip_packages = Package.query \
|
||||
.filter(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
|
||||
releases = None
|
||||
if can_approve_rel:
|
||||
releases = PackageRelease.query.filter_by(approved=False, task_id=None).all()
|
||||
|
||||
screenshots = None
|
||||
if can_approve_scn:
|
||||
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
|
||||
|
||||
if not can_approve_new and not can_approve_rel and not can_approve_scn:
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form["action"] == "screenshots_approve_all":
|
||||
if not can_approve_scn:
|
||||
abort(403)
|
||||
|
||||
PackageScreenshot.query.update({"approved": True})
|
||||
db.session.commit()
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
license_needed = Package.query \
|
||||
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
|
||||
.filter(or_(Package.license.has(License.name.like("Other %")),
|
||||
Package.media_license.has(License.name.like("Other %")))) \
|
||||
.all()
|
||||
|
||||
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
|
||||
|
||||
unfulfilled_meta_packages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).count()
|
||||
|
||||
audit_log = AuditLogEntry.query \
|
||||
.filter(AuditLogEntry.package.has()) \
|
||||
.order_by(db.desc(AuditLogEntry.created_at)) \
|
||||
.limit(20).all()
|
||||
|
||||
reports = Report.query.filter_by(is_resolved=False).order_by(db.asc(Report.created_at)).all() if current_user.rank.at_least(UserRank.EDITOR) else None
|
||||
|
||||
return render_template("todo/editor.html", current_tab="editor",
|
||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
||||
can_approve_new=can_approve_new, can_approve_rel=can_approve_rel, can_approve_scn=can_approve_scn,
|
||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log, reports=reports)
|
||||
|
||||
|
||||
@bp.route("/todo/tags/")
|
||||
@login_required
|
||||
def tags():
|
||||
qb = QueryBuilder(request.args, cookies=True)
|
||||
qb.set_sort_if_none("score", "desc")
|
||||
query = qb.build_package_query()
|
||||
|
||||
only_no_tags = is_yes(request.args.get("no_tags"))
|
||||
if only_no_tags:
|
||||
query = query.filter(Package.tags == None)
|
||||
|
||||
tags = Tag.query.order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("todo/tags.html", current_tab="tags", packages=query.all(),
|
||||
tags=tags, only_no_tags=only_no_tags)
|
||||
|
||||
|
||||
@bp.route("/todo/modnames/")
|
||||
@login_required
|
||||
def modnames():
|
||||
mnames = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).all()
|
||||
|
||||
return render_template("todo/modnames.html", modnames=mnames)
|
||||
|
||||
|
||||
@bp.route("/todo/outdated/")
|
||||
@login_required
|
||||
def outdated():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
query = db.session.query(Package).select_from(PackageUpdateConfig) \
|
||||
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
|
||||
.join(PackageUpdateConfig.package) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/outdated.html", current_tab="outdated",
|
||||
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
||||
|
||||
|
||||
@bp.route("/todo/screenshots/")
|
||||
@login_required
|
||||
def screenshots():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
query = db.session.query(Package) \
|
||||
.filter(~Package.screenshots.any()) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(Package.approved_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/screenshots.html", current_tab="screenshots",
|
||||
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
||||
|
||||
|
||||
@bp.route("/todo/mtver_support/")
|
||||
@login_required
|
||||
def mtver_support():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
current_stable = LuantiRelease.query.filter(~LuantiRelease.name.like("%-dev")).order_by(db.desc(LuantiRelease.id)).first()
|
||||
|
||||
query = db.session.query(Package) \
|
||||
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(Package.approved_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/mtver_support.html", current_tab="screenshots",
|
||||
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only, current_stable=current_stable)
|
||||
|
||||
|
||||
@bp.route("/todo/topics/mismatch/")
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def topics_mismatch():
|
||||
missing_topics = Package.query.filter(Package.forums.is_not(None)) .filter(~ForumTopic.query.filter(ForumTopic.topic_id == Package.forums).exists()).all()
|
||||
|
||||
packages_bad_author = (
|
||||
db.session.query(Package, ForumTopic)
|
||||
.select_from(Package)
|
||||
.join(ForumTopic, Package.forums == ForumTopic.topic_id)
|
||||
.filter(Package.author_id != ForumTopic.author_id)
|
||||
.all())
|
||||
|
||||
packages_bad_title = (
|
||||
db.session.query(Package, ForumTopic)
|
||||
.select_from(Package)
|
||||
.join(ForumTopic, Package.forums == ForumTopic.topic_id)
|
||||
.filter(and_(ForumTopic.name != Package.name, ~ForumTopic.title.ilike("%" + Package.title + "%"), ~ForumTopic.title.ilike("%" + Package.name + "%")))
|
||||
.all())
|
||||
|
||||
return render_template("todo/topics_mismatch.html",
|
||||
missing_topics=missing_topics,
|
||||
packages_bad_author=packages_bad_author,
|
||||
packages_bad_title=packages_bad_title)
|
||||
194
app/blueprints/todo/user.py
Normal file
194
app/blueprints/todo/user.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from celery import uuid
|
||||
from flask import redirect, url_for, abort, render_template, flash
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import User, Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
||||
PackageRelease, Permission, NotificationType, AuditSeverity, UserRank, PackageType
|
||||
from app.tasks.importtasks import make_vcs_release
|
||||
from app.utils import add_notification, add_audit_log
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/user/tags/")
|
||||
def tags_user():
|
||||
return redirect(url_for('todo.tags', author=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/user/todo/")
|
||||
@bp.route("/users/<username>/todo/")
|
||||
@login_required
|
||||
def view_user(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("todo.view_user", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.APPROVER):
|
||||
abort(403)
|
||||
|
||||
unapproved_packages = user.packages \
|
||||
.filter(or_(Package.state == PackageState.WIP,
|
||||
Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.asc(Package.created_at)).all()
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
missing_game_support = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
packages_with_no_screenshots = user.maintained_packages.filter(
|
||||
~Package.screenshots.any(), Package.state == PackageState.APPROVED).all()
|
||||
|
||||
packages_with_small_screenshots = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
||||
.all()
|
||||
|
||||
topics_to_add = ForumTopic.query \
|
||||
.filter_by(author_id=user.id) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED, ~Package.tags.any()) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
return render_template("todo/user.html", current_tab="user", user=user,
|
||||
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
|
||||
missing_game_support=missing_game_support, needs_tags=needs_tags, topics_to_add=topics_to_add,
|
||||
packages_with_no_screenshots=packages_with_no_screenshots,
|
||||
packages_with_small_screenshots=packages_with_small_screenshots,
|
||||
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
|
||||
@login_required
|
||||
def apply_all_updates(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
for package in outdated_packages:
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
continue
|
||||
|
||||
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
|
||||
PackageRelease.commit_hash == package.update_config.last_commit)).count() > 0:
|
||||
continue
|
||||
|
||||
title = package.update_config.title
|
||||
ref = package.update_config.get_ref()
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.name = title
|
||||
rel.title = title
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
db.session.add(rel)
|
||||
db.session.commit()
|
||||
|
||||
make_vcs_release.apply_async((rel.id, ref),
|
||||
task_id=rel.task_id)
|
||||
|
||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
||||
package.get_url("packages.create_edit"), package)
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("todo.view_user", username=username))
|
||||
|
||||
|
||||
@bp.route("/user/game_support/")
|
||||
@bp.route("/users/<username>/game_support/")
|
||||
@login_required
|
||||
def all_game_support(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("todo.all_game_support", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).one_or_404()
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
packages = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP])) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
bulk_support_names = db.session.query(Package.title) \
|
||||
.select_from(Package).filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
bulk_support_names = ", ".join([x[0] for x in bulk_support_names])
|
||||
|
||||
return render_template("todo/game_support.html", user=user, packages=packages, bulk_support_names=bulk_support_names)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/confirm_supports_all_games/", methods=["POST"])
|
||||
@login_required
|
||||
def confirm_supports_all_games(username=None):
|
||||
user: User = User.query.filter_by(username=username).one_or_404()
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
packages = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.all()
|
||||
|
||||
for package in packages:
|
||||
package.supports_all_games = True
|
||||
db.session.merge(package)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, "Enabled 'Supports all games' (bulk)",
|
||||
package.get_url("packages.game_support"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Done"), "success")
|
||||
return redirect(url_for("todo.all_game_support", username=current_user.username))
|
||||
48
app/blueprints/translate/__init__.py
Normal file
48
app/blueprints/translate/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint, render_template, request
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models import Package, PackageState, db, PackageTranslation
|
||||
|
||||
bp = Blueprint("translate", __name__)
|
||||
|
||||
|
||||
@bp.route("/translate/")
|
||||
def translate():
|
||||
query = Package.query.filter(
|
||||
Package.state == PackageState.APPROVED,
|
||||
or_(
|
||||
Package.translation_url.is_not(None),
|
||||
Package.translations.any(PackageTranslation.language_id != "en")
|
||||
))
|
||||
|
||||
has_langs = request.args.getlist("has_lang")
|
||||
for lang in has_langs:
|
||||
query = query.filter(Package.translations.any(PackageTranslation.language_id == lang))
|
||||
|
||||
not_langs = request.args.getlist("not_lang")
|
||||
for lang in not_langs:
|
||||
query = query.filter(~Package.translations.any(PackageTranslation.language_id == lang))
|
||||
|
||||
supports_translation = (query
|
||||
.order_by(Package.translation_url.is_(None), db.desc(Package.score))
|
||||
.all())
|
||||
|
||||
return render_template("translate/index.html",
|
||||
supports_translation=supports_translation, has_langs=has_langs, not_langs=not_langs)
|
||||
5
app/blueprints/users/__init__.py
Normal file
5
app/blueprints/users/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("users", __name__)
|
||||
|
||||
from . import profile, claim, account, settings
|
||||
391
app/blueprints/users/account.py
Normal file
391
app/blueprints/users/account.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import redirect, abort, render_template, flash, request, url_for, Response
|
||||
from flask_babel import gettext, get_locale, lazy_gettext
|
||||
from flask_login import current_user, login_required, logout_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators
|
||||
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, Email, EqualTo
|
||||
|
||||
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
|
||||
from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \
|
||||
nonempty_or_none, post_login
|
||||
from . import bp
|
||||
from app.models import User, AuditSeverity, db, EmailSubscription, UserEmailVerification
|
||||
from app.logic.users import create_user
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField(lazy_gettext("Username or email"), [InputRequired()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
||||
submit = SubmitField(lazy_gettext("Sign in"))
|
||||
|
||||
|
||||
def handle_login(form):
|
||||
def show_safe_err(err):
|
||||
if "@" in username:
|
||||
flash(gettext("Incorrect email or password"), "danger")
|
||||
else:
|
||||
flash(err, "danger")
|
||||
|
||||
username = form.username.data.strip()
|
||||
user = User.query.filter(or_(User.username == username, User.email == username)).first()
|
||||
if user is None:
|
||||
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
|
||||
|
||||
if not check_password_hash(user.password, form.password.data):
|
||||
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
|
||||
|
||||
if not user.is_active:
|
||||
flash(gettext("You need to confirm the registration email"), "danger")
|
||||
return
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Logged in using password",
|
||||
url_for("users.profile", username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
if not login_user(user, remember=form.remember_me.data):
|
||||
flash(gettext("Login failed"), "danger")
|
||||
return
|
||||
|
||||
return post_login(user, request.args.get("next"))
|
||||
|
||||
|
||||
@bp.route("/user/login/", methods=["GET", "POST"])
|
||||
def login():
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(next or url_for("homepage.home"))
|
||||
|
||||
form = LoginForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
ret = handle_login(form)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
if request.method == "GET":
|
||||
form.remember_me.data = True
|
||||
|
||||
return render_template("users/login.html", form=form, next=next)
|
||||
|
||||
|
||||
@bp.route("/user/logout/", methods=["GET", "POST"])
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonempty_or_none])
|
||||
username = StringField(lazy_gettext("Username"), [InputRequired(),
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext(
|
||||
"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed"))])
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(12, 100)])
|
||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||
first_name = StringField("First name", [])
|
||||
submit = SubmitField(lazy_gettext("Register"))
|
||||
|
||||
|
||||
def handle_register(form):
|
||||
if form.question.data.strip().lower() != "19":
|
||||
flash(gettext("Incorrect captcha answer"), "danger")
|
||||
return
|
||||
|
||||
user = create_user(form.username.data, form.display_name.data, form.email.data)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
elif user is None:
|
||||
return
|
||||
elif form.first_name.data != "":
|
||||
abort(500)
|
||||
|
||||
user.password = make_flask_login_password(form.password.data)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
token = random_string(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
ver.token = token
|
||||
ver.email = form.email.data
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
|
||||
@bp.route("/user/register/", methods=["GET", "POST"])
|
||||
def register():
|
||||
form = RegisterForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
ret = handle_register(form)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("users/register.html", form=form)
|
||||
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
submit = SubmitField(lazy_gettext("Reset Password"))
|
||||
|
||||
|
||||
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
form = ForgotPasswordForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
email = form.email.data
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
token = random_string(32)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
ver.token = token
|
||||
ver.email = email
|
||||
ver.is_password_reset = True
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
else:
|
||||
html = render_template("emails/unable_to_find_account.html")
|
||||
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
|
||||
html, html)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
return render_template("users/forgot_password.html", form=form)
|
||||
|
||||
|
||||
class SetPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
|
||||
EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class ChangePasswordForm(FlaskForm):
|
||||
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(6, 100)])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
|
||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
def handle_set_password(form):
|
||||
one = form.password.data
|
||||
two = form.password2.data
|
||||
if one != two:
|
||||
flash(gettext("Passwords do not match"), "danger")
|
||||
return
|
||||
|
||||
add_audit_log(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
|
||||
|
||||
current_user.password = make_flask_login_password(form.password.data)
|
||||
|
||||
if hasattr(form, "email"):
|
||||
new_email = nonempty_or_none(form.email.data)
|
||||
if new_email and new_email != current_user.email:
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
return
|
||||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
||||
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
||||
display_name=user_by_email.display_name))
|
||||
else:
|
||||
token = random_string(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = current_user
|
||||
ver.token = token
|
||||
ver.email = new_email
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
flash(gettext("Your password has been changed successfully."), "success")
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
db.session.commit()
|
||||
flash(gettext("Your password has been changed successfully."), "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
@bp.route("/user/change-password/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
form = ChangePasswordForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
if check_password_hash(current_user.password, form.old_password.data):
|
||||
ret = handle_set_password(form)
|
||||
if ret:
|
||||
return ret
|
||||
else:
|
||||
flash(gettext("Old password is incorrect"), "danger")
|
||||
|
||||
return render_template("users/change_set_password.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/user/set-password/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def set_password():
|
||||
if current_user.password:
|
||||
return redirect(url_for("users.change_password"))
|
||||
|
||||
form = SetPasswordForm(request.form)
|
||||
if current_user.email is None:
|
||||
form.email.validators = [InputRequired(), Email()]
|
||||
|
||||
if form.validate_on_submit():
|
||||
ret = handle_set_password(form)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("users/change_set_password.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/user/verify/")
|
||||
def verify_email():
|
||||
token = request.args.get("token")
|
||||
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
|
||||
if ver is None:
|
||||
flash(gettext("Unknown verification token!"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
if ver.is_expired:
|
||||
flash(gettext("Token has expired"), "danger")
|
||||
db.session.delete(ver)
|
||||
db.session.commit()
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
user = ver.user
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Confirmed their email",
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
was_activating = not user.is_active
|
||||
|
||||
if ver.email and user.email != ver.email:
|
||||
if User.query.filter_by(email=ver.email).count() > 0:
|
||||
flash(gettext("Another user is already using that email"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
flash(gettext("Confirmed email change"), "success")
|
||||
|
||||
if user.email:
|
||||
send_user_email.delay(user.email,
|
||||
user.locale or "en",
|
||||
gettext("Email address changed"),
|
||||
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
|
||||
|
||||
user.is_active = True
|
||||
user.email = ver.email
|
||||
|
||||
db.session.delete(ver)
|
||||
db.session.commit()
|
||||
|
||||
if ver.is_password_reset:
|
||||
login_user(user)
|
||||
user.password = None
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("users.set_password"))
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("users.profile", username=current_user.username))
|
||||
elif was_activating:
|
||||
flash(gettext("You may now log in"), "success")
|
||||
return redirect(url_for("users.login"))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
class UnsubscribeForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
submit = SubmitField(lazy_gettext("Send"))
|
||||
|
||||
|
||||
def unsubscribe_verify():
|
||||
form = UnsubscribeForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
email = form.email.data
|
||||
sub = EmailSubscription.query.filter_by(email=email).first()
|
||||
if not sub:
|
||||
sub = EmailSubscription(email)
|
||||
db.session.add(sub)
|
||||
|
||||
sub.token = random_string(32)
|
||||
db.session.commit()
|
||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
return render_template("users/unsubscribe.html", form=form)
|
||||
|
||||
|
||||
def unsubscribe_manage(sub: EmailSubscription):
|
||||
user = User.query.filter_by(email=sub.email).first()
|
||||
|
||||
if request.method == "POST":
|
||||
if user:
|
||||
user.email = None
|
||||
|
||||
sub.blacklisted = True
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
return render_template("users/unsubscribe.html", user=user)
|
||||
|
||||
|
||||
@bp.route("/unsubscribe/", methods=["GET", "POST"])
|
||||
def unsubscribe():
|
||||
token = request.args.get("token")
|
||||
if token:
|
||||
sub = EmailSubscription.query.filter_by(token=token).first()
|
||||
if sub:
|
||||
return unsubscribe_manage(sub)
|
||||
|
||||
return unsubscribe_verify()
|
||||
|
||||
|
||||
@bp.route("/email_sent/")
|
||||
def email_sent():
|
||||
return render_template("users/email_sent.html")
|
||||
118
app/blueprints/users/claim.py
Normal file
118
app/blueprints/users/claim.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user
|
||||
|
||||
from . import bp
|
||||
from flask import redirect, render_template, session, request, flash, url_for
|
||||
from app.models import db, User, UserRank
|
||||
from app.utils import random_string, login_user_set_active
|
||||
from app.tasks.forumtasks import check_forum_account
|
||||
from app.utils.phpbbparser import get_profile
|
||||
|
||||
|
||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||
def claim():
|
||||
return render_template("users/claim.html")
|
||||
|
||||
|
||||
@bp.route("/user/claim-forums/", methods=["GET", "POST"])
|
||||
def claim_forums():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
username = request.args.get("username")
|
||||
if username is None:
|
||||
username = ""
|
||||
else:
|
||||
method = request.args.get("method")
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.at_least(UserRank.NEW_MEMBER):
|
||||
flash(gettext("User has already been claimed"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
elif method == "github":
|
||||
if user is None or user.github_username is None:
|
||||
flash(gettext("Unable to get GitHub username for user. Make sure the forum account exists."), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
else:
|
||||
return redirect(url_for("vcs.github_start"))
|
||||
|
||||
if "forum_token" in session:
|
||||
token = session["forum_token"]
|
||||
else:
|
||||
token = random_string(12)
|
||||
session["forum_token"] = token
|
||||
|
||||
if request.method == "POST":
|
||||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if User.query.filter(User.username == username, User.forums_username.is_(None)).first():
|
||||
flash(gettext("A ContentDB user with that name already exists. Please contact an admin to link to your forum account"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
if ctype == "github":
|
||||
task = check_forum_account.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
|
||||
elif ctype == "forum":
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is not None and user.rank.at_least(UserRank.NEW_MEMBER):
|
||||
flash(gettext("That user has already been claimed!"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
# Get signature
|
||||
try:
|
||||
profile = get_profile("https://forum.luanti.org", username)
|
||||
sig = profile.signature if profile else None
|
||||
except IOError as e:
|
||||
if hasattr(e, 'message'):
|
||||
message = e.message
|
||||
else:
|
||||
message = str(e)
|
||||
|
||||
flash(gettext(u"Error whilst attempting to access forums: %(message)s", message=message), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
|
||||
if profile is None:
|
||||
flash(gettext("Unable to get forum signature - does the user exist?"), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
|
||||
# Look for key
|
||||
if sig and token in sig:
|
||||
# Try getting again to fix crash
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is None:
|
||||
user = User(username)
|
||||
user.forums_username = username
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
ret = login_user_set_active(user, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Unable to login as user"), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
|
||||
return ret
|
||||
|
||||
else:
|
||||
flash(gettext("Could not find the key in your signature!"), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
else:
|
||||
flash(gettext("Unknown claim type"), "danger")
|
||||
|
||||
return render_template("users/claim_forums.html", username=username, key="cdb_" + token)
|
||||
287
app/blueprints/users/profile.py
Normal file
287
app/blueprints/users/profile.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from flask import redirect, url_for, abort, render_template, request
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func, text
|
||||
|
||||
from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank, Collection
|
||||
from app.utils import get_daterange_options
|
||||
from app.tasks.forumtasks import check_forum_account
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/users/", methods=["GET"])
|
||||
def list_all():
|
||||
users = db.session.query(User, func.count(Package.id)) \
|
||||
.select_from(User).outerjoin(Package) \
|
||||
.order_by(db.desc(User.rank), db.asc(User.display_name)) \
|
||||
.group_by(User.id).all()
|
||||
|
||||
return render_template("users/list.html", users=users)
|
||||
|
||||
|
||||
@bp.route("/user/forum/<username>/")
|
||||
def by_forums_username(username):
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user:
|
||||
return redirect(url_for("users.profile", username=user.username))
|
||||
|
||||
return render_template("users/forums_no_such_user.html", username=username)
|
||||
|
||||
|
||||
class Medal:
|
||||
description: str
|
||||
color: Optional[str]
|
||||
icon: str
|
||||
title: Optional[str]
|
||||
progress: Optional[Tuple[int, int]]
|
||||
|
||||
def __init__(self, description: str, **kwargs):
|
||||
self.description = description
|
||||
self.color = kwargs.get("color", "white")
|
||||
self.icon = kwargs.get("icon", None)
|
||||
self.title = kwargs.get("title", None)
|
||||
self.progress = kwargs.get("progress", None)
|
||||
|
||||
@classmethod
|
||||
def make_unlocked(cls, color: str, icon: str, title: str, description: str):
|
||||
return Medal(description=description, color=color, icon=icon, title=title)
|
||||
|
||||
@classmethod
|
||||
def make_locked(cls, description: str, progress: Tuple[int, int]):
|
||||
if progress[0] is None or progress[1] is None:
|
||||
raise Exception("Invalid progress")
|
||||
|
||||
return Medal(description=description, progress=progress)
|
||||
|
||||
|
||||
def place_to_color(place: int) -> str:
|
||||
if place == 1:
|
||||
return "gold"
|
||||
elif place == 2:
|
||||
return "#888"
|
||||
elif place == 3:
|
||||
return "#cd7f32"
|
||||
else:
|
||||
return "white"
|
||||
|
||||
|
||||
def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
|
||||
unlocked = []
|
||||
locked = []
|
||||
|
||||
#
|
||||
# REVIEWS
|
||||
#
|
||||
|
||||
users_by_reviews = db.session.query(User.username, func.sum(PackageReview.score).label("karma")) \
|
||||
.select_from(User).join(PackageReview) \
|
||||
.group_by(User.username).order_by(text("karma DESC")).all()
|
||||
try:
|
||||
review_boundary = users_by_reviews[math.floor(len(users_by_reviews) * 0.25)][1] + 1
|
||||
except IndexError:
|
||||
review_boundary = None
|
||||
usernames_by_reviews = [username for username, _ in users_by_reviews]
|
||||
|
||||
review_idx = None
|
||||
review_percent = None
|
||||
review_karma = 0
|
||||
try:
|
||||
review_idx = usernames_by_reviews.index(user.username)
|
||||
review_percent = round(100 * review_idx / len(users_by_reviews), 1)
|
||||
review_karma = max(users_by_reviews[review_idx][1], 0)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if review_percent is not None and review_percent < 25:
|
||||
if review_idx == 0:
|
||||
title = gettext(u"Top reviewer")
|
||||
description = gettext(
|
||||
u"%(display_name)s has written the most helpful reviews on ContentDB.",
|
||||
display_name=user.display_name)
|
||||
elif review_idx <= 2:
|
||||
if review_idx == 1:
|
||||
title = gettext(u"2nd most helpful reviewer")
|
||||
else:
|
||||
title = gettext(u"3rd most helpful reviewer")
|
||||
description = gettext(
|
||||
u"This puts %(display_name)s in the top %(perc)s%%",
|
||||
display_name=user.display_name, perc=review_percent)
|
||||
else:
|
||||
title = gettext(u"Top %(perc)s%% reviewer", perc=review_percent)
|
||||
description = gettext(u"Only %(place)d users have written more helpful reviews.", place=review_idx)
|
||||
|
||||
unlocked.append(Medal.make_unlocked(
|
||||
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
|
||||
elif review_boundary is not None:
|
||||
description = gettext(u"Consider writing more helpful reviews to get a medal.")
|
||||
if review_idx:
|
||||
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
|
||||
locked.append(Medal.make_locked(
|
||||
description, (review_karma, review_boundary)))
|
||||
|
||||
#
|
||||
# TOP PACKAGES
|
||||
#
|
||||
all_package_ranks = db.session.query(
|
||||
Package.type,
|
||||
Package.author_id,
|
||||
func.rank().over(
|
||||
order_by=db.desc(Package.score),
|
||||
partition_by=Package.type) \
|
||||
.label("rank")).order_by(db.asc(text("rank"))) \
|
||||
.filter_by(state=PackageState.APPROVED).subquery()
|
||||
|
||||
user_package_ranks = db.session.query(all_package_ranks) \
|
||||
.filter_by(author_id=user.id) \
|
||||
.filter(text("rank <= 30")) \
|
||||
.all()
|
||||
|
||||
user_package_ranks = next(
|
||||
(x for x in user_package_ranks if x[0] == PackageType.MOD or x[2] <= 10),
|
||||
None)
|
||||
if user_package_ranks:
|
||||
top_rank = user_package_ranks[2]
|
||||
top_type = PackageType.coerce(user_package_ranks[0])
|
||||
title = top_type.get_top_ordinal(top_rank)
|
||||
if top_type == PackageType.MOD:
|
||||
icon = "fa-box"
|
||||
elif top_type == PackageType.GAME:
|
||||
icon = "fa-gamepad"
|
||||
else:
|
||||
icon = "fa-paint-brush"
|
||||
|
||||
description = top_type.get_top_ordinal_description(user.display_name, top_rank)
|
||||
unlocked.append(
|
||||
Medal.make_unlocked(place_to_color(top_rank), icon, title, description))
|
||||
|
||||
#
|
||||
# DOWNLOADS
|
||||
#
|
||||
total_downloads = db.session.query(func.sum(Package.downloads)) \
|
||||
.select_from(User) \
|
||||
.join(User.packages) \
|
||||
.filter(User.id == user.id,
|
||||
Package.state == PackageState.APPROVED).scalar()
|
||||
if total_downloads is None:
|
||||
pass
|
||||
elif total_downloads < 50000:
|
||||
description = gettext(u"Your packages have %(downloads)d downloads in total.", downloads=total_downloads)
|
||||
description += " " + gettext(u"First medal is at 50k.")
|
||||
locked.append(Medal.make_locked(description, (total_downloads, 50000)))
|
||||
else:
|
||||
if total_downloads >= 300000:
|
||||
place = 1
|
||||
title = gettext(u">300k downloads")
|
||||
elif total_downloads >= 100000:
|
||||
place = 2
|
||||
title = gettext(u">100k downloads")
|
||||
elif total_downloads >= 75000:
|
||||
place = 3
|
||||
title = gettext(u">75k downloads")
|
||||
else:
|
||||
place = 10
|
||||
title = gettext(u">50k downloads")
|
||||
description = gettext(u"Has received %(downloads)d downloads across all packages.",
|
||||
display_name=user.display_name, downloads=total_downloads)
|
||||
unlocked.append(Medal.make_unlocked(place_to_color(place), "fa-users", title, description))
|
||||
|
||||
return unlocked, locked
|
||||
|
||||
|
||||
@bp.route("/users/<username>/")
|
||||
def profile(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not current_user.is_authenticated or (user != current_user and not current_user.can_access_todo_list()):
|
||||
packages = user.packages.filter_by(state=PackageState.APPROVED)
|
||||
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
|
||||
else:
|
||||
packages = user.packages.filter(Package.state != PackageState.DELETED)
|
||||
maintained_packages = user.maintained_packages.filter(Package.state != PackageState.DELETED)
|
||||
|
||||
packages = packages.order_by(db.asc(Package.title)).all()
|
||||
maintained_packages = maintained_packages \
|
||||
.filter(Package.author != user) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
pinned_collections = user.collections.filter(Collection.private == False,
|
||||
Collection.pinned == True, Collection.packages.any()).all()
|
||||
|
||||
unlocked, locked = get_user_medals(user)
|
||||
# Process GET or invalid POST
|
||||
return render_template("users/profile.html", user=user,
|
||||
packages=packages, maintained_packages=maintained_packages,
|
||||
medals_unlocked=unlocked, medals_locked=locked, pinned_collections=pinned_collections)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/check-forums/", methods=["POST"])
|
||||
@login_required
|
||||
def user_check_forums(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR):
|
||||
abort(403)
|
||||
|
||||
if user.forums_username is None:
|
||||
abort(404)
|
||||
|
||||
task = check_forum_account.delay(user.forums_username, force_replace_pic=True)
|
||||
next_url = url_for("users.profile", username=username)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/remove-profile-pic/", methods=["POST"])
|
||||
@login_required
|
||||
def user_remove_profile_pic(username):
|
||||
user = User.query.filter_by(username=username).one_or_404()
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR):
|
||||
abort(403)
|
||||
|
||||
user.profile_pic = None
|
||||
db.session.commit()
|
||||
return redirect(url_for("users.profile_edit", username=username))
|
||||
|
||||
|
||||
@bp.route("/user/stats/")
|
||||
@login_required
|
||||
def statistics_redirect():
|
||||
return redirect(url_for("users.statistics", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/stats/")
|
||||
def statistics(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
downloads = db.session.query(func.sum(Package.downloads)).filter(Package.author==user).one()
|
||||
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
return render_template("users/stats.html", user=user, downloads=downloads[0],
|
||||
start=start, end=end, options=get_daterange_options(), noindex=start or end)
|
||||
472
app/blueprints/users/settings.py
Normal file
472
app/blueprints/users/settings.py
Normal file
@@ -0,0 +1,472 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import redirect, abort, render_template, request, flash, url_for
|
||||
from flask_babel import gettext, get_locale, lazy_gettext
|
||||
from flask_login import current_user, login_required, logout_user
|
||||
from flask_wtf import FlaskForm
|
||||
from kombu import uuid
|
||||
from sqlalchemy import or_
|
||||
from wtforms import StringField, SubmitField, BooleanField, SelectField
|
||||
from wtforms.validators import Length, Optional, Email, URL
|
||||
|
||||
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
|
||||
UserEmailVerification, Permission, NotificationType, UserBan
|
||||
from app.tasks.emails import send_verify_email
|
||||
from app.tasks.usertasks import update_github_user_id
|
||||
from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains
|
||||
from . import bp
|
||||
|
||||
|
||||
def get_setting_tabs(user):
|
||||
ret = [
|
||||
{
|
||||
"id": "edit_profile",
|
||||
"title": gettext("Edit Profile"),
|
||||
"url": url_for("users.profile_edit", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"title": gettext("Account and Security"),
|
||||
"url": url_for("users.account", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "notifications",
|
||||
"title": gettext("Email and Notifications"),
|
||||
"url": url_for("users.email_notifications", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "api_tokens",
|
||||
"title": gettext("API Tokens"),
|
||||
"url": url_for("api.list_tokens", username=user.username)
|
||||
},
|
||||
]
|
||||
|
||||
if user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
ret.append({
|
||||
"id": "oauth_clients",
|
||||
"title": gettext("OAuth2 Applications"),
|
||||
"url": url_for("oauth.list_clients", username=user.username)
|
||||
})
|
||||
|
||||
if current_user.rank.at_least(UserRank.MODERATOR):
|
||||
ret.append({
|
||||
"id": "modtools",
|
||||
"title": gettext("Moderator Tools"),
|
||||
"url": url_for("users.modtools", username=user.username)
|
||||
})
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class UserProfileForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonempty_or_none(x.strip())])
|
||||
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
def handle_profile_edit(form: UserProfileForm, user: User, username: str):
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
display_name = form.display_name.data or user.username
|
||||
if user.check_perm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
|
||||
user.display_name != display_name:
|
||||
|
||||
if User.query.filter(User.id != user.id,
|
||||
or_(User.username == display_name,
|
||||
User.display_name.ilike(display_name))).count() > 0:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return None
|
||||
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author == display_name)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return
|
||||
|
||||
user.display_name = display_name
|
||||
|
||||
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Changed display name of {} to {}"
|
||||
.format(user.username, user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_PROFILE_URLS):
|
||||
if has_blocked_domains(form.website_url.data, current_user.username, f"{user.username}'s website_url") or \
|
||||
has_blocked_domains(form.donate_url.data, current_user.username, f"{user.username}'s donate_url"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
return
|
||||
|
||||
user.website_url = form.website_url.data
|
||||
user.donate_url = form.donate_url.data
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("users.profile", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/settings/profile/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def profile_edit(username):
|
||||
user : User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.can_see_edit_profile(current_user):
|
||||
abort(403)
|
||||
|
||||
form = UserProfileForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
ret = handle_profile_edit(form, user, username)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("users/profile_edit.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="edit_profile")
|
||||
|
||||
|
||||
def make_settings_form():
|
||||
attrs = {
|
||||
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
|
||||
"submit": SubmitField(lazy_gettext("Save"))
|
||||
}
|
||||
|
||||
for notificationType in NotificationType:
|
||||
key = "pref_" + notificationType.to_name()
|
||||
attrs[key] = BooleanField("")
|
||||
attrs[key + "_digest"] = BooleanField("")
|
||||
|
||||
return type("SettingsForm", (FlaskForm,), attrs)
|
||||
|
||||
SettingsForm = make_settings_form()
|
||||
|
||||
|
||||
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
|
||||
for notificationType in NotificationType:
|
||||
field_email = getattr(form, "pref_" + notificationType.to_name()).data
|
||||
field_digest = getattr(form, "pref_" + notificationType.to_name() + "_digest").data or field_email
|
||||
prefs.set_can_email(notificationType, field_email)
|
||||
prefs.set_can_digest(notificationType, field_digest)
|
||||
|
||||
if is_new:
|
||||
db.session.add(prefs)
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
newEmail = form.email.data
|
||||
if newEmail and newEmail != user.email and newEmail.strip() != "":
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
return
|
||||
|
||||
token = random_string(32)
|
||||
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
|
||||
msg = "Changed email of {}".format(user.display_name)
|
||||
add_audit_log(severity, current_user, msg, url_for("users.profile", username=user.username))
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
ver.token = token
|
||||
ver.email = newEmail
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(newEmail, token, get_locale().language)
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for("users.email_notifications", username=user.username))
|
||||
|
||||
|
||||
@bp.route("/user/settings/email/")
|
||||
@bp.route("/users/<username>/settings/email/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def email_notifications(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("users.email_notifications", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
is_new = False
|
||||
prefs = user.notification_preferences
|
||||
if prefs is None:
|
||||
is_new = True
|
||||
prefs = UserNotificationPreferences(user)
|
||||
|
||||
data = {}
|
||||
types = []
|
||||
for notificationType in NotificationType:
|
||||
types.append(notificationType)
|
||||
data["pref_" + notificationType.to_name()] = prefs.get_can_email(notificationType)
|
||||
data["pref_" + notificationType.to_name() + "_digest"] = prefs.get_can_digest(notificationType)
|
||||
|
||||
data["email"] = user.email
|
||||
|
||||
form = SettingsForm(data=data)
|
||||
if form.validate_on_submit():
|
||||
ret = handle_email_notifications(user, prefs, is_new, form)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("users/settings_email.html",
|
||||
form=form, user=user, types=types, is_new=is_new,
|
||||
tabs=get_setting_tabs(user), current_tab="notifications")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/settings/account/")
|
||||
@login_required
|
||||
def account(username):
|
||||
user : User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.can_see_edit_profile(current_user):
|
||||
abort(403)
|
||||
|
||||
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/settings/account/disconnect-github/", methods=["POST"])
|
||||
def disconnect_github(username: str):
|
||||
user: User = User.query.filter_by(username=username).one_or_404()
|
||||
|
||||
if not user.can_see_edit_profile(current_user):
|
||||
abort(403)
|
||||
|
||||
if user.password and user.email:
|
||||
user.github_user_id = None
|
||||
user.github_username = None
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Removed GitHub account"), "success")
|
||||
else:
|
||||
flash(gettext("You need to add an email address and password before you can remove your GitHub account"), "danger")
|
||||
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def delete(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if user.rank.at_least(UserRank.MODERATOR):
|
||||
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
|
||||
|
||||
if "delete" in request.form and (user.can_delete() or current_user.rank.at_least(UserRank.ADMIN)):
|
||||
msg = "Deleted user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
if current_user.rank.at_least(UserRank.ADMIN):
|
||||
for pkg in user.packages.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
|
||||
db.session.delete(user)
|
||||
elif "deactivate" in request.form:
|
||||
for reply in user.replies.all():
|
||||
db.session.delete(reply)
|
||||
for thread in user.threads.all():
|
||||
db.session.delete(thread)
|
||||
for token in user.tokens.all():
|
||||
db.session.delete(token)
|
||||
user.profile_pic = None
|
||||
user.email = None
|
||||
|
||||
if user.rank != UserRank.BANNED:
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
|
||||
msg = "Deactivated user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
else:
|
||||
assert False
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if user == current_user:
|
||||
logout_user()
|
||||
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
class ModToolsForm(FlaskForm):
|
||||
username = StringField(lazy_gettext("Username"), [Optional(), Length(1, 50)])
|
||||
display_name = StringField(lazy_gettext("Display name"), [Optional(), Length(2, 100)])
|
||||
forums_username = StringField(lazy_gettext("Forums Username"), [Optional(), Length(2, 50)])
|
||||
github_username = StringField(lazy_gettext("GitHub Username"), [Optional(), Length(2, 50)])
|
||||
rank = SelectField(lazy_gettext("Rank"), [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
|
||||
default=UserRank.NEW_MEMBER)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
form = ModToolsForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
redirect_target = url_for("users.modtools", username=username)
|
||||
|
||||
# Copy form fields to user_profile fields
|
||||
if user.check_perm(current_user, Permission.CHANGE_USERNAMES):
|
||||
if user.username != form.username.data:
|
||||
for package in user.packages:
|
||||
alias = PackageAlias(user.username, package.name)
|
||||
package.aliases.append(alias)
|
||||
db.session.add(alias)
|
||||
|
||||
user.username = form.username.data
|
||||
|
||||
user.display_name = form.display_name.data
|
||||
user.forums_username = nonempty_or_none(form.forums_username.data)
|
||||
github_username = nonempty_or_none(form.github_username.data)
|
||||
if github_username is None:
|
||||
user.github_username = None
|
||||
user.github_user_id = None
|
||||
else:
|
||||
task_id = uuid()
|
||||
update_github_user_id.apply_async((user.id, github_username), task_id=task_id)
|
||||
redirect_target = url_for("tasks.check", id=task_id, r=redirect_target)
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
new_rank = form.rank.data
|
||||
if current_user.rank.at_least(new_rank):
|
||||
if new_rank != user.rank:
|
||||
user.rank = form.rank.data
|
||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.title)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg,
|
||||
url_for("users.profile", username=username))
|
||||
else:
|
||||
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(redirect_target)
|
||||
|
||||
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_set_email(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
user.email = request.form["email"]
|
||||
user.is_active = False
|
||||
|
||||
token = random_string(32)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
ver.token = token
|
||||
ver.email = user.email
|
||||
ver.is_password_reset = True
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(user.email, token, user.locale or "en")
|
||||
|
||||
flash(f"Set email and sent a password reset on {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_ban(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
message = request.form["message"]
|
||||
expires_at = request.form.get("expires_at")
|
||||
|
||||
user.ban = UserBan()
|
||||
user.ban.banned_by = current_user
|
||||
user.ban.message = message
|
||||
|
||||
if expires_at and expires_at != "":
|
||||
user.ban.expires_at = expires_at
|
||||
else:
|
||||
user.rank = UserRank.BANNED
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Banned {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/unban/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_unban(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
if user.ban:
|
||||
db.session.delete(user.ban)
|
||||
|
||||
if user.rank == UserRank.BANNED:
|
||||
user.rank = UserRank.MEMBER
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Unbanned {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
22
app/blueprints/vcs/__init__.py
Normal file
22
app/blueprints/vcs/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("vcs", __name__)
|
||||
|
||||
from . import github, gitlab
|
||||
43
app/blueprints/vcs/common.py
Normal file
43
app/blueprints/vcs/common.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from app.blueprints.api.support import error
|
||||
from app.models import Package, APIToken, Permission, PackageState
|
||||
|
||||
|
||||
def get_packages_for_vcs_and_token(token: APIToken, repo_url: str) -> list[Package]:
|
||||
repo_url = repo_url.replace("https://", "").replace("http://", "").lower()
|
||||
if token.package:
|
||||
packages = [token.package]
|
||||
if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
actual_repo_url: str = token.package.repo or ""
|
||||
if repo_url not in actual_repo_url.lower():
|
||||
return error(400, "Repo URL does not match the API token's package")
|
||||
else:
|
||||
# Get package
|
||||
packages = Package.query.filter(
|
||||
Package.repo.ilike("%{}%".format(repo_url)), Package.state != PackageState.DELETED).all()
|
||||
if len(packages) == 0:
|
||||
return error(400,
|
||||
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(repo_url))
|
||||
packages = [x for x in packages if x.check_perm(token.owner, Permission.APPROVE_RELEASE)]
|
||||
if len(packages) == 0:
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
return packages
|
||||
200
app/blueprints/vcs/github.py
Normal file
200
app/blueprints/vcs/github.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-24 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import hmac
|
||||
|
||||
import requests
|
||||
from flask import abort, Response
|
||||
from flask import redirect, url_for, request, flash, jsonify, current_app
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user
|
||||
|
||||
from app import github, csrf
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
from app.logic.users import create_user
|
||||
from app.models import db, User, APIToken, AuditSeverity
|
||||
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
|
||||
from . import bp
|
||||
from .common import get_packages_for_vcs_and_token
|
||||
|
||||
|
||||
@bp.route("/github/start/")
|
||||
def github_start():
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
return github.authorize("", redirect_uri=abs_url_for("vcs.github_callback", next=next))
|
||||
|
||||
|
||||
@bp.route("/github/view/")
|
||||
def github_view_permissions():
|
||||
url = "https://github.com/settings/connections/applications/" + \
|
||||
current_app.config["GITHUB_CLIENT_ID"]
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@bp.route("/github/callback/")
|
||||
@github.authorized_handler
|
||||
def github_callback(oauth_token):
|
||||
if oauth_token is None:
|
||||
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
redirect_to = next
|
||||
if redirect_to is None:
|
||||
redirect_to = url_for("homepage.home")
|
||||
|
||||
# Get GitGub username
|
||||
url = "https://api.github.com/user"
|
||||
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
|
||||
json = r.json()
|
||||
user_id = json["id"]
|
||||
github_username = json["login"]
|
||||
if type(user_id) is not int:
|
||||
abort(400)
|
||||
|
||||
# Get user by GitHub user ID
|
||||
user_by_github = User.query.filter(User.github_user_id == user_id).one_or_none()
|
||||
|
||||
# If logged in, connect
|
||||
if current_user and current_user.is_authenticated:
|
||||
if user_by_github is None:
|
||||
current_user.github_username = github_username
|
||||
current_user.github_user_id = user_id
|
||||
db.session.commit()
|
||||
flash(gettext("Linked GitHub to account"), "success")
|
||||
return redirect(redirect_to)
|
||||
elif user_by_github == current_user:
|
||||
return redirect(redirect_to)
|
||||
else:
|
||||
flash(gettext("GitHub account is already associated with another user: %(username)s",
|
||||
username=user_by_github.username), "danger")
|
||||
return redirect(redirect_to)
|
||||
|
||||
# Log in to existing account
|
||||
elif user_by_github:
|
||||
ret = login_user_set_active(user_by_github, next, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
user_by_github.github_username = github_username
|
||||
add_audit_log(AuditSeverity.USER, user_by_github, "Logged in using GitHub OAuth",
|
||||
url_for("users.profile", username=user_by_github.username))
|
||||
db.session.commit()
|
||||
return ret
|
||||
|
||||
# Sign up
|
||||
else:
|
||||
user = create_user(github_username, github_username, None, "GitHub")
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
elif user is None:
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
user.github_username = github_username
|
||||
user.github_user_id = user_id
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Registered with GitHub, display name=" + user.display_name,
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
ret = login_user_set_active(user, next, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def _find_api_token(header_signature: str) -> APIToken:
|
||||
sha_name, signature = header_signature.split('=')
|
||||
if sha_name != 'sha1':
|
||||
error(403, "Expected SHA1 payload signature")
|
||||
|
||||
for token in APIToken.query.all():
|
||||
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
|
||||
|
||||
if hmac.compare_digest(str(mac.hexdigest()), signature):
|
||||
return token
|
||||
|
||||
error(401, "Invalid authentication, couldn't validate API token")
|
||||
|
||||
|
||||
@bp.route("/github/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def github_webhook():
|
||||
json = request.json
|
||||
|
||||
header_signature = request.headers.get('X-Hub-Signature')
|
||||
if header_signature is None:
|
||||
return error(403, "Expected payload signature")
|
||||
|
||||
token = _find_api_token(header_signature)
|
||||
packages = get_packages_for_vcs_and_token(token, "github.com/" + json["repository"]["full_name"])
|
||||
|
||||
for package in packages:
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
event = request.headers.get("X-GitHub-Event")
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
|
||||
branch = json["ref"].replace("refs/heads/", "")
|
||||
if package.update_config and package.update_config.ref:
|
||||
if branch != package.update_config.ref:
|
||||
continue
|
||||
elif branch not in ["master", "main"]:
|
||||
continue
|
||||
|
||||
elif event == "create":
|
||||
ref_type = json.get("ref_type")
|
||||
if ref_type != "tag":
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
|
||||
})
|
||||
|
||||
ref = json["ref"]
|
||||
title = ref
|
||||
|
||||
elif event == "ping":
|
||||
return jsonify({"success": True, "message": "Ping successful"})
|
||||
|
||||
else:
|
||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
||||
.format(event or "null"))
|
||||
|
||||
#
|
||||
# Perform release
|
||||
#
|
||||
if package.releases.filter_by(commit_hash=ref).count() > 0:
|
||||
return
|
||||
|
||||
return api_create_vcs_release(token, package, title, title, None, ref, reason="Webhook")
|
||||
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "No release made. Either the release already exists or the event was filtered based on the branch"
|
||||
})
|
||||
86
app/blueprints/vcs/gitlab.py
Normal file
86
app/blueprints/vcs/gitlab.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020-24 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
from app import csrf
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
from app.models import APIToken
|
||||
|
||||
from . import bp
|
||||
from .common import get_packages_for_vcs_and_token
|
||||
|
||||
|
||||
def webhook_impl():
|
||||
json = request.json
|
||||
|
||||
# Get all tokens for package
|
||||
secret = request.headers.get("X-Gitlab-Token")
|
||||
if secret is None:
|
||||
return error(403, "Token required")
|
||||
|
||||
token: APIToken = APIToken.query.filter_by(access_token=secret).first()
|
||||
if token is None:
|
||||
return error(403, "Invalid authentication")
|
||||
|
||||
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"])
|
||||
for package in packages:
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
event = json["event_name"]
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
|
||||
branch = json["ref"].replace("refs/heads/", "")
|
||||
if package.update_config and package.update_config.ref:
|
||||
if branch != package.update_config.ref:
|
||||
continue
|
||||
elif branch not in ["master", "main"]:
|
||||
continue
|
||||
|
||||
elif event == "tag_push":
|
||||
ref = json["ref"]
|
||||
title = ref.replace("refs/tags/", "")
|
||||
|
||||
else:
|
||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
||||
.format(event or "null"))
|
||||
|
||||
#
|
||||
# Perform release
|
||||
#
|
||||
if package.releases.filter_by(commit_hash=ref).count() > 0:
|
||||
continue
|
||||
|
||||
return api_create_vcs_release(token, package, title, title, None, ref, reason="Webhook")
|
||||
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "No release made. Either the release already exists or the event was filtered based on the branch"
|
||||
})
|
||||
|
||||
|
||||
|
||||
@bp.route("/gitlab/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def gitlab_webhook():
|
||||
try:
|
||||
return webhook_impl()
|
||||
except KeyError as err:
|
||||
return error(400, "Missing field: {}".format(err.args[0]))
|
||||
70
app/blueprints/zipgrep/__init__.py
Normal file
70
app/blueprints/zipgrep/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from celery import uuid
|
||||
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, SelectMultipleField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.tasks import celery
|
||||
from app.utils import rank_required
|
||||
|
||||
bp = Blueprint("zipgrep", __name__)
|
||||
|
||||
from app.models import UserRank, Package, PackageType
|
||||
from app.tasks.zipgrep import search_in_releases
|
||||
|
||||
|
||||
class SearchForm(FlaskForm):
|
||||
query = StringField(lazy_gettext("Text to find (regex)"), [InputRequired(), Length(1, 100)])
|
||||
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
||||
type = SelectMultipleField(lazy_gettext("Type"), [Optional()],
|
||||
choices=PackageType.choices(), coerce=PackageType.coerce)
|
||||
submit = SubmitField(lazy_gettext("Search"))
|
||||
|
||||
|
||||
@bp.route("/zipgrep/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def zipgrep_search():
|
||||
form = SearchForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
task_id = uuid()
|
||||
search_in_releases.apply_async((form.query.data, form.file_filter.data, [x.name for x in form.type.data]), task_id=task_id)
|
||||
result_url = url_for("zipgrep.view_results", id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
||||
|
||||
return render_template("zipgrep/search.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/zipgrep/<id>/")
|
||||
def view_results(id):
|
||||
result = celery.AsyncResult(id)
|
||||
if result.status == "PENDING":
|
||||
abort(404)
|
||||
|
||||
if result.status != "SUCCESS" or isinstance(result.result, Exception):
|
||||
result_url = url_for("zipgrep.view_results", id=id)
|
||||
return redirect(url_for("tasks.check", id=id, r=result_url))
|
||||
|
||||
matches = result.result["matches"]
|
||||
for match in matches:
|
||||
match["package"] = Package.query.filter(
|
||||
Package.name == match["package"]["name"],
|
||||
Package.author.has(username=match["package"]["author"])).one()
|
||||
|
||||
return render_template("zipgrep/view_results.html", query=result.result["query"], matches=matches)
|
||||
@@ -1,83 +1,133 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
# ContentDB
|
||||
# Copyright (C) rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
import os, sys, datetime
|
||||
from .models import User, UserRank, LuantiRelease, Tag, License, Notification, NotificationType, Package, \
|
||||
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
||||
from .utils import make_flask_login_password
|
||||
|
||||
if not "FLASK_CONFIG" in os.environ:
|
||||
os.environ["FLASK_CONFIG"] = "../config.cfg"
|
||||
|
||||
test_data = len(sys.argv) >= 2 and sys.argv[1].strip() == "-t"
|
||||
def populate(session):
|
||||
admin_user = User("rubenwardy")
|
||||
admin_user.is_active = True
|
||||
admin_user.password = make_flask_login_password("tuckfrump")
|
||||
admin_user.github_username = "rubenwardy"
|
||||
admin_user.forums_username = "rubenwardy"
|
||||
admin_user.rank = UserRank.ADMIN
|
||||
session.add(admin_user)
|
||||
|
||||
from app.models import *
|
||||
system_user = User("ContentDB", active=False)
|
||||
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
|
||||
system_user.rank = UserRank.BOT
|
||||
session.add(system_user)
|
||||
|
||||
session.add(LuantiRelease("None", 0))
|
||||
session.add(LuantiRelease("0.4.16/17", 32))
|
||||
session.add(LuantiRelease("5.0", 37))
|
||||
session.add(LuantiRelease("5.1", 38))
|
||||
session.add(LuantiRelease("5.2", 39))
|
||||
session.add(LuantiRelease("5.3", 39))
|
||||
|
||||
tags = {}
|
||||
for tag in ["Inventory", "Mapgen", "Building",
|
||||
"Mobs and NPCs", "Tools", "Player effects",
|
||||
"Environment", "Transport", "Maintenance", "Plants and farming",
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
|
||||
row = Tag(tag)
|
||||
tags[row.name] = row
|
||||
session.add(row)
|
||||
|
||||
licenses = {}
|
||||
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
|
||||
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
|
||||
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
|
||||
row = License(license)
|
||||
licenses[row.name] = row
|
||||
session.add(row)
|
||||
|
||||
for license in ["CC-BY-NC-SA", "Other (Non-free)"]:
|
||||
row = License(license, False)
|
||||
licenses[row.name] = row
|
||||
session.add(row)
|
||||
|
||||
|
||||
def populate_test_data(session):
|
||||
licenses = { x.name : x for x in License.query.all() }
|
||||
tags = { x.name : x for x in Tag.query.all() }
|
||||
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
||||
v4 = LuantiRelease.query.filter_by(protocol=32).first()
|
||||
v51 = LuantiRelease.query.filter_by(protocol=38).first()
|
||||
|
||||
def defineDummyData(licenses, tags, ruben):
|
||||
ez = User("Shara")
|
||||
ez.github_username = "Ezhh"
|
||||
ez.forums_username = "Shara"
|
||||
ez.rank = UserRank.EDITOR
|
||||
db.session.add(ez)
|
||||
session.add(ez)
|
||||
|
||||
not1 = Notification(ruben, ez, "Awards approved", "/packages/rubenwardy/awards/")
|
||||
db.session.add(not1)
|
||||
not1 = Notification(admin_user, ez, NotificationType.PACKAGE_APPROVAL, "Awards approved", "/packages/rubenwardy/awards/")
|
||||
session.add(not1)
|
||||
|
||||
jeija = User("Jeija")
|
||||
jeija.github_username = "Jeija"
|
||||
db.session.add(jeija)
|
||||
|
||||
jeija.forums_username = "Jeija"
|
||||
session.add(jeija)
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "alpha"
|
||||
mod.title = "Alpha Test"
|
||||
mod.license = licenses["MIT"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.MOD
|
||||
mod.author = ruben
|
||||
mod.author = admin_user
|
||||
mod.tags.append(tags["mapgen"])
|
||||
mod.tags.append(tags["environment"])
|
||||
mod.repo = "https://github.com/ezhh/other_worlds"
|
||||
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
|
||||
mod.forums = 16015
|
||||
mod.shortDesc = "The content library should not be used yet as it is still in alpha"
|
||||
mod.short_desc = "The content library should not be used yet as it is still in alpha"
|
||||
mod.desc = "This is the long desc"
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = mod
|
||||
rel.name = "v1.0.0"
|
||||
rel.title = "v1.0.0"
|
||||
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
|
||||
rel.approved = True
|
||||
db.session.add(rel)
|
||||
session.add(rel)
|
||||
|
||||
mod1 = Package()
|
||||
mod1.approved = True
|
||||
mod1.state = PackageState.APPROVED
|
||||
mod1.name = "awards"
|
||||
mod1.title = "Awards"
|
||||
mod1.license = licenses["LGPLv2.1"]
|
||||
mod1.media_license = licenses["MIT"]
|
||||
mod1.type = PackageType.MOD
|
||||
mod1.author = ruben
|
||||
mod1.author = admin_user
|
||||
mod1.tags.append(tags["player_effects"])
|
||||
mod1.repo = "https://github.com/rubenwardy/awards"
|
||||
mod1.issueTracker = "https://github.com/rubenwardy/awards/issues"
|
||||
mod1.forums = 4870
|
||||
mod1.shortDesc = "Adds achievements and an API to register new ones."
|
||||
mod1.short_desc = "Adds achievements and an API to register new ones."
|
||||
mod1.desc = """
|
||||
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
|
||||
|
||||
```
|
||||
```lua
|
||||
awards.register_achievement("award_mesefind",{
|
||||
title = "First Mese Find",
|
||||
description = "Found some Mese!",
|
||||
@@ -92,34 +142,27 @@ awards.register_achievement("award_mesefind",{
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = mod1
|
||||
rel.min_rel = v51
|
||||
rel.name = "v1.0.0"
|
||||
rel.title = "v1.0.0"
|
||||
rel.url = "https://github.com/rubenwardy/awards/archive/master.zip"
|
||||
rel.approved = True
|
||||
db.session.add(rel)
|
||||
session.add(rel)
|
||||
|
||||
mod2 = Package()
|
||||
mod2.approved = True
|
||||
mod2.state = PackageState.APPROVED
|
||||
mod2.name = "mesecons"
|
||||
mod2.title = "Mesecons"
|
||||
mod2.tags.append(tags["tools"])
|
||||
mod2.type = PackageType.MOD
|
||||
mod2.license = licenses["LGPLv3"]
|
||||
mod2.media_license = licenses["MIT"]
|
||||
mod2.author = jeija
|
||||
mod2.repo = "https://github.com/minetest-mods/mesecons/"
|
||||
mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues"
|
||||
mod2.forums = 628
|
||||
mod2.shortDesc = "Mesecons adds everything digital, from all kinds of sensors, switches, solar panels, detectors, pistons, lamps, sound blocks to advanced digital circuitry like logic gates and programmable blocks."
|
||||
mod2.short_desc = "Mesecons adds everything digital, from all kinds of sensors, switches, solar panels, detectors, pistons, lamps, sound blocks to advanced digital circuitry like logic gates and programmable blocks."
|
||||
mod2.desc = """
|
||||
########################################################################
|
||||
## __ __ _____ _____ _____ _____ _____ _ _ _____ ##
|
||||
## | \ / | | ___| | ___| | ___| | ___| | _ | | \ | | | ___| ##
|
||||
## | \/ | | |___ | |___ | |___ | | | | | | | \| | | |___ ##
|
||||
## | |\__/| | | ___| |___ | | ___| | | | | | | | | |___ | ##
|
||||
## | | | | | |___ ___| | | |___ | |___ | |_| | | |\ | ___| | ##
|
||||
## |_| |_| |_____| |_____| |_____| |_____| |_____| |_| \_| |_____| ##
|
||||
## ##
|
||||
########################################################################
|
||||
|
||||
MESECONS by Jeija and contributors
|
||||
|
||||
Mezzee-what?
|
||||
@@ -192,36 +235,40 @@ No warranty is provided, express or implied, for any part of the project.
|
||||
|
||||
"""
|
||||
|
||||
db.session.add(mod1)
|
||||
db.session.add(mod2)
|
||||
session.add(mod1)
|
||||
session.add(mod2)
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "handholds"
|
||||
mod.title = "Handholds"
|
||||
mod.license = licenses["MIT"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.MOD
|
||||
mod.author = ez
|
||||
mod.tags.append(tags["player_effects"])
|
||||
mod.repo = "https://github.com/ezhh/handholds"
|
||||
mod.issueTracker = "https://github.com/ezhh/handholds/issues"
|
||||
mod.forums = 17069
|
||||
mod.shortDesc = "Adds hand holds and climbing thingies"
|
||||
mod.short_desc = "Adds hand holds and climbing thingies"
|
||||
mod.desc = "This is the long desc"
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = mod
|
||||
rel.name = "v1.0.0"
|
||||
rel.title = "v1.0.0"
|
||||
rel.max_rel = v4
|
||||
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
|
||||
rel.approved = True
|
||||
db.session.add(rel)
|
||||
session.add(rel)
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "other_worlds"
|
||||
mod.title = "Other Worlds"
|
||||
mod.license = licenses["MIT"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.MOD
|
||||
mod.author = ez
|
||||
mod.tags.append(tags["mapgen"])
|
||||
@@ -229,141 +276,139 @@ No warranty is provided, express or implied, for any part of the project.
|
||||
mod.repo = "https://github.com/ezhh/other_worlds"
|
||||
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
|
||||
mod.forums = 16015
|
||||
mod.shortDesc = "Adds space with asteroids and comets"
|
||||
mod.short_desc = "Adds space with asteroids and comets"
|
||||
mod.desc = "This is the long desc"
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "food"
|
||||
mod.title = "Food"
|
||||
mod.license = licenses["LGPLv2.1"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.MOD
|
||||
mod.author = ruben
|
||||
mod.author = admin_user
|
||||
mod.tags.append(tags["player_effects"])
|
||||
mod.repo = "https://github.com/rubenwardy/food/"
|
||||
mod.issueTracker = "https://github.com/rubenwardy/food/issues/"
|
||||
mod.forums = 2960
|
||||
mod.shortDesc = "Adds lots of food and an API to manage ingredients"
|
||||
mod.short_desc = "Adds lots of food and an API to manage ingredients"
|
||||
mod.desc = "This is the long desc"
|
||||
food = mod
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "food_sweet"
|
||||
mod.title = "Sweet Foods"
|
||||
mod.license = licenses["CC0"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.MOD
|
||||
mod.author = ruben
|
||||
mod.author = admin_user
|
||||
mod.tags.append(tags["player_effects"])
|
||||
mod.repo = "https://github.com/rubenwardy/food_sweet/"
|
||||
mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/"
|
||||
mod.forums = 9039
|
||||
mod.shortDesc = "Adds sweet food"
|
||||
mod.short_desc = "Adds sweet food"
|
||||
mod.desc = "This is the long desc"
|
||||
food_sweet = mod
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
game1 = Package()
|
||||
game1.approved = True
|
||||
game1.state = PackageState.APPROVED
|
||||
game1.name = "capturetheflag"
|
||||
game1.title = "Capture The Flag"
|
||||
game1.type = PackageType.GAME
|
||||
game1.license = licenses["LGPLv2.1"]
|
||||
game1.author = ruben
|
||||
game1.media_license = licenses["MIT"]
|
||||
game1.author = admin_user
|
||||
game1.tags.append(tags["pvp"])
|
||||
game1.tags.append(tags["survival"])
|
||||
game1.tags.append(tags["multiplayer"])
|
||||
game1.repo = "https://github.com/rubenwardy/capturetheflag"
|
||||
game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues"
|
||||
game1.forums = 12835
|
||||
game1.shortDesc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
|
||||
game1.short_desc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
|
||||
game1.desc = """
|
||||
As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
|
||||
|
||||
` `[`javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/`](javascript:/*--%3E%3C/title%3E%3C/style%3E%3C/textarea%3E%3C/script%3E%3C/xmp%3E%3Csvg/onload='+/%22/+/onmouseover=1/+/)`[*/[]/+alert(1)//'>`
|
||||
|
||||
<IMG SRC="javascript:alert('XSS');">
|
||||
|
||||
<IMG SRC=javascript:alert(&quot;XSS&quot;)>
|
||||
|
||||
``<IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>``
|
||||
|
||||
\<a onmouseover="alert(document.cookie)"\>xxs link\</a\>
|
||||
|
||||
\<a onmouseover=alert(document.cookie)\>xxs link\</a\>
|
||||
|
||||
<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>
|
||||
|
||||
<script>alert("hello");</script>
|
||||
|
||||
<SCRIPT SRC=`[`http://xss.rocks/xss.js></SCRIPT>`](http://xss.rocks/xss.js%3E%3C/SCRIPT%3E)`;`
|
||||
|
||||
`<IMG \"\"\">`
|
||||
|
||||
<SCRIPT>
|
||||
|
||||
alert("XSS")
|
||||
|
||||
</SCRIPT>
|
||||
|
||||
<IMG SRC= onmouseover="alert('xxs')">
|
||||
|
||||
<img src=x onerror="javascript:alert('XSS')">
|
||||
|
||||
"\>
|
||||
|
||||
Uses the CTF PvP Engine.
|
||||
"""
|
||||
|
||||
db.session.add(game1)
|
||||
session.add(game1)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = game1
|
||||
rel.name = "v1.0.0"
|
||||
rel.title = "v1.0.0"
|
||||
rel.url = "https://github.com/rubenwardy/capturetheflag/archive/master.zip"
|
||||
rel.approved = True
|
||||
db.session.add(rel)
|
||||
session.add(rel)
|
||||
|
||||
|
||||
mod = Package()
|
||||
mod.approved = True
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "pixelbox"
|
||||
mod.title = "PixelBOX Reloaded"
|
||||
mod.license = licenses["CC0"]
|
||||
mod.media_license = licenses["CC0"]
|
||||
mod.type = PackageType.TXP
|
||||
mod.author = ruben
|
||||
mod.author = admin_user
|
||||
mod.forums = 14132
|
||||
mod.shortDesc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
|
||||
mod.short_desc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
|
||||
mod.desc = "This is the long desc"
|
||||
db.session.add(mod)
|
||||
session.add(mod)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = mod
|
||||
rel.name = "v1.0.0"
|
||||
rel.title = "v1.0.0"
|
||||
rel.url = "http://mamadou3.free.fr/Minetest/PixelBOX.zip"
|
||||
rel.approved = True
|
||||
db.session.add(rel)
|
||||
session.add(rel)
|
||||
|
||||
db.session.commit()
|
||||
session.commit()
|
||||
|
||||
metas = {}
|
||||
for package in Package.query.filter_by(type=PackageType.MOD).all():
|
||||
meta = None
|
||||
try:
|
||||
meta = metas[package.name]
|
||||
except KeyError:
|
||||
meta = MetaPackage(package.name)
|
||||
db.session.add(meta)
|
||||
session.add(meta)
|
||||
metas[package.name] = meta
|
||||
package.provides.append(meta)
|
||||
|
||||
dep = Dependency(food_sweet, meta=metas["food"])
|
||||
db.session.add(dep)
|
||||
|
||||
|
||||
|
||||
delete_db = len(sys.argv) >= 2 and sys.argv[1].strip() == "-d"
|
||||
if delete_db and os.path.isfile("db.sqlite"):
|
||||
os.remove("db.sqlite")
|
||||
|
||||
print("Creating database tables...")
|
||||
db.create_all()
|
||||
print("Filling database...")
|
||||
|
||||
ruben = User("rubenwardy")
|
||||
ruben.github_username = "rubenwardy"
|
||||
ruben.forums_username = "rubenwardy"
|
||||
ruben.rank = UserRank.ADMIN
|
||||
db.session.add(ruben)
|
||||
|
||||
tags = {}
|
||||
for tag in ["Inventory", "Mapgen", "Building", \
|
||||
"Mobs and NPCs", "Tools", "Player effects", \
|
||||
"Environment", "Transport", "Maintenance", "Plants and farming", \
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
|
||||
row = Tag(tag)
|
||||
tags[row.name] = row
|
||||
db.session.add(row)
|
||||
|
||||
licenses = {}
|
||||
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
|
||||
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
|
||||
"CC-BY", "CC-BY-NC-SA", "MIT", "ZLib"]:
|
||||
row = License(license)
|
||||
licenses[row.name] = row
|
||||
db.session.add(row)
|
||||
|
||||
if test_data:
|
||||
defineDummyData(licenses, tags, ruben)
|
||||
|
||||
db.session.commit()
|
||||
session.add(dep)
|
||||
49
app/flatpages/about.md
Normal file
49
app/flatpages/about.md
Normal file
@@ -0,0 +1,49 @@
|
||||
title: About ContentDB
|
||||
description: Information about ContentDB's development, history, and more
|
||||
toc: False
|
||||
|
||||
## Development
|
||||
|
||||
ContentDB was created by [rubenwardy](https://rubenwardy.com/) in 2018, he was lucky enough to have the time available
|
||||
as it was submitted as university coursework. To learn about the history and development of ContentDB,
|
||||
[see the blog post](https://blog.rubenwardy.com/2022/03/24/contentdb/).
|
||||
|
||||
ContentDB is open source software, licensed under AGPLv3.0.
|
||||
|
||||
<a href="https://github.com/luanti-org/contentdb/" class="btn btn-primary me-1">Source code</a>
|
||||
<a href="https://github.com/luanti-org/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
|
||||
<a href="{{ admin_contact_url }}" class="btn btn-secondary me-1">Contact admin</a>
|
||||
{% if monitoring_url -%}
|
||||
<a href="{{ monitoring_url }}" class="btn btn-secondary">Stats / monitoring</a>
|
||||
{%- endif %}
|
||||
|
||||
## Why was ContentDB created?
|
||||
|
||||
Before ContentDB, users had to manually install mods and games by unzipping their files into a directory. This is
|
||||
poor user experience, especially for first-time users.
|
||||
|
||||
ContentDB isn't just about supporting the in-game content downloader; it's common for technical users to find
|
||||
and review packages using the ContentDB website, but install using Git rather than the in-game installer.
|
||||
**ContentDB's purpose is to be a well-formatted source of information about mods, games,
|
||||
and texture packs for Luanti**.
|
||||
|
||||
## How do I learn how to make mods and games for Luanti?
|
||||
|
||||
You should read
|
||||
[the official Luanti Modding Book](https://rubenwardy.com/minetest_modding_book/)
|
||||
for a guide to making mods and games using Luanti.
|
||||
|
||||
|
||||
<h2 id="donate">How can I support / donate to ContentDB?</h2>
|
||||
|
||||
You can donate to rubenwardy to cover ContentDB's costs and support future development.
|
||||
|
||||
For more information about the cost of ContentDB and what rubenwardy does, see his donation page:
|
||||
|
||||
<a href="https://rubenwardy.com/donate/" class="btn btn-primary me-1">Donate</a>
|
||||
<a href="/donate/" class="btn btn-secondary">Support Creators</a>
|
||||
|
||||
## Sponsorships
|
||||
|
||||
Luanti and ContentDB are sponsored by <a href="https://sentry.io/" rel="nofollow">sentry.io</a>.
|
||||
This provides us with improved error logging and performance insights.
|
||||
@@ -1,4 +1,42 @@
|
||||
title: Help
|
||||
toc: False
|
||||
|
||||
* [Package Tags](package_tags)
|
||||
* [Ranks and Permissions](ranks_permissions)
|
||||
|
||||
## Rules
|
||||
|
||||
* [Terms of Service](/terms/)
|
||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
||||
|
||||
## General Help
|
||||
|
||||
* [Frequently Asked Questions](faq/)
|
||||
* [Installing content](installing/)
|
||||
* [Content Ratings and Flags](content_flags/)
|
||||
* [Non-free Licenses](non_free/)
|
||||
* [Why WTFPL is a terrible license](wtfpl/)
|
||||
* [Ranks and Permissions](ranks_permissions/)
|
||||
* [Contact Us](contact_us/)
|
||||
* [Top Packages Algorithm](top_packages/)
|
||||
* [Featured Packages](featured/)
|
||||
* [Feeds](feeds/)
|
||||
|
||||
## Help for Package Authors
|
||||
|
||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
||||
* [Copyright Guide](copyright/)
|
||||
* [Git Update Detection](update_config/)
|
||||
* [Creating Releases using Webhooks](release_webhooks/)
|
||||
* [Package Configuration and Releases Guide](package_config/)
|
||||
* [Supported Games](game_support/)
|
||||
* [Creating an appealing ContentDB page](appealing_page/)
|
||||
|
||||
|
||||
## Help for Specific User Ranks
|
||||
|
||||
* [Editors](editors/)
|
||||
|
||||
## APIs
|
||||
|
||||
* [API](api/)
|
||||
* [OAuth2 Applications](oauth/)
|
||||
* [Prometheus Metrics](metrics/)
|
||||
|
||||
592
app/flatpages/help/api.md
Normal file
592
app/flatpages/help/api.md
Normal file
@@ -0,0 +1,592 @@
|
||||
title: API
|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
* [How the Luanti client uses the API](https://github.com/luanti-org/contentdb/blob/master/docs/luanti_client.md)
|
||||
|
||||
|
||||
## Responses and Error Handling
|
||||
|
||||
If there is an error, the response will be JSON similar to the following with a non-200 status code:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "The error message"
|
||||
}
|
||||
```
|
||||
|
||||
Successful GET requests will return the resource's information directly as a JSON response.
|
||||
|
||||
Other successful results will return a dictionary with `success` equaling true, and
|
||||
often other keys with information. For example:
|
||||
|
||||
```js
|
||||
{
|
||||
"success": true,
|
||||
"release": {
|
||||
/* same as returned by a GET */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Paginated Results
|
||||
|
||||
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
|
||||
the number of items is specified using `num`
|
||||
|
||||
The response will be a dictionary with the following keys:
|
||||
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `per_page`: number of items per page, same as `n`
|
||||
* `page_count`: number of pages
|
||||
* `total`: total number of results
|
||||
* `urls`: dictionary containing
|
||||
* `next`: url to next page
|
||||
* `previous`: url to previous page
|
||||
* `items`: array of items
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
Not all endpoints require authentication, but it is done using Bearer tokens:
|
||||
|
||||
```bash
|
||||
curl https://content.luanti.org/api/whoami/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
||||
|
||||
* GET `/api/whoami/`: JSON dictionary with the following keys:
|
||||
* `is_authenticated`: True on successful API authentication
|
||||
* `username`: Username of the user authenticated as, null otherwise.
|
||||
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
|
||||
* DELETE `/api/delete-token/`: Deletes the currently used token.
|
||||
|
||||
```bash
|
||||
# Logout
|
||||
curl -X DELETE https://content.luanti.org/api/delete-token/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
* GET `/api/packages/` (List)
|
||||
* See [Package Queries](#package-queries)
|
||||
* GET `/api/packages/<username>/<name>/` (Read)
|
||||
* Redirects a JSON object with the keys documented by the PUT endpoint, below.
|
||||
* Plus:
|
||||
* `forum_url`: String or null.
|
||||
* PUT `/api/packages/<author>/<name>/` (Update)
|
||||
* Requires authentication.
|
||||
* JSON object with any of these keys (all are optional, null to delete Nullables):
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `title`: Human-readable title.
|
||||
* `name`: Technical name (needs permission if already approved).
|
||||
* `short_description`
|
||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
||||
`LOOKING_FOR_MAINTAINER`.
|
||||
* `tags`: List of [tag](#tags) names.
|
||||
* `content_warnings`: List of [content warning](#content-warnings) names.
|
||||
* `license`: A [license](#licenses) name.
|
||||
* `media_license`: A [license](#licenses) name.
|
||||
* `long_description`: Long markdown description.
|
||||
* `repo`: Source repository (eg: Git)
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `donate_url`: URL to a donation page.
|
||||
* `translation_url`: URL to send users interested in translating your package.
|
||||
* `game_support`: Array of game support information objects. Not currently documented,
|
||||
* Returns a JSON object with:
|
||||
* `success`
|
||||
* `package`: updated package
|
||||
* `was_modified`: bool, whether anything changed
|
||||
* GET `/api/packages/<username>/<name>/for-client/`
|
||||
* Similar to the read endpoint, but optimised for the Luanti client
|
||||
* `long_description` is given as a hypertext object, see `/hypertext/` below.
|
||||
* `info_hypertext` is the info sidebar as a hypertext object.
|
||||
* Query arguments
|
||||
* `formspec_version`: Required. See /hypertext/ below.
|
||||
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked.
|
||||
* `protocol_version`: Optional, used to get the correct release.
|
||||
* `engine_version`: Optional, used to get the correct release. Ex: `5.3.0`.
|
||||
* GET `/api/packages/<username>/<name>/for-client/reviews/`
|
||||
* Returns hypertext representing the package's reviews
|
||||
* Query arguments
|
||||
* `formspec_version`: Required. See /hypertext/ below.
|
||||
* Returns JSON dictionary with following keys:
|
||||
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
|
||||
* `body`: markup for long description.
|
||||
* `links`: dictionary of anchor name to link URL.
|
||||
* `images`: dictionary of img name to image URL.
|
||||
* `image_tooltips`: dictionary of img name to tooltip text.
|
||||
* The hypertext body contains some placeholders that should be replaced client-side:
|
||||
* `<thumbsup>` with a thumbs up icon.
|
||||
* `<neutral>` with a thumbs up icon.
|
||||
* `<thumbsdown>` with a thumbs up icon.
|
||||
* GET `/api/packages/<author>/<name>/hypertext/`
|
||||
* Converts the long description to [Luanti Markup Language](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md#markup-language)
|
||||
to be used in a `hypertext` formspec element.
|
||||
* Query arguments:
|
||||
* `formspec_version`: Required, maximum supported formspec version.
|
||||
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked.
|
||||
* Returns JSON dictionary with following keys:
|
||||
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
|
||||
* `body`: markup for long description.
|
||||
* `links`: dictionary of anchor name to link URL.
|
||||
* `images`: dictionary of img name to image URL.
|
||||
* `image_tooltips`: dictionary of img name to tooltip text.
|
||||
* GET `/api/packages/<username>/<name>/dependencies/`
|
||||
* Returns dependencies, with suggested candidates
|
||||
* If query argument `only_hard` is present, only hard deps will be returned.
|
||||
* GET `/api/dependencies/`
|
||||
* Returns `provides` and raw dependencies for all packages.
|
||||
* Supports [Package Queries](#package-queries)
|
||||
* [Paginated result](#paginated-results), max 300 results per page
|
||||
* Each item in `items` will be a dictionary with the following keys:
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `author`: Username of the package author.
|
||||
* `name`: Package name.
|
||||
* `provides`: List of technical mod names inside the package.
|
||||
* `depends`: List of hard dependencies.
|
||||
* Each dep will either be a modname dependency (`name`), or a
|
||||
package dependency (`author/name`).
|
||||
* `optional_depends`: list of optional dependencies
|
||||
* Same as above.
|
||||
* GET `/api/packages/<username>/<name>/stats/`
|
||||
* Returns daily stats for package, or null if there is no data.
|
||||
* Daily date is done based on the UTC timezone.
|
||||
* EXPERIMENTAL. This API may change without warning.
|
||||
* Query args:
|
||||
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
|
||||
* `end`: end date, inclusive. Optional. Default: today. UTC.
|
||||
* An object with the following keys:
|
||||
* `start`: start date, inclusive. Ex: 2022-10-22. M
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `platform_minetest`: list of integers per day.
|
||||
* `platform_other`: list of integers per day.
|
||||
* `reason_new`: list of integers per day.
|
||||
* `reason_dependency`: list of integers per day.
|
||||
* `reason_update`: list of integers per day.
|
||||
* GET `/api/package_stats/`
|
||||
* Returns last 30 days of daily stats for _all_ packages.
|
||||
* An object with the following keys:
|
||||
* `start`: start date, inclusive. Ex: 2022-10-22.
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `package_downloads`: map from package key to list of download integers.
|
||||
|
||||
You can download a package by building one of the two URLs:
|
||||
|
||||
```
|
||||
https://content.luanti.org/packages/${author}/${name}/download/`
|
||||
https://content.luanti.org/packages/${author}/${name}/releases/${release}/download/`
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Edit package
|
||||
curl -X PUT https://content.luanti.org/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
|
||||
|
||||
# Remove website URL
|
||||
curl -X PUT https://content.luanti.org/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "website": null }'
|
||||
```
|
||||
|
||||
### Package Queries
|
||||
|
||||
Example:
|
||||
|
||||
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
|
||||
|
||||
Filter query parameters:
|
||||
|
||||
* `type`: Filter by package type (`mod`, `game`, `txp`). Multiple types are OR-ed together.
|
||||
* `q`: Query string.
|
||||
* `author`: Filter by author.
|
||||
* `tag`: Filter by tags. Multiple tags are AND-ed together.
|
||||
* `flag`: Filter to show packages with [Content Flags](/help/content_flags/).
|
||||
* `hide`: Hide content based on tags or [Content Flags](/help/content_flags/).
|
||||
* `license`: Filter by [license name](#licenses). Multiple licenses are OR-ed together, ie: `&license=MIT&license=LGPL-2.1-only`
|
||||
* `game`: Filter by [Game Support](/help/game_support/), ex: `Warr1024/nodecore`. (experimental, doesn't show items that support every game currently).
|
||||
* `lang`: Filter by translation support, eg: `en`/`de`/`ja`/`zh_TW`.
|
||||
* `protocol_version`: Only show packages supported by this Luanti protocol version.
|
||||
* `engine_version`: Only show packages supported by this Luanti engine version, eg: `5.3.0`.
|
||||
|
||||
Sorting query parameters:
|
||||
|
||||
* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
|
||||
* `order`: Sort ascending (`asc`) or descending (`desc`).
|
||||
* `random`: When present, enable random ordering and ignore `sort`.
|
||||
|
||||
Format query parameters:
|
||||
|
||||
* `limit`: Return at most `limit` packages.
|
||||
* `fmt`: How the response is formatted.
|
||||
* `keys`: author/name only.
|
||||
* `short`: stuff needed for the Luanti client.
|
||||
* `vcs`: `short` but with `repo`.
|
||||
|
||||
|
||||
### Releases
|
||||
|
||||
* GET `/api/releases/` (List)
|
||||
* Limited to 30 most recent releases.
|
||||
* Optional arguments:
|
||||
* `author`: Filter by author
|
||||
* `maintainer`: Filter by maintainer
|
||||
* Returns array of release dictionaries with keys:
|
||||
* `id`: release ID
|
||||
* `name`: short release name
|
||||
* `title`: human-readable title
|
||||
* `release_notes`: string or null, what's new in this release. Markdown.
|
||||
* `release_date`: Date released
|
||||
* `url`: download URL
|
||||
* `commit`: commit hash or null
|
||||
* `downloads`: number of downloads
|
||||
* `min_minetest_version`: dict or null, minimum supported Luanti version (inclusive).
|
||||
* `max_minetest_version`: dict or null, minimum supported Luanti version (inclusive).
|
||||
* `size`: size of zip file, in bytes.
|
||||
* `package`
|
||||
* `author`: author username
|
||||
* `name`: technical name
|
||||
* `type`: `mod`, `game`, or `txp`
|
||||
* GET `/api/updates/` (Look-up table)
|
||||
* Returns a look-up table from package key (`author/name`) to latest release id
|
||||
* Query arguments
|
||||
* `protocol_version`: Only show packages supported by this Luanti protocol version.
|
||||
* `engine_version`: Only show packages supported by this Luanti engine version, eg: `5.3.0`.
|
||||
* GET `/api/packages/<username>/<name>/releases/` (List)
|
||||
* Returns array of release dictionaries, see above, but without package info.
|
||||
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
|
||||
* POST `/api/packages/<username>/<name>/releases/new/` (Create)
|
||||
* Requires authentication.
|
||||
* Body can be JSON or multipart form data. Zip uploads must be multipart form data.
|
||||
* `title`: human-readable name of the release.
|
||||
* `release_notes`: string or null, what's new in this release.
|
||||
* For Git release creation:
|
||||
* `method`: must be `git`.
|
||||
* `ref`: (Optional) git reference, eg: `master`.
|
||||
* For zip upload release creation:
|
||||
* `file`: multipart file to upload, like `<input type="file" name="file">`.
|
||||
* `commit`: (Optional) Source Git commit hash, for informational purposes.
|
||||
* You can set min and max Luanti Versions [using the content's .conf file](/help/package_config/).
|
||||
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
|
||||
* Requires authentication.
|
||||
* Deletes release.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Create release from Git
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"method": "git",
|
||||
"name": "1.2.3",
|
||||
"title": "My Release",
|
||||
"ref": "master",
|
||||
"release_notes": "some\nrelease\nnotes\n"
|
||||
}'
|
||||
|
||||
# Create release from zip upload
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/file.zip
|
||||
|
||||
# Create release from zip upload with commit hash
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip
|
||||
|
||||
# Delete release
|
||||
curl -X DELETE https://content.luanti.org/api/packages/username/name/releases/3/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
|
||||
### Screenshots
|
||||
|
||||
* GET `/api/packages/<username>/<name>/screenshots/` (List)
|
||||
* Returns array of screenshot dictionaries with keys:
|
||||
* `id`: screenshot ID
|
||||
* `approved`: true if approved and visible.
|
||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
||||
* `url`: absolute URL to screenshot.
|
||||
* `created_at`: ISO time.
|
||||
* `order`: Number used in ordering.
|
||||
* `is_cover_image`: true for cover image.
|
||||
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
|
||||
* Returns screenshot dictionary like above.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
|
||||
* Requires authentication.
|
||||
* Body is multipart form data.
|
||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
||||
* `file`: multipart file to upload, like `<input type=file>`.
|
||||
* `is_cover_image`: set cover image to this.
|
||||
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
|
||||
* Requires authentication.
|
||||
* Deletes screenshot.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/order/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON array containing the screenshot IDs in their order.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
|
||||
|
||||
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
|
||||
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Create screenshot
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png
|
||||
|
||||
# Create screenshot and set it as the cover image
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
|
||||
|
||||
# Delete screenshot
|
||||
curl -X DELETE https://content.luanti.org/api/packages/username/name/screenshots/3/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
|
||||
# Reorder screenshots
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/order/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "[13, 2, 5, 7]"
|
||||
|
||||
# Set cover image
|
||||
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/cover-image/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "{ 'cover_image': 123 }"
|
||||
```
|
||||
|
||||
|
||||
### Reviews
|
||||
|
||||
* GET `/api/packages/<username>/<name>/reviews/` (List)
|
||||
* Returns array of review dictionaries with keys:
|
||||
* `user`: dictionary with `display_name` and `username`.
|
||||
* `title`: review title
|
||||
* `comment`: the text
|
||||
* `rating`: 1 for negative, 3 for neutral, 5 for positive
|
||||
* `is_positive`: boolean
|
||||
* `created_at`: iso timestamp
|
||||
* `votes`: dictionary with `helpful` and `unhelpful`,
|
||||
* GET `/api/reviews/` (List)
|
||||
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
|
||||
* [Paginated result](#paginated-results)
|
||||
* `items`: array of review dictionaries, like above
|
||||
* Each review also has a `package` dictionary with `type`, `author` and `name`
|
||||
* Ordered by created at, newest to oldest.
|
||||
* Query arguments:
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `n`: number of results per page, max 200
|
||||
* `author`: filter by review author username
|
||||
* `for_user`: filter by package author
|
||||
* `rating`: 1 for negative, 3 for neutral, 5 for positive
|
||||
* `is_positive`: true or false. Default: null
|
||||
* `q`: filter by title (case-insensitive, no fulltext search)
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"comment": "This is a really good mod!",
|
||||
"created_at": "2021-11-24T16:18:33.764084",
|
||||
"is_positive": true,
|
||||
"title": "Really good",
|
||||
"user": {
|
||||
"display_name": "rubenwardy",
|
||||
"username": "rubenwardy"
|
||||
},
|
||||
"votes": {
|
||||
"helpful": 0,
|
||||
"unhelpful": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## Users
|
||||
|
||||
* GET `/api/users/<username>/`
|
||||
* `username`
|
||||
* `display_name`: human-readable name to be displayed in GUIs.
|
||||
* `rank`: ContentDB [rank](/help/ranks_permissions/).
|
||||
* `profile_pic_url`: URL to profile picture, or null.
|
||||
* `website_url`: URL to website, or null.
|
||||
* `donate_url`: URL to donate page, or null.
|
||||
* `connections`: object
|
||||
* `github`: GitHub username, or null.
|
||||
* `forums`: forums username, or null.
|
||||
* `links`: object
|
||||
* `api_packages`: URL to API to list this user's packages.
|
||||
* `profile`: URL to the HTML profile page.
|
||||
* GET `/api/users/<username>/stats/`
|
||||
* Returns daily stats for the user's packages, or null if there is no data.
|
||||
* Daily date is done based on the UTC timezone.
|
||||
* EXPERIMENTAL. This API may change without warning.
|
||||
* Query args:
|
||||
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
|
||||
* `end`: end date, inclusive. Optional. Default: today. UTC.
|
||||
* A table with the following keys:
|
||||
* `from`: start date, inclusive. Ex: 2022-10-22.
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `package_downloads`: map of package title to list of integers per day.
|
||||
* `platform_minetest`: list of integers per day.
|
||||
* `platform_other`: list of integers per day.
|
||||
* `reason_new`: list of integers per day.
|
||||
* `reason_dependency`: list of integers per day.
|
||||
* `reason_update`: list of integers per day.
|
||||
|
||||
|
||||
## Topics
|
||||
|
||||
* GET `/api/topics/` ([View](/api/topics/))
|
||||
* See [Topic Queries](#topic-queries)
|
||||
|
||||
### Topic Queries
|
||||
|
||||
Example:
|
||||
|
||||
/api/topics/?q=mobs&type=mod&type=game
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
* `q`: Query string.
|
||||
* `type`: Package types (`mod`, `game`, `txp`).
|
||||
* `sort`: Sort by (`name`, `views`, `created_at`).
|
||||
* `show_added`: Show topics that have an existing package.
|
||||
* `limit`: Return at most `limit` topics.
|
||||
|
||||
|
||||
## Collections
|
||||
|
||||
* GET `/api/collections/`
|
||||
* Query args:
|
||||
* `author`: collection author username.
|
||||
* `package`: collections that contain the package.
|
||||
* Returns JSON array of collection entries:
|
||||
* `author`: author username.
|
||||
* `name`: collection name.
|
||||
* `title`
|
||||
* `short_description`
|
||||
* `created_at`: creation time in iso format.
|
||||
* `private`: whether collection is private, boolean.
|
||||
* `package_count`: number of packages, integer.
|
||||
* GET `/api/collections/<username>/<name>/`
|
||||
* Returns JSON object for collection:
|
||||
* `author`: author username.
|
||||
* `name`: collection name.
|
||||
* `title`
|
||||
* `short_description`
|
||||
* `long_description`
|
||||
* `created_at`: creation time in iso format.
|
||||
* `private`: whether collection is private, boolean.
|
||||
* `items`: array of item objects:
|
||||
* `package`: short info about the package.
|
||||
* `description`: custom short description.
|
||||
* `created_at`: when the package was added to the collection.
|
||||
* `order`: integer.
|
||||
|
||||
## Types
|
||||
|
||||
### Tags
|
||||
|
||||
* GET `/api/tags/` ([View](/api/tags/))
|
||||
* List of objects with:
|
||||
* `name`: technical name.
|
||||
* `title`: human-readable title.
|
||||
* `description`: tag description or null.
|
||||
* `views`: number of views of this tag.
|
||||
|
||||
### Content Warnings
|
||||
|
||||
* GET `/api/content_warnings/` ([View](/api/content_warnings/))
|
||||
* List of objects with
|
||||
* `name`: technical name
|
||||
* `title`: human-readable title
|
||||
* `description`: tag description or null
|
||||
|
||||
### Licenses
|
||||
|
||||
* GET `/api/licenses/` ([View](/api/licenses/))
|
||||
* List of objects with:
|
||||
* `name`
|
||||
* `is_foss`: whether the license is foss
|
||||
|
||||
### Luanti Versions
|
||||
|
||||
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
|
||||
* List of objects with:
|
||||
* `name`: Version name.
|
||||
* `is_dev`: boolean, is dev version.
|
||||
* `protocol_version`: protocol version number.
|
||||
|
||||
### Languages
|
||||
|
||||
* GET `/api/languages/` ([View](/api/languages/))
|
||||
* List of objects with:
|
||||
* `id`: language code.
|
||||
* `title`: native language name.
|
||||
* `has_contentdb_translation`: whether ContentDB has been translated into this language.
|
||||
|
||||
|
||||
## Misc
|
||||
|
||||
* GET `/api/scores/` ([View](/api/scores/))
|
||||
* See [Top Packages Algorithm](/help/top_packages/).
|
||||
* Supports [Package Queries](#package-queries).
|
||||
* Returns list of:
|
||||
* `author`: package author name.
|
||||
* `name`: package technical name.
|
||||
* `downloads`: number of downloads.
|
||||
* `score`: total package score.
|
||||
* `score_reviews`: score from reviews.
|
||||
* `score_downloads`: score from downloads.
|
||||
* `reviews`: a dictionary of
|
||||
* `positive`: int, number of positive reviews.
|
||||
* `neutral`: int, number of neutral reviews.
|
||||
* `negative`: int, number of negative reviews.
|
||||
* GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage.
|
||||
* `count`: number of packages
|
||||
* `downloads`: get number of downloads
|
||||
* `new`: new packages
|
||||
* `updated`: recently updated packages
|
||||
* `pop_mod`: popular mods
|
||||
* `pop_txp`: popular textures
|
||||
* `pop_game`: popular games
|
||||
* `high_reviewed`: highest reviewed
|
||||
* GET `/api/cdb_schema/` ([View](/api/cdb_schema/))
|
||||
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
|
||||
* See [JSON Schema Reference](https://json-schema.org/).
|
||||
* POST `/api/hypertext/`
|
||||
* Converts HTML or Markdown to [Luanti Markup Language](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md#markup-language)
|
||||
to be used in a `hypertext` formspec element.
|
||||
* Post data: HTML or Markdown as plain text.
|
||||
* Content-Type: `text/html` or `text/markdown`.
|
||||
* Query arguments:
|
||||
* `formspec_version`: Required, maximum supported formspec version. Ie: 6
|
||||
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked.
|
||||
* Returns JSON dictionary with following key:
|
||||
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
|
||||
* `body`: markup for long description.
|
||||
* `links`: dictionary of anchor name to link URL.
|
||||
* `images`: dictionary of img name to image URL
|
||||
* `image_tooltips`: dictionary of img name to tooltip text.
|
||||
74
app/flatpages/help/appealing_page.md
Normal file
74
app/flatpages/help/appealing_page.md
Normal file
@@ -0,0 +1,74 @@
|
||||
title: Creating an appealing ContentDB page
|
||||
|
||||
## Title and short description
|
||||
|
||||
Make sure that your package's title is unique, short, and descriptive.
|
||||
|
||||
Expand on the title with the short description. You have a limited number
|
||||
of characters, use them wisely!
|
||||
|
||||
```ini
|
||||
# Bad, we know this is a mod for Luanti. Doesn't give much information other than "food"
|
||||
description = The food mod for Luanti
|
||||
# Much better, says what is actually in this mod!
|
||||
description = Adds soup, cakes, bakes and juices
|
||||
```
|
||||
|
||||
## Thumbnail
|
||||
|
||||
A good thumbnail goes a long way to making a package more appealing. It's one of the few things
|
||||
a user sees before clicking on your package. Make sure it's possible to tell what a
|
||||
thumbnail is when it's small.
|
||||
|
||||
For a preview of what your package will look like inside Luanti, see
|
||||
Edit Package > Screenshots.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Upload a good selection of screenshots that show what is possible with your packages.
|
||||
You may wish to focus on a different key feature in each of your screenshots.
|
||||
|
||||
A lot of users won't bother reading text, and will just look at screenshots.
|
||||
|
||||
## Long description
|
||||
|
||||
The target audience of your package page is end users.
|
||||
The long description should explain what your package is about,
|
||||
why the user should choose it, and how to use it if they download it.
|
||||
|
||||
[NodeCore](https://content.luanti.org/packages/Warr1024/nodecore/) is a good
|
||||
example of what to do. For inspiration, you might want to look at how games on
|
||||
Steam write their descriptions.
|
||||
|
||||
Your long description might contain:
|
||||
|
||||
* What does the package contain/have? ie: list of high-level features.
|
||||
* What makes it special? Why should users choose this over another package?
|
||||
* How can you use it?
|
||||
|
||||
The following are redundant and should probably not be included:
|
||||
|
||||
* A heading with the title of the package
|
||||
* The short description
|
||||
* Links to a Git repository, the forum topic, the package's ContentDB page (ContentDB has fields for this)
|
||||
* License (unless you need to give more information than ContentDB's license fields)
|
||||
* API reference (unless your mod is a library only)
|
||||
* Development instructions for your package (this should be in the repo's README)
|
||||
* Screenshots that are already uploaded (unless you want to embed a recipe image in a specific place)
|
||||
* Note: you should avoid images in the long description as they won't be visible inside Luanti,
|
||||
when support for showing the long description is added.
|
||||
|
||||
## Localize / Translate your package
|
||||
|
||||
According to Google Play, 64% of Luanti Android users don't have English as their main language.
|
||||
Adding translation support to your package increases accessibility. Using content translation, you
|
||||
can also translate your ContentDB page. See Edit Package > Translation for more information.
|
||||
|
||||
<p>
|
||||
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
|
||||
{{ _("Translation - Luanti Modding Book") }}
|
||||
</a>
|
||||
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
|
||||
{{ _("Translating content meta - lua_api.md") }}
|
||||
</a>
|
||||
</p>
|
||||
14
app/flatpages/help/contact_us.md
Normal file
14
app/flatpages/help/contact_us.md
Normal file
@@ -0,0 +1,14 @@
|
||||
title: Contact Us
|
||||
|
||||
## Reports
|
||||
|
||||
Please let us know if anything on the ContentDB violates our rules or any applicable
|
||||
laws.
|
||||
|
||||
We take copyright violation and other offenses very seriously.
|
||||
|
||||
<a href="/report/" class="btn btn-primary">Report</a>
|
||||
|
||||
## Other
|
||||
|
||||
<a href="{{ admin_contact_url }}" class="btn btn-primary">Contact the admin</a>
|
||||
41
app/flatpages/help/content_flags.md
Normal file
41
app/flatpages/help/content_flags.md
Normal file
@@ -0,0 +1,41 @@
|
||||
title: Content Flags
|
||||
|
||||
Content flags allow you to hide content based on your preferences.
|
||||
The filtering is done server-side, which means that you don't need to update
|
||||
your client to use new flags.
|
||||
|
||||
## Flags
|
||||
|
||||
Luanti allows you to specify a comma-separated list of flags to hide in the
|
||||
client:
|
||||
|
||||
```
|
||||
contentdb_flag_blacklist = nonfree, bad_language, drugs
|
||||
```
|
||||
|
||||
A flag can be:
|
||||
|
||||
* `nonfree`: can be used to hide packages which do not qualify as
|
||||
'free software', as defined by the Free Software Foundation.
|
||||
* `wip`: packages marked as Work in Progress
|
||||
* `deprecated`: packages marked as Deprecated
|
||||
* A content warning, given below.
|
||||
* `*`: hides all content warnings.
|
||||
|
||||
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
|
||||
without making a release.
|
||||
|
||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
|
||||
|
||||
## Content Warnings
|
||||
|
||||
Packages with mature content will be tagged with a content warning based
|
||||
on the content type.
|
||||
|
||||
* `alcohol_tobacco`: alcohol or tobacco.
|
||||
* `bad_language`: swearing.
|
||||
* `gambling`
|
||||
* `gore`: blood, etc.
|
||||
* `horror`: shocking and scary content.
|
||||
* `violence`: non-cartoon violence.
|
||||
147
app/flatpages/help/copyright.md
Normal file
147
app/flatpages/help/copyright.md
Normal file
@@ -0,0 +1,147 @@
|
||||
title: Copyright Guide
|
||||
|
||||
## Why should I care?
|
||||
|
||||
Falling foul of copyright law can put you and ContentDB into legal trouble. Receiving a Cease and Desist, DMCA notice,
|
||||
or a Court Summons isn't pleasant for anyone, and can turn out to be very expensive. This page contains some
|
||||
guidance on how to ensure your content is clearly licensed and attributed to avoid these issues.
|
||||
|
||||
Additionally, ContentDB and the forums both have some
|
||||
[requirements on the licenses](/policy_and_guidance/#41-allowed-licenses) you are allowed to use. Both require
|
||||
[free distribution and modification](/help/non_free/), allowing us to remain an open community where people can fork
|
||||
and remix each other's content. To this end, you need to make sure your content is clearly licensed.
|
||||
|
||||
**As always, we are not lawyers and this does not constitute legal advice.**
|
||||
|
||||
|
||||
## What do I need to do?
|
||||
|
||||
### Follow the licenses
|
||||
|
||||
Make sure you understand the licenses for anything you copy into your content.
|
||||
[TL;DR Legal](https://tldrlegal.com/license/mit-license) is a good resource for quickly understanding
|
||||
licenses, although you should actually read the text as well.
|
||||
|
||||
If you use code from other sources (such as mods or games), you'll need to make sure you follow
|
||||
their license. A common one is attribution, you should do this by adding a comment next to the
|
||||
code and crediting the author in your LICENSE file.
|
||||
|
||||
It's sometimes fine to copy trivial/small amounts of code under fair use, but this
|
||||
is a bit of a grey area. It's better to understand the solution and rewrite it yourself.
|
||||
|
||||
### List the sources of your media
|
||||
|
||||
It's a good idea to create a list of all the media you used in your package, as it allows
|
||||
you to keep track of where the media came from. Media includes textures, 3d models,
|
||||
sounds, and more.
|
||||
|
||||
You should have the following information:
|
||||
|
||||
* File name (as found in your package)
|
||||
* Author name
|
||||
* License
|
||||
* Source (URL to the webpage, mod name, website name)
|
||||
|
||||
It's common to do this in README.md or LICENSE.md like so:
|
||||
|
||||
```md
|
||||
* conquer_arrow_*.png from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC0 1.0.
|
||||
* conquer_arrow.b3d from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC-BY-SA 3.0.
|
||||
* conquer_arrow_head.png from MTG, CC-BY-SA 3.0.
|
||||
* health_*.png from [Gauges](https://content.luanti.org/packages/Calinou/gauges/) by Calinou, CC0.
|
||||
```
|
||||
|
||||
if you have a lot of media, then you can split it up by author like so:
|
||||
|
||||
```md
|
||||
[Kenney](https://www.kenney.nl/assets/voxel-pack), CC0:
|
||||
|
||||
* mymod_fence.png
|
||||
|
||||
John Green, CC BY-SA 4.0 from [OpenGameArt](https://opengameart.org/content/tiny-16-basic):
|
||||
|
||||
* mymod_texture.png
|
||||
* mymod_another.png
|
||||
|
||||
Your Name, CC BY-SA 4.0:
|
||||
|
||||
* mymod_texture_i_made.png
|
||||
```
|
||||
|
||||
|
||||
## Where can I get freely licensed media?
|
||||
|
||||
* [OpenGameArt](https://opengameart.org/) - everything
|
||||
* [Kenney game assets](https://www.kenney.nl/assets) - everything
|
||||
* [Free Sound](https://freesound.org/) - sounds
|
||||
* [PolyHaven](https://polyhaven.com/) - 3d models and textures.
|
||||
* Other Luanti mods/games
|
||||
|
||||
Don't assume the author has correctly licensed their work.
|
||||
Make sure they have clearly indicated the source in a list [like above](#list-the-sources-of-your-media).
|
||||
If they didn't make it, then go to the actual source to check the license.
|
||||
|
||||
|
||||
## Common Situations
|
||||
|
||||
### I made it myself, using X as a guide
|
||||
|
||||
Copying by hand is still copying, the law doesn't distinguish this from copy+paste.
|
||||
Make your own art without copying colors or patterns from existing games/art.
|
||||
|
||||
If you need a good set of colors, see [LOSPEC](https://lospec.com/palette-list).
|
||||
|
||||
### I got it from Google Images / Search / the Internet
|
||||
|
||||
You do not have permission to use things unless you are given permission to do so by the author.
|
||||
No license is exactly the same as "Copyright © All Rights Reserved".
|
||||
To use on ContentDB or the forums, you must also be given a clear license.
|
||||
|
||||
Try searching with "creative commons" in the search term, and then clicking through to the page
|
||||
and looking for a license. Make sure the source looks trustworthy, as there are a lot of websites
|
||||
that rip off art and give an incorrect license. But it might be better to use a trusted source directly, see
|
||||
[the section above](#where-can-i-get-freely-licensed-media) for a list.
|
||||
|
||||
### I have permission from the author
|
||||
|
||||
You'll also need to make sure that the author gives you an explicit license for it, such as CC BY-SA 4.0.
|
||||
Permission for *you* to use it doesn't mean that *everyone* has permission to use it. A license outlines the terms of
|
||||
the permission, making things clearer and less vague.
|
||||
|
||||
### The author said it's free for anyone to use, is that enough?
|
||||
|
||||
No, you need an explicit license like CC0 or CC BY-SA 4.0. ContentDB does not allow custom licenses
|
||||
or public domain.
|
||||
|
||||
### I used an AI
|
||||
|
||||
Errrr. This is a legally untested area, we highly recommend that **you don't use AI art/code** in packages
|
||||
for that reason.
|
||||
|
||||
For now, we haven't banned AI art/code from ContentDB. Make sure to clearly include it in your package's
|
||||
credit list (include the name of the AI tool used).
|
||||
|
||||
Check the tools terms and conditions to see if there are any constraints on use. It looks
|
||||
like AI-generated art and code isn't copyrightable by itself, but the tool's T&Cs may still
|
||||
impose conditions.
|
||||
|
||||
AI art/code may regurgitate copyrighted things. Make sure that you don't include the
|
||||
names of any copyrighted materials in your AI prompts, such as names of games or artists.
|
||||
|
||||
## What does ContentDB do?
|
||||
|
||||
The package authors and maintainers are responsible for the licenses and copyright of packages on ContentDB.
|
||||
ContentDB editors will check packages to make sure the package page's license matches up with the list of licenses
|
||||
inside the package download, but do not investigate each piece of media or line of code.
|
||||
|
||||
If a copyright violation is reported to us, we will unlist the package and contact the author/maintainers.
|
||||
Once the problem has been fixed, the package can be restored. Repeated copyright infringement may lead to
|
||||
permanent bans.
|
||||
|
||||
|
||||
## Where can I get help?
|
||||
|
||||
[Join](https://www.luanti.org/get-involved/) IRC, Matrix, or Discord to ask for help.
|
||||
In Discord, there are the #assets or #contentdb channels. In IRC or Matrix, you can just ask in the main channels.
|
||||
|
||||
If your package is already on ContentDB, you can open a thread.
|
||||
56
app/flatpages/help/editors.md
Normal file
56
app/flatpages/help/editors.md
Normal file
@@ -0,0 +1,56 @@
|
||||
title: Editors
|
||||
|
||||
## What should editors do?
|
||||
|
||||
Editors are users of rank Editor or above.
|
||||
They are responsible for ensuring that the package listings of ContentDB are useful.
|
||||
For this purpose, they can/will:
|
||||
|
||||
* Review and approve packages.
|
||||
* Edit any package - including tags, releases, screenshots, and maintainers.
|
||||
* Create packages on behalf of authors who aren't present.
|
||||
|
||||
Editors should make sure they are familiar with the
|
||||
[Package Inclusion Policy and Guidance](/policy_and_guidance/).
|
||||
|
||||
## ContentDB is not a curated platform
|
||||
|
||||
It's important to note that ContentDB isn't a curated platform - a mod doesn't need to be good
|
||||
to be accepted, but there are some minimum requirements when it comes to usefulness and other things.
|
||||
See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
|
||||
|
||||
## Editor Work Queue
|
||||
|
||||
The [Editor Work Queue](/todo/) and related pages contain useful information for editors, such as:
|
||||
|
||||
* The package, release, and screenshot approval queues.
|
||||
* Packages which are outdated or are missing tags.
|
||||
* A list of forum topics without packages.
|
||||
|
||||
## Editor Notifications
|
||||
|
||||
Editors currently receive notifications for any new thread opened on a package, so that they
|
||||
know when a user is asking for help. These notifications are shown separately in the notifications
|
||||
interface, and can be configured separately in Emails and Notifications.
|
||||
|
||||
## Crash Course to being an Editor
|
||||
|
||||
The [Package Inclusion Policy and Guidance](/policy_and_guidance/) is our go-to resource for making decisions in
|
||||
changes needed, similar to how lua_api.txt is the doc for modders to consult.
|
||||
|
||||
In the [Editor console](/todo/), the two most important tabs are the Editor Work Queue and the Forum
|
||||
Topics tab. Primarily you will be focusing on the Editor Work Queue tab, where a list of packages to review is.
|
||||
|
||||
When you have some free time, feel free to scroll through the Forum Topics page and import mods into ContentDB.
|
||||
But don't import a mod if it's broken, outdated, not that useful, or not worth importing - click Discard instead.
|
||||
|
||||
A simplified process for reviewing a package is as follows:
|
||||
|
||||
1. scan the package image if present for any obvious closed source assets.
|
||||
2. if right to a name warning is present, check its validity and if the package meets
|
||||
the exceptions.
|
||||
3. if the forums topic missing warning is present, feel free to check it, but it's
|
||||
usually incorrect.
|
||||
4. check source, etc links to make sure they work and are correct.
|
||||
5. verify that the package has license file that matches what is on the contentdb fields
|
||||
6. if the above steps pass, approve the package, else request changes needed from the author
|
||||
68
app/flatpages/help/faq.md
Normal file
68
app/flatpages/help/faq.md
Normal file
@@ -0,0 +1,68 @@
|
||||
title: Frequently Asked Questions
|
||||
description: FAQ about using ContentDB
|
||||
|
||||
## Users and Logins
|
||||
|
||||
### How do I create an account?
|
||||
|
||||
How you create an account depends on whether you have a forum account.
|
||||
|
||||
If you have a forum account, then you'll need to prove that you are the owner of the account. This can
|
||||
be done using a GitHub account or a random string in your forum account signature.
|
||||
|
||||
If you don't, then you can just sign up using an email address and password.
|
||||
|
||||
GitHub can only be used to log in, not to register.
|
||||
|
||||
<a class="btn btn-primary" href="/user/claim/">Register</a>
|
||||
|
||||
|
||||
### My verification email never arrived
|
||||
|
||||
There are a number of reasons this may have happened:
|
||||
|
||||
* Incorrect email address entered.
|
||||
* Temporary problem with ContentDB.
|
||||
* Email has been unsubscribed.
|
||||
|
||||
**When creating an account by email:**
|
||||
If the email doesn't arrive after registering by email, then you'll need to
|
||||
try registering again in 12 hours. Unconfirmed accounts are deleted after 12 hours.
|
||||
|
||||
**When changing your email (or it was set after a forum-based registration)**:
|
||||
then you can just set a new email in
|
||||
[Settings > Email and Notifications](/user/settings/email/).
|
||||
|
||||
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
|
||||
address. You'll need to use a different email address, or [contact the admin]({{ admin_contact_url }}) to
|
||||
remove your email from the blacklist.
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
### How can I create releases automatically?
|
||||
|
||||
There are a number of methods:
|
||||
|
||||
* [Git Update Detection](/help/update_config/): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
||||
* [Webhooks](/help/release_webhooks/): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
||||
* the [API](/help/api/): This is especially powerful when combined with CI/CD and other API endpoints.
|
||||
|
||||
### How do I learn how to make mods and games for Luanti?
|
||||
|
||||
You should read
|
||||
[the official Luanti Modding Book](https://rubenwardy.com/minetest_modding_book/)
|
||||
for a guide to making mods and games using Luanti.
|
||||
|
||||
### How do I install something from here?
|
||||
|
||||
See [Installing content](/help/installing/).
|
||||
|
||||
### How can my package get more downloads?
|
||||
|
||||
See [Creating an appealing ContentDB page](/help/appealing_page/).
|
||||
|
||||
|
||||
## How do I get help?
|
||||
|
||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
||||
137
app/flatpages/help/featured.md
Normal file
137
app/flatpages/help/featured.md
Normal file
@@ -0,0 +1,137 @@
|
||||
title: Featured Packages
|
||||
|
||||
<p class="alert alert-warning">
|
||||
<b>Note:</b> This is a draft, and is likely to change
|
||||
</p>
|
||||
|
||||
## What are Featured Packages?
|
||||
|
||||
Featured Packages are shown at the top of the ContentDB homepage. In the future,
|
||||
featured packages may be shown inside the Luanti client.
|
||||
|
||||
The purpose is to promote content that demonstrates a high quality of what is
|
||||
possible in Luanti. The selection should be varied, and should vary over time.
|
||||
The featured content should be content that we are comfortable recommending to
|
||||
a first time player.
|
||||
|
||||
## How are the packages chosen?
|
||||
|
||||
Before a package can be considered, it must fulfil the criteria in the below lists.
|
||||
There are three types of criteria:
|
||||
|
||||
* "MUST": These must absolutely be fulfilled, no exceptions!
|
||||
* "SHOULD": Most of them should be fulfilled, if possible. Some of them can be
|
||||
left out if there's a reason.
|
||||
* "CAN": Can be fulfilled for bonus points, they are entirely optional.
|
||||
|
||||
For a chance to get featured, a package must fulfil all "MUST" criteria and
|
||||
ideally as many "SHOULD" criteria as possible. The more, the better. Thankfully,
|
||||
many criteria are trivial to fulfil. Note that ticking off all the boxes is not
|
||||
enough: Just because a package completes the checklist does not make it good.
|
||||
Other aspects of the package should be rated as well. See this list as a
|
||||
starting point, not as an exhaustive quality control.
|
||||
|
||||
Editors are responsible for maintaining the list of featured packages. Authors
|
||||
can request that their package be considered by opening a thread titled
|
||||
"Feature Package" on their package. To speed things up, they should justify
|
||||
why they meet (or don't meet) the below criteria. Editors must abstain from
|
||||
voting on packages where they have a conflict of interest.
|
||||
|
||||
A package being featured does not mean that it will be featured forever. A
|
||||
package may be unfeatured if it no longer meets the criteria, to make space for
|
||||
other packages to be featured, or for another reason.
|
||||
|
||||
## General Requirements
|
||||
|
||||
### General
|
||||
|
||||
* MUST: Be 100% free and open source (as marked as Free on ContentDB).
|
||||
* MUST: Work out-of-the-box (no weird setup or settings required).
|
||||
* MUST: Be compatible with the latest stable Luanti release.
|
||||
* SHOULD: Use public source control (such as Git).
|
||||
* SHOULD: Have at least 3 reviews, and be largely positive.
|
||||
|
||||
### Stability
|
||||
|
||||
* MUST: Be well maintained (author is present and active).
|
||||
* MUST: Be reasonably stable, with no game-breaking or major bugs.
|
||||
* MUST: The author does not consider the package to be in an
|
||||
experimental/development/alpha state. Beta and "unfinished" packages are fine.
|
||||
* MUST: No error messages from the engine (e.g. missing textures).
|
||||
* SHOULD: No major map breakages (including unknown nodes, corruption, loss of inventories).
|
||||
Map breakages are a sign that the package isn't sufficiently stable.
|
||||
|
||||
Note: Any map breakage will be excused if "disaster relief" (i.e. tools to repair the damage)
|
||||
is available.
|
||||
|
||||
### Meta and packaging
|
||||
|
||||
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
|
||||
* MUST: Have a high resolution cover image on ContentDB (at least 1280x720 pixels).
|
||||
It may be shown cropped to 16:9 aspect ratio, or shorter.
|
||||
* MUST: mod.conf/game.conf/texture_pack.conf present with:
|
||||
* name (if mod or game)
|
||||
* description
|
||||
* dependencies (if relevant)
|
||||
* `min_minetest_version` and `max_minetest_version` (if relevant)
|
||||
* MUST: Contain a README file and a LICENSE file. These may be `.md` or `.txt`.
|
||||
* README files typically contain helpful links (download, manual, bugtracker, etc), and other
|
||||
information that players or (potential) contributors may need.
|
||||
* SHOULD: All important settings are in settingtypes.txt with description.
|
||||
|
||||
## Game-specific Requirements
|
||||
|
||||
### Meta and packaging
|
||||
|
||||
* MUST: Have a main menu icon and header image.
|
||||
|
||||
### Stability
|
||||
|
||||
* MUST: If any major setting (like `enable_damage`) is unsupported, the game must disable it
|
||||
using `disabled_settings` in the `game.conf`, and deal with it appropriately in the code
|
||||
(e.g. force-disable the setting, as the user may still set the setting in `minetest.conf`)
|
||||
|
||||
### Usability
|
||||
|
||||
* MUST: Unsupported mapgens are disabled in game.conf.
|
||||
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Luanti) wouldn't get completely
|
||||
stuck within the first 5 minutes of playing.
|
||||
* SHOULD: Have good documentation. This may include one or more of:
|
||||
* A craftguide, or other in-game learning system
|
||||
* A manual
|
||||
* A wiki
|
||||
* Something else
|
||||
|
||||
### Gameplay
|
||||
|
||||
* CAN: Passes the Six Hour Test (only applies to sandbox games): The game doesn't run out of new
|
||||
content before the first 6 hours of playing.
|
||||
* CAN: Players don't feel that something in the game is "lacking".
|
||||
|
||||
### Audiovisuals
|
||||
|
||||
* MUST: Audiovisual design should be of good quality.
|
||||
* MUST: No obvious GUI/HUD breakages.
|
||||
* MUST: Sounds have no obvious artifacts like clicks or unintentional noise.
|
||||
* SHOULD: Graphical design is mostly consistent.
|
||||
* SHOULD: Sounds are used.
|
||||
* SHOULD: Sounds are normalized (more or less).
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
* MUST: No flooding the console/log file with warnings.
|
||||
* MUST: No duplicate crafting recipes.
|
||||
* MUST: Highly experimental game features are disabled by default.
|
||||
* MUST: Experimental game features are clearly marked as such.
|
||||
* SHOULD: No unknown nodes/items/objects appear.
|
||||
* SHOULD: No dependency on legacy API calls.
|
||||
* SHOULD: No console warnings.
|
||||
|
||||
### Writing
|
||||
|
||||
* MUST: All items that can be obtained in normal gameplay have `description` set (whether in the definition or meta).
|
||||
* MUST: Game is not littered with typos or bad grammar (a few typos are OK but should be fixed, when found).
|
||||
* SHOULD: All items have unique names (items which disguise themselves as another item are exempt).
|
||||
* SHOULD: The writing style of all item names is grammatical and consistent.
|
||||
* SHOULD: Descriptions of things convey useful and meaningful information (if applicable).
|
||||
* CAN: Text is written in clear and (if possible) simple language.
|
||||
16
app/flatpages/help/feeds.md
Normal file
16
app/flatpages/help/feeds.md
Normal file
@@ -0,0 +1,16 @@
|
||||
title: Feeds
|
||||
|
||||
You can follow updates from ContentDB in your RSS feed reader. If in doubt, copy the Atom URL.
|
||||
|
||||
* All events: [Atom]({{ url_for('feeds.all_atom') }}) | [JSONFeed]({{ url_for('feeds.all_json') }})
|
||||
* New packages: [Atom]({{ url_for('feeds.packages_all_atom') }}) | [JSONFeed]({{ url_for('feeds.packages_all_json') }})
|
||||
* New releases: [Atom]({{ url_for('feeds.releases_all_atom') }}) | [JSONFeed]({{ url_for('feeds.releases_all_json') }})
|
||||
|
||||
## Package feeds
|
||||
|
||||
Follow new releases for a package:
|
||||
|
||||
```
|
||||
https://content.luanti.org/packages/AUTHOR/NAME/releases_feed.atom
|
||||
https://content.luanti.org/packages/AUTHOR/NAME/releases_feed.json
|
||||
```
|
||||
52
app/flatpages/help/game_support.md
Normal file
52
app/flatpages/help/game_support.md
Normal file
@@ -0,0 +1,52 @@
|
||||
title: Supported Games
|
||||
|
||||
## Why?
|
||||
|
||||
The supported/compatible games feature allows mods to specify the games that
|
||||
they work with, which improves user experience.
|
||||
|
||||
|
||||
## Support sources
|
||||
|
||||
### mod.conf / texture_pack.conf
|
||||
|
||||
You can use `supported_games` to specify games that your mod/modpack/texture
|
||||
pack is compatible with.
|
||||
|
||||
You can use `unsupported_games` to specify games that your package doesn't work
|
||||
with, which is useful for overriding ContentDB's automatic detection.
|
||||
|
||||
Both of these are comma-separated lists of game technical ids. Any `_game`
|
||||
suffixes are ignored, just like in Luanti.
|
||||
|
||||
supported_games = minetest_game, repixture
|
||||
unsupported_games = lordofthetest, nodecore, whynot
|
||||
|
||||
If your package supports all games by default, you can put "*" in
|
||||
supported_games. You can still use unsupported_games to mark games as
|
||||
unsupported. You can also specify games that you've tested in supported_games.
|
||||
|
||||
# Should work with all games but I've only tested using Minetest Game:
|
||||
supported_games = *, minetest_game
|
||||
|
||||
# But doesn't work in capturetheflag
|
||||
unsupported_game = capturetheflag
|
||||
|
||||
### Dependencies
|
||||
|
||||
ContentDB will analyse hard dependencies and work out which games a mod
|
||||
supports.
|
||||
|
||||
This uses a recursive algorithm that works out whether a dependency can be
|
||||
installed independently, or if it requires a certain game.
|
||||
|
||||
### On ContentDB
|
||||
|
||||
You can define supported games on ContentDB, but using .conf is recommended
|
||||
instead.
|
||||
|
||||
|
||||
## Combining all the sources
|
||||
|
||||
.conf will override anything ContentDB detects. The manual override on ContentDB
|
||||
overrides .conf and dependencies.
|
||||
89
app/flatpages/help/installing.md
Normal file
89
app/flatpages/help/installing.md
Normal file
@@ -0,0 +1,89 @@
|
||||
title: How to install mods, games, and texture packs
|
||||
description: A guide to installing mods, games, and texture packs in Luanti.
|
||||
|
||||
## Installing from the main menu (recommended)
|
||||
|
||||
### Install
|
||||
|
||||
1. Open the mainmenu
|
||||
2. Go to the Content tab and click "Browse online content".
|
||||
If you don't see this, then you need to update Luanti to v5.
|
||||
3. Search for the package you want to install, and click "Install".
|
||||
4. When installing a mod, you may be shown a dialog about dependencies here.
|
||||
Make sure the base game dropdown box is correct, and then click "Install".
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_content_tab.png">
|
||||
<img class="w-100" src="/static/installing_content_tab.png" alt="Screenshot of the content tab in Luanti">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
1. Click Browser Online Content in the content tab.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_cdb_dialog.png">
|
||||
<img class="w-100" src="/static/installing_cdb_dialog.png" alt="Screenshot of the content tab in Luanti">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
2. Search for the package and click "Install".
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
* I can't find it in the ContentDB dialog (Browse online content)
|
||||
* Make sure that you're on the latest version of Luanti.
|
||||
* Are you using Android? Packages with content warnings are hidden by default on android,
|
||||
you can show them by removing `android_default` from the `contentdb_flag_blacklist` setting.
|
||||
* Does the webpage show "Non-free" warnings? Non-free content is hidden by default from all clients,
|
||||
you can show them by removing `nonfree` from the `contentdb_flag_blacklist` setting.
|
||||
* It says "required dependencies could not be found"
|
||||
* Make sure you're using the correct "Base Game". A lot of packages only work with certain games, you can look
|
||||
at "Compatible Games" on the web page to see which.
|
||||
|
||||
### Enable in Select Mods
|
||||
|
||||
1. Mods: Enable the content using "Select Mods" when selecting a world.
|
||||
2. Games: choose a game when making a world.
|
||||
3. Texture packs: Content > Select pack > Click enable.
|
||||
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_select_mods.png">
|
||||
<img class="w-100" src="/static/installing_select_mods.png" alt="Screenshot of Select Mods in Luanti">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
Enable mods using the Select Mods dialog.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Installing using the command line
|
||||
|
||||
### Git clone
|
||||
|
||||
1. Install git
|
||||
2. Find the package on ContentDB and copy "source" link.
|
||||
3. Find the user data directory.
|
||||
In 5.4.0 and above, you can click "Open user data directory" in the Credits tab.
|
||||
Otherwise:
|
||||
* Windows: wherever you extracted or installed Luanti to.
|
||||
* Linux: usually `~/.minetest/`
|
||||
4. Open or create the folder for the type of content (`mods`, `games`, or `textures`)
|
||||
5. Git clone there
|
||||
6. For mods, make sure to install any required dependencies.
|
||||
|
||||
### Enable
|
||||
|
||||
* Mods: Edit world.mt in the world's folder to contain `load_file_MODNAME = true`
|
||||
* Games: Use `--game` or edit game_id in world.mt.
|
||||
* Texture packs: change the `texture_path` setting to the texture pack absolute path.
|
||||
25
app/flatpages/help/metrics.md
Normal file
25
app/flatpages/help/metrics.md
Normal file
@@ -0,0 +1,25 @@
|
||||
title: Prometheus Metrics
|
||||
|
||||
## What is Prometheus?
|
||||
|
||||
[Prometheus](https://prometheus.io) is an "open-source monitoring system with a
|
||||
dimensional data model, flexible query language, efficient time series database
|
||||
and modern alerting approach".
|
||||
|
||||
Prometheus Metrics can be accessed at [/metrics](/metrics), or you can view them
|
||||
on the Grafana instance below.
|
||||
|
||||
{% if monitoring_url %}
|
||||
<p>
|
||||
<a class="btn btn-primary" href="{{ monitoring_url }}">
|
||||
View ContentDB on Grafana
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
## Metrics
|
||||
|
||||
* `contentdb_packages` - Total packages (counter).
|
||||
* `contentdb_users` - Number of registered users (counter).
|
||||
* `contentdb_downloads` - Total downloads (counter).
|
||||
* `contentdb_score` - Total package score (gauge).
|
||||
83
app/flatpages/help/non_free.md
Normal file
83
app/flatpages/help/non_free.md
Normal file
@@ -0,0 +1,83 @@
|
||||
title: Non-free Licenses
|
||||
|
||||
## What are Non-Free, Free, and Open Source licenses?
|
||||
|
||||
A non-free license is one that does not meet the
|
||||
[Free Software Definition](https://www.gnu.org/philosophy/free-sw.en.html)
|
||||
or the [Open Source Definition](https://opensource.org/osd).
|
||||
ContentDB will clearly label any packages with non-free licenses,
|
||||
and they will be subject to limited promotion.
|
||||
|
||||
## How does ContentDB deal with Non-Free Licenses?
|
||||
|
||||
**ContentDB does not allow certain non-free licenses, and will limit the promotion
|
||||
of packages with non-free licenses.**
|
||||
|
||||
Luanti is free and open source software, and is only as big as it is now
|
||||
because of this. It's pretty amazing you can take nearly any published mod and modify it
|
||||
to how you like - add some features, maybe fix some bugs - and then share those
|
||||
modifications without the worry of legal issues. The project, itself, relies on open
|
||||
source contributions to survive - if it were non-free, then it would have died
|
||||
when celeron55 lost interest.
|
||||
|
||||
If you have played nearly any game with a large modding scene, you will find
|
||||
that most mods are legally ambiguous. A lot of them don't even provide the
|
||||
source code to allow you to bug fix or extend as you need.
|
||||
|
||||
Limiting the promotion of problematic licenses helps Luanti avoid ending up in
|
||||
such a state. Licenses that prohibit redistribution or modification are
|
||||
completely banned from ContentDB and the Luanti forums. Other non-free licenses
|
||||
will be subject to limited promotion - they won't be shown by default in
|
||||
the client.
|
||||
|
||||
Not providing full promotion on ContentDB, or not allowing your package at all,
|
||||
doesn't mean you can't make such content - it just means we're not going to help
|
||||
you spread it.
|
||||
|
||||
## What's so bad about licenses that forbid commercial use?
|
||||
|
||||
Please read [reasons not to use a Creative Commons -NC license](https://freedomdefined.org/Licenses/NC).
|
||||
Here's a quick summary related to Luanti content:
|
||||
|
||||
1. They make your work incompatible with a growing body of free content, even if
|
||||
you do want to allow derivative works or combinations.
|
||||
This means that it can cause problems when another modder wishes to include your
|
||||
work in a modpack or game.
|
||||
2. They may rule out other basic and beneficial uses that you want to allow.
|
||||
For example, CC -NC will forbid showing your content in a monetised YouTube
|
||||
video.
|
||||
3. They are unlikely to increase the potential profit from your work, and a
|
||||
share-alike license serves the goal to protect your work from unethical
|
||||
exploitation equally well.
|
||||
|
||||
## How can I show non-free packages in the client?
|
||||
|
||||
Non-free packages are hidden in the client by default, partly in order to comply
|
||||
with the rules of various Linux distributions.
|
||||
|
||||
Users can opt in to showing non-free software, if they wish:
|
||||
|
||||
1. In the main menu, go to Settings > All settings
|
||||
2. Search for "ContentDB Flag Blacklist".
|
||||
3. Edit that setting to remove `nonfree, `.
|
||||
|
||||
<figure class="figure my-4">
|
||||
<img class="figure-img img-fluid rounded" src="/static/contentdb_flag_blacklist.png" alt="Screenshot of the ContentDB Flag Blacklist setting">
|
||||
<figcaption class="figure-caption">Screenshot of the ContentDB Flag Blacklist setting</figcaption>
|
||||
</figure>
|
||||
|
||||
The [`platform_default` flag](/help/content_flags/) is used to control what content
|
||||
each platforms shows. It doesn't hide anything on Desktop, but hides all mature
|
||||
content on Android. You may wish to remove all text from that setting completely,
|
||||
leaving it blank. See [Content Warnings](/help/content_flags/#content-warnings)
|
||||
for information on mature content.
|
||||
|
||||
## How can I hide non-free packages on the website?
|
||||
|
||||
Clicking "Hide non-free packages" in the footer of ContentDB will hide non-free packages from search results.
|
||||
It will not hide non-free packages from user profiles.
|
||||
|
||||
## See also
|
||||
|
||||
* [List of non-free packages](/packages/?flag=nonfree)
|
||||
* [Copyright Guide](/help/copyright)
|
||||
103
app/flatpages/help/oauth.md
Normal file
103
app/flatpages/help/oauth.md
Normal file
@@ -0,0 +1,103 @@
|
||||
title: OAuth2 API
|
||||
|
||||
<p class="alert alert-warning">
|
||||
The OAuth2 applications API is currently experimental, and may break without notice.
|
||||
</p>
|
||||
|
||||
ContentDB allows you to create an OAuth2 Application and obtain access tokens
|
||||
for users.
|
||||
|
||||
|
||||
## Scopes
|
||||
|
||||
OAuth2 applications can currently only access public user data, using the whoami API.
|
||||
|
||||
|
||||
## Create an OAuth2 Client
|
||||
|
||||
Go to Settings > [OAuth2 Applications](/user/apps/) > Create
|
||||
|
||||
|
||||
## Obtaining access tokens
|
||||
|
||||
ContentDB supports the Authorization Code OAuth2 method.
|
||||
|
||||
### Authorize
|
||||
|
||||
Get the user to open the following URL in a web browser:
|
||||
|
||||
```
|
||||
https://content.luanti.org/oauth/authorize/
|
||||
?response_type=code
|
||||
&client_id={CLIENT_ID}
|
||||
&redirect_uri={REDIRECT_URL}
|
||||
```
|
||||
|
||||
The redirect_url must much the value set in your oauth client. Make sure to URL encode it.
|
||||
ContentDB also supports `state`.
|
||||
|
||||
Afterwards, the user will be redirected to your callback URL.
|
||||
If the user accepts the authorization, you'll receive an authorization code (`code`).
|
||||
Otherwise, the redirect_url will not be modified.
|
||||
|
||||
For example, with `REDIRECT_URL` set as `https://example.com/callback/`:
|
||||
|
||||
* If the user accepts: `https://example.com/callback/?code=abcdef`
|
||||
* If the user cancels: `https://example.com/callback/`
|
||||
|
||||
### Exchange auth code for access token
|
||||
|
||||
Next, you'll need to exchange the auth for an access token.
|
||||
|
||||
Do this by making a POST request to the `/oauth/token/` API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://content.luanti.org/oauth/token/ \
|
||||
-F grant_type=authorization_code \
|
||||
-F client_id="CLIENT_ID" \
|
||||
-F client_secret="CLIENT_SECRET" \
|
||||
-F code="abcdef"
|
||||
```
|
||||
|
||||
<p class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
You should make this request on a server to prevent the user
|
||||
from getting access to your client secret.
|
||||
</p>
|
||||
|
||||
If successful, you'll receive:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"access_token": "access_token",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
If there's an error, you'll receive a standard API error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "The error message"
|
||||
}
|
||||
```
|
||||
|
||||
Possible errors:
|
||||
|
||||
* Unsupported grant_type, only authorization_code is supported
|
||||
* Missing client_id
|
||||
* Missing client_secret
|
||||
* Missing code
|
||||
* client_id and/or client_secret is incorrect
|
||||
* Incorrect code. It may have already been redeemed
|
||||
|
||||
### Check access token
|
||||
|
||||
Next, you should check the access token works by getting the user information:
|
||||
|
||||
```bash
|
||||
curl https://content.luanti.org/api/whoami/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
143
app/flatpages/help/package_config.md
Normal file
143
app/flatpages/help/package_config.md
Normal file
@@ -0,0 +1,143 @@
|
||||
title: Package Configuration and Releases Guide
|
||||
|
||||
## Introduction
|
||||
|
||||
ContentDB will read configuration files in your package when doing several
|
||||
tasks, including package and release creation. This page details how you can use
|
||||
this to your advantage.
|
||||
|
||||
## .conf files
|
||||
|
||||
### What is a content .conf file?
|
||||
|
||||
Every type of content can have a `.conf` file that contains the metadata.
|
||||
|
||||
The filename of the `.conf` file depends on the content type:
|
||||
|
||||
* `mod.conf` for mods.
|
||||
* `modpack.conf` for mod packs.
|
||||
* `game.conf` for games.
|
||||
* `texture_pack.conf` for texture packs.
|
||||
|
||||
The `.conf` uses a key-value format, separated using equals.
|
||||
|
||||
Here's a simple example of `mod.conf`, `modpack.conf`, or `texture_pack.conf`:
|
||||
|
||||
name = mymod
|
||||
title = My Mod
|
||||
description = A short description to show in the client.
|
||||
|
||||
Here's a simple example of `game.conf`:
|
||||
|
||||
title = My Game
|
||||
description = A short description to show in the client.
|
||||
|
||||
Note that you should not specify `name` in game.conf.
|
||||
|
||||
### Understood values
|
||||
|
||||
ContentDB understands the following information:
|
||||
|
||||
* `title` - A human-readable title.
|
||||
* `description` - A short description to show in the client.
|
||||
* `depends` - Comma-separated hard dependencies.
|
||||
* `optional_depends` - Comma-separated soft dependencies.
|
||||
* `min_minetest_version` - The minimum Luanti version this runs on, see [Min and Max Luanti Versions](#min_max_versions).
|
||||
* `max_minetest_version` - The maximum Luanti version this runs on, see [Min and Max Luanti Versions](#min_max_versions).
|
||||
|
||||
and for mods only:
|
||||
|
||||
* `name` - the mod technical name.
|
||||
* `supported_games` - List of supported game technical names.
|
||||
* `unsupported_games` - List of not supported game technical names. Useful to override game support detection.
|
||||
|
||||
|
||||
## .cdb.json
|
||||
|
||||
You can include a `.cdb.json` file in the root of your content directory (ie: next to a .conf)
|
||||
to update the package meta.
|
||||
|
||||
It should be a JSON dictionary with one or more of the following optional keys:
|
||||
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `title`: Human-readable title.
|
||||
* `name`: Technical name (needs permission if already approved).
|
||||
* `short_description`
|
||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
||||
`LOOKING_FOR_MAINTAINER`.
|
||||
* `tags`: List of tag names, see [/api/tags/](/api/tags/).
|
||||
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
|
||||
* `license`: A license name, see [/api/licenses/](/api/licenses/).
|
||||
* `media_license`: A license name.
|
||||
* `long_description`: Long markdown description.
|
||||
* `repo`: Source repository (eg: Git).
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `donate_url`: URL to a donation page.
|
||||
* `translation_url`: URL to send users interested in translating your package.
|
||||
|
||||
Use `null` or `[]` to unset fields where relevant.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Foo bar",
|
||||
"tags": ["pvp", "survival"],
|
||||
"license": "MIT",
|
||||
"website": null
|
||||
}
|
||||
```
|
||||
|
||||
## Controlling Release Creation
|
||||
|
||||
### Git-based Releases and Submodules
|
||||
|
||||
ContentDB can create releases from a Git repository.
|
||||
It will include submodules in the resulting archive.
|
||||
Simply set VCS Repository in the package's meta to a Git repository, and then
|
||||
choose Git as the method when creating a release.
|
||||
|
||||
### Automatic Release Creation
|
||||
|
||||
See [Git Update Detection](/help/update_config/).
|
||||
You can also use [GitLab/GitHub webhooks](/help/release_webhooks/) or the [API](/help/api/)
|
||||
to create releases.
|
||||
|
||||
### Min and Max Luanti Versions
|
||||
|
||||
<a name="min_max_versions" />
|
||||
|
||||
When creating a release, the `.conf` file will be read to determine what Luanti
|
||||
versions the release supports. If the `.conf` doesn't specify, then it is assumed
|
||||
that it supports all versions.
|
||||
|
||||
This happens when you create a release via the ContentDB web interface, the
|
||||
[API](/help/api/), or using a [GitLab/GitHub webhook](/help/release_webhooks/).
|
||||
|
||||
Here's an example config:
|
||||
|
||||
name = mymod
|
||||
min_minetest_version = 5.0
|
||||
max_minetest_version = 5.3
|
||||
|
||||
Leaving out min or max to have them set as "None".
|
||||
|
||||
### Excluding files
|
||||
|
||||
When using Git to create releases,
|
||||
you can exclude files from a release by using [gitattributes](https://git-scm.com/docs/gitattributes):
|
||||
|
||||
|
||||
.* export-ignore
|
||||
sources export-ignore
|
||||
*.zip export-ignore
|
||||
|
||||
|
||||
This will prevent any files from being included if they:
|
||||
|
||||
* Beginning with `.`
|
||||
* or are named `sources` or are inside any directory named `sources`.
|
||||
* or have an extension of "zip".
|
||||
@@ -1,33 +0,0 @@
|
||||
title: Package Tags
|
||||
|
||||
## Overview
|
||||
|
||||
Tags should be added to packages to enable easy identification of different types of mods, games and texture packs.
|
||||
|
||||
They are only beneficial when applied correctly, so please use the following guidelines.
|
||||
|
||||
## Tag Usage
|
||||
|
||||
* **Building** - For mods that focus on adding new materials or nodes to build with.
|
||||
* **Education** - For mods or games created for educational purposes.
|
||||
* **Environment** - For mods that add environmental effects, including ambient sound and weather effects.
|
||||
* **Inventory** - For mods that add new inventory systems or new inventory pages.
|
||||
* **Machines and Electronics** - For mods that include placeable machinery or electronic components which interact to complete tasks.
|
||||
* **Maintenance** - For mods that assist with world or player maintenance. This includes large-scale map manipulation, area protection and other administrative tools.
|
||||
* **Mapgen** - For mods that add new biomes, new mapgen decorations, or any other mapgen elements.
|
||||
* **Mobs and NPCs** - For mods that add mobs or NPCs, or provide tools that assist with mob and NPC creation or manipulation.
|
||||
* **Plants and Farming** - For mods that add new plants or other farmable resources.
|
||||
* **Player effects/Food** - For mods that change player effects, for example speed, jump height or gravity, and food.
|
||||
* **Tools** - For mods that add new tools or new features for existing tools.
|
||||
* **Transport** - For mods that add transportation methods. This includes teleportation, vehicles and ridable mobs.
|
||||
* **Survival** - For mods written specifically for survival games. For example, these mods might focus on game-balance or increase the difficulty level. This tag should also be used for games with a heavy survival focus.
|
||||
* **Creative** - For mods written specifically (and often exclusively) for use in creative mode. For example, these mods may add a large amount of decorative content, or content without crafting recipes. This tag should also be used for games with a heavy creative focus.
|
||||
* **Multiplayer-focused** - For games that can be played with other players.
|
||||
* **Singleplayer-focused** - For games that can be played alone.
|
||||
* **PvP** - For games designed to be played competitively against other players.
|
||||
* **PvE** - For games designed for one or multiple players which focus on combat against mobs or NPCs.
|
||||
* **Puzzle** - For mods and games with a focus on puzzle solving instead of combat.
|
||||
* **16px** - For 16px texture packs.
|
||||
* **32px** - For 32px texture packs.
|
||||
* **64px** - For 64px texture packs.
|
||||
* **128px+** - For 128px or higher texture packs.
|
||||
@@ -2,25 +2,27 @@ title: Ranks and Permissions
|
||||
|
||||
## Overview
|
||||
|
||||
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
|
||||
* **Members** - Trusted to change the meta data of their own packages', but cannot publish releases.
|
||||
* **Trusted Members** - Same as above, but can approve their own releases and packages.
|
||||
* **Editors** - Trusted to change the meta data of any package, and also make and publish releases.
|
||||
* **New Members** - mostly untrusted, cannot change package metadata or publish releases without approval.
|
||||
* **Members** - Trusted to change the metadata of their own packages', but cannot approve their own packages.
|
||||
* **Trusted Members** - Same as above, but can approve their own releases.
|
||||
* **Approvers** - Responsible for approving new packages, screenshots, and releases.
|
||||
* **Editors** - Same as above, and can edit any package or release.
|
||||
* **Moderators** - Same as above, but can manage users.
|
||||
* **Admins** - Full access.
|
||||
|
||||
## Breakdown
|
||||
|
||||
<table class="fancyTable">
|
||||
<table class="table table-striped ranks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th colspan=2>New Member</th>
|
||||
<th colspan=2>Member</th>
|
||||
<th colspan=2>Trusted Member</th>
|
||||
<th colspan=2>Editor</th>
|
||||
<th colspan=2>Moderator</th>
|
||||
<th colspan=2>Admin</th>
|
||||
<th colspan=2 class="NEW_MEMBER">New Member</th>
|
||||
<th colspan=2 class="MEMBER">Member</th>
|
||||
<th colspan=2 class="TRUSTED_MEMBER">Trusted</th>
|
||||
<th colspan=2 class="APPROVER">Approver</th>
|
||||
<th colspan=2 class="EDITOR">Editor</th>
|
||||
<th colspan=2 class="MODERATOR">Moderator</th>
|
||||
<th colspan=2 class="ADMIN">Admin</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Owner of thing</th>
|
||||
@@ -34,193 +36,271 @@ title: Ranks and Permissions
|
||||
<th>N</th>
|
||||
<th>Y</th>
|
||||
<th>N</th>
|
||||
<th>Y</th>
|
||||
<th>N</th>
|
||||
<th>Y</th>
|
||||
<th>N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Create Package</td>
|
||||
<th>✓</th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Approve Package</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th></th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td></td> <!-- member -->
|
||||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete Package</td>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Edit Package</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Edit Maintainers</td>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add/Delete Screenshot</td>
|
||||
<th>✓</th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Approve Screenshot</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Approve EditRequest</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Edit EditRequest</td>
|
||||
<th>✓<sup>1</sup></th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td></td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Make Release</td>
|
||||
<th>✓</th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Approve Release</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th></th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓</th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Change Release URL</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th></th> <!-- member -->
|
||||
<th></th>
|
||||
<th></th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th></th> <!-- editor -->
|
||||
<th></th>
|
||||
<th></th> <!-- moderator -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td></td> <!-- member -->
|
||||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td></td> <!-- approver -->
|
||||
<td></td>
|
||||
<td></td> <!-- editor -->
|
||||
<td></td>
|
||||
<td></td> <!-- moderator -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>See Private Thread</td>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Edit Comments</td>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Set Email</td>
|
||||
<th>✓</th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th></th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<th>✓<sup>2</sup></th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Create Token</td>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<th>✓<sup>2</sup></th>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Set Rank</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th></th> <!-- member -->
|
||||
<th></th>
|
||||
<th></th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th></th> <!-- editor -->
|
||||
<th></th>
|
||||
<th>✓<sup>3</sup></th> <!-- moderator -->
|
||||
<th>✓<sup>2</sup><sup>3</sup></th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td></td> <!-- member -->
|
||||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td></td> <!-- approver -->
|
||||
<td></td>
|
||||
<td></td> <!-- editor -->
|
||||
<td></td>
|
||||
<th>✓<sup>2</sup></th> <!-- moderator -->
|
||||
<th>✓<sup>1</sup><sup>2</sup></th>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
1. User must be the author of the EditRequest.
|
||||
2. Target user cannot be an admin.
|
||||
3. Cannot set user to a higher rank than themselves.
|
||||
1. Target user cannot be an admin.
|
||||
2 Cannot set user to a higher rank than themselves.
|
||||
|
||||
71
app/flatpages/help/release_webhooks.md
Normal file
71
app/flatpages/help/release_webhooks.md
Normal file
@@ -0,0 +1,71 @@
|
||||
title: Creating Releases using Webhooks
|
||||
|
||||
## What does this mean?
|
||||
|
||||
A webhook is a notification from one service to another. Put simply, a webhook
|
||||
is used to notify ContentDB that the git repository has changed.
|
||||
|
||||
ContentDB offers the ability to automatically create releases using webhooks
|
||||
from either GitHub or GitLab. If you're not using either of those services,
|
||||
you can also use the [API](../api) to create releases.
|
||||
|
||||
ContentDB also offers the ability to poll a Git repo and check for updates
|
||||
without any web hooks, this is limited to once a day.
|
||||
See [Git Update Detection](/help/update_config/).
|
||||
|
||||
The process is as follows:
|
||||
|
||||
1. The user creates an API Token and a webhook to use it.
|
||||
2. The user pushes a commit to the git host (GitLab or GitHub).
|
||||
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
|
||||
4. ContentDB checks the API token and issues a new release.
|
||||
* If multiple packages match, then only the first will have a release created.
|
||||
|
||||
### Branch filtering
|
||||
|
||||
By default, "New commit" or "push" based webhooks will only work on "master"/"main" branches.
|
||||
You can configure the branch used by changing "Branch name" in [Git update detection](update_config).
|
||||
|
||||
For example, to support production and beta packages you can have multiple packages with the same VCS repo URL
|
||||
but different [Git update detection](update_config) branch names.
|
||||
|
||||
Tag-based webhooks are accepted on any branch.
|
||||
|
||||
|
||||
## Setting up
|
||||
|
||||
### GitHub
|
||||
|
||||
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
||||
2. Copy the access token that was generated.
|
||||
3. Go to the GitLab repository's settings > Webhooks > Add Webhook.
|
||||
4. Set the payload URL to `https://content.luanti.org/github/webhook/`
|
||||
5. Set the content type to JSON.
|
||||
6. Set the secret to the access token that you copied.
|
||||
7. Set the events
|
||||
* If you want a rolling release, choose "just the push event".
|
||||
* Or if you want a stable release cycle based on tags, choose "Let me select" > Branch or tag creation.
|
||||
8. Create.
|
||||
9. If desired, change [Git update detection](update_config) > Branch name to configure the [branch filtering](#branch-filtering).
|
||||
|
||||
### GitLab
|
||||
|
||||
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
||||
2. Copy the access token that was generated.
|
||||
3. Go to the GitLab repository's settings > Webhooks.
|
||||
4. Set the URL to `https://content.luanti.org/gitlab/webhook/`
|
||||
6. Set the secret token to the ContentDB access token that you copied.
|
||||
7. Set the events
|
||||
* If you want a rolling release, choose "Push events".
|
||||
* Or if you want a stable release cycle based on tags,
|
||||
choose "Tag push events".
|
||||
8. Add webhook.
|
||||
9. If desired, change [Git update detection](update_config) > Branch name to configure the [branch filtering](#branch-filtering).
|
||||
|
||||
## Configuring Release Creation
|
||||
|
||||
See the [Package Configuration and Releases Guide](/help/package_config/) for
|
||||
documentation on configuring the release creation.
|
||||
|
||||
From the Git repository, you can set the min/max Luanti versions, which files are included,
|
||||
and update the package meta.
|
||||
36
app/flatpages/help/top_packages.md
Normal file
36
app/flatpages/help/top_packages.md
Normal file
@@ -0,0 +1,36 @@
|
||||
title: Top Packages Algorithm
|
||||
|
||||
## Package Score
|
||||
|
||||
Each package is given a `score`, which is used when ordering them in the
|
||||
"Top Games/Mods/Texture Packs" lists. The intention of this feature is
|
||||
to make it easier for new users to find good packages.
|
||||
|
||||
A package's score is equal to a rolling average of recent downloads,
|
||||
plus the sum of the score given by reviews.
|
||||
|
||||
A review score is 100 if positive, -100 if negative.
|
||||
|
||||
```c
|
||||
reviews_sum = sum(100 * (positive ? 1 : -1));
|
||||
score = avg_downloads + reviews_sum;
|
||||
```
|
||||
|
||||
## Pseudo rolling average of downloads
|
||||
|
||||
Each package adds 1 to `avg_downloads` for each unique download,
|
||||
and then loses 6.66% (=1/15) of the value every day.
|
||||
|
||||
This is called a [Frecency](https://en.wikipedia.org/wiki/Frecency) heuristic,
|
||||
a measure which combines both frequency and recency.
|
||||
|
||||
"Unique download" is counted per IP per package.
|
||||
Downloading an update won't increase the download count if it's already been
|
||||
downloaded from that IP.
|
||||
|
||||
## Transparency and Feedback
|
||||
|
||||
You can see all scores using the [scores REST API](/api/scores/), or by
|
||||
using the [Prometheus metrics](/help/metrics/) endpoint.
|
||||
|
||||
Consider [suggesting improvements](https://github.com/luanti-org/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).
|
||||
43
app/flatpages/help/update_config.md
Normal file
43
app/flatpages/help/update_config.md
Normal file
@@ -0,0 +1,43 @@
|
||||
title: Git Update Detection
|
||||
|
||||
## Introduction
|
||||
|
||||
When you push a change to your Git repository, ContentDB can create a new release automatically or
|
||||
send you a reminder. ContentDB will check your Git repository one per day, but you can use
|
||||
webhooks or the API for faster updates.
|
||||
|
||||
Git Update Detection is clever enough to not create a release again if you've already created
|
||||
it manually or using webhooks/the API.
|
||||
|
||||
## Setting up
|
||||
|
||||
* Set "VCS Repository URL" in your package.
|
||||
* Open the "Configure Git Update Detection" page:
|
||||
* Go to the Create Release page and click "Set up" on the banner.
|
||||
* If the "How do you want to create releases?" wizard appears, choose "Automatic".
|
||||
* Choose a trigger:
|
||||
* **New Commit** - this will trigger for each pushed commit on the default branch, or the branch you specify.
|
||||
* **New Tag** - this will trigger when a New Tag is created.
|
||||
* Choose action to occur when the trigger happens:
|
||||
* **Notification** - All maintainers receive a notification under the Bot category, and the package
|
||||
will appear under "Outdated Packages" in [your to do list](/user/todo/).
|
||||
* **Create Release** - A new release is created.
|
||||
If New Commit, the title will be the iso date (eg: 2021-02-01).
|
||||
If New Tag, the title will the tag name.
|
||||
|
||||
## Marking a package as up-to-date
|
||||
|
||||
Git Update Detection shouldn't erroneously mark packages as outdated if it is configured currently,
|
||||
so the first thing you should do is make sure the Update Settings are set correctly.
|
||||
|
||||
There are some situations where the settings are correct, but you want to mark a package as
|
||||
up-to-date - for example, if you don't want to make a release for a particular tag.
|
||||
Clicking "Save" on "Update Settings" will mark a package as up-to-date.
|
||||
|
||||
## Configuring Release Creation
|
||||
|
||||
See the [Package Configuration and Releases Guide](/help/package_config/) for
|
||||
documentation on configuring the release creation.
|
||||
|
||||
From the Git repository, you can set the min/max Luanti versions, which files are included,
|
||||
and update the package meta.
|
||||
21
app/flatpages/help/wtfpl.md
Normal file
21
app/flatpages/help/wtfpl.md
Normal file
@@ -0,0 +1,21 @@
|
||||
title: WTFPL is a terrible license
|
||||
toc: False
|
||||
|
||||
The use of WTFPL as a license is discouraged for multiple reasons.
|
||||
|
||||
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
||||
* **Swearing:** This prevents settings like schools from using your content.
|
||||
* **Not OSI Approved:** Same as public domain?
|
||||
|
||||
The Open Source Initiative chose not to approve the license as an open-source
|
||||
license, saying:<sup>[3]</sup>
|
||||
|
||||
> It's no different from dedication to the public domain.
|
||||
> Author has submitted license approval request – author is free to make public domain dedication.
|
||||
> Although he agrees with the recommendation, Mr. Michlmayr notes that public domain doesn't exist in Europe. Recommend: Reject.
|
||||
|
||||
## Sources
|
||||
|
||||
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
|
||||
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
|
||||
3. [OSI](https://opensource.org/meeting-minutes/minutes20090304)
|
||||
220
app/flatpages/policy_and_guidance.md
Normal file
220
app/flatpages/policy_and_guidance.md
Normal file
@@ -0,0 +1,220 @@
|
||||
title: Package Inclusion Policy and Guidance
|
||||
|
||||
|
||||
## 1. General
|
||||
|
||||
The ContentDB admin reserves the right to remove packages for any reason,
|
||||
including ones not covered by this document, and to ban users who abuse this service.
|
||||
|
||||
|
||||
## 2. Accepted Content
|
||||
|
||||
### 2.1. Mature Content
|
||||
|
||||
See the [Terms of Service](/terms/) for a full list of prohibited content.
|
||||
|
||||
Other mature content is permitted providing that it is labelled with the applicable
|
||||
[content warning](/help/content_flags/).
|
||||
|
||||
### 2.2. Useful Content / State of Completion
|
||||
|
||||
ContentDB is for playable and useful content - content which is sufficiently
|
||||
complete to be useful to end-users.
|
||||
|
||||
It's fine to add stuff which is still a Work in Progress (WIP) as long as it
|
||||
adds sufficient value. You must make sure to mark Work in Progress stuff as
|
||||
such in the "maintenance status" dropdown, as this will help advise players.
|
||||
|
||||
Adding non-player facing mods, such as libraries and server tools, is perfectly
|
||||
fine and encouraged. ContentDB isn't just for player-facing things and adding
|
||||
libraries allows Luanti to automatically install dependencies.
|
||||
|
||||
### 2.3. Language
|
||||
|
||||
We require packages to be in English with (optional) client-side translations for
|
||||
other languages. This is because Luanti currently requires English as the base language
|
||||
([Issue to change this](https://github.com/luanti-org/luanti/issues/6503)).
|
||||
|
||||
Your package's title and short description must be in English. You can use client-side
|
||||
translations to [translate content meta](https://api.luanti.org/translations/#translating-content-meta).
|
||||
|
||||
### 2.4. Attempt to contribute before forking
|
||||
|
||||
You should attempt to contribute upstream before forking a package. If you choose
|
||||
to fork, you should have a justification (different objectives, maintainer is unavailable, etc).
|
||||
You should use a different title and make it clear in the long description what the
|
||||
benefit of your fork is over the original package.
|
||||
|
||||
### 2.5. Copyright and trademarks
|
||||
|
||||
Your package must not violate copyright or trademarks. You should avoid the use of
|
||||
trademarks in the package's title or short description. If you do use a trademark,
|
||||
ensure that you phrase it in a way that does not imply official association or
|
||||
endorsement.
|
||||
|
||||
|
||||
## 3. Technical Names
|
||||
|
||||
### 3.1. Right to a Name
|
||||
|
||||
A package uses a name when it has that name or contains a mod that uses that name.
|
||||
|
||||
The first package to use a name based on the creation of its forum topic or
|
||||
ContentDB submission has the right to the technical name. The use of a package
|
||||
on a server or in private doesn't reserve its name. No other packages of the same
|
||||
type may use the same name, except for the exception given by 3.2.
|
||||
|
||||
If it turns out that we made a mistake by approving a package and that the
|
||||
name should have been given to another package, then we *may* unapprove the
|
||||
package and give the name to the correct one.
|
||||
|
||||
If you submit a package where you don't have the right to the name you will be asked
|
||||
to change the name of the package, or your package won't be accepted.
|
||||
|
||||
We reserve the right to issue exceptions for this where we feel necessary.
|
||||
|
||||
### 3.2. Forks and Reimplementations
|
||||
|
||||
An exception to the above is that mods are allowed to have the same name as a
|
||||
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
|
||||
must be possible to use the new mod as a drop-in replacement.
|
||||
|
||||
We reserve the right to decide whether a mod counts as a fork or
|
||||
reimplementation of the mod that owns the name.
|
||||
|
||||
### 3.3. Game Mod Namespacing
|
||||
|
||||
New mods introduced by a game must have a unique common prefix to avoid conflicts with
|
||||
other games and standalone mods. For example, the NodeCore game's first-party mods all
|
||||
start with `nc_`: `nc_api`, `nc_doors`.
|
||||
|
||||
You may include existing or standard mods in your game without renaming them to use the
|
||||
namespace. For example, NodeCore could include the `awards` mod without needing to rename it.
|
||||
|
||||
Standalone mods may not use a game's namespace unless they have been given permission by
|
||||
the game's author.
|
||||
|
||||
The exception given by 3.2 also applies to game namespaces - you may use another game's
|
||||
prefix if your game is a fork.
|
||||
|
||||
|
||||
## 4. Licenses
|
||||
|
||||
### 4.1. License file
|
||||
|
||||
You must have a LICENSE, LICENSE.txt, or LICENSE.md file describing the licensing of your package.
|
||||
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
||||
that you have used in your package.
|
||||
|
||||
You may use lowercase or include a suffix in the filename (ie: `license-code.txt`). If
|
||||
you are making a game or modpack, your top level license file may just be a summary or
|
||||
refer to the license files of individual components.
|
||||
|
||||
For help on doing copyright correctly, see the [Copyright help page](/help/copyright/).
|
||||
|
||||
### 4.2. Allowed Licenses
|
||||
|
||||
**The use of licenses that do not allow derivatives or redistribution is not
|
||||
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
|
||||
The use of licenses that discriminate between groups of people or forbid the use
|
||||
of the content on servers or singleplayer is also not permitted.**
|
||||
|
||||
However, closed sourced licenses are allowed if they allow the above.
|
||||
|
||||
If the license you use is not on the list then please select "Other", and we'll
|
||||
get around to adding it. We reject custom/untested licenses and reserve the right
|
||||
to decide whether a license should be included.
|
||||
|
||||
Please note that the definitions of "free" and "non-free" is the same as that
|
||||
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
|
||||
|
||||
### 4.3. Recommended Licenses
|
||||
|
||||
It is highly recommended that you use a Free and Open Source software (FOSS)
|
||||
license. FOSS licenses result in a sharing community and will increase the
|
||||
number of potential users your package has. Using a closed source license will
|
||||
result in your package not being shown in Luanti by default. See the help page
|
||||
on [non-free licenses](/help/non_free/) for more information.
|
||||
|
||||
It is recommended that you use a proper license for code with a warranty
|
||||
disclaimer, such as the (L)GPL or MIT. You should also use a proper media license
|
||||
for media, such as a Creative Commons license.
|
||||
|
||||
The use of WTFPL is discouraged as it doesn't contain a
|
||||
[valid warranty disclaimer](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html),
|
||||
and also includes swearing which prevents settings like schools from using your content.
|
||||
[Read more](/help/wtfpl/).
|
||||
|
||||
Public domain is not a valid license in many countries, please use CC0 or MIT instead.
|
||||
|
||||
|
||||
## 5. Promotions and Advertisements (inc. asking for donations)
|
||||
|
||||
You may not place any promotions or advertisements in any metadata including
|
||||
screenshots. This includes asking for donations, promoting online shops,
|
||||
or linking to personal websites and social media. Please instead use the
|
||||
fields provided on your user profile page to place links to websites and
|
||||
donation pages.
|
||||
|
||||
ContentDB is for the community. We may remove any promotions if we feel that
|
||||
they're inappropriate.
|
||||
|
||||
|
||||
## 6. Reviews and Package Score
|
||||
|
||||
You may invite players to review your package(s). One way to do this is by sharing the link found in the
|
||||
"Share and Badges" page of the package's settings.
|
||||
|
||||
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
|
||||
including but not limited to monetary rewards, in-game items, features, and/or privileges.
|
||||
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
|
||||
rating of the review.
|
||||
|
||||
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
|
||||
Doing so may result in temporary or permanent suspension from ContentDB.
|
||||
|
||||
|
||||
## 7. Screenshots
|
||||
|
||||
1. We require all packages to have at least one screenshot. For packages without visual
|
||||
content, we recommend making a symbolic image with icons, graphics, or text to depict
|
||||
the package.
|
||||
|
||||
2. **Screenshots must not violate copyright.** This means don't just copy images
|
||||
from Google search, see [the copyright guide](/help/copyright/).
|
||||
|
||||
3. **Screenshots must depict the actual content of the package in some way, and
|
||||
not be misleading.**
|
||||
|
||||
Do not use idealized mockups or blender concept renders if they do not
|
||||
accurately reflect in-game appearance.
|
||||
|
||||
Content in screenshots that is prominently displayed or "focal" should be
|
||||
either present in, or interact with, the package in some way. These can
|
||||
include things in other packages if they have a dependency relationship
|
||||
(either way), or if the submitted package in some way enhances, extends, or
|
||||
alters that content.
|
||||
|
||||
Unrelated package content can be allowed to show what the package content
|
||||
will look like in a typical/realistic game scene, but should be "in the
|
||||
background" only as far as possible.
|
||||
|
||||
4. **Screenshots must only contain content appropriate for the Content Warnings of
|
||||
the package.**
|
||||
|
||||
|
||||
## 8. Security
|
||||
|
||||
The submission of malware is strictly prohibited. This includes software that
|
||||
does not do as it advertises, for example, if it posts telemetry without stating
|
||||
clearly that it does in the package meta.
|
||||
|
||||
Packages must not ask that users disable mod security (`secure.enable_security`).
|
||||
Instead, they should use the insecure environment API.
|
||||
|
||||
Packages must not contain obfuscated code.
|
||||
|
||||
|
||||
## 9. Reporting Violations
|
||||
|
||||
Please click "Report" on the package page.
|
||||
118
app/flatpages/privacy_policy.md
Normal file
118
app/flatpages/privacy_policy.md
Normal file
@@ -0,0 +1,118 @@
|
||||
title: Privacy Policy
|
||||
---
|
||||
|
||||
Last Updated: 2024-04-30
|
||||
([View updates](https://github.com/luanti-org/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
||||
|
||||
## What Information is Collected
|
||||
|
||||
**All users:**
|
||||
|
||||
* HTTP requests are logged, with the following information:
|
||||
* Time
|
||||
* IP address
|
||||
* Page URL
|
||||
* Platform and Operating System
|
||||
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
|
||||
* Whether an IP address has downloaded a particular package in the last 14 days
|
||||
|
||||
**With an account:**
|
||||
|
||||
* Email address
|
||||
* Passwords (hashed and salted using BCrypt)
|
||||
* Profile information, such as website URLs and donation URLs
|
||||
* Comments, threads, and reviews
|
||||
* Audit log actions (such as edits and logins) and their time stamps
|
||||
|
||||
ContentDB collects usernames of content creators from the forums,
|
||||
as this is required to index forum topics.
|
||||
|
||||
Packages, including releases, screenshots, and any meta information,
|
||||
are not considered personal information.
|
||||
|
||||
Please avoid giving other personal information as we do not want it.
|
||||
|
||||
## How this information is used
|
||||
|
||||
* Logged HTTP requests may be used for debugging ContentDB and combating abuse.
|
||||
* Email addresses are used to:
|
||||
* Provide essential system messages, such as password resets and privacy policy updates.
|
||||
* Send notifications - the user may configure this to their needs, including opting out.
|
||||
* The admin may use ContentDB to send emails when they need to contact a user.
|
||||
* Passwords are used to authenticate the user.
|
||||
* The audit log is used to record actions that may be harmful.
|
||||
* Preferred language/locale is used to translate emails and the ContentDB interface.
|
||||
* Requests (such as downloads) are used for aggregated statistics and for
|
||||
calculating the popularity of packages. For example, download counts are shown
|
||||
for each package and release and there are also download graphs available for
|
||||
each package.
|
||||
* Whether an IP address has downloaded a package or release is cached to prevent
|
||||
downloads from being counted multiple times per IP address, but this
|
||||
information is deleted after 14 days.
|
||||
* IP addresses are used to monitor and combat abuse.
|
||||
* Other information is displayed as part of ContentDB's service.
|
||||
|
||||
## Who has access
|
||||
|
||||
* Only the admin has access to the HTTP requests.
|
||||
The logs may be shared with others to aid in debugging, but care will be taken to remove any personal information.
|
||||
* Encrypted backups may be shared with selected Luanti staff members (moderators + core devs).
|
||||
The keys and the backups themselves are given to different people,
|
||||
requiring at least two staff members to read a backup.
|
||||
* Email addresses are visible to moderators and the admin.
|
||||
They have access to assist users, and they are not permitted to share email addresses.
|
||||
* Hashing protects passwords from being read whilst stored in the database or in backups.
|
||||
* Profile information is public, including URLs and linked accounts.
|
||||
* The visibility of comments depends on the visibility of threads.
|
||||
They are either public, or visible only to the package author and editors.
|
||||
* The complete audit log is visible to moderators.
|
||||
Users may see their own audit log actions on their account settings page.
|
||||
Owners, maintainers, and editors can see the actions on a package.
|
||||
* Preferred language can only be viewed by those with access to the database or a backup.
|
||||
* We may be required to share information with law enforcement.
|
||||
|
||||
## Third-parties
|
||||
|
||||
We do not share any personal information with third parties.
|
||||
|
||||
We use <a href="https://sentry.io/">Sentry.io</a> for error logging and performance monitoring.
|
||||
|
||||
## Location
|
||||
|
||||
The ContentDB production server is currently located in Germany.
|
||||
Backups are stored in the UK.
|
||||
Encrypted backups may be stored in other countries, such as the US or EU.
|
||||
|
||||
By using this service, you give permission for the data to be moved within the
|
||||
United Kingdom and/or EU.
|
||||
|
||||
## Period of Retention
|
||||
|
||||
Logged HTTP requests are automatically deleted within 2 weeks.
|
||||
|
||||
Usernames may be kept indefinitely, but other user information will be deleted
|
||||
if requested. See below.
|
||||
|
||||
Whether an IP address has downloaded a package or release is deleted after 14 days.
|
||||
|
||||
## Removal Requests
|
||||
|
||||
Please [raise a report](/report/?anon=0) if you wish to remove your personal
|
||||
information.
|
||||
|
||||
ContentDB keeps a record of each username and forum topic on the forums, for use
|
||||
in indexing mod/game topics. ContentDB also requires the use of a username to
|
||||
uniquely identify a package. Therefore, an author cannot be removed completely
|
||||
from ContentDB if they have any packages or mod/game topics on the forum.
|
||||
|
||||
If we are unable to remove your account for one of the above reasons, your user
|
||||
account will instead be wiped and deactivated, ending up exactly like an author
|
||||
who has not yet joined ContentDB. All personal information will be removed from
|
||||
the profile, and any comments or threads will be deleted.
|
||||
|
||||
## Future Changes to Privacy Policy
|
||||
|
||||
We will alert any future changes to the privacy policy via notices on the
|
||||
ContentDB website.
|
||||
|
||||
By continuing to use this service, you agree to the privacy policy.
|
||||
133
app/flatpages/terms.md
Normal file
133
app/flatpages/terms.md
Normal file
@@ -0,0 +1,133 @@
|
||||
title: Terms of Service
|
||||
|
||||
Also see the [Package Inclusion Policy](/policy_and_guidance/).
|
||||
|
||||
## Content
|
||||
|
||||
### Prohibited content
|
||||
|
||||
You must not post/transmit anything which is illegal under the laws in any part of the United Kingdom.
|
||||
|
||||
You must not (or use the service to) facilitate or commit any offence under the laws in any part of the United Kingdom.
|
||||
|
||||
This includes, in particular, terrorism content (as set out in Schedule 5, Online Safety Act 2023),
|
||||
child sexual exploitation and abuse content (as set out in Schedule 6, Online Safety Act 2023), and
|
||||
content that amounts to an offence specified in Schedule 7, Online Safety Act 2023.
|
||||
|
||||
Prohibited content includes:
|
||||
|
||||
* Pornographic content. This includes content of such a nature that it is reasonable to assume that it was produced
|
||||
solely or principally for the purpose of sexual arousal.
|
||||
* Content which encourages, promotes or provides instructions for suicide
|
||||
* Content which encourages, promotes or provides instructions for an act of deliberate self-injury
|
||||
* Content which encourages, promotes or provides instructions for an eating disorder or behaviours associated with an eating disorder
|
||||
* Content which is abusive and which targets any of the following characteristics: race, religion, sex,
|
||||
sexual orientation, disability, gender reassignment.
|
||||
* Content which incites hatred against people:
|
||||
* of a particular race, religion, sex or sexual orientation
|
||||
* who have a disability
|
||||
* who have the characteristic of gender reassignment
|
||||
* Content which encourages, promotes or provides instructions for an act of serious violence against a person
|
||||
* Bullying content
|
||||
* Content which:
|
||||
* depicts real or realistic serious violence against a person
|
||||
* depicts the real or realistic serious injury of a person in graphic detail
|
||||
* Content which:
|
||||
* depicts real or realistic serious violence against an animal
|
||||
* depicts the real or realistic serious injury of an animal in graphic detail
|
||||
* realistically depicts serious violence against a fictional creature or the serious injury of a fictional
|
||||
creature in graphic detail
|
||||
* Content which encourages, promotes or provides instructions for a challenge or stunt highly likely to result in
|
||||
serious injury to the person who does it or to someone else
|
||||
* Content which encourages a person to ingest, inject, inhale or in any other way self-administer:
|
||||
* a physically harmful substance
|
||||
* a substance in such a quantity as to be physically harmful
|
||||
|
||||
### Protecting users from illegal content
|
||||
|
||||
We provide this service free of charge, and on the basis that we may:
|
||||
|
||||
* take down, or restrict access to, anything that you generate, upload or share; and
|
||||
* suspend or ban you from using all or part of the service
|
||||
|
||||
if we think that this is reasonable to protect you, other users, the service, or us. This applies, in particular,
|
||||
to prohibited content.
|
||||
|
||||
If we are alerted by a person to the presence of any illegal content, or we become aware of it in any other way,
|
||||
we will swiftly take down that content.
|
||||
|
||||
To minimise the length of time for which any illegal content within the scope of the Online Safety Act 2023 is present:
|
||||
|
||||
* in respect of terrorism content, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
|
||||
* in respect of child sexual exploitation or abuse content, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
|
||||
* in respect of other content that amounts to an offence specified in Schedule 7, Online Safety Act 2023, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
|
||||
|
||||
### Protecting children
|
||||
|
||||
We protect all children from the kinds of content listed in "Prohibited Content" by:
|
||||
|
||||
* prohibiting that type of content from our service; and
|
||||
* swiftly taking down that content, if we are alerted by a person to its presence, or we become aware of it in any other way.
|
||||
|
||||
### Proactive technology
|
||||
|
||||
We do not use proactive technology to detect illegal content.
|
||||
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
THE SERVICE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL WE BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
OR IN CONNECTION WITH THE SERVICE OR THE USE OR OTHER DEALINGS IN THE SERVICE.
|
||||
|
||||
We reserve the right to ban or suspend your account, or take down your content, for any reason.
|
||||
|
||||
|
||||
## Jurisdiction and Governing Law
|
||||
|
||||
This service is subject to the jurisdiction of the United Kingdom.
|
||||
|
||||
|
||||
## Complaints
|
||||
|
||||
### Reporting content
|
||||
|
||||
You may report content by clicking the report flag next to a comment or "Report" on the page containing the content.
|
||||
|
||||
You can also make reports by [contacting the admin]({{ admin_contact_url }}).
|
||||
|
||||
### Complaints and Appeals
|
||||
|
||||
You may send a complaint / request an appeal by [contacting the admin]({{ admin_contact_url }}).
|
||||
|
||||
### Your right to bring a claim
|
||||
|
||||
This clause applies only to users within the United Kingdom.
|
||||
|
||||
The Online Safety Act 2023 says that you have a right to bring a claim for breach of contract if:
|
||||
|
||||
* anything that you generate, upload or share is taken down, or access to it is restricted, in breach of the terms of service, or
|
||||
* you are suspended or banned from using the service in breach of the terms of service.
|
||||
|
||||
This does not apply to emails, SMS messages, MMS messages, one-to-one live aural communications,
|
||||
comments and reviews (together with any further comments on such comments or reviews), or content which identifies
|
||||
you as a user (e.g. a user name or profile picture).
|
||||
|
||||
Whether or not a contract exists between you and us is a question of fact. If we do not have a contractual
|
||||
relationship with you in respect of the service, there can be no breach of contract and, as such, this cannot apply.
|
||||
|
||||
It is for a court to determine:
|
||||
|
||||
- if there is a contract between you and us and, if so, its terms
|
||||
- if there has been a breach by us of that contract
|
||||
- if that breach has caused you any recoverable loss
|
||||
- the size (e.g. value) of your loss
|
||||
|
||||
This clause is subject to "Limitation of liability" and "Jurisdiction".
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
This terms of service was written based on [a template](https://onlinesafetyact.co.uk/online_safety_act_terms/)
|
||||
created by Neil Brown, CC BY-SA 4.0.
|
||||
24
app/logic/LogicError.py
Normal file
24
app/logic/LogicError.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2021 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class LogicError(Exception):
|
||||
def __init__(self, code, message):
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return repr("LogicError {}: {}".format(self.code, self.message))
|
||||
0
app/logic/__init__.py
Normal file
0
app/logic/__init__.py
Normal file
148
app/logic/approval_stats.py
Normal file
148
app/logic/approval_stats.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2024 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
from collections import namedtuple, defaultdict
|
||||
from typing import Dict, Optional
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models import AuditLogEntry, db, PackageState
|
||||
|
||||
|
||||
class PackageInfo:
|
||||
state: Optional[PackageState]
|
||||
first_submitted: Optional[datetime.datetime]
|
||||
last_change: Optional[datetime.datetime]
|
||||
approved_at: Optional[datetime.datetime]
|
||||
wait_time: int
|
||||
total_approval_time: int
|
||||
is_in_range: bool
|
||||
events: list[tuple[str, str, str]]
|
||||
|
||||
def __init__(self):
|
||||
self.state = None
|
||||
self.first_submitted = None
|
||||
self.last_change = None
|
||||
self.approved_at = None
|
||||
self.wait_time = 0
|
||||
self.total_approval_time = -1
|
||||
self.is_in_range = False
|
||||
self.events = []
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.wait_time < other.wait_time
|
||||
|
||||
def __dict__(self):
|
||||
return {
|
||||
"first_submitted": self.first_submitted.isoformat(),
|
||||
"last_change": self.last_change.isoformat(),
|
||||
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
|
||||
"wait_time": self.wait_time,
|
||||
"total_approval_time": self.total_approval_time if self.total_approval_time >= 0 else None,
|
||||
"events": [ { "date": x[0], "by": x[1], "title": x[2] } for x in self.events ],
|
||||
}
|
||||
|
||||
def add_event(self, created_at: datetime.datetime, causer: str, title: str):
|
||||
self.events.append((created_at.isoformat(), causer, title))
|
||||
|
||||
|
||||
def get_state(title: str):
|
||||
if title.startswith("Approved "):
|
||||
return PackageState.APPROVED
|
||||
|
||||
assert title.startswith("Marked ")
|
||||
|
||||
for state in PackageState:
|
||||
if state.value in title:
|
||||
return state
|
||||
|
||||
if "Work in Progress" in title:
|
||||
return PackageState.WIP
|
||||
|
||||
raise Exception(f"Unable to get state for title {title}")
|
||||
|
||||
|
||||
Result = namedtuple("Result", "editor_approvals packages_info avg_turnaround_time max_turnaround_time")
|
||||
|
||||
|
||||
def _get_approval_statistics(entries: list[AuditLogEntry], start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
|
||||
editor_approvals = defaultdict(int)
|
||||
package_info: Dict[str, PackageInfo] = {}
|
||||
ignored_packages = set()
|
||||
turnaround_times: list[int] = []
|
||||
|
||||
for entry in entries:
|
||||
package_id = str(entry.package.get_id())
|
||||
if package_id in ignored_packages:
|
||||
continue
|
||||
|
||||
info = package_info.get(package_id, PackageInfo())
|
||||
package_info[package_id] = info
|
||||
|
||||
is_in_range = (((start_date is None or entry.created_at >= start_date) and
|
||||
(end_date is None or entry.created_at <= end_date)))
|
||||
info.is_in_range = info.is_in_range or is_in_range
|
||||
|
||||
new_state = get_state(entry.title.replace("…", "") + (entry.description or ""))
|
||||
if new_state == info.state:
|
||||
continue
|
||||
|
||||
info.add_event(entry.created_at, entry.causer.username if entry.causer else None, new_state.value)
|
||||
|
||||
if info.state == PackageState.READY_FOR_REVIEW:
|
||||
seconds = int((entry.created_at - info.last_change).total_seconds())
|
||||
info.wait_time += seconds
|
||||
if is_in_range:
|
||||
turnaround_times.append(seconds)
|
||||
|
||||
if new_state == PackageState.APPROVED:
|
||||
ignored_packages.add(package_id)
|
||||
info.approved_at = entry.created_at
|
||||
if is_in_range:
|
||||
editor_approvals[entry.causer.username] += 1
|
||||
if info.first_submitted is not None:
|
||||
info.total_approval_time = int((entry.created_at - info.first_submitted).total_seconds())
|
||||
elif new_state == PackageState.READY_FOR_REVIEW:
|
||||
if info.first_submitted is None:
|
||||
info.first_submitted = entry.created_at
|
||||
|
||||
info.state = new_state
|
||||
info.last_change = entry.created_at
|
||||
|
||||
packages_info_2 = {}
|
||||
package_count = 0
|
||||
for package_id, info in package_info.items():
|
||||
if info.first_submitted and info.is_in_range:
|
||||
package_count += 1
|
||||
packages_info_2[package_id] = info
|
||||
|
||||
if len(turnaround_times) > 0:
|
||||
avg_turnaround_time = sum(turnaround_times) / len(turnaround_times)
|
||||
max_turnaround_time = max(turnaround_times)
|
||||
else:
|
||||
avg_turnaround_time = 0
|
||||
max_turnaround_time = 0
|
||||
|
||||
return Result(editor_approvals, packages_info_2, avg_turnaround_time, max_turnaround_time)
|
||||
|
||||
|
||||
def get_approval_statistics(start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
|
||||
entries = AuditLogEntry.query.filter(AuditLogEntry.package).filter(or_(
|
||||
AuditLogEntry.title.like("Approved %"),
|
||||
AuditLogEntry.title.like("Marked %"))
|
||||
).order_by(db.asc(AuditLogEntry.created_at)).all()
|
||||
|
||||
return _get_approval_statistics(entries, start_date, end_date)
|
||||
361
app/logic/game_support.py
Normal file
361
app/logic/game_support.py
Normal file
@@ -0,0 +1,361 @@
|
||||
# ContentDB
|
||||
# Copyright (C) rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from app.models import PackageType, Package, PackageState, PackageGameSupport
|
||||
from app.utils import post_bot_message
|
||||
|
||||
|
||||
minetest_game_mods = {
|
||||
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
|
||||
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
|
||||
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
|
||||
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
|
||||
}
|
||||
|
||||
|
||||
mtg_mod_blacklist = {
|
||||
"pacman", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
||||
"survivethedays", "holidayhorrors",
|
||||
}
|
||||
|
||||
|
||||
class GSPackage:
|
||||
author: str
|
||||
name: str
|
||||
type: PackageType
|
||||
|
||||
provides: set[str]
|
||||
depends: set[str]
|
||||
|
||||
user_supported_games: set[str]
|
||||
user_unsupported_games: set[str]
|
||||
detected_supported_games: set[str]
|
||||
supports_all_games: bool
|
||||
|
||||
detection_disabled: bool
|
||||
|
||||
is_confirmed: bool
|
||||
errors: set[str]
|
||||
|
||||
def __init__(self, author: str, name: str, type: PackageType, provides: set[str]):
|
||||
self.author = author
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.provides = provides
|
||||
self.depends = set()
|
||||
self.user_supported_games = set()
|
||||
self.user_unsupported_games = set()
|
||||
self.detected_supported_games = set()
|
||||
self.supports_all_games = False
|
||||
self.detection_disabled = False
|
||||
self.is_confirmed = type == PackageType.GAME
|
||||
self.errors = set()
|
||||
|
||||
# For dodgy games, discard MTG mods
|
||||
if self.type == PackageType.GAME and self.name in mtg_mod_blacklist:
|
||||
self.provides.difference_update(minetest_game_mods)
|
||||
|
||||
@property
|
||||
def id_(self) -> str:
|
||||
return f"{self.author}/{self.name}"
|
||||
|
||||
@property
|
||||
def supported_games(self) -> set[str]:
|
||||
ret = set()
|
||||
ret.update(self.user_supported_games)
|
||||
if not self.detection_disabled:
|
||||
ret.update(self.detected_supported_games)
|
||||
ret.difference_update(self.user_unsupported_games)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def unsupported_games(self) -> set[str]:
|
||||
return self.user_unsupported_games
|
||||
|
||||
def add_error(self, error: str):
|
||||
return self.errors.add(error)
|
||||
|
||||
|
||||
class GameSupport:
|
||||
packages: Dict[str, GSPackage]
|
||||
modified_packages: set[GSPackage]
|
||||
|
||||
def __init__(self):
|
||||
self.packages = {}
|
||||
self.modified_packages = set()
|
||||
|
||||
@property
|
||||
def all_confirmed(self):
|
||||
return all([x.is_confirmed for x in self.packages.values()])
|
||||
|
||||
@property
|
||||
def has_errors(self):
|
||||
return any([len(x.errors) > 0 for x in self.packages.values()])
|
||||
|
||||
@property
|
||||
def error_count(self):
|
||||
return sum([len(x.errors) for x in self.packages.values()])
|
||||
|
||||
@property
|
||||
def all_errors(self) -> set[str]:
|
||||
errors = set()
|
||||
for package in self.packages.values():
|
||||
for err in package.errors:
|
||||
errors.add(package.id_ + ": " + err)
|
||||
return errors
|
||||
|
||||
def add(self, package: GSPackage) -> GSPackage:
|
||||
self.packages[package.id_] = package
|
||||
return package
|
||||
|
||||
def get(self, id_: str) -> Optional[GSPackage]:
|
||||
return self.packages.get(id_)
|
||||
|
||||
def get_all_that_provide(self, modname: str) -> List[GSPackage]:
|
||||
return [package for package in self.packages.values() if modname in package.provides]
|
||||
|
||||
def get_all_that_depend_on(self, modname: str) -> List[GSPackage]:
|
||||
return [package for package in self.packages.values() if modname in package.depends]
|
||||
|
||||
def _get_supported_games_for_modname(self, depend: str, visited: list[str]):
|
||||
dep_supports_all = False
|
||||
for_dep = set()
|
||||
for provider in self.get_all_that_provide(depend):
|
||||
found_in = self._get_supported_games(provider, visited)
|
||||
if found_in is None:
|
||||
# Unsupported, keep going
|
||||
pass
|
||||
elif len(found_in) == 0:
|
||||
dep_supports_all = True
|
||||
break
|
||||
else:
|
||||
for_dep.update(found_in)
|
||||
|
||||
return dep_supports_all, for_dep
|
||||
|
||||
def _get_supported_games_for_deps(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
|
||||
ret = set()
|
||||
|
||||
for depend in package.depends:
|
||||
dep_supports_all, for_dep = self._get_supported_games_for_modname(depend, visited)
|
||||
|
||||
if dep_supports_all:
|
||||
# Dep is game independent
|
||||
pass
|
||||
elif len(for_dep) == 0:
|
||||
package.add_error(f"Unable to fulfill dependency {depend}")
|
||||
return None
|
||||
elif len(ret) == 0:
|
||||
ret = for_dep
|
||||
else:
|
||||
ret.intersection_update(for_dep)
|
||||
if len(ret) == 0:
|
||||
package.add_error("Game support conflict, unable to install package on any games")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
|
||||
if package.id_ in visited:
|
||||
return None
|
||||
|
||||
if package.type == PackageType.GAME:
|
||||
return {package.name}
|
||||
elif package.is_confirmed:
|
||||
return package.supported_games
|
||||
|
||||
visited = visited.copy()
|
||||
visited.append(package.id_)
|
||||
|
||||
ret = self._get_supported_games_for_deps(package, visited)
|
||||
if ret is None:
|
||||
assert len(package.errors) > 0
|
||||
return None
|
||||
|
||||
ret = ret.copy()
|
||||
ret.difference_update(package.user_unsupported_games)
|
||||
package.detected_supported_games = ret
|
||||
self.modified_packages.add(package)
|
||||
|
||||
if len(ret) > 0:
|
||||
for supported in package.user_supported_games:
|
||||
if supported not in ret:
|
||||
package.add_error(f"`{supported}` is specified in supported_games but it is impossible to run {package.name} in that game. " +
|
||||
f"Its dependencies can only be fulfilled in {', '.join([f'`{x}`' for x in ret])}. " +
|
||||
"Check your hard dependencies.")
|
||||
|
||||
if package.supports_all_games:
|
||||
package.add_error(
|
||||
"This package cannot support all games as some dependencies require specific game(s): " +
|
||||
", ".join([f'`{x}`' for x in ret]))
|
||||
|
||||
package.is_confirmed = True
|
||||
return package.supported_games
|
||||
|
||||
def on_update(self, package: GSPackage, old_provides: Optional[set[str]] = None):
|
||||
to_update = {package}
|
||||
checked = set()
|
||||
|
||||
while len(to_update) > 0:
|
||||
current_package = to_update.pop()
|
||||
if current_package.id_ in self.packages and current_package.type != PackageType.GAME:
|
||||
self._get_supported_games(current_package, [])
|
||||
|
||||
provides = current_package.provides
|
||||
if current_package == package and old_provides is not None:
|
||||
provides = provides.union(old_provides)
|
||||
|
||||
for modname in provides:
|
||||
for depending_package in self.get_all_that_depend_on(modname):
|
||||
if depending_package not in checked:
|
||||
if depending_package.id_ in self.packages and depending_package.type != PackageType.GAME:
|
||||
depending_package.is_confirmed = False
|
||||
depending_package.detected_supported_games = []
|
||||
|
||||
to_update.add(depending_package)
|
||||
checked.add(depending_package)
|
||||
|
||||
def on_remove(self, package: GSPackage):
|
||||
del self.packages[package.id_]
|
||||
self.on_update(package)
|
||||
|
||||
def on_first_run(self):
|
||||
for package in self.packages.values():
|
||||
if not package.is_confirmed:
|
||||
self.on_update(package)
|
||||
|
||||
|
||||
def _convert_package(support: GameSupport, package: Package) -> GSPackage:
|
||||
# Unapproved packages shouldn't be considered to fulfill anything
|
||||
provides = set()
|
||||
if package.state == PackageState.APPROVED:
|
||||
provides = set([x.name for x in package.provides])
|
||||
|
||||
gs_package = GSPackage(package.author.username, package.name, package.type, provides)
|
||||
gs_package.depends = set([x.meta_package.name for x in package.dependencies if not x.optional])
|
||||
gs_package.detection_disabled = not package.enable_game_support_detection
|
||||
gs_package.supports_all_games = package.supports_all_games
|
||||
|
||||
existing_game_support = (package.supported_games
|
||||
.filter(PackageGameSupport.game.has(state=PackageState.APPROVED),
|
||||
PackageGameSupport.confidence > 5)
|
||||
.all())
|
||||
if not package.supports_all_games:
|
||||
gs_package.user_supported_games = [x.game.name for x in existing_game_support if x.supports]
|
||||
gs_package.user_unsupported_games = [x.game.name for x in existing_game_support if not x.supports]
|
||||
return support.add(gs_package)
|
||||
|
||||
|
||||
def _create_instance(session: sqlalchemy.orm.Session) -> GameSupport:
|
||||
support = GameSupport()
|
||||
|
||||
packages: List[Package] = (session.query(Package)
|
||||
.filter(Package.state == PackageState.APPROVED, Package.type.in_([PackageType.GAME, PackageType.MOD]))
|
||||
.all())
|
||||
|
||||
for package in packages:
|
||||
_convert_package(support, package)
|
||||
|
||||
return support
|
||||
|
||||
|
||||
def _persist(session: sqlalchemy.orm.Session, support: GameSupport):
|
||||
for gs_package in support.packages.values():
|
||||
if len(gs_package.errors) != 0:
|
||||
msg = "\n".join([f"- {x}" for x in gs_package.errors])
|
||||
package = session.query(Package).filter(
|
||||
Package.author.has(username=gs_package.author),
|
||||
Package.name == gs_package.name).one()
|
||||
post_bot_message(package, "Error when checking game support", msg, session)
|
||||
|
||||
for gs_package in support.modified_packages:
|
||||
if not gs_package.detection_disabled:
|
||||
package = session.query(Package).filter(
|
||||
Package.author.has(username=gs_package.author),
|
||||
Package.name == gs_package.name).one()
|
||||
|
||||
# Clear existing
|
||||
session.query(PackageGameSupport) \
|
||||
.filter_by(package=package, confidence=1) \
|
||||
.delete()
|
||||
|
||||
# Add new
|
||||
supported_games = gs_package.supported_games \
|
||||
.difference(gs_package.user_supported_games)
|
||||
for game_name in supported_games:
|
||||
game_id = session.query(Package.id) \
|
||||
.filter(Package.type == PackageType.GAME, Package.name == game_name, Package.state == PackageState.APPROVED) \
|
||||
.one()[0]
|
||||
|
||||
new_support = PackageGameSupport()
|
||||
new_support.package = package
|
||||
new_support.game_id = game_id
|
||||
new_support.confidence = 1
|
||||
new_support.supports = True
|
||||
session.add(new_support)
|
||||
|
||||
|
||||
def game_support_update(session: sqlalchemy.orm.Session, package: Package, old_provides: Optional[set[str]]) -> set[str]:
|
||||
support = _create_instance(session)
|
||||
gs_package = support.get(package.get_id())
|
||||
if gs_package is None:
|
||||
gs_package = _convert_package(support, package)
|
||||
support.on_update(gs_package, old_provides)
|
||||
_persist(session, support)
|
||||
return gs_package.errors
|
||||
|
||||
|
||||
def game_support_update_all(session: sqlalchemy.orm.Session):
|
||||
support = _create_instance(session)
|
||||
support.on_first_run()
|
||||
_persist(session, support)
|
||||
|
||||
|
||||
def game_support_remove(session: sqlalchemy.orm.Session, package: Package):
|
||||
support = _create_instance(session)
|
||||
gs_package = support.get(package.get_id())
|
||||
if gs_package is None:
|
||||
gs_package = _convert_package(support, package)
|
||||
support.on_remove(gs_package)
|
||||
_persist(session, support)
|
||||
|
||||
|
||||
def game_support_set(session, package: Package, game_is_supported: Dict[int, bool], confidence: int):
|
||||
previous_supported: Dict[int, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.id] = support
|
||||
|
||||
for game_id, supports in game_is_supported.items():
|
||||
game = session.query(Package).get(game_id)
|
||||
lookup = previous_supported.pop(game_id, None)
|
||||
if lookup is None:
|
||||
support = PackageGameSupport()
|
||||
support.package = package
|
||||
support.game = game
|
||||
support.confidence = confidence
|
||||
support.supports = supports
|
||||
session.add(support)
|
||||
elif lookup.confidence <= confidence:
|
||||
lookup.supports = supports
|
||||
lookup.confidence = confidence
|
||||
|
||||
for game, support in previous_supported.items():
|
||||
if support.confidence == confidence:
|
||||
session.delete(support)
|
||||
166
app/logic/graphs.py
Normal file
166
app/logic/graphs.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# ContentDB
|
||||
# Copyright (C) rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.models import User, Package, PackageDailyStats, db, PackageState
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
def daterange(start_date, end_date):
|
||||
for n in range(int((end_date - start_date).days) + 1):
|
||||
yield start_date + timedelta(n)
|
||||
|
||||
|
||||
keys = ["platform_minetest", "platform_other", "reason_new",
|
||||
"reason_dependency", "reason_update"]
|
||||
|
||||
|
||||
def flatten_data(stats):
|
||||
start_date = stats[0].date
|
||||
end_date = stats[-1].date
|
||||
result = {
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
}
|
||||
|
||||
for key in keys:
|
||||
result[key] = []
|
||||
|
||||
i = 0
|
||||
for date in daterange(start_date, end_date):
|
||||
stat = stats[i]
|
||||
if stat.date == date:
|
||||
for key in keys:
|
||||
result[key].append(getattr(stat, key))
|
||||
|
||||
i += 1
|
||||
else:
|
||||
for key in keys:
|
||||
result[key].append(0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_package_stats(package: Package, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
|
||||
query = package.daily_stats.order_by(db.asc(PackageDailyStats.date))
|
||||
if start_date:
|
||||
query = query.filter(PackageDailyStats.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(PackageDailyStats.date <= end_date)
|
||||
|
||||
stats = query.all()
|
||||
if len(stats) == 0:
|
||||
return None
|
||||
|
||||
return flatten_data(stats)
|
||||
|
||||
|
||||
def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
|
||||
query = db.session \
|
||||
.query(PackageDailyStats.date,
|
||||
func.sum(PackageDailyStats.platform_minetest).label("platform_minetest"),
|
||||
func.sum(PackageDailyStats.platform_other).label("platform_other"),
|
||||
func.sum(PackageDailyStats.reason_new).label("reason_new"),
|
||||
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
|
||||
func.sum(PackageDailyStats.reason_update).label("reason_update")) \
|
||||
.filter(PackageDailyStats.package.has(author_id=user.id))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(PackageDailyStats.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(PackageDailyStats.date <= end_date)
|
||||
|
||||
stats = query.order_by(db.asc(PackageDailyStats.date)) \
|
||||
.group_by(PackageDailyStats.date) \
|
||||
.all()
|
||||
if len(stats) == 0:
|
||||
return None
|
||||
|
||||
results = flatten_data(stats)
|
||||
results["package_downloads"] = get_package_overview_for_user(user, stats[0].date, stats[-1].date)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_package_overview_for_user(user: Optional[User], start_date: datetime.date, end_date: datetime.date):
|
||||
query = db.session \
|
||||
.query(PackageDailyStats.package_id, PackageDailyStats.date,
|
||||
(PackageDailyStats.platform_minetest + PackageDailyStats.platform_other).label("downloads"))
|
||||
|
||||
if user:
|
||||
query = query.filter(PackageDailyStats.package.has(author_id=user.id))
|
||||
|
||||
all_stats = query \
|
||||
.filter(PackageDailyStats.package.has(state=PackageState.APPROVED),
|
||||
PackageDailyStats.date >= start_date, PackageDailyStats.date <= end_date) \
|
||||
.order_by(db.asc(PackageDailyStats.package_id), db.asc(PackageDailyStats.date)) \
|
||||
.all()
|
||||
|
||||
stats_by_package = {}
|
||||
for stat in all_stats:
|
||||
bucket = stats_by_package.get(stat.package_id, [])
|
||||
stats_by_package[stat.package_id] = bucket
|
||||
|
||||
bucket.append(stat)
|
||||
|
||||
package_title_by_id = {}
|
||||
pkg_query = user.packages if user else Package.query
|
||||
for package in pkg_query.filter_by(state=PackageState.APPROVED).all():
|
||||
if user:
|
||||
package_title_by_id[package.id] = package.title
|
||||
else:
|
||||
package_title_by_id[package.id] = package.get_id()
|
||||
|
||||
result = {}
|
||||
|
||||
for package_id, stats in stats_by_package.items():
|
||||
i = 0
|
||||
row = []
|
||||
result[package_title_by_id[package_id]] = row
|
||||
for date in daterange(start_date, end_date):
|
||||
if i >= len(stats):
|
||||
row.append(0)
|
||||
continue
|
||||
|
||||
stat = stats[i]
|
||||
if stat.date == date:
|
||||
row.append(stat.downloads)
|
||||
i += 1
|
||||
elif stat.date > date:
|
||||
row.append(0)
|
||||
else:
|
||||
raise Exception(f"Invalid logic, expected stat {stat.date} to be later than {date}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_package_stats(start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None):
|
||||
now_date = datetime.datetime.utcnow().date()
|
||||
if end_date is None or end_date > now_date:
|
||||
end_date = now_date
|
||||
|
||||
min_start_date = (datetime.datetime.utcnow() - datetime.timedelta(days=29)).date()
|
||||
if start_date is None or start_date < min_start_date:
|
||||
start_date = min_start_date
|
||||
|
||||
return {
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"package_downloads": get_package_overview_for_user(None, start_date, end_date),
|
||||
}
|
||||
204
app/logic/package_approval.py
Normal file
204
app/logic/package_approval.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# ContentDB
|
||||
# Copyright (C) rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import List, Tuple, Union, Optional
|
||||
|
||||
from flask_babel import lazy_gettext, LazyString
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from app.models import Package, PackageType, PackageState, PackageRelease, db, MetaPackage, ForumTopic, User, \
|
||||
Permission, UserRank
|
||||
|
||||
|
||||
class PackageValidationNote:
|
||||
# level is danger, warning, or info
|
||||
level: str
|
||||
message: LazyString
|
||||
buttons: List[Tuple[str, LazyString]]
|
||||
|
||||
# False to prevent "Approve"
|
||||
allow_approval: bool
|
||||
|
||||
# False to prevent "Submit for Approval"
|
||||
allow_submit: bool
|
||||
|
||||
def __init__(self, level: str, message: LazyString, allow_approval: bool, allow_submit: bool):
|
||||
self.level = level
|
||||
self.message = message
|
||||
self.buttons = []
|
||||
self.allow_approval = allow_approval
|
||||
self.allow_submit = allow_submit
|
||||
|
||||
def add_button(self, url: str, label: LazyString) -> "PackageValidationNote":
|
||||
self.buttons.append((url, label))
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.message)
|
||||
|
||||
|
||||
def is_package_name_taken(normalised_name: str) -> bool:
|
||||
return Package.query.filter(
|
||||
and_(Package.state == PackageState.APPROVED,
|
||||
or_(Package.name == normalised_name,
|
||||
Package.name == normalised_name + "_game"))).count() > 0
|
||||
|
||||
|
||||
def get_conflicting_mod_names(package: Package) -> set[str]:
|
||||
conflicting_modnames = (db.session.query(MetaPackage.name)
|
||||
.filter(MetaPackage.id.in_([mp.id for mp in package.provides]))
|
||||
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED)))
|
||||
.all())
|
||||
conflicting_modnames += (db.session.query(ForumTopic.name)
|
||||
.filter(ForumTopic.name.in_([mp.name for mp in package.provides]))
|
||||
.filter(ForumTopic.topic_id != package.forums)
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id))
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title))
|
||||
.all())
|
||||
return set([x[0] for x in conflicting_modnames])
|
||||
|
||||
|
||||
def count_packages_with_forum_topic(topic_id: int) -> int:
|
||||
return Package.query.filter(Package.forums == topic_id, Package.state != PackageState.DELETED).count() > 1
|
||||
|
||||
|
||||
def get_forum_topic(topic_id: int) -> Optional[ForumTopic]:
|
||||
return ForumTopic.query.get(topic_id)
|
||||
|
||||
|
||||
def validate_package_for_approval(package: Package) -> List[PackageValidationNote]:
|
||||
retval: List[PackageValidationNote] = []
|
||||
|
||||
def template(level: str, allow_approval: bool, allow_submit: bool):
|
||||
def inner(msg: LazyString):
|
||||
note = PackageValidationNote(level, msg, allow_approval, allow_submit)
|
||||
retval.append(note)
|
||||
return note
|
||||
|
||||
return inner
|
||||
|
||||
danger = template("danger", allow_approval=False, allow_submit=False)
|
||||
warning = template("warning", allow_approval=True, allow_submit=True)
|
||||
info = template("info", allow_approval=False, allow_submit=True)
|
||||
|
||||
if package.type != PackageType.MOD and is_package_name_taken(package.normalised_name):
|
||||
danger(lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3"))
|
||||
|
||||
if package.releases.filter(PackageRelease.task_id.is_(None)).count() == 0:
|
||||
if package.releases.count() == 0:
|
||||
message = lazy_gettext("You need to create a release before this package can be approved.")
|
||||
else:
|
||||
message = lazy_gettext("Release is still importing, or has an error.")
|
||||
|
||||
danger(message) \
|
||||
.add_button(package.get_url("packages.create_release"), lazy_gettext("Create release")) \
|
||||
.add_button(package.get_url("packages.setup_releases"), lazy_gettext("Set up releases"))
|
||||
|
||||
# Don't bother validating any more until we have a release
|
||||
return retval
|
||||
|
||||
if package.screenshots.count() == 0:
|
||||
danger(lazy_gettext("You need to add at least one screenshot."))
|
||||
|
||||
missing_deps = package.get_missing_hard_dependencies_query().all()
|
||||
if len(missing_deps) > 0:
|
||||
missing_deps = ", ".join([ x.name for x in missing_deps])
|
||||
danger(lazy_gettext(
|
||||
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps))
|
||||
|
||||
if package.type != PackageType.GAME and not package.supports_all_games and package.supported_games.count() == 0:
|
||||
danger(lazy_gettext(
|
||||
"What games does your package support? Please specify on the supported games page", deps=missing_deps)) \
|
||||
.add_button(package.get_url("packages.game_support"), lazy_gettext("Supported Games"))
|
||||
|
||||
if "Other" in package.license.name or "Other" in package.media_license.name:
|
||||
info(lazy_gettext("Please wait for the license to be added to CDB."))
|
||||
|
||||
# Check similar mod name
|
||||
conflicting_modnames = set()
|
||||
if package.type != PackageType.TXP:
|
||||
conflicting_modnames = get_conflicting_mod_names(package)
|
||||
|
||||
if len(conflicting_modnames) > 4:
|
||||
warning(lazy_gettext("Please make sure that this package has the right to the names it uses."))
|
||||
elif len(conflicting_modnames) > 0:
|
||||
names_list = list(conflicting_modnames)
|
||||
names_list.sort()
|
||||
warning(lazy_gettext("Please make sure that this package has the right to the names %(names)s",
|
||||
names=", ".join(names_list))) \
|
||||
.add_button(package.get_url('packages.similar'), lazy_gettext("See more"))
|
||||
|
||||
# Check forum topic
|
||||
if package.state != PackageState.APPROVED and package.forums is not None:
|
||||
if count_packages_with_forum_topic(package.forums) > 1:
|
||||
danger("<b>" + lazy_gettext("Error: Another package already uses this forum topic!") + "</b>")
|
||||
|
||||
topic = get_forum_topic(package.forums)
|
||||
if topic is not None:
|
||||
if topic.author != package.author:
|
||||
danger("<b>" + lazy_gettext("Error: Forum topic author doesn't match package author.") + "</b>")
|
||||
elif package.type != PackageType.TXP:
|
||||
warning(lazy_gettext("Warning: Forum topic not found. The topic may have been created since the last forum crawl."))
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
PACKAGE_STATE_FLOW = {
|
||||
PackageState.WIP: {PackageState.READY_FOR_REVIEW},
|
||||
PackageState.CHANGES_NEEDED: {PackageState.READY_FOR_REVIEW},
|
||||
PackageState.READY_FOR_REVIEW: {PackageState.WIP, PackageState.CHANGES_NEEDED, PackageState.APPROVED},
|
||||
PackageState.APPROVED: {PackageState.CHANGES_NEEDED},
|
||||
PackageState.DELETED: {PackageState.READY_FOR_REVIEW},
|
||||
}
|
||||
|
||||
|
||||
def can_move_to_state(package: Package, user: User, new_state: Union[str, PackageState]) -> bool:
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(new_state) == str:
|
||||
new_state = PackageState[new_state]
|
||||
elif type(new_state) != PackageState:
|
||||
raise Exception("Unknown state given to can_move_to_state()")
|
||||
|
||||
if new_state not in PACKAGE_STATE_FLOW[package.state]:
|
||||
return False
|
||||
|
||||
if new_state == PackageState.READY_FOR_REVIEW or new_state == PackageState.APPROVED:
|
||||
# Can the user approve?
|
||||
if new_state == PackageState.APPROVED and not package.check_perm(user, Permission.APPROVE_NEW):
|
||||
return False
|
||||
|
||||
# Must be able to edit or approve package to change its state
|
||||
if not (package.check_perm(user, Permission.APPROVE_NEW) or package.check_perm(user, Permission.EDIT_PACKAGE)):
|
||||
return False
|
||||
|
||||
# Are there any validation warnings?
|
||||
validation_notes = validate_package_for_approval(package)
|
||||
for note in validation_notes:
|
||||
if not note.allow_submit or (new_state == PackageState.APPROVED and not note.allow_approval):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
elif new_state == PackageState.CHANGES_NEEDED:
|
||||
return package.check_perm(user, Permission.APPROVE_NEW)
|
||||
|
||||
elif new_state == PackageState.WIP:
|
||||
return package.check_perm(user, Permission.EDIT_PACKAGE) and \
|
||||
(user in package.maintainers or user.rank.at_least(UserRank.ADMIN))
|
||||
|
||||
return True
|
||||
236
app/logic/packages.py
Normal file
236
app/logic/packages.py
Normal file
@@ -0,0 +1,236 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2021 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import re
|
||||
import typing
|
||||
|
||||
import validators
|
||||
from flask_babel import lazy_gettext, LazyString
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
||||
License, PackageDevState, PackageState
|
||||
from app.utils import add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference, normalize_line_endings
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: typing.Union[str, LazyString]):
|
||||
if not cond:
|
||||
raise LogicError(400, msg)
|
||||
|
||||
|
||||
def get_license(name):
|
||||
if type(name) == License:
|
||||
return name
|
||||
|
||||
license = License.query.filter(License.name.ilike(name)).first()
|
||||
if license is None:
|
||||
raise LogicError(400, "Unknown license " + name)
|
||||
return license
|
||||
|
||||
|
||||
name_re = re.compile("^[a-z0-9_]+$")
|
||||
|
||||
AnyType = "?"
|
||||
ALLOWED_FIELDS = {
|
||||
"type": AnyType,
|
||||
"title": str,
|
||||
"name": str,
|
||||
"short_description": str,
|
||||
"short_desc": str,
|
||||
"dev_state": AnyType,
|
||||
"tags": list,
|
||||
"content_warnings": list,
|
||||
"license": AnyType,
|
||||
"media_license": AnyType,
|
||||
"long_description": str,
|
||||
"desc": str,
|
||||
"repo": str,
|
||||
"website": str,
|
||||
"issue_tracker": str,
|
||||
"issueTracker": str,
|
||||
"forums": int,
|
||||
"video_url": str,
|
||||
"donate_url": str,
|
||||
"translation_url": str,
|
||||
}
|
||||
|
||||
NULLABLE = {
|
||||
"tags",
|
||||
"content_warnings",
|
||||
"repo",
|
||||
"website",
|
||||
"issue_tracker",
|
||||
"issueTracker",
|
||||
"forums",
|
||||
"video_url",
|
||||
"donate_url",
|
||||
"translation_url",
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
"short_description": "short_desc",
|
||||
"issue_tracker": "issueTracker",
|
||||
"long_description": "desc"
|
||||
}
|
||||
|
||||
|
||||
def is_int(val):
|
||||
try:
|
||||
int(val)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def validate(data: dict):
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
check(key in NULLABLE, f"{key} must not be null")
|
||||
else:
|
||||
typ = ALLOWED_FIELDS.get(key)
|
||||
check(typ is not None, f"{key} is not a known field")
|
||||
if typ != AnyType:
|
||||
check(isinstance(value, typ), f"{key} must be a " + typ.__name__)
|
||||
|
||||
if "name" in data:
|
||||
name = data["name"]
|
||||
check(isinstance(name, str), "Name must be a string")
|
||||
check(bool(name_re.match(name)),
|
||||
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)"))
|
||||
|
||||
for key in ["repo", "website", "issue_tracker", "issueTracker"]:
|
||||
value = data.get(key)
|
||||
if value is not None:
|
||||
check(value.startswith("http://") or value.startswith("https://"),
|
||||
f"{key} must start with http:// or https://")
|
||||
check(validators.url(value), f"{key} must be a valid URL")
|
||||
|
||||
|
||||
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
||||
reason: str = None) -> bool:
|
||||
if not package.check_perm(user, Permission.EDIT_PACKAGE):
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to edit this package"))
|
||||
|
||||
if "name" in data and package.name != data["name"] and \
|
||||
not package.check_perm(user, Permission.CHANGE_NAME):
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to change the package name"))
|
||||
|
||||
before_dict = None
|
||||
if not was_new:
|
||||
before_dict = package.as_dict("/")
|
||||
|
||||
for alias, to in ALIASES.items():
|
||||
if alias in data:
|
||||
if to in data and data[to] != data[alias]:
|
||||
raise LogicError(403, f"Aliased field ({alias}) does not match new field ({to})")
|
||||
|
||||
data[to] = data[alias]
|
||||
|
||||
validate(data)
|
||||
|
||||
for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url", "translation_url"]:
|
||||
if field in data and has_blocked_domains(data[field], user.username,
|
||||
f"{field} of {package.get_id()}"):
|
||||
raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed"))
|
||||
|
||||
if "type" in data:
|
||||
new_type = PackageType.coerce(data["type"])
|
||||
if new_type == package.type:
|
||||
pass
|
||||
elif package.state != PackageState.APPROVED:
|
||||
package.type = new_type
|
||||
else:
|
||||
raise LogicError(403, lazy_gettext("You cannot change package type once approved"))
|
||||
|
||||
if "dev_state" in data:
|
||||
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
|
||||
|
||||
if "license" in data:
|
||||
data["license"] = get_license(data["license"])
|
||||
|
||||
if "media_license" in data:
|
||||
data["media_license"] = get_license(data["media_license"])
|
||||
|
||||
if "desc" in data:
|
||||
data["desc"] = normalize_line_endings(data["desc"])
|
||||
|
||||
if "video_url" in data and data["video_url"] is not None:
|
||||
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
|
||||
if "dQw4w9WgXcQ" in data["video_url"]:
|
||||
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
|
||||
|
||||
for key in ["name", "title", "short_desc", "desc", "dev_state", "license", "media_license",
|
||||
"repo", "website", "issueTracker", "forums", "video_url", "donate_url", "translation_url"]:
|
||||
if key in data:
|
||||
setattr(package, key, data[key])
|
||||
|
||||
if package.type == PackageType.TXP:
|
||||
package.license = package.media_license
|
||||
|
||||
if was_new and package.type == PackageType.MOD:
|
||||
m = MetaPackage.GetOrCreate(package.name, {})
|
||||
package.provides.append(m)
|
||||
|
||||
if "tags" in data:
|
||||
package.tags.clear()
|
||||
for tag_id in (data["tags"] or []):
|
||||
if is_int(tag_id):
|
||||
tag = Tag.query.get(tag_id)
|
||||
else:
|
||||
tag = Tag.query.filter_by(name=tag_id).first()
|
||||
if tag is None:
|
||||
raise LogicError(400, "Unknown tag: " + tag_id)
|
||||
|
||||
package.tags.append(tag)
|
||||
|
||||
if "content_warnings" in data:
|
||||
package.content_warnings.clear()
|
||||
for warning_id in (data["content_warnings"] or []):
|
||||
if is_int(warning_id):
|
||||
package.content_warnings.append(ContentWarning.query.get(warning_id))
|
||||
else:
|
||||
warning = ContentWarning.query.filter_by(name=warning_id).first()
|
||||
if warning is None:
|
||||
raise LogicError(400, "Unknown warning: " + warning_id)
|
||||
package.content_warnings.append(warning)
|
||||
|
||||
was_modified = was_new
|
||||
if was_new:
|
||||
msg = f"Created package {package.author.username}/{package.name}"
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
else:
|
||||
after_dict = package.as_dict("/")
|
||||
diff = diff_dictionaries(before_dict, after_dict)
|
||||
was_modified = len(diff) > 0
|
||||
|
||||
if reason is None:
|
||||
msg = "Edited {}".format(package.title)
|
||||
else:
|
||||
msg = "Edited {} ({})".format(package.title, reason)
|
||||
|
||||
diff_desc = describe_difference(diff, 100 - len(msg) - 3) if diff else None
|
||||
if diff_desc:
|
||||
msg += " [" + diff_desc + "]"
|
||||
|
||||
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
|
||||
add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4))
|
||||
|
||||
if was_modified:
|
||||
db.session.commit()
|
||||
|
||||
return was_modified
|
||||
106
app/logic/releases.py
Normal file
106
app/logic/releases.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from celery import uuid
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import PackageRelease, db, Permission, User, Package, LuantiRelease
|
||||
from app.tasks.importtasks import make_vcs_release, check_zip_release
|
||||
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none, normalize_line_endings
|
||||
|
||||
|
||||
def check_can_create_release(user: User, package: Package, name: str):
|
||||
if not package.check_perm(user, Permission.MAKE_RELEASE):
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
count = package.releases.filter(PackageRelease.created_at > five_minutes_ago).count()
|
||||
if count >= 5:
|
||||
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again"))
|
||||
|
||||
if PackageRelease.query.filter_by(package_id=package.id, name=name).count() > 0:
|
||||
raise LogicError(403, lazy_gettext("A release with this name already exists"))
|
||||
|
||||
|
||||
def do_create_vcs_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None):
|
||||
check_can_create_release(user, package, name)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.name = name
|
||||
rel.title = title or name
|
||||
rel.release_notes = normalize_line_endings(release_notes)
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
rel.min_rel = min_v
|
||||
rel.max_rel = max_v
|
||||
db.session.add(rel)
|
||||
|
||||
if reason is None:
|
||||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
make_vcs_release.apply_async((rel.id, nonempty_or_none(ref)), task_id=rel.task_id)
|
||||
|
||||
return rel
|
||||
|
||||
|
||||
def do_create_zip_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None,
|
||||
commit_hash: str = None):
|
||||
check_can_create_release(user, package, name)
|
||||
|
||||
if commit_hash:
|
||||
commit_hash = commit_hash.lower()
|
||||
if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)):
|
||||
raise LogicError(400, lazy_gettext("Invalid commit hash; it must be a 40 character long base16 string"))
|
||||
|
||||
uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file")
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.name = name
|
||||
rel.title = title or name
|
||||
rel.release_notes = normalize_line_endings(release_notes)
|
||||
rel.url = uploaded_url
|
||||
rel.task_id = uuid()
|
||||
rel.commit_hash = commit_hash
|
||||
rel.min_rel = min_v
|
||||
rel.max_rel = max_v
|
||||
db.session.add(rel)
|
||||
|
||||
if reason is None:
|
||||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
check_zip_release.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
|
||||
|
||||
return rel
|
||||
103
app/logic/screenshots.py
Normal file
103
app/logic/screenshots.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# ContentDB
|
||||
# Copyright (C) rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime, json
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
|
||||
from app.utils import add_notification, add_audit_log
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
|
||||
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
|
||||
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
|
||||
if count >= 20:
|
||||
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
|
||||
|
||||
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG, JPEG, or WebP image file"))
|
||||
|
||||
counter = 1
|
||||
for screenshot in package.screenshots.all():
|
||||
screenshot.order = counter
|
||||
counter += 1
|
||||
|
||||
ss = PackageScreenshot()
|
||||
ss.package = package
|
||||
ss.title = title or "Untitled"
|
||||
ss.url = uploaded_url
|
||||
ss.approved = package.check_perm(user, Permission.APPROVE_SCREENSHOT)
|
||||
ss.order = counter
|
||||
ss.width, ss.height = get_image_size(uploaded_path)
|
||||
|
||||
if ss.is_too_small():
|
||||
raise LogicError(429,
|
||||
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
|
||||
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
|
||||
|
||||
db.session.add(ss)
|
||||
|
||||
if reason is None:
|
||||
msg = "Created screenshot {}".format(ss.title)
|
||||
else:
|
||||
msg = "Created screenshot {} ({})".format(ss.title, reason)
|
||||
|
||||
add_notification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.get_url("packages.view"), package)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if is_cover_image:
|
||||
package.cover_image = ss
|
||||
db.session.commit()
|
||||
|
||||
return ss
|
||||
|
||||
|
||||
def do_order_screenshots(_user: User, package: Package, order: [any]):
|
||||
lookup = {}
|
||||
for screenshot in package.screenshots.all():
|
||||
lookup[screenshot.id] = screenshot
|
||||
|
||||
counter = 1
|
||||
for ss_id in order:
|
||||
try:
|
||||
lookup[int(ss_id)].order = counter
|
||||
counter += 1
|
||||
except KeyError:
|
||||
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
|
||||
except (ValueError, TypeError):
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_set_cover_image(_user: User, package: Package, cover_image):
|
||||
try:
|
||||
cover_image = int(cover_image)
|
||||
except (ValueError, TypeError):
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
|
||||
|
||||
for screenshot in package.screenshots.all():
|
||||
if screenshot.id == cover_image:
|
||||
package.cover_image = screenshot
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
raise LogicError(400, "Unable to find screenshot")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user