Compare commits
1168 Commits
Author | SHA1 | Date |
---|---|---|
rubenwardy | 8ad066409c | |
rubenwardy | 4ac8949c3a | |
rubenwardy | 83b2cf48d4 | |
rubenwardy | 2bbb117eac | |
rubenwardy | f61112a8d7 | |
rubenwardy | 3566b030c5 | |
rubenwardy | 2d54fe4ed7 | |
rubenwardy | 7fdd2cc7c9 | |
rubenwardy | 81a85cbbe5 | |
rubenwardy | 4902436b6b | |
rubenwardy | b82bcb0af9 | |
rubenwardy | eeea5d004a | |
rubenwardy | 97ee0a9f85 | |
rubenwardy | 958f92fd63 | |
rubenwardy | dfef268b05 | |
rubenwardy | e7d2f09eb4 | |
rubenwardy | 5bb9012655 | |
rubenwardy | a291b2cd6f | |
rubenwardy | ead077fb92 | |
rubenwardy | 1c9d6ac865 | |
rubenwardy | d098ee9dff | |
rubenwardy | b8d95dd222 | |
rubenwardy | 7c93db95a3 | |
rubenwardy | d529634b7f | |
Y.W | 765b5603c1 | |
Gao Tiesuan | eec39a3fc5 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 72f66530aa | |
Nikita Epifanov | 99ee1cfc7e | |
rubenwardy | f8e82b63e3 | |
rubenwardy | afdf06b3f6 | |
rubenwardy | d21a86587f | |
rubenwardy | 38071165d1 | |
rubenwardy | 1cfc152d3b | |
rubenwardy | 2db2f61992 | |
Balázs Kovács | 4543f6ca39 | |
Nikita Epifanov | f8d518300d | |
Andrij Mizyk | 347e214944 | |
Mikitko | 99b4d8e084 | |
Nikita Epifanov | 313cab6b2d | |
debiankaios | 494559cfd7 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | e3326aa0f1 | |
rubenwardy | bdd3ab4360 | |
rubenwardy | 4f9ec2e8a4 | |
rubenwardy | 14fd30c4f4 | |
rubenwardy | a7103b5b35 | |
rubenwardy | f6ce676e7e | |
rubenwardy | c2fbf7603a | |
rubenwardy | c3a4ea239c | |
rubenwardy | e2708933d3 | |
rubenwardy | cb2d9d4b07 | |
rubenwardy | 1ba70226b8 | |
rubenwardy | d08710684d | |
rubenwardy | 625e4cf9ee | |
rubenwardy | c8b310ebdb | |
rubenwardy | d971dd6700 | |
rubenwardy | e20863a7e1 | |
rubenwardy | 8f2a87e5ed | |
rubenwardy | ae88360e20 | |
rubenwardy | 7d97c2a27b | |
rubenwardy | 02b7d55c2d | |
rubenwardy | 55b5893cce | |
rubenwardy | 1018e1c29c | |
rubenwardy | e5a4161e76 | |
rubenwardy | a3f437e482 | |
rubenwardy | 9fcbbdc472 | |
rubenwardy | 7aac597216 | |
rubenwardy | 95b3c66366 | |
rubenwardy | 3b354de2fc | |
rubenwardy | 411392eb76 | |
rubenwardy | 15c3e4edec | |
rubenwardy | fa0572ae44 | |
rubenwardy | ade75ace49 | |
Hugo Locurcio | 56539bb369 | |
Y.W | 1c63bf0beb | |
pampogo kiraly | b10949d8cd | |
debiankaios | 853cc3ff6e | |
rubenwardy | a0cc6eb997 | |
J. Lavoie | 8b18e6f86d | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 68e4d98bc5 | |
rubenwardy | 390bf7a657 | |
rubenwardy | deb5c02ce6 | |
rubenwardy | 004c5cd383 | |
rubenwardy | 7b4254da58 | |
rubenwardy | d4903f04f1 | |
debiankaios | f2b544ae68 | |
Lemente | ec91295677 | |
rubenwardy | 4943fbd776 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 2478df8c0d | |
rubenwardy | 85a178d90e | |
rubenwardy | a48c0fb2b4 | |
rubenwardy | 3c944cbd72 | |
rubenwardy | 727db52c19 | |
rubenwardy | 80d534a53f | |
rubenwardy | fe2d08c395 | |
rubenwardy | 97e2e1c16e | |
rubenwardy | a32b63f932 | |
rubenwardy | e0421c1e57 | |
rubenwardy | f457f7f5d7 | |
rubenwardy | 3ac2d937d7 | |
rubenwardy | 45eca10859 | |
rubenwardy | 38aa8fa03a | |
rubenwardy | 11036b113b | |
Wuzzy | f5893676eb | |
Lemente | d7b5b1eedb | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | e44ec8720d | |
Gao Tiesuan | 6b592053f1 | |
rubenwardy | ef28fa026e | |
rubenwardy | e1a86f3be0 | |
rubenwardy | 7f5656df08 | |
rubenwardy | a47e6e8998 | |
rubenwardy | b6fe0466ca | |
rubenwardy | 9ea4ee3449 | |
rubenwardy | d9a6127c35 | |
rubenwardy | 3ad003140f | |
Wuzzy | d7152485bb | |
AFCMS | 0f17dbc15d | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | e1cc4bbdf0 | |
rubenwardy | a325d2c2cd | |
rubenwardy | da1ae4c270 | |
rubenwardy | 9cc79d9fa5 | |
rubenwardy | a09f11d110 | |
rubenwardy | 6e93e6d777 | |
Joaquín Villalba | b05bd78e20 | |
waxtatect | 5a27e1a03b | |
Lemente | 0f3628f2a4 | |
Mehmet Ali | b3fcf4d1c2 | |
debiankaios | 01a9afdd9d | |
Yiu Man Ho | 3ad1ebdb7b | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 903d567e3c | |
rubenwardy | 6a4bf7129d | |
rubenwardy | e02c014890 | |
rubenwardy | beb916d521 | |
rubenwardy | f3856b5db5 | |
rubenwardy | 8af2942097 | |
pampogo kiraly | dcfdf299e3 | |
debiankaios | ca139bab54 | |
pampogo kiraly | 80b63d3d24 | |
cx384 | c550f2395f | |
debiankaios | f7040ecc8f | |
debiankaios | 8bd0fe0662 | |
cx384 | e5cb738252 | |
debiankaios | c016060553 | |
cx384 | 9e59be7d65 | |
Joaquín Villalba | 5e2fc9155c | |
rubenwardy | 543499560d | |
cx384 | cff7964831 | |
Muhammad Rifqi Priyo Susanto | 9ad8a7f420 | |
Minetest-j45 | 2fab6cd6ae | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | ef192dcaee | |
Buckaroo Banzai | 9d817c71e3 | |
debiankaios | 0bee59d7c3 | |
activivan | 76cd2a6786 | |
Buckaroo Banzai | 04bba2e135 | |
Linerly | dab25f6789 | |
Joaquín Villalba | c725451206 | |
Imre Kristoffer Eilertsen | 617c7900ff | |
waxtatect | e8dea0d69d | |
AFCMS | 0fe71ec86f | |
Mikitko | 5ac69c5051 | |
debiankaios | 0518aa8650 | |
rubenwardy | 7ffecbb318 | |
rubenwardy | e0a92c6455 | |
rubenwardy | 3af5fccd61 | |
rubenwardy | fbadb05037 | |
rubenwardy | 416daa868b | |
rubenwardy | 34ccd76b0c | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | db14b3f4ef | |
rubenwardy | ca0823c460 | |
rubenwardy | 33d9ab4b86 | |
rubenwardy | ceed91b6d7 | |
Minetest-j45 | aa8409b0be | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 5f45c31240 | |
rubenwardy | 71b4a0416f | |
rubenwardy | 00bb8a486d | |
rubenwardy | 3a0a3c5325 | |
Allan Nordhøy | 1e839f731a | |
AFCMS | 97ae05b864 | |
rubenwardy | 48a8a45140 | |
Muhammad Rifqi Priyo Susanto | 88da170bb0 | |
rubenwardy | ec6f16c229 | |
rubenwardy | db4e3dabb7 | |
rubenwardy | b2a72da219 | |
rubenwardy | cf0a69a702 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 572d6bd9ea | |
rubenwardy | 574339f935 | |
rubenwardy | baa8c871b0 | |
rubenwardy | b62bdb016a | |
rubenwardy | 63c6ccfee9 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | db24385f40 | |
rubenwardy | c5a6ae3035 | |
rubenwardy | c8b0f9e6ce | |
rubenwardy | bd59fa8ef3 | |
AFCMS | 503ae701ae | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | a7089b26e7 | |
rubenwardy | 1b26acaaae | |
rubenwardy | dcd7e31738 | |
rubenwardy | c4dd380218 | |
rubenwardy | ad05ba1ee8 | |
rubenwardy | a175162186 | |
rubenwardy | b40bc8c20d | |
rubenwardy | 44b02cfb4e | |
rubenwardy | 9de4ad5cb3 | |
rubenwardy | 482c9e5905 | |
Joaquín Villalba | eba1626f2e | |
J. Lavoie | f5f6671d48 | |
Allan Nordhøy | 868bbed290 | |
AFCMS | 10846d481c | |
rubenwardy | 757c1f8c45 | |
rubenwardy | d8f164ffc1 | |
rubenwardy | 324cbe9efc | |
rubenwardy | e4ea44aa5b | |
rubenwardy | 122e1a4677 | |
rubenwardy | 933d8ebfe7 | |
rubenwardy | 8f4e214c52 | |
rubenwardy | e346587111 | |
rubenwardy | 4dfb35a57b | |
rubenwardy | d16666c0f8 | |
rubenwardy | 4d37f53a04 | |
rubenwardy | e3ed5fbc58 | |
rubenwardy | 2e7d4277e1 | |
rubenwardy | 5932ac3c7c | |
rubenwardy | 5d32d7922f | |
rubenwardy | a800685947 | |
rubenwardy | 7aca5a54dc | |
rubenwardy | e1cd2ceb1d | |
rubenwardy | c46cca519a | |
rubenwardy | da41fb5738 | |
rubenwardy | bd25a8d601 | |
rubenwardy | c13b13268b | |
rubenwardy | 10cfbc6e45 | |
rubenwardy | 6c99732673 | |
rubenwardy | 3c4085eb0b | |
rubenwardy | 443dd9f18f | |
rubenwardy | 95c0fb8a70 | |
rubenwardy | a04b2542b5 | |
rubenwardy | 49355f5db1 | |
rubenwardy | 41e0e65a6b | |
rubenwardy | f714d809f8 | |
rubenwardy | cd39f7b2c6 | |
rubenwardy | c4c8390ead | |
dependabot[bot] | 02311f190b | |
rubenwardy | 085c99272e | |
rubenwardy | 5fd1666a5d | |
rubenwardy | c0eb10521d | |
rubenwardy | bc371f1ef3 | |
rubenwardy | 0486eb76c0 | |
rubenwardy | 3b5c9950de | |
rubenwardy | dd352faa31 | |
rubenwardy | 4f69dd8d32 | |
rubenwardy | d0741fde6e | |
rubenwardy | d485e686d9 | |
rubenwardy | ae37a551e1 | |
rubenwardy | afb2f9ec00 | |
rubenwardy | 21d5d9d47e | |
rubenwardy | 20c93925a8 | |
rubenwardy | e5ae41901c | |
dependabot[bot] | d8b68136ef | |
rubenwardy | 5319ea8771 | |
rubenwardy | 43fcf5ee3b | |
rubenwardy | 46a38753a9 | |
rubenwardy | 32372e8e86 | |
rubenwardy | c1edea4dc3 | |
rubenwardy | 86e1f57198 | |
rubenwardy | fab814c46f | |
rubenwardy | 4a1f654798 | |
rubenwardy | 895a113478 | |
rubenwardy | 37a7dd28d6 | |
rubenwardy | e5cc140d42 | |
rubenwardy | 59a5cf2df5 | |
rubenwardy | d6b1adf613 | |
rubenwardy | 562b0ceffe | |
rubenwardy | 6bbe2307e9 | |
rubenwardy | aae546a08e | |
rubenwardy | 2f2141f524 | |
rubenwardy | aee59626ee | |
rubenwardy | 825801b867 | |
rubenwardy | 447f3e2d5b | |
rubenwardy | ff846f4478 | |
rubenwardy | c794de680b | |
rubenwardy | 034e5382ec | |
rubenwardy | e06ac1689c | |
rubenwardy | 4de802c68d | |
rubenwardy | 33aedb233d | |
rubenwardy | 95bd1a50d9 | |
rubenwardy | 76675ad76b | |
rubenwardy | ac9b2207bf | |
rubenwardy | d7c83f58b9 | |
rubenwardy | d17bd5580e | |
rubenwardy | 94568c851a | |
rubenwardy | 29bfc91683 | |
rubenwardy | cb5fa4d6e7 | |
rubenwardy | fc7739be2c | |
rubenwardy | 5a12b9e6c4 | |
rubenwardy | 4e83adc032 | |
rubenwardy | 187202d363 | |
rubenwardy | 545968a71f | |
rubenwardy | f5aee035b3 | |
rubenwardy | 1389cf450c | |
rubenwardy | 24e3b1505b | |
rubenwardy | 347f8e5a22 | |
rubenwardy | 0614e6b28b | |
rubenwardy | 823c06d3ea | |
Warr1024 | 3049d17f5e | |
rubenwardy | e7818d7fb4 | |
rubenwardy | 7db6c6bba4 | |
rubenwardy | b87401a0c8 | |
rubenwardy | 13b6ab04bb | |
rubenwardy | 148ece162c | |
rubenwardy | ce2bb3abad | |
rubenwardy | cfddf0ada3 | |
rubenwardy | 54304cf3e0 | |
rubenwardy | f1597622ea | |
rubenwardy | 8c44b08682 | |
rubenwardy | 6fa6203ce0 | |
rubenwardy | 75c118c483 | |
rubenwardy | 4238dbd412 | |
rubenwardy | 9a54ada0ec | |
rubenwardy | ce8ae30311 | |
rubenwardy | 2f77a84ec5 | |
rubenwardy | f83605c35f | |
rubenwardy | 4c4bddeed6 | |
rubenwardy | 4523849641 | |
dependabot[bot] | f49c60d7f6 | |
dependabot[bot] | 2452fceeda | |
rubenwardy | fb2f71e1dc | |
rubenwardy | 52437d4e2e | |
rubenwardy | 72a95ecfca | |
Jordan Irwin | 9e95b69c11 | |
rubenwardy | 231c2a3a1e | |
rubenwardy | 7dbea9f042 | |
rubenwardy | 9dfb95a524 | |
rubenwardy | e9161610c4 | |
rubenwardy | f4792ac537 | |
rubenwardy | 588b03cf34 | |
rubenwardy | 94bf83c611 | |
rubenwardy | 4bb35953b1 | |
rubenwardy | c6f3f61ff6 | |
rubenwardy | d64463235c | |
rubenwardy | dcc34570d5 | |
rubenwardy | 464c85295a | |
rubenwardy | 95bdababb3 | |
rubenwardy | a33a4bd894 | |
rubenwardy | c0719fdeaa | |
dependabot[bot] | 3612c1747e | |
dependabot[bot] | a30b1bbf71 | |
dependabot[bot] | a0ace027d3 | |
rubenwardy | 8dbd22f56c | |
rubenwardy | c2994a27fd | |
rubenwardy | 9cb9f8a4f6 | |
rubenwardy | 4d2833de88 | |
rubenwardy | adcbf7455e | |
rubenwardy | df8ef542dd | |
rubenwardy | c11e5c1f99 | |
rubenwardy | 63cfb5eac0 | |
rubenwardy | 6861524641 | |
rubenwardy | 032d8bf67b | |
rubenwardy | 47797f1fb1 | |
rubenwardy | 92764465e0 | |
rubenwardy | 04e108c31e | |
rubenwardy | aead579f0b | |
rubenwardy | f9089319d3 | |
rubenwardy | ff2f7caee1 | |
rubenwardy | da81df535a | |
rubenwardy | 7078ed3ac3 | |
rubenwardy | da6b4b210f | |
rubenwardy | 04f659bc2b | |
rubenwardy | 8ef74deec1 | |
rubenwardy | 7e20a09499 | |
rubenwardy | dea5a52c86 | |
rubenwardy | 96b5b4ea5b | |
rubenwardy | aec346e2d4 | |
rubenwardy | b41e4b50d9 | |
rubenwardy | 3c095544d0 | |
rubenwardy | 77dcb85912 | |
rubenwardy | 3ed73c4145 | |
rubenwardy | c37f589765 | |
rubenwardy | 7ff92bc7c1 | |
rubenwardy | 3839dfbf90 | |
rubenwardy | 0ff4f40652 | |
rubenwardy | 2797792322 | |
rubenwardy | 3ce653ba74 | |
rubenwardy | 0918b8b676 | |
rubenwardy | 63204575eb | |
rubenwardy | fb3b0be50e | |
rubenwardy | 0c08738a66 | |
rubenwardy | 21cf5b57c1 | |
rubenwardy | b5f47b1b73 | |
rubenwardy | 05c140da78 | |
rubenwardy | 8225e4098b | |
rubenwardy | 90aeb6e1a7 | |
rubenwardy | 12e364969b | |
rubenwardy | ca58c70206 | |
rubenwardy | 551996ca14 | |
rubenwardy | bb79d564a8 | |
rubenwardy | 878872799e | |
rubenwardy | aa7b8a0fc0 | |
rubenwardy | 14810b2cc5 | |
rubenwardy | 5017a9ba7e | |
rubenwardy | a040c7dd2e | |
rubenwardy | 912ebbc409 | |
rubenwardy | e1fe63ab19 | |
rubenwardy | 509f03ce65 | |
rubenwardy | 64a897b52f | |
rubenwardy | 2f66db5989 | |
rubenwardy | 033f40c263 | |
rubenwardy | a78fe8ceb9 | |
rubenwardy | c6a973f7e1 | |
rubenwardy | d7647520c8 | |
rubenwardy | 70f491fd27 | |
rubenwardy | f07f2803f8 | |
rubenwardy | 4364ce5d6f | |
rubenwardy | 7c3d738756 | |
rubenwardy | ede010c25d | |
rubenwardy | db09b8eb84 | |
rubenwardy | a0cd155730 | |
rubenwardy | b7814d9541 | |
rubenwardy | 912b917a47 | |
rubenwardy | c0112828eb | |
rubenwardy | b3237b0c49 | |
rubenwardy | b22ef5ae83 | |
rubenwardy | 8d6661511a | |
rubenwardy | 607c534174 | |
rubenwardy | 3b213889ca | |
rubenwardy | 36dc51ef4a | |
rubenwardy | 663cbd91f5 | |
rubenwardy | 82fe0e7bbf | |
rubenwardy | 324815d58d | |
rubenwardy | a67e3af172 | |
rubenwardy | 0cd23f7883 | |
rubenwardy | 1b296fcae5 | |
rubenwardy | 84d7030f7d | |
rubenwardy | 2fddc276de | |
rubenwardy | a92ef0a8a1 | |
rubenwardy | 99eee9c758 | |
rubenwardy | 56ff354021 | |
rubenwardy | ac4d5c8c88 | |
rubenwardy | c5fa76dab0 | |
rubenwardy | 33bf3304a1 | |
rubenwardy | 53d2d18b89 | |
rubenwardy | fa23a00014 | |
rubenwardy | 81b24c6cb3 | |
rubenwardy | 60a33a6492 | |
rubenwardy | 9acb7698ef | |
rubenwardy | 9e6ded6544 | |
rubenwardy | ff5f98558d | |
rubenwardy | a088b1b0b5 | |
rubenwardy | 29adccb6d1 | |
rubenwardy | c6d39fcba3 | |
rubenwardy | fe2acddb5b | |
rubenwardy | 3dde8c05ad | |
rubenwardy | f49da74c3a | |
rubenwardy | 53babc1113 | |
rubenwardy | 09f8302e74 | |
rubenwardy | 665bfd64d2 | |
rubenwardy | cf5360f6f6 | |
rubenwardy | f1edfcebc0 | |
rubenwardy | ef9860b6cc | |
rubenwardy | 4f920f011f | |
rubenwardy | b613ac4b89 | |
rubenwardy | e8dca43f44 | |
rubenwardy | 46b60f9d24 | |
rubenwardy | a02942b7e0 | |
rubenwardy | 693cf4250a | |
rubenwardy | ee9f6454e0 | |
rubenwardy | c5d99e00d8 | |
rubenwardy | 7be0616d38 | |
rubenwardy | ea527f9598 | |
rubenwardy | 8fcea988ca | |
rubenwardy | 6b8b98c15b | |
rubenwardy | 17798df342 | |
rubenwardy | 2f2b8dc983 | |
rubenwardy | 6e763b8453 | |
rubenwardy | 09a9219fcd | |
rubenwardy | c8406b45d4 | |
rubenwardy | 14a67b99ba | |
rubenwardy | 7461acdd1f | |
rubenwardy | 88a8e85b12 | |
rubenwardy | 5a1656b8d0 | |
rubenwardy | 8fad3a15cd | |
rubenwardy | ce4c2142e2 | |
rubenwardy | 6f9c01c375 | |
rubenwardy | 5e255a07f6 | |
rubenwardy | 5314fda342 | |
rubenwardy | dfc6f6fd6e | |
rubenwardy | 05a08b4c05 | |
rubenwardy | 07d7282383 | |
rubenwardy | 01bed3e307 | |
dependabot[bot] | aabf70d458 | |
rubenwardy | 6d2558a921 | |
rubenwardy | 28995ffdd6 | |
rubenwardy | e4d0b57f3c | |
rubenwardy | 0054f362a7 | |
rubenwardy | 12bcdf2d47 | |
rubenwardy | e709fc9ce3 | |
Lars Mueller | e0b490fdc0 | |
rubenwardy | 7964f5979a | |
rubenwardy | 6ebab36877 | |
rubenwardy | afb699f8d3 | |
rubenwardy | d772f157eb | |
rubenwardy | 1b81ff4d3b | |
rubenwardy | 8c5d997c6e | |
rubenwardy | c065519cca | |
rubenwardy | df79159e2e | |
rubenwardy | 1064885a2c | |
rubenwardy | 60362abef1 | |
rubenwardy | d7d9131de8 | |
rubenwardy | c44cc8082c | |
rubenwardy | 7a4335b8bc | |
rubenwardy | 8e3930d092 | |
rubenwardy | 5cbdaae5b3 | |
rubenwardy | c7aecd32be | |
rubenwardy | 4820d11ce3 | |
rubenwardy | fc8cd3cfb8 | |
rubenwardy | fc9b8c2a5a | |
rubenwardy | 9ec91fc52d | |
rubenwardy | 2ae4a2ed5a | |
rubenwardy | dfa5d0c5a7 | |
rubenwardy | fc3a481e6f | |
rubenwardy | 5ab8c2f0f1 | |
rubenwardy | 5a0aa636f3 | |
rubenwardy | fb1d33d27a | |
rubenwardy | 8d8577a941 | |
rubenwardy | 70ac8fa6ab | |
rubenwardy | 7088ffd321 | |
rubenwardy | e175e489e8 | |
rubenwardy | 7efdf5cfef | |
rubenwardy | 5fb01f01bf | |
rubenwardy | 333dd60b32 | |
rubenwardy | 4433c32afc | |
rubenwardy | d5190b0d76 | |
rubenwardy | 58e1b924ca | |
rubenwardy | ac7714b997 | |
rubenwardy | 778a602aa6 | |
rubenwardy | fd0b203f1e | |
rubenwardy | b28732ee74 | |
rubenwardy | d8f33a4111 | |
rubenwardy | 396a620cf4 | |
rubenwardy | f7b3f4573d | |
rubenwardy | 9ead6c1481 | |
rubenwardy | 55dc6460d2 | |
rubenwardy | 3aa12be544 | |
rubenwardy | 35e1659b77 | |
rubenwardy | 2a9e52d36b | |
rubenwardy | 3f48905331 | |
rubenwardy | cf307e25d0 | |
rubenwardy | 4046c00a01 | |
rubenwardy | 4226e945e6 | |
rubenwardy | f93a2d8717 | |
rubenwardy | 2910fcc1a4 | |
rubenwardy | 8ff61b4517 | |
rubenwardy | 4944463f56 | |
rubenwardy | b3bd7ac615 | |
rubenwardy | 64a180ba8f | |
rubenwardy | 5a2ce15f96 | |
rubenwardy | f6f4fe4fc6 | |
rubenwardy | a17260a4ee | |
rubenwardy | 4019e82f4a | |
rubenwardy | 79230c1b0e | |
rubenwardy | da3175e7bd | |
rubenwardy | d654113204 | |
rubenwardy | 6e3d32a9d5 | |
rubenwardy | e1d6c4f5f5 | |
rubenwardy | 085f0b49c6 | |
rubenwardy | 5fe3b0b459 | |
rubenwardy | 3efda30b98 | |
rubenwardy | 683b855584 | |
rubenwardy | 9c10e190bc | |
rubenwardy | 19308b645b | |
rubenwardy | c46430c663 | |
rubenwardy | d976269f1a | |
rubenwardy | c8e93a9f52 | |
rubenwardy | d32bb30071 | |
rubenwardy | d5263acdf8 | |
rubenwardy | 8872ad33ad | |
rubenwardy | 7e29a621c3 | |
rubenwardy | dfb216a8df | |
rubenwardy | f75bdec756 | |
rubenwardy | 0082870864 | |
rubenwardy | d0e1a95d9c | |
rubenwardy | f69fb47d69 | |
rubenwardy | 4f52f82a15 | |
rubenwardy | 7c07ac22ad | |
rubenwardy | afb87c525d | |
rubenwardy | 9b0ce41fd7 | |
rubenwardy | 5f7c0a3b24 | |
rubenwardy | f7d90f2f53 | |
rubenwardy | 43aab057c8 | |
rubenwardy | bfcdd642fd | |
rubenwardy | a8537659e2 | |
rubenwardy | 9620ceb842 | |
rubenwardy | 5ef15e91d4 | |
rubenwardy | 2358ed1b24 | |
rubenwardy | af8d8c330d | |
rubenwardy | 14f643592c | |
rubenwardy | 8c5cdb630e | |
rubenwardy | b18903b59b | |
rubenwardy | 42f96618e2 | |
rubenwardy | 0c0d3e1715 | |
rubenwardy | 2b06bca015 | |
rubenwardy | 78630b3071 | |
rubenwardy | 15063d92cd | |
rubenwardy | 15821fe796 | |
rubenwardy | 7d558ad7a2 | |
rubenwardy | 4242898e5d | |
rubenwardy | d24f024cca | |
rubenwardy | ff93be7a89 | |
rubenwardy | a47d222a47 | |
rubenwardy | 9f62c251f2 | |
rubenwardy | aff20f1a6d | |
rubenwardy | 6841a295ff | |
rubenwardy | 7a584e1a6e | |
rubenwardy | 00be054135 | |
dependabot[bot] | 6eb4a803fd | |
rubenwardy | 6503a82094 | |
rubenwardy | 31f52580c2 | |
rubenwardy | 2aa0c3cc84 | |
rubenwardy | a3b3525b78 | |
rubenwardy | d76f10c312 | |
rubenwardy | a1e0e37223 | |
rubenwardy | 9a1c1c56e6 | |
rubenwardy | 3a5fe25e12 | |
rubenwardy | f56b6021d8 | |
rubenwardy | 380c88b5a3 | |
rubenwardy | dd1288dc3c | |
rubenwardy | 258a23cd9a | |
rubenwardy | 92fb54556a | |
rubenwardy | e81eb9c8d5 | |
rubenwardy | 8ec4006cc7 | |
rubenwardy | b3fdb991d6 | |
rubenwardy | 5b086bb559 | |
rubenwardy | 934d581737 | |
rubenwardy | e85d1755f0 | |
rubenwardy | 1c4fe1b80c | |
rubenwardy | f6ff5cba82 | |
rubenwardy | 193e4e39b1 | |
rubenwardy | ab7d5a3feb | |
rubenwardy | 2279208b00 | |
rubenwardy | a8e1863341 | |
rubenwardy | 506974a50d | |
rubenwardy | 996ba82663 | |
rubenwardy | 68524adadf | |
rubenwardy | b8ee612b45 | |
rubenwardy | 5db633d911 | |
rubenwardy | 2f208d9239 | |
rubenwardy | 0c81d0ae2b | |
rubenwardy | 6167bdc7f0 | |
rubenwardy | b50a306e66 | |
rubenwardy | 0b06cfffba | |
rubenwardy | 85551539f0 | |
rubenwardy | 3914659718 | |
rubenwardy | 8fd229b739 | |
rubenwardy | d69da8e3ea | |
rubenwardy | 9a64809542 | |
rubenwardy | ce034fddd4 | |
rubenwardy | e931d6a88b | |
rubenwardy | a8a3067ac9 | |
rubenwardy | 64dab0c4b6 | |
rubenwardy | dd7146205a | |
rubenwardy | 68a132f271 | |
rubenwardy | c7b1dcec4f | |
rubenwardy | 7d0a93483a | |
rubenwardy | 836caf0fe0 | |
rubenwardy | 980e1c9eb1 | |
rubenwardy | e2a9ea91cf | |
rubenwardy | 2a7318eca2 | |
rubenwardy | b067fd2e77 | |
rubenwardy | 6a674c3c79 | |
rubenwardy | 0ac2827468 | |
rubenwardy | 054dfa4cbd | |
rubenwardy | 74371d3fcb | |
rubenwardy | 9d3ba8991d | |
rubenwardy | 0e4722ea98 | |
rubenwardy | 208a47b41d | |
rubenwardy | 7fb2f3170c | |
rubenwardy | 9663e87838 | |
rubenwardy | 8dd1cd9045 | |
rubenwardy | 643380038b | |
rubenwardy | 27dfbabe2f | |
rubenwardy | 15bbc35e65 | |
rubenwardy | c9e4638b34 | |
rubenwardy | ff2cd6dc2f | |
rubenwardy | aa6892da82 | |
rubenwardy | 3fbc5f7751 | |
rubenwardy | a57e06d09b | |
rubenwardy | bbc89bb2c2 | |
rubenwardy | ab58570a0c | |
rubenwardy | cd520a0251 | |
rubenwardy | 8bcf12e1a7 | |
rubenwardy | ec087e4687 | |
rubenwardy | ae4352068e | |
rubenwardy | 2faa0e4219 | |
rubenwardy | 2e3a9035c4 | |
rubenwardy | 2e6f99d09e | |
rubenwardy | f437850a50 | |
rubenwardy | 820c968f73 | |
rubenwardy | 9d1f098d8a | |
rubenwardy | d7ecf8041a | |
rubenwardy | a123f42291 | |
rubenwardy | e6a7df6144 | |
rubenwardy | 4bd9411d87 | |
rubenwardy | 284683e7e5 | |
rubenwardy | 868ced76a8 | |
rubenwardy | 729241c0fe | |
rubenwardy | 8d48723158 | |
rubenwardy | 2fb2f1ae49 | |
rubenwardy | d5b8dd8909 | |
rubenwardy | dfbcbbbb47 | |
rubenwardy | 08f6bd8bef | |
rubenwardy | 31b8a7931b | |
rubenwardy | a4dd4f0429 | |
rubenwardy | bf927c50f0 | |
rubenwardy | 5f7be4b433 | |
rubenwardy | 9bf20df941 | |
rubenwardy | adc31962c0 | |
rubenwardy | 0e9b8a1a82 | |
rubenwardy | 6150447c85 | |
rubenwardy | dd86fb0e14 | |
rubenwardy | b483d5413f | |
rubenwardy | c80ff2e709 | |
rubenwardy | 2181e57e42 | |
rubenwardy | c490df7f50 | |
rubenwardy | b9e1be57e4 | |
rubenwardy | c3d96c7459 | |
rubenwardy | b9386d5a47 | |
rubenwardy | 1d8abd8f4b | |
rubenwardy | 0bf61dda08 | |
rubenwardy | 660b813ff7 | |
rubenwardy | ba3b108239 | |
rubenwardy | 42b08f9bcd | |
rubenwardy | 849cdd257d | |
rubenwardy | 16b174d882 | |
rubenwardy | 61e2c8a1c0 | |
rubenwardy | c7a7609763 | |
rubenwardy | 13130a217c | |
rubenwardy | daa2d2989e | |
rubenwardy | ee6de95a52 | |
rubenwardy | 1daf59b7db | |
rubenwardy | 94e91e33b8 | |
rubenwardy | d91f537bdd | |
rubenwardy | 436a4cce2b | |
rubenwardy | 71f9fe469a | |
rubenwardy | 76b0c8446c | |
rubenwardy | 069c7de78c | |
rubenwardy | 3eeaf3be22 | |
rubenwardy | 1989eabf86 | |
rubenwardy | 491f9ed679 | |
rubenwardy | 000259fc88 | |
rubenwardy | 078765fe44 | |
rubenwardy | 45877bb3a4 | |
rubenwardy | eb3d067e26 | |
rubenwardy | db80c441ec | |
rubenwardy | 849b814034 | |
rubenwardy | 37a4dbe66b | |
rubenwardy | 75ab56cad1 | |
rubenwardy | 25b481ac0a | |
rubenwardy | 893507691b | |
rubenwardy | ac7adde4b1 | |
rubenwardy | d0aecd0ee5 | |
rubenwardy | 307b8f8dde | |
rubenwardy | 9d033acfff | |
rubenwardy | 2617c53abf | |
rubenwardy | bbf1143090 | |
rubenwardy | 2a37608cb0 | |
rubenwardy | 3dd5e7445e | |
rubenwardy | 8dcbcd8b62 | |
rubenwardy | d00428eb7e | |
rubenwardy | 0e2ea27f54 | |
rubenwardy | b2809ed12e | |
rubenwardy | a72b9a174a | |
rubenwardy | ecb3d83c57 | |
rubenwardy | 2cfb59d042 | |
rubenwardy | 4c3063cadf | |
rubenwardy | 66885fedaa | |
rubenwardy | 064eb9df04 | |
rubenwardy | c3cef1eed6 | |
rubenwardy | ba8c4d3d24 | |
rubenwardy | c99a2a554b | |
rubenwardy | 749e7c6cd0 | |
rubenwardy | 4d29087431 | |
rubenwardy | 183b769ee2 | |
rubenwardy | 14cf3912f0 | |
rubenwardy | 720457e876 | |
rubenwardy | 27d004d299 | |
rubenwardy | 7f650a619e | |
rubenwardy | d7977dec84 | |
rubenwardy | 99a8f3d5d6 | |
rubenwardy | c1b4256d44 | |
rubenwardy | ed78a2e06f | |
rubenwardy | 55a90e0464 | |
rubenwardy | fb78136870 | |
rubenwardy | b477556698 | |
rubenwardy | fc5cca9def | |
rubenwardy | dc455bcd87 | |
TumeniNodes | bda82d2792 | |
rubenwardy | a36e233051 | |
rubenwardy | 8484c0f0aa | |
rubenwardy | ffb5b49521 | |
rubenwardy | c15dd183a0 | |
rubenwardy | 0eca2d49ba | |
rubenwardy | 57e7cbfd09 | |
Lars Mueller | e94bd9b845 | |
rubenwardy | 05bf8e3b3d | |
rubenwardy | 3992b19be3 | |
rubenwardy | a678a61c23 | |
rubenwardy | b5ce0a786a | |
rubenwardy | d58579d308 | |
rubenwardy | 0620c3e00f | |
rubenwardy | a8374ec779 | |
David Leal | 24090235d1 | |
rubenwardy | bbaa687aa7 | |
rubenwardy | dadfe72b48 | |
rubenwardy | 9cc3eba009 | |
rubenwardy | 54a636d79e | |
rubenwardy | 0087c1ef9d | |
rubenwardy | 39881e0d04 | |
rubenwardy | 39a09c5d92 | |
rubenwardy | 663a9ba07b | |
rubenwardy | 144ae69f5c | |
rubenwardy | 3e07bed51b | |
rubenwardy | 9de219fd80 | |
rubenwardy | 4a25435f7a | |
rubenwardy | b0f32affcb | |
rubenwardy | 99548ea65f | |
rubenwardy | 325ee02b49 | |
rubenwardy | a60786d32c | |
rubenwardy | 2976afd5d1 | |
rubenwardy | 744c52ba18 | |
rubenwardy | c31c1fd92a | |
rubenwardy | 36615ef656 | |
rubenwardy | 53a5dffb26 | |
rubenwardy | 74f3a77a84 | |
rubenwardy | a15f1ac223 | |
rubenwardy | 19a626e237 | |
rubenwardy | 43c2ee6b7b | |
rubenwardy | b1555bfcd5 | |
rubenwardy | d5541791b6 | |
rubenwardy | 62b1cae0ab | |
rubenwardy | 933a23c9c7 | |
rubenwardy | f2799349ab | |
rubenwardy | 1d223cc16f | |
rubenwardy | b7101a403b | |
rubenwardy | 493917d8b1 | |
rubenwardy | e12aec4ccd | |
rubenwardy | d4936e18ee | |
rubenwardy | beb9c0e959 | |
rubenwardy | 14faae3fd1 | |
rubenwardy | 6f1472addb | |
rubenwardy | 2fa2c3afec | |
rubenwardy | 6e938ba74c | |
rubenwardy | 53a63367dc | |
rubenwardy | ddf5c7f665 | |
rubenwardy | 4e331c7f14 | |
rubenwardy | 5e60cb83de | |
rubenwardy | 595d6ea3b6 | |
rubenwardy | 71fa62fd6a | |
rubenwardy | be5bb11fe3 | |
rubenwardy | 981ae74e5c | |
rubenwardy | 2b66193969 | |
rubenwardy | ed304f7687 | |
rubenwardy | 7ac7af4774 | |
rubenwardy | 5fa0a7866a | |
rubenwardy | f24148d431 | |
rubenwardy | 980023a80c | |
rubenwardy | b68a1d7ab9 | |
rubenwardy | 2ef90902aa | |
rubenwardy | e115b0678c | |
rubenwardy | 0bda16de6d | |
rubenwardy | fd6ba459f9 | |
rubenwardy | d503908a65 | |
rubenwardy | 215839c423 | |
rubenwardy | 783bc86aaf | |
rubenwardy | 6e626c0f89 | |
rubenwardy | facdd35b11 | |
rubenwardy | ec8a88a7a8 | |
rubenwardy | 1b1c94ffa0 | |
rubenwardy | bcd003685e | |
rubenwardy | 59039a14a5 | |
rubenwardy | 0d6e217405 | |
rubenwardy | 64e1805b53 | |
rubenwardy | 22d02edbd8 | |
rubenwardy | 5a496f6858 | |
rubenwardy | f4209d7a67 | |
rubenwardy | 077bdeb01c | |
rubenwardy | 095494f96f | |
rubenwardy | 6f230ee4b2 | |
rubenwardy | 311e0218af | |
rubenwardy | 3fee369dc1 | |
rubenwardy | e57f2dfe7d | |
rubenwardy | dd5de1787f | |
rubenwardy | 62f1aecfaf | |
rubenwardy | 4ce388c8aa | |
rubenwardy | cb5451fe5d | |
rubenwardy | 5466a2d64d | |
rubenwardy | 77f8a79c51 | |
rubenwardy | 33b2b38308 | |
rubenwardy | 94426e97aa | |
rubenwardy | 5b68e494db | |
rubenwardy | 39d4cf362b | |
rubenwardy | b977a42738 | |
rubenwardy | ff2a74367f | |
rubenwardy | 3f666d2302 | |
rubenwardy | a7d22973ff | |
rubenwardy | 20583784f5 | |
rubenwardy | 64f131ae27 | |
rubenwardy | 015abe5a25 | |
rubenwardy | 719a652235 | |
rubenwardy | 50892ce9fc | |
rubenwardy | 2e14836ed6 | |
rubenwardy | 35e1aba4ad | |
rubenwardy | 913537f96f | |
rubenwardy | b36a60d3a2 | |
rubenwardy | df247b021e | |
rubenwardy | 9f678d8fde | |
rubenwardy | d89442438f | |
rubenwardy | 08a9ae7b94 | |
rubenwardy | 904e09f0dd | |
Alex | 038ef5b739 | |
TumeniNodes | f8958ae1bc | |
rubenwardy | 03eccbd56a | |
rubenwardy | fb31ea3c22 | |
rubenwardy | 4082863b5a | |
rubenwardy | cc564af44e | |
rubenwardy | 655ed2255a | |
rubenwardy | 96b22744ec | |
rubenwardy | 130d0bc7a0 | |
rubenwardy | 1469e37c38 | |
rubenwardy | 6ce495fcd3 | |
rubenwardy | 776a3eff2a | |
rubenwardy | 04e8ae5bdd | |
rubenwardy | 18b9fb3876 | |
rubenwardy | 1da86f27a7 | |
rubenwardy | 85340a2fe9 | |
rubenwardy | c4a4d9c116 | |
rubenwardy | 87a184595c | |
rubenwardy | b3b1e421f2 | |
rubenwardy | 60483ef542 | |
rubenwardy | 3c8a8b8988 | |
rubenwardy | 2f8bdd8f0f | |
rubenwardy | e87db8b87f | |
rubenwardy | b36273a848 | |
Hugo Locurcio | 7b087158d7 | |
rubenwardy | 2fbc44bd54 | |
rubenwardy | 950512c2a7 | |
rubenwardy | f4010d498f | |
rubenwardy | f04d4ff3cd | |
rubenwardy | f8b290fc45 | |
rubenwardy | 7e4eb29db7 | |
rubenwardy | 93a74b7681 | |
rubenwardy | 2677e088a8 | |
rubenwardy | 0fd4984e5a | |
rubenwardy | 896a65fd99 | |
rubenwardy | 885209a614 | |
rubenwardy | 4c109d6bd3 | |
rubenwardy | 9c2c8c21f1 | |
rubenwardy | e40b247a97 | |
rubenwardy | a79cc758ed | |
rubenwardy | bafd426eaf | |
rubenwardy | 36f9572cbb | |
rubenwardy | 2586a11bcf | |
rubenwardy | d36138d5e1 | |
rubenwardy | 7810bb54e0 | |
rubenwardy | 2844773e4d | |
rubenwardy | 23c406bff9 | |
rubenwardy | 0f3adda592 | |
rubenwardy | 441ed3beeb | |
rubenwardy | d1f5585fda | |
rubenwardy | 0fd3ed8f6b | |
rubenwardy | 0e5c1f83ff | |
rubenwardy | f112756b04 | |
rubenwardy | f822027ec5 | |
rubenwardy | 034315d421 | |
rubenwardy | 5cd8b35d1f | |
rubenwardy | 84b996c489 | |
rubenwardy | d77403c0be | |
rubenwardy | e9fe936aa9 | |
rubenwardy | 8afe17b984 | |
rubenwardy | 2691105513 | |
rubenwardy | 5f7efd4f31 | |
rubenwardy | 7d52931a20 | |
rubenwardy | a45df0e173 | |
rubenwardy | 0db49efe4a | |
rubenwardy | 9639cf04f1 | |
rubenwardy | 9866e43b4b | |
rubenwardy | 014370ea06 | |
rubenwardy | fbf374ff5d | |
rubenwardy | a68ac9cb4d | |
rubenwardy | 7943598528 | |
rubenwardy | 4bc8b58af7 | |
rubenwardy | ec0e89c21d | |
rubenwardy | 2975f94d9e | |
rubenwardy | a9a045eefd | |
rubenwardy | d09ede00fb | |
rubenwardy | 515248eb8b | |
rubenwardy | 66ee706a6c | |
rubenwardy | d44178cb0c | |
rubenwardy | c926a812d3 | |
rubenwardy | 0b83d2f2b5 | |
rubenwardy | 21960f2404 | |
rubenwardy | f94885a58f | |
rubenwardy | f7d4b4bf6d | |
rubenwardy | d04e060854 | |
rubenwardy | 7801be3d39 | |
rubenwardy | b10660030a | |
rubenwardy | f5744f5188 | |
rubenwardy | 272be09ba1 | |
rubenwardy | 09150a4dbb | |
rubenwardy | c726f56b3e | |
rubenwardy | daded6d193 | |
rubenwardy | b0a5980833 | |
rubenwardy | 1eaed55bc6 | |
rubenwardy | c2265313d8 | |
rubenwardy | 49d5a123e5 | |
rubenwardy | c79c970171 | |
rubenwardy | fa0506f58a | |
rubenwardy | 50889ccca5 | |
rubenwardy | b8ca5d24c5 | |
rubenwardy | 63969529ad | |
rubenwardy | 08434300d8 | |
rubenwardy | 86566bcd39 | |
rubenwardy | a7fcce4448 | |
rubenwardy | 366ed9913e | |
rubenwardy | 79f4e16286 | |
rubenwardy | 137a6928bc | |
rubenwardy | de9135f44f | |
rubenwardy | 31f57e1f12 | |
rubenwardy | 89cae279cd | |
rubenwardy | fd901726b0 | |
rubenwardy | 5f40d68441 | |
rubenwardy | 8eedbf64a4 | |
rubenwardy | c551201f79 | |
rubenwardy | a21a5c24d8 | |
rubenwardy | 0a969e597b | |
rubenwardy | a1700b5f7e | |
rubenwardy | d61f77a805 | |
rubenwardy | f6384e2e15 | |
rubenwardy | 09a201759b | |
rubenwardy | 5dcff01436 | |
rubenwardy | f355721cdb | |
rubenwardy | a25f77ce3c | |
rubenwardy | 692628653c | |
rubenwardy | 35f798c862 | |
rubenwardy | 3a0e0377f9 | |
rubenwardy | c6a26786ec | |
rubenwardy | e5cb7a3721 | |
rubenwardy | 03a155c17b | |
rubenwardy | 266d579e9d | |
rubenwardy | c97eefc7b2 | |
rubenwardy | 9da6b45cc3 | |
rubenwardy | c9bf7a3245 | |
rubenwardy | dd368d87aa | |
rubenwardy | e5b279d013 | |
rubenwardy | 8ca3437689 | |
ClobberXD | aeafb8247f | |
rubenwardy | 75bab28d82 | |
rubenwardy | 328d05bdf6 | |
rubenwardy | 2229b32c90 | |
rubenwardy | ed409df323 | |
rubenwardy | b8decafd75 | |
rubenwardy | 5aaee010c1 | |
rubenwardy | a01fe4043e | |
rubenwardy | e0ef0e018d | |
rubenwardy | 0210a3e601 | |
rubenwardy | 36000b1592 | |
rubenwardy | b296b9b299 | |
rubenwardy | dd6257a0a0 | |
rubenwardy | 23b324cc9c | |
rubenwardy | f61f9e8654 | |
rubenwardy | 286207ffa2 | |
rubenwardy | a3e82ad42f | |
rubenwardy | 404200b8f0 | |
rubenwardy | dfecf470fa | |
rubenwardy | c737f58fc0 | |
rubenwardy | ab59b7f4ba | |
rubenwardy | 514a24e2c4 | |
rubenwardy | 742a327cbb | |
rubenwardy | 864e067412 | |
rubenwardy | 1c7a192854 | |
rubenwardy | c298f64295 | |
rubenwardy | e82166f87e | |
rubenwardy | 909a2b4ce9 | |
rubenwardy | df8d05f09d | |
rubenwardy | 8c3b1c8c95 | |
rubenwardy | ecdb755dd3 | |
rubenwardy | 901e115a21 | |
rubenwardy | d4c2166019 | |
rubenwardy | cbc98ef624 | |
nOOb3167 | 794bc8a018 | |
nOOb3167 | 34900222dc | |
rubenwardy | f9a1d25c57 | |
rubenwardy | 8fe7bcfb71 | |
rubenwardy | 28ee65809e | |
rubenwardy | 1b42f3310a | |
rubenwardy | 8d2144895e | |
rubenwardy | 13837ce88b | |
rubenwardy | 73c65e3561 | |
rubenwardy | 67a229b8a3 | |
rubenwardy | 9dd3570a52 | |
rubenwardy | a6c8b12cdd | |
rubenwardy | 7813c766ac | |
rubenwardy | 9fc9826d30 | |
rubenwardy | 19e1ed8b32 | |
cx384 | eb6b1d6375 | |
rubenwardy | 8c6d352d07 | |
rubenwardy | cfa7654efc | |
rubenwardy | 87af23248e | |
rubenwardy | ba08becd3a | |
rubenwardy | 68b7a5e922 | |
rubenwardy | e8cc685f89 | |
rubenwardy | 86dd137f75 | |
rubenwardy | b48f684c0a | |
rubenwardy | e0e6f3392d | |
rubenwardy | b1c349cc35 | |
rubenwardy | 40aac38d43 | |
rubenwardy | 051df7ab87 | |
rubenwardy | bb1f6702f6 | |
rubenwardy | c9542427b4 | |
rubenwardy | 8601c5e075 | |
rubenwardy | 3d97eca387 | |
rubenwardy | 99b21f996c | |
rubenwardy | 700cd7ce1f | |
rubenwardy | 8d9da5a750 | |
rubenwardy | 9a36bb7d72 | |
rubenwardy | e424dc57e7 | |
rubenwardy | 7d60e2f671 | |
rubenwardy | 8b2018852e | |
rubenwardy | 0aeefa2387 | |
rubenwardy | 4420f489ac | |
rubenwardy | aad4fd2a70 | |
rubenwardy | d2bda0fded | |
rubenwardy | b84727b187 | |
rubenwardy | 6fd36dbfff | |
rubenwardy | 8e134a7c85 | |
rubenwardy | 389258a10c | |
rubenwardy | 3657316fa2 | |
rubenwardy | a6f4249afb | |
rubenwardy | 70afb94d3b | |
rubenwardy | 8984adaa72 | |
rubenwardy | c523624696 | |
rubenwardy | 072f189006 | |
rubenwardy | 9967101d9f | |
rubenwardy | 1ed09b646b | |
rubenwardy | f554bfc92b | |
rubenwardy | c80ea2c1b1 | |
rubenwardy | edd51b86d0 | |
rubenwardy | 944b8a4eb0 | |
rubenwardy | a627893355 | |
rubenwardy | 1600687449 | |
rubenwardy | fa2f17526f | |
rubenwardy | 002e6828b6 | |
rubenwardy | a947472c67 | |
rubenwardy | e7acd7faa3 | |
rubenwardy | f755c7d429 | |
rubenwardy | b6652547fa | |
rubenwardy | be20146f25 | |
rubenwardy | df291db69b | |
rubenwardy | 63a3b5e872 | |
rubenwardy | 6353ac29e9 | |
rubenwardy | a4b583bac5 | |
rubenwardy | 52fdc8c212 | |
rubenwardy | 7e80adad56 | |
rubenwardy | bf5080aa18 | |
rubenwardy | 89f95a22dc | |
rubenwardy | f1b21b73b2 | |
rubenwardy | 6a13dca2d5 | |
rubenwardy | 048b604a75 | |
rubenwardy | f7bb29c839 | |
Ezhh | ba506cb16d | |
Pavel Puchkin | 179d0be933 |
|
@ -0,0 +1,6 @@
|
|||
.git
|
||||
data*
|
||||
uploads
|
||||
*.pyc
|
||||
__pycache__
|
||||
env
|
|
@ -0,0 +1,4 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
patreon: rubenwardy
|
||||
custom: [ "https://rubenwardy.com/donate/" ]
|
|
@ -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
|
|
@ -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.
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: Policy suggestion
|
||||
about: Suggest a change to the guidelines
|
||||
title: ''
|
||||
labels: Policy
|
||||
assignees: ''
|
||||
---
|
|
@ -0,0 +1,19 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only support the latest production version, deployed to <https://content.minetest.net>.
|
||||
See the [releases page](https://github.com/minetest/contentdb/releases).
|
||||
|
||||
## 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/
|
||||
|
||||
Depending on severity, we will either create a private issue for the vulnerability
|
||||
and release a security update, or give you permission to file the issue publicly.
|
||||
|
||||
For more information on the justification of this policy, see
|
||||
[Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure).
|
|
@ -0,0 +1,21 @@
|
|||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- 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
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
FROM python:3.10
|
||||
|
||||
RUN groupadd -g 5123 cdb && \
|
||||
useradd -r -u 5123 -g cdb cdb
|
||||
|
||||
WORKDIR /home/cdb
|
||||
|
||||
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
|
||||
RUN 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
|
|
@ -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>.
|
130
README.md
130
README.md
|
@ -1,64 +1,94 @@
|
|||
# Content Database
|
||||
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
|
||||
|
||||
## Setup
|
||||
Content database for Minetest 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
|
||||
|
||||
then use pip:
|
||||
|
||||
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/
|
||||
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### 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 of 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 --> MinetestVersion
|
||||
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
|
||||
```
|
||||
|
|
163
app/__init__.py
163
app/__init__.py
|
@ -1,41 +1,172 @@
|
|||
# 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/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
import flask_menu as menu
|
||||
from flask_gravatar import Gravatar
|
||||
from flask_mail import Mail
|
||||
from flask.ext import markdown
|
||||
from flask_github import GitHub
|
||||
from flask_wtf.csrf import CsrfProtect
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_flatpages import FlatPages
|
||||
import os
|
||||
from flask_babel import Babel, gettext
|
||||
from flask_login import logout_user, current_user, LoginManager
|
||||
import os, redis
|
||||
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder="public/static")
|
||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
|
||||
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
|
||||
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
|
||||
app.config["LANGUAGES"] = {
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"ms": "Bahasa Melayu",
|
||||
"ru": "русский язык",
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
|
||||
menu.Menu(app=app)
|
||||
markdown.Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
|
||||
r = 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(app)
|
||||
gravatar = Gravatar(app,
|
||||
size=64,
|
||||
rating="g",
|
||||
default="retro",
|
||||
force_default=False,
|
||||
force_lower=False,
|
||||
use_ssl=True,
|
||||
base_url=None)
|
||||
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)
|
||||
|
||||
|
||||
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
|
||||
from .maillogger import build_handler
|
||||
app.logger.addHandler(build_handler(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.rank == models.UserRank.BANNED:
|
||||
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.MEMBER
|
||||
models.db.session.commit()
|
||||
|
||||
from .utils import clearNotifications, is_safe_url
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_for_notifications():
|
||||
if current_user.is_authenticated:
|
||||
clearNotifications(request.path)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
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:
|
||||
new_session = models.db.create_session({})()
|
||||
new_session.query(models.User) \
|
||||
.filter(models.User.username == current_user.username) \
|
||||
.update({ "locale": locale })
|
||||
new_session.commit()
|
||||
new_session.close()
|
||||
|
||||
return 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)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.locale = locale
|
||||
models.db.session.commit()
|
||||
|
||||
return resp
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import os, importlib
|
||||
|
||||
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)
|
|
@ -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, email
|
|
@ -0,0 +1,338 @@
|
|||
# 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
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group
|
||||
from flask import redirect, url_for, flash, current_app, jsonify
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.logic.game_support import GameSupportResolver
|
||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
|
||||
from app.tasks.emails import send_pending_digests
|
||||
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
|
||||
from app.utils import addNotification, get_system_user
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
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("Check ZIP releases")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
tasks.append(checkZipRelease.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("Check the first release of all packages")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(checkZipRelease.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("Import forum topic list")
|
||||
def import_topic_list():
|
||||
task = importTopicList.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
||||
|
||||
|
||||
@action("Check all forum accounts")
|
||||
def check_all_forum_accounts():
|
||||
task = checkAllForumAccounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Import screenshots")
|
||||
def import_screenshots():
|
||||
packages = Package.query \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id.is_(None)) \
|
||||
.all()
|
||||
for package in packages:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Remove 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)
|
||||
|
||||
db_urls = release_urls.union(screenshot_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 metapackages")
|
||||
def del_meta_packages():
|
||||
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 meta packages", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete removed packages")
|
||||
def del_removed_packages():
|
||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
||||
count = query.count()
|
||||
for pkg in query.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(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]
|
||||
packages_list = ", ".join(packages)
|
||||
else:
|
||||
packages_list = " and ".join(packages)
|
||||
return packages_list
|
||||
|
||||
|
||||
@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 = db.session.query(Package.title).filter(
|
||||
Package.author_id == user.id,
|
||||
or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
havent = "haven't" if len(packages) > 1 else "hasn't"
|
||||
if len(packages_list) + 54 > 100:
|
||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
||||
|
||||
addNotification(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 = db.session.query(Package.title).filter(
|
||||
Package.maintainers.any(User.id==user.id),
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
addNotification(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 in License.query.all():
|
||||
assert license.name not in renames.keys()
|
||||
existing_licenses[license.name.lower()] = license
|
||||
|
||||
for license in licenses:
|
||||
obj = existing_licenses.get(license["licenseId"].lower())
|
||||
if obj:
|
||||
obj.url = license["reference"]
|
||||
elif license.get("isOsiApproved") and license.get("isFsfLibre") and \
|
||||
not license["isDeprecatedLicenseId"]:
|
||||
obj = License(license["licenseId"], True, license["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.is_(None), User.forum_topics.is_(None),
|
||||
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.is_(None), Package.type==PackageType.GAME, Package.state==PackageState.APPROVED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
or_(Package.author==user, Package.maintainers.any(User.id==user.id)),
|
||||
Package.video_url.is_(None),
|
||||
Package.type == PackageType.GAME,
|
||||
Package.state == PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
addNotification(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("Update screenshot sizes")
|
||||
def update_screenshot_sizes():
|
||||
import sys
|
||||
|
||||
for screenshot in PackageScreenshot.query.all():
|
||||
width, height = get_image_size(screenshot.file_path)
|
||||
print(f"{screenshot.url}: {width}, {height}", file=sys.stderr)
|
||||
screenshot.width = width
|
||||
screenshot.height = height
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Detect game support")
|
||||
def detect_game_support():
|
||||
resolver = GameSupportResolver()
|
||||
resolver.update_all()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send pending notif digests")
|
||||
def do_send_pending_digests():
|
||||
send_pending_digests.delay()
|
|
@ -0,0 +1,130 @@
|
|||
# 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
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
|
||||
from . import bp
|
||||
from .actions import actions
|
||||
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
|
||||
|
||||
|
||||
@bp.route("/admin/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def admin_page():
|
||||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
|
||||
if action == "restore":
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "danger")
|
||||
else:
|
||||
package.state = PackageState.READY_FOR_REVIEW
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action in actions:
|
||||
ret = actions[action]["func"]()
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
else:
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages, 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():
|
||||
addAuditLog(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()
|
||||
addNotification(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
|
||||
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
||||
package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("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)
|
|
@ -0,0 +1,46 @@
|
|||
# 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 app.models import db, AuditLogEntry, UserRank, User
|
||||
from app.utils import rank_required, get_int_or_abort
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/admin/audit/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
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))
|
||||
|
||||
if "username" in request.args:
|
||||
user = User.query.filter_by(username=request.args.get("username")).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
query = query.filter_by(causer=user)
|
||||
|
||||
pagination = query.paginate(page, num, True)
|
||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/<int:id_>/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def audit_view(id_):
|
||||
entry = AuditLogEntry.query.get(id_)
|
||||
return render_template("admin/audit_view.html", entry=entry)
|
|
@ -0,0 +1,78 @@
|
|||
# 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
|
||||
from app.utils import rank_required, addAuditLog
|
||||
from . import bp
|
||||
from ...models import UserRank, User, AuditSeverity
|
||||
|
||||
|
||||
class SendEmailForm(FlaskForm):
|
||||
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
|
||||
text = TextAreaField("Message", [InputRequired()])
|
||||
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():
|
||||
addAuditLog(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():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
for user in User.query.filter(User.email.isnot(None)).all():
|
||||
send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
return render_template("admin/send_bulk_email.html", form=form)
|
|
@ -0,0 +1,66 @@
|
|||
# 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_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.utils import rank_required, nonEmptyOrNone
|
||||
from . import bp
|
||||
from ...models import UserRank, License, db
|
||||
|
||||
|
||||
@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=[nonEmptyOrNone])
|
||||
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")
|
||||
else:
|
||||
flash("Updated license " + form.name.data, "success")
|
||||
|
||||
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)
|
|
@ -0,0 +1,82 @@
|
|||
# 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 ...models import Permission, Tag, db
|
||||
|
||||
|
||||
@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)])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
is_protected = BooleanField("Is Protected")
|
||||
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.checkPerm(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
|
||||
tag.is_protected = form.is_protected.data
|
||||
db.session.add(tag)
|
||||
else:
|
||||
form.populate_obj(tag)
|
||||
|
||||
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)
|
|
@ -0,0 +1,63 @@
|
|||
# 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_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, MinetestRelease, db
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def version_list():
|
||||
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.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 = MinetestRelease.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 = MinetestRelease(form.name.data)
|
||||
db.session.add(version)
|
||||
flash("Created version " + form.name.data, "success")
|
||||
else:
|
||||
flash("Updated version " + form.name.data, "success")
|
||||
|
||||
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)
|
|
@ -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, flash
|
||||
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
|
||||
from . import bp
|
||||
from ...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)])
|
||||
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)
|
|
@ -0,0 +1,21 @@
|
|||
# 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("api", __name__)
|
||||
|
||||
from . import tokens, endpoints
|
|
@ -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:
|
||||
abort(403, "Unsupported authentication method")
|
||||
|
||||
return f(token=token, *args, **kwargs)
|
||||
|
||||
return decorated_function
|
|
@ -0,0 +1,569 @@
|
|||
# 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 List
|
||||
|
||||
import flask_sqlalchemy
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
from app.markdown import render_markdown
|
||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
|
||||
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
|
||||
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
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def cors_allowed(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
res = f(*args, **kwargs)
|
||||
res.headers["Access-Control-Allow-Origin"] = "*"
|
||||
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
return res
|
||||
return inner
|
||||
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
@cors_allowed
|
||||
def packages():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
if request.args.get("fmt") == "keys":
|
||||
return jsonify([package.getAsDictionaryKey() for package in query.all()])
|
||||
|
||||
pkgs = qb.convertToDictionary(query.all())
|
||||
if "engine_version" in request.args or "protocol_version" in request.args:
|
||||
pkgs = [package for package in pkgs if package.get("release")]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package(package):
|
||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@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.getId()
|
||||
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.getId() ]
|
||||
resolve_package_deps(out, dep.package, only_hard, depth)
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
|
||||
|
||||
if depth == 1 and not dep.optional:
|
||||
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), 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
|
||||
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.buildTopicQuery(show_added=True)
|
||||
return jsonify([t.getAsDictionary() for t in query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/topic_discard/", methods=["POST"])
|
||||
@login_required
|
||||
def topic_set_discard():
|
||||
tid = request.args.get("tid")
|
||||
discard = request.args.get("discard")
|
||||
if tid is None or discard is None:
|
||||
error(400, "Missing topic ID or discard bool")
|
||||
|
||||
topic = ForumTopic.query.get(tid)
|
||||
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
||||
error(403, "Permission denied, need: TOPIC_DISCARD")
|
||||
|
||||
topic.discarded = discard == "true"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(topic.getAsDictionary())
|
||||
|
||||
|
||||
@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/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.releaseDate))
|
||||
|
||||
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.any(id=maintainer.id))
|
||||
|
||||
return jsonify([ rel.getLongAsDictionary() 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.getAsDictionary() 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.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||
error(403, "You do not have the permission to approve releases")
|
||||
|
||||
data = request.json or request.form
|
||||
if "title" not in data:
|
||||
error(400, "Title is required in the POST data")
|
||||
|
||||
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, data["title"], 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, data["title"], 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(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.getAsDictionary())
|
||||
|
||||
|
||||
@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.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
if not release.checkPerm(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()
|
||||
|
||||
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.getAsDictionary(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.checkPerm(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, isYes(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.getAsDictionary(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.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to delete screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(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()
|
||||
|
||||
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.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(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.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(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.getAsDictionary() 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), 100)
|
||||
|
||||
query = PackageReview.query
|
||||
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
|
||||
|
||||
if request.args.get("author"):
|
||||
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
|
||||
|
||||
if request.args.get("is_positive"):
|
||||
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
|
||||
|
||||
q = request.args.get("q")
|
||||
if q:
|
||||
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
|
||||
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
||||
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.getAsDictionary(True) for review in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/scores/")
|
||||
@cors_allowed
|
||||
def package_scores():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
pkgs = [package.getScoreDict() for package in query.all()]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/tags/")
|
||||
@cors_allowed
|
||||
def tags():
|
||||
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/content_warnings/")
|
||||
@cors_allowed
|
||||
def content_warnings():
|
||||
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/licenses/")
|
||||
@cors_allowed
|
||||
def licenses():
|
||||
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
|
||||
for license in License.query.order_by(db.asc(License.name)).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/homepage/")
|
||||
@cors_allowed
|
||||
def homepage():
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
featured = query.filter(Package.tags.any(name="featured")).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.releaseDate)) \
|
||||
.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 mapPackages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"count": count,
|
||||
"downloads": downloads,
|
||||
"featured": mapPackages(featured),
|
||||
"new": mapPackages(new),
|
||||
"updated": mapPackages(updated),
|
||||
"pop_mod": mapPackages(pop_mod),
|
||||
"pop_txp": mapPackages(pop_txp),
|
||||
"pop_game": mapPackages(pop_gam),
|
||||
"high_reviewed": mapPackages(high_reviewed)
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/welcome/v1/")
|
||||
@cors_allowed
|
||||
def welcome_v1():
|
||||
featured = Package.query \
|
||||
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
|
||||
Package.tags.any(name="featured")) \
|
||||
.order_by(func.random()) \
|
||||
.limit(5).all()
|
||||
|
||||
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
|
||||
featured.insert(2, mtg)
|
||||
|
||||
def map_packages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"featured": map_packages(featured),
|
||||
})
|
||||
|
||||
|
||||
@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 = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
|
||||
if rel is None:
|
||||
error(404, "No releases found")
|
||||
|
||||
return jsonify(rel.getAsDictionary())
|
||||
|
||||
return jsonify([rel.getAsDictionary() \
|
||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||
|
||||
|
||||
@bp.route("/api/dependencies/")
|
||||
@cors_allowed
|
||||
def all_deps():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
def format_pkg(pkg: Package):
|
||||
return {
|
||||
"type": pkg.type.toName(),
|
||||
"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, num, True)
|
||||
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],
|
||||
})
|
|
@ -0,0 +1,119 @@
|
|||
# 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 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, MinetestRelease, 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, title: str, ref: str,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
|
||||
if not token.canOperateOnPackage(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, title, ref, min_v, max_v, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None):
|
||||
if not token.canOperateOnPackage(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, title, file, min_v, max_v, reason, commit_hash)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
||||
if not token.canOperateOnPackage(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.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
||||
if not token.canOperateOnPackage(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.canOperateOnPackage(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.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"package": package.getAsDictionary(current_app.config["BASE_URL"])
|
||||
})
|
|
@ -0,0 +1,151 @@
|
|||
# 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 *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
from app.utils import randomString
|
||||
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.checkPerm(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).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(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:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
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()
|
||||
token.owner = user
|
||||
token.access_token = randomString(32)
|
||||
|
||||
form.populate_obj(token)
|
||||
db.session.add(token)
|
||||
db.session.commit() # save
|
||||
|
||||
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.checkPerm(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 = randomString(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.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
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))
|
|
@ -0,0 +1,165 @@
|
|||
# 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
|
||||
|
||||
bp = Blueprint("github", __name__)
|
||||
|
||||
from flask import redirect, url_for, request, flash, jsonify, current_app
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, or_, and_
|
||||
from app import github, csrf
|
||||
from app.models import db, User, APIToken, Package, Permission, AuditSeverity
|
||||
from app.utils import abs_url_for, addAuditLog, login_user_set_active
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
import hmac, requests
|
||||
|
||||
@bp.route("/github/start/")
|
||||
def start():
|
||||
return github.authorize("", redirect_uri=abs_url_for("github.callback"))
|
||||
|
||||
@bp.route("/github/view/")
|
||||
def 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 callback(oauth_token):
|
||||
next_url = request.args.get("next")
|
||||
if oauth_token is None:
|
||||
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
# Get Github username
|
||||
url = "https://api.github.com/user"
|
||||
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
|
||||
username = r.json()["login"]
|
||||
|
||||
# Get user by github username
|
||||
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
|
||||
|
||||
# If logged in, connect
|
||||
if current_user and current_user.is_authenticated:
|
||||
if userByGithub is None:
|
||||
current_user.github_username = username
|
||||
db.session.commit()
|
||||
flash(gettext("Linked GitHub to account"), "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
else:
|
||||
flash(gettext("GitHub account is already associated with another user"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# If not logged in, log in
|
||||
else:
|
||||
if userByGithub is None:
|
||||
flash(gettext("Unable to find an account for that GitHub user"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
ret = login_user_set_active(userByGithub, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
||||
url_for("users.profile", username=userByGithub.username))
|
||||
db.session.commit()
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route("/github/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def webhook():
|
||||
json = request.json
|
||||
|
||||
# Get package
|
||||
github_url = "github.com/" + json["repository"]["full_name"]
|
||||
package = Package.query.filter(Package.repo.ilike("%{}%".format(github_url))).first()
|
||||
if package is None:
|
||||
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
|
||||
|
||||
# Get all tokens for package
|
||||
tokens_query = APIToken.query.filter(or_(APIToken.package==package,
|
||||
and_(APIToken.package==None, APIToken.owner==package.author)))
|
||||
|
||||
possible_tokens = tokens_query.all()
|
||||
actual_token = None
|
||||
|
||||
#
|
||||
# Check signature
|
||||
#
|
||||
|
||||
header_signature = request.headers.get('X-Hub-Signature')
|
||||
if header_signature is None:
|
||||
return error(403, "Expected payload signature")
|
||||
|
||||
sha_name, signature = header_signature.split('=')
|
||||
if sha_name != 'sha1':
|
||||
return error(403, "Expected SHA1 payload signature")
|
||||
|
||||
for token in possible_tokens:
|
||||
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
|
||||
|
||||
if hmac.compare_digest(str(mac.hexdigest()), signature):
|
||||
actual_token = token
|
||||
break
|
||||
|
||||
if actual_token is None:
|
||||
return error(403, "Invalid authentication, couldn't validate API token")
|
||||
|
||||
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
|
||||
event = request.headers.get("X-GitHub-Event")
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = json["head_commit"]["message"].partition("\n")[0]
|
||||
branch = json["ref"].replace("refs/heads/", "")
|
||||
if branch not in [ "master", "main" ]:
|
||||
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
|
||||
|
||||
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(actual_token, package, title, ref, reason="Webhook")
|
|
@ -0,0 +1,85 @@
|
|||
# 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 Blueprint, request, jsonify
|
||||
|
||||
bp = Blueprint("gitlab", __name__)
|
||||
|
||||
from app import csrf
|
||||
from app.models import Package, APIToken, Permission
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
|
||||
|
||||
def webhook_impl():
|
||||
json = request.json
|
||||
|
||||
# Get package
|
||||
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
|
||||
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
|
||||
if package is None:
|
||||
return error(400,
|
||||
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
|
||||
|
||||
# Get all tokens for package
|
||||
secret = request.headers.get("X-Gitlab-Token")
|
||||
if secret is None:
|
||||
return error(403, "Token required")
|
||||
|
||||
token = APIToken.query.filter_by(access_token=secret).first()
|
||||
if token is None:
|
||||
return error(403, "Invalid authentication")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
|
||||
event = json["event_name"]
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = ref[:5]
|
||||
|
||||
branch = json["ref"].replace("refs/heads/", "")
|
||||
if branch not in ["master", "main"]:
|
||||
return jsonify({"success": False,
|
||||
"message": "Webhook ignored, as it's not on the master/main branch"})
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
return api_create_vcs_release(token, package, title, ref, reason="Webhook")
|
||||
|
||||
|
||||
@bp.route("/gitlab/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def webhook():
|
||||
try:
|
||||
return webhook_impl()
|
||||
except KeyError as err:
|
||||
return error(400, "Missing field: {}".format(err.args[0]))
|
|
@ -0,0 +1,44 @@
|
|||
from flask import Blueprint, render_template, redirect
|
||||
|
||||
bp = Blueprint("homepage", __name__)
|
||||
|
||||
from app.models import *
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def home():
|
||||
def join(query):
|
||||
return query.options(
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
featured = query.filter(Package.tags.any(name="featured")).order_by(func.random()).limit(6).all()
|
||||
|
||||
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_gam = join(query.filter_by(type=PackageType.GAME).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_by(state=PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
reviews = PackageReview.query.filter_by(recommends=True).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).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, featured=featured,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)
|
|
@ -0,0 +1,64 @@
|
|||
# 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 *
|
||||
from sqlalchemy import func
|
||||
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
|
||||
|
||||
bp = Blueprint("metapackages", __name__)
|
||||
|
||||
|
||||
@bp.route("/metapackages/")
|
||||
def list_all():
|
||||
mpackages = 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("metapackages/list.html", mpackages=mpackages)
|
||||
|
||||
|
||||
@bp.route("/metapackages/<name>/")
|
||||
def view(name):
|
||||
mpackage = MetaPackage.query.filter_by(name=name).first()
|
||||
if mpackage 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("metapackages/view.html", mpackage=mpackage,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
similar_topics=similar_topics)
|
|
@ -0,0 +1,74 @@
|
|||
# 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 Blueprint, make_response
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app.models import Package, db, User, UserRank, PackageState
|
||||
|
||||
bp = Blueprint("metrics", __name__)
|
||||
|
||||
def generate_metrics(full=False):
|
||||
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 = [key + "=" + str(val) for key, val in labels.items()]
|
||||
return ",".join(pieces)
|
||||
|
||||
|
||||
def write_array_stat(name, help, type, data):
|
||||
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
|
||||
.format(name=name, help=help, type=type)
|
||||
|
||||
for entry in data:
|
||||
assert(len(entry) == 2)
|
||||
ret += "{name}{{{labels}}} {value}\n" \
|
||||
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
|
||||
|
||||
return ret + "\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).count()
|
||||
|
||||
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_downloads", "Total downloads", "gauge", downloads)
|
||||
|
||||
if full:
|
||||
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
|
||||
.filter(Package.state==PackageState.APPROVED).all()
|
||||
|
||||
ret += write_array_stat("contentdb_package_score", "Package score", "gauge",
|
||||
[({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
|
||||
else:
|
||||
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]
|
||||
ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
|
||||
|
||||
return ret
|
||||
|
||||
@bp.route("/metrics")
|
||||
def metrics():
|
||||
response = make_response(generate_metrics(), 200)
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
|
@ -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/>.
|
||||
|
||||
|
||||
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"))
|
|
@ -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
|
||||
from flask_babel import gettext
|
||||
|
||||
from app.models import User, Package, Permission
|
||||
|
||||
bp = Blueprint("packages", __name__)
|
||||
|
||||
|
||||
def get_package_tabs(user: User, package: Package):
|
||||
if package is None or not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"id": "edit",
|
||||
"title": gettext("Edit Details"),
|
||||
"url": package.getURL("packages.create_edit")
|
||||
},
|
||||
{
|
||||
"id": "releases",
|
||||
"title": gettext("Releases"),
|
||||
"url": package.getURL("packages.list_releases")
|
||||
},
|
||||
{
|
||||
"id": "screenshots",
|
||||
"title": gettext("Screenshots"),
|
||||
"url": package.getURL("packages.screenshots")
|
||||
},
|
||||
{
|
||||
"id": "maintainers",
|
||||
"title": gettext("Maintainers"),
|
||||
"url": package.getURL("packages.edit_maintainers")
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"title": gettext("Audit Log"),
|
||||
"url": package.getURL("packages.audit")
|
||||
},
|
||||
{
|
||||
"id": "share",
|
||||
"title": gettext("Share and Badges"),
|
||||
"url": package.getURL("packages.share")
|
||||
},
|
||||
{
|
||||
"id": "remove",
|
||||
"title": gettext("Remove"),
|
||||
"url": package.getURL("packages.remove")
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
from . import packages, screenshots, releases, reviews, game_hub
|
|
@ -0,0 +1,54 @@
|
|||
# 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 ...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), 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_gam = join(query.filter_by(type=PackageType.GAME).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), Package.state==PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
||||
.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, pop_gam=pop_gam,
|
||||
high_reviewed=high_reviewed)
|
|
@ -0,0 +1,613 @@
|
|||
# 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 urllib.parse import quote as urlescape
|
||||
|
||||
from flask import render_template
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import or_, func
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.rediscache import has_key, set_key
|
||||
from app.tasks.importtasks import importRepoScreenshot
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.models.packages import PackageProvides
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
|
||||
@bp.route("/packages/")
|
||||
def list_all():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
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_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.getURL("packages.view"))
|
||||
|
||||
topic = qb.buildTopicQuery().first()
|
||||
if qb.search and topic:
|
||||
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
|
||||
|
||||
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, num, True)
|
||||
|
||||
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:
|
||||
qb.show_discarded = True
|
||||
topics = qb.buildTopicQuery().all()
|
||||
|
||||
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
|
||||
tags = qb.filterPackageQuery(tags_query).all()
|
||||
|
||||
selected_tags = set(qb.tags)
|
||||
|
||||
return render_template("packages/list.html",
|
||||
query_hint=title, packages=query.items, pagination=query,
|
||||
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
|
||||
authors=authors, packages_count=query.total, topics=topics)
|
||||
|
||||
|
||||
def getReleases(package):
|
||||
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return package.releases.limit(5)
|
||||
else:
|
||||
return package.releases.filter_by(approved=True).limit(5)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
show_similar = not package.approved and (
|
||||
current_user in package.maintainers or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
||||
|
||||
conflicting_modnames = None
|
||||
if show_similar and package.type != PackageType.TXP:
|
||||
conflicting_modnames = db.session.query(MetaPackage.name) \
|
||||
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
|
||||
.filter(MetaPackage.packages.any(Package.id != package.id)) \
|
||||
.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()
|
||||
|
||||
conflicting_modnames = set([x[0] for x in conflicting_modnames])
|
||||
|
||||
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 = getReleases(package)
|
||||
|
||||
review_thread = package.review_thread
|
||||
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
review_thread = None
|
||||
|
||||
topic_error = None
|
||||
topic_error_lvl = "warning"
|
||||
if package.state != PackageState.APPROVED and package.forums is not None:
|
||||
errors = []
|
||||
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
|
||||
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
|
||||
topic_error_lvl = "danger"
|
||||
|
||||
topic = ForumTopic.query.get(package.forums)
|
||||
if topic is not None:
|
||||
if topic.author != package.author:
|
||||
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
|
||||
topic_error_lvl = "danger"
|
||||
elif package.type != PackageType.TXP:
|
||||
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
|
||||
|
||||
topic_error = "<br />".join(errors)
|
||||
|
||||
|
||||
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.atLeast(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
|
||||
|
||||
return render_template("packages/view.html",
|
||||
package=package, releases=releases, packages_uses=packages_uses,
|
||||
conflicting_modnames=conflicting_modnames,
|
||||
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
||||
threads=threads.all(), has_review=has_review)
|
||||
|
||||
|
||||
@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", author=package.author.username, name=package.name)
|
||||
api_url = "https://content.minetest.net" + url_for("api.package", 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:
|
||||
abort(404)
|
||||
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/download/")
|
||||
@is_package_page
|
||||
def download(package):
|
||||
release = package.getDownloadRelease()
|
||||
|
||||
if release is None:
|
||||
if "application/zip" in request.accept_mimetypes and \
|
||||
not "text/html" in request.accept_mimetypes:
|
||||
return "", 204
|
||||
else:
|
||||
flash(gettext("No download available."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
else:
|
||||
return redirect(release.getDownloadURL())
|
||||
|
||||
|
||||
def makeLabel(obj):
|
||||
if obj.description:
|
||||
return "{}: {}".format(obj.title, obj.description)
|
||||
else:
|
||||
return obj.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"), [InputRequired()], 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=makeLabel)
|
||||
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=makeLabel)
|
||||
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)])
|
||||
|
||||
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])
|
||||
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@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):
|
||||
package = None
|
||||
if author is None:
|
||||
form = PackageForm(formdata=request.form)
|
||||
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.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
else:
|
||||
package = getPackageByInfo(author, name)
|
||||
if package is None:
|
||||
abort(404)
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
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():
|
||||
wasNew = False
|
||||
if not package:
|
||||
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
||||
if package is not None:
|
||||
if package.state == PackageState.READY_FOR_REVIEW:
|
||||
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
||||
else:
|
||||
flash(gettext("Package already exists!"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
package = 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,
|
||||
})
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
next_url = package.getURL("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.getURL("packages.setup_releases")
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
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(),
|
||||
mpackages=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 not package.canMoveToState(current_user, state):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
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.username,
|
||||
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
|
||||
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)
|
||||
elif state == PackageState.READY_FOR_REVIEW:
|
||||
post_discord_webhook.delay(package.author.username,
|
||||
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
|
||||
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
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.getViewURL())
|
||||
else:
|
||||
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def remove(package):
|
||||
if request.method == "GET":
|
||||
return render_template("packages/remove.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="remove")
|
||||
|
||||
reason = request.form.get("reason") or "?"
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.DELETED
|
||||
|
||||
url = url_for("users.profile", username=package.author.username)
|
||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Deleted package"), "success")
|
||||
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.WIP
|
||||
|
||||
msg = "Unapproved {}, reason={}".format(package.title, reason)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Unapproved package"), "success")
|
||||
|
||||
return redirect(package.getURL("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.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
flash(gettext("You do not have permission to edit maintainers"), "danger")
|
||||
return redirect(package.getURL("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:
|
||||
thread.watchers.append(user)
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
|
||||
for user in package.maintainers:
|
||||
if user != package.author and not user in users:
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Removed you as a maintainer of {}".format(package.title), package.getURL("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)
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("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)
|
||||
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER,
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/audit/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def audit(package):
|
||||
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
|
||||
package.checkPerm(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, num, True)
|
||||
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.EDITOR)
|
||||
@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.EDITOR)
|
||||
@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.getURL("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 metapackage in package.provides:
|
||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
||||
Package.state != PackageState.DELETED) \
|
||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.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)
|
|
@ -0,0 +1,358 @@
|
|||
# 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 *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
from app.rediscache import has_key, set_key, make_download_key
|
||||
from app.tasks.importtasks import check_update_config
|
||||
from app.utils import *
|
||||
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 = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
|
||||
if is_max:
|
||||
query = query.limit(query.count() - 1)
|
||||
else:
|
||||
query = query.filter(MinetestRelease.name != "0.4.17")
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
|
||||
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
|
||||
fileUpload = FileField(lazy_gettext("File Upload"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest 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 Minetest 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):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
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 Minetest 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 Minetest 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 not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreatePackageReleaseForm()
|
||||
if package.repo is not None:
|
||||
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
|
||||
if request.method == "GET":
|
||||
form["uploadOpt"].data = "vcs"
|
||||
form.vcsLabel.data = request.args.get("ref")
|
||||
|
||||
if request.method == "GET":
|
||||
form.title.data = request.args.get("title")
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
if form["uploadOpt"].data == "vcs":
|
||||
rel = do_create_vcs_release(current_user, package, form.title.data,
|
||||
form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
||||
else:
|
||||
rel = do_create_zip_release(current_user, package, form.title.data,
|
||||
form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
||||
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
|
||||
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/<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():
|
||||
key = make_download_key(ip, release.package)
|
||||
if not has_key(key):
|
||||
set_key(key, "true")
|
||||
|
||||
bonus = 1
|
||||
|
||||
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/<id>/", 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.checkPerm(current_user, Permission.MAKE_RELEASE)
|
||||
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.getURL("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.title = form["title"].data
|
||||
release.min_rel = form["min_rel"].data.getActual()
|
||||
release.max_rel = form["max_rel"].data.getActual()
|
||||
|
||||
if package.checkPerm(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.getURL("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 Minetest 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 Minetest 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.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("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.getActual()
|
||||
if form["set_max"].data and (not only_change_none or release.max_rel is None):
|
||||
release.max_rel = form["max_rel"].data.getActual()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<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.checkPerm(current_user, Permission.DELETE_RELEASE):
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
db.session.delete(release)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("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 = nonEmptyOrNone(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.checkPerm(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.getURL("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.getURL("packages.create_release"))
|
||||
|
||||
return redirect(package.getURL("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.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
abort(403)
|
||||
|
||||
if package.update_config:
|
||||
return redirect(package.getURL("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.atLeast(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)
|
|
@ -0,0 +1,240 @@
|
|||
# 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
|
||||
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
|
||||
from . import bp
|
||||
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
||||
Permission, AuditSeverity
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
|
||||
@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, num, True)
|
||||
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
|
||||
|
||||
|
||||
class ReviewForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
|
||||
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
|
||||
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.getURL("packages.view"))
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
|
||||
form = ReviewForm(formdata=request.form, obj=review)
|
||||
|
||||
# Set default values
|
||||
if request.method == "GET" and review:
|
||||
form.title.data = review.thread.title
|
||||
form.recommends.data = "yes" if review.recommends else "no"
|
||||
form.comment.data = review.thread.replies[0].comment
|
||||
|
||||
# Validate and submit
|
||||
elif form.validate_on_submit():
|
||||
was_new = False
|
||||
if not review:
|
||||
was_new = True
|
||||
review = PackageReview()
|
||||
review.package = package
|
||||
review.author = current_user
|
||||
db.session.add(review)
|
||||
|
||||
review.recommends = form.recommends.data == "yes"
|
||||
|
||||
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.replies[0]
|
||||
reply.comment = form.comment.data
|
||||
|
||||
thread.title = form.title.data
|
||||
|
||||
db.session.commit()
|
||||
|
||||
package.recalcScore()
|
||||
|
||||
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
|
||||
|
||||
addNotification(package.maintainers, current_user, type, notif_msg,
|
||||
url_for("threads.view", id=thread.id), package)
|
||||
|
||||
if was_new:
|
||||
post_discord_webhook.delay(thread.author.username,
|
||||
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("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.checkPerm(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_"
|
||||
db.session.add(reply)
|
||||
|
||||
thread.review = None
|
||||
|
||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
||||
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
||||
current_user, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
|
||||
db.session.delete(review)
|
||||
|
||||
package.recalcScore()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
def handle_review_vote(package: Package, review_id: int):
|
||||
if current_user in package.maintainers:
|
||||
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
|
||||
return
|
||||
|
||||
review: PackageReview = PackageReview.query.get(review_id)
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if review.author == current_user:
|
||||
flash(gettext("You can't vote on your own reviews!"), "danger")
|
||||
return
|
||||
|
||||
is_positive = isYes(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):
|
||||
handle_review_vote(package, review_id)
|
||||
|
||||
next_url = request.args.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect(review.thread.getViewURL())
|
||||
|
||||
|
||||
|
||||
@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 = 1 if review.recommends else -1
|
||||
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
|
||||
|
||||
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(package.reviews) - total_votes, perc_with))
|
||||
|
||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
||||
|
||||
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
|
||||
user_biases=user_biases_info)
|
|
@ -0,0 +1,149 @@
|
|||
# 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 *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
||||
|
||||
|
||||
class CreateScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
||||
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.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
if package.screenshots.count() == 0:
|
||||
return redirect(package.getURL("packages.create_screenshot"))
|
||||
|
||||
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.getURL("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.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("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.fileUpload.data, False)
|
||||
return redirect(package.getURL("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/<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)
|
||||
|
||||
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
|
||||
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditScreenshotForm(obj=screenshot)
|
||||
if form.validate_on_submit():
|
||||
wasApproved = screenshot.approved
|
||||
|
||||
if canEdit:
|
||||
screenshot.title = form["title"].data or "Untitled"
|
||||
|
||||
if canApprove:
|
||||
screenshot.approved = form["approved"].data
|
||||
else:
|
||||
screenshot.approved = wasApproved
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
|
||||
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<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.checkPerm(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()
|
||||
|
||||
return redirect(package.getURL("packages.screenshots"))
|
|
@ -0,0 +1,64 @@
|
|||
# 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
|
||||
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
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.models import User, UserRank
|
||||
from app.tasks.emails import send_user_email
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.utils import isNo, abs_url_samesite
|
||||
|
||||
bp = Blueprint("report", __name__)
|
||||
|
||||
|
||||
class ReportForm(FlaskForm):
|
||||
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)])
|
||||
submit = SubmitField(lazy_gettext("Report"))
|
||||
|
||||
|
||||
@bp.route("/report/", methods=["GET", "POST"])
|
||||
def report():
|
||||
is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
|
||||
|
||||
url = request.args.get("url")
|
||||
if url:
|
||||
url = abs_url_samesite(url)
|
||||
|
||||
form = ReportForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
if current_user.is_authenticated:
|
||||
user_info = f"{current_user.username}"
|
||||
else:
|
||||
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
|
||||
text = f"{url}\n\n{form.message.data}"
|
||||
|
||||
task = None
|
||||
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
||||
task = send_user_email.delay(admin.email, admin.locale or "en",
|
||||
f"User report from {user_info}", text)
|
||||
|
||||
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
|
||||
|
||||
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)
|
|
@ -1,51 +1,49 @@
|
|||
# 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 *
|
||||
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 flask_login import login_required
|
||||
|
||||
from app import csrf
|
||||
from app.tasks import celery
|
||||
from app.tasks.importtasks import getMeta
|
||||
from app.utils import *
|
||||
|
||||
bp = Blueprint("tasks", __name__)
|
||||
|
||||
@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():
|
||||
author = request.args.get("author")
|
||||
author = current_user.forums_username if author is None else author
|
||||
aresult = getMeta.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
|
||||
None
|
||||
if isinstance(result, Exception):
|
||||
info = {
|
||||
'id': id,
|
|
@ -0,0 +1,383 @@
|
|||
# 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 *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
|
||||
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 *
|
||||
from app.utils import addNotification, isYes, addAuditLog, get_system_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
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)
|
||||
|
||||
pid = request.args.get("pid")
|
||||
if pid:
|
||||
pid = get_int_or_abort(pid)
|
||||
query = query.filter_by(package_id=pid)
|
||||
|
||||
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, num, True)
|
||||
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
|
||||
|
||||
|
||||
@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.checkPerm(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.getViewURL())
|
||||
|
||||
|
||||
@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.checkPerm(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.getViewURL())
|
||||
|
||||
|
||||
@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.checkPerm(current_user, Permission.LOCK_THREAD):
|
||||
abort(404)
|
||||
|
||||
thread.locked = isYes(request.args.get("lock"))
|
||||
if thread.locked is None:
|
||||
abort(400)
|
||||
|
||||
msg = None
|
||||
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")
|
||||
|
||||
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
@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.checkPerm(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)
|
||||
|
||||
addAuditLog(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.replies[0] == reply:
|
||||
flash(gettext("Cannot delete thread opening post!"), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if not reply.checkPerm(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)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
||||
|
||||
db.session.delete(reply)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
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.query.get(reply_id)
|
||||
if reply is None or reply.thread != thread:
|
||||
abort(404)
|
||||
|
||||
if not reply.checkPerm(current_user, Permission.EDIT_REPLY):
|
||||
abort(403)
|
||||
|
||||
form = CommentForm(formdata=request.form, obj=reply)
|
||||
if form.validate_on_submit():
|
||||
comment = form.comment.data
|
||||
|
||||
msg = "Edited reply by {}".format(reply.author.display_name)
|
||||
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
||||
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
||||
|
||||
reply.comment = comment
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
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.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user.is_authenticated and request.method == "POST":
|
||||
comment = request.form["comment"]
|
||||
|
||||
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
|
||||
flash(gettext("You cannot comment on this thread"), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if not current_user.canCommentRL():
|
||||
flash(gettext("Please wait before commenting again"), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if 2000 >= len(comment) > 3:
|
||||
reply = ThreadReply()
|
||||
reply.author = current_user
|
||||
reply.comment = comment
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
if not current_user 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)
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.getViewURL(), thread.package)
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
else:
|
||||
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
|
||||
|
||||
return render_template("threads/view.html", thread=thread)
|
||||
|
||||
|
||||
class ThreadForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
private = BooleanField(lazy_gettext("Private"))
|
||||
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:
|
||||
flash(gettext("Unable to find that package!"), "danger")
|
||||
|
||||
# Don't allow making orphan threads on approved packages for now
|
||||
if package is None:
|
||||
abort(403)
|
||||
|
||||
def_is_private = request.args.get("private") or False
|
||||
if package is None:
|
||||
def_is_private = True
|
||||
allow_change = package and package.approved
|
||||
is_review_thread = package and not package.approved
|
||||
|
||||
# Check that user can make the thread
|
||||
if not package.checkPerm(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:
|
||||
flash(gettext("An approval thread already exists!"), "danger")
|
||||
return redirect(package.review_thread.getViewURL())
|
||||
|
||||
elif not current_user.canOpenThreadRL():
|
||||
flash(gettext("Please wait before opening another thread"), "danger")
|
||||
|
||||
if package:
|
||||
return redirect(package.getURL("packages.view"))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Set default values
|
||||
elif request.method == "GET":
|
||||
form.private.data = def_is_private
|
||||
form.title.data = request.args.get("title") or ""
|
||||
|
||||
# Validate and submit
|
||||
elif form.validate_on_submit():
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.title = form.title.data
|
||||
thread.private = form.private.data if allow_change else def_is_private
|
||||
thread.package = package
|
||||
db.session.add(thread)
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
if package is not None 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)
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
notif_msg = "New thread '{}'".format(thread.title)
|
||||
if package is not None:
|
||||
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
|
||||
if is_review_thread:
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
return render_template("threads/new.html", form=form, allow_private_change=allow_change, 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)
|
||||
|
||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
|
@ -0,0 +1,85 @@
|
|||
# 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 abort, send_file, Blueprint, current_app
|
||||
|
||||
bp = Blueprint("thumbnails", __name__)
|
||||
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
|
||||
|
||||
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):
|
||||
try:
|
||||
img = Image.open(img_path)
|
||||
except FileNotFoundError:
|
||||
abort(404)
|
||||
|
||||
# Get current and desired ratio for the images
|
||||
img_ratio = img.size[0] / float(img.size[1])
|
||||
ratio = size[0] / float(size[1])
|
||||
|
||||
# Is more portrait than target, scale and crop
|
||||
if 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 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)
|
||||
|
||||
img.save(modified_path)
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
upload_dir = current_app.config["UPLOAD_DIR"]
|
||||
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)
|
||||
source_filepath = os.path.join(upload_dir, img)
|
||||
|
||||
resize_and_crop(source_filepath, cache_filepath, (w, h))
|
||||
return send_file(cache_filepath)
|
|
@ -0,0 +1,265 @@
|
|||
# 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 celery import uuid
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import *
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import get_int_or_abort, addNotification, addAuditLog, isYes
|
||||
from app.tasks.importtasks import makeVCSRelease
|
||||
|
||||
bp = Blueprint("todo", __name__)
|
||||
|
||||
@bp.route("/todo/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def view_editor():
|
||||
canApproveNew = Permission.APPROVE_NEW.check(current_user)
|
||||
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
|
||||
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
||||
|
||||
packages = None
|
||||
wip_packages = None
|
||||
if canApproveNew:
|
||||
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 canApproveRel:
|
||||
releases = PackageRelease.query.filter_by(approved=False).all()
|
||||
|
||||
screenshots = None
|
||||
if canApproveScn:
|
||||
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
|
||||
|
||||
if not canApproveNew and not canApproveRel and not canApproveScn:
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form["action"] == "screenshots_approve_all":
|
||||
if not canApproveScn:
|
||||
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(Package.state == PackageState.APPROVED, optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).count()
|
||||
|
||||
return render_template("todo/editor.html", current_tab="editor",
|
||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
||||
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
|
||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages)
|
||||
|
||||
|
||||
@bp.route("/todo/topics/")
|
||||
@login_required
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.setSortIfNone("date")
|
||||
query = qb.buildTopicQuery()
|
||||
|
||||
tmp_q = ForumTopic.query
|
||||
if not qb.show_discarded:
|
||||
tmp_q = tmp_q.filter_by(discarded=False)
|
||||
total = tmp_q.count()
|
||||
topic_count = query.count()
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = get_int_or_abort(request.args.get("n"), 100)
|
||||
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
num = 100
|
||||
|
||||
query = query.paginate(page, num, True)
|
||||
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_next else None
|
||||
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_prev else None
|
||||
|
||||
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
|
||||
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
|
||||
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
|
||||
n=num, sort_by=qb.order_by)
|
||||
|
||||
|
||||
@bp.route("/todo/tags/")
|
||||
@login_required
|
||||
def tags():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.setSortIfNone("score", "desc")
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
only_no_tags = isYes(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("/user/tags/")
|
||||
def tags_user():
|
||||
return redirect(url_for('todo.tags', author=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/todo/metapackages/")
|
||||
@login_required
|
||||
def metapackages():
|
||||
mpackages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).all()
|
||||
|
||||
return render_template("todo/metapackages.html", mpackages=mpackages)
|
||||
|
||||
|
||||
@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.atLeast(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()
|
||||
|
||||
packages_with_small_screenshots = user.maintained_packages \
|
||||
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
||||
.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()
|
||||
|
||||
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==None) \
|
||||
.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,
|
||||
needs_tags=needs_tags, topics_to_add=topics_to_add,
|
||||
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.atLeast(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.checkPerm(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.get_title()
|
||||
ref = package.update_config.get_ref()
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.title = title
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
db.session.add(rel)
|
||||
db.session.commit()
|
||||
|
||||
makeVCSRelease.apply_async((rel.id, ref),
|
||||
task_id=rel.task_id)
|
||||
|
||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
||||
rel.getURL("packages.create_edit"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("todo.view_user", username=username))
|
||||
|
||||
|
||||
@bp.route("/todo/outdated/")
|
||||
@login_required
|
||||
def outdated():
|
||||
is_mtm_only = isYes(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)
|
|
@ -0,0 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("users", __name__)
|
||||
|
||||
from . import profile, claim, account, settings
|
|
@ -0,0 +1,427 @@
|
|||
# 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 *
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
|
||||
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
|
||||
nonEmptyOrNone, post_login, is_username_valid
|
||||
from passlib.pwd import genphrase
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
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
|
||||
|
||||
addAuditLog(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():
|
||||
if current_user.is_authenticated:
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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=[nonEmptyOrNone])
|
||||
username = StringField(lazy_gettext("Username"), [InputRequired(),
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
|
||||
submit = SubmitField(lazy_gettext("Register"))
|
||||
|
||||
|
||||
def handle_register(form):
|
||||
if form.question.data.strip().lower() != "19":
|
||||
flash(gettext("Incorrect captcha answer"), "danger")
|
||||
return
|
||||
|
||||
if not is_username_valid(form.username.data):
|
||||
flash(gettext("Username is invalid"))
|
||||
return
|
||||
|
||||
user_by_name = User.query.filter(or_(
|
||||
User.username == form.username.data,
|
||||
User.username == form.display_name.data,
|
||||
User.display_name == form.display_name.data,
|
||||
User.forums_username == form.username.data,
|
||||
User.github_username == form.username.data)).first()
|
||||
if user_by_name:
|
||||
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
|
||||
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
|
||||
else:
|
||||
flash(gettext("That username/display name is already in use, please choose another."), "danger")
|
||||
return
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author==form.username.data,
|
||||
PackageAlias.author==form.display_name.data)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("That username/display name is already in use, please choose another."), "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("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))
|
||||
return redirect(url_for("users.email_sent"))
|
||||
elif 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
|
||||
|
||||
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
|
||||
user.notification_preferences = UserNotificationPreferences(user)
|
||||
if form.display_name.data:
|
||||
user.display_name = form.display_name.data
|
||||
db.session.add(user)
|
||||
|
||||
addAuditLog(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
token = randomString(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,
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
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 = randomString(32)
|
||||
|
||||
addAuditLog(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(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
||||
validators.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(8, 100)])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 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
|
||||
|
||||
addAuditLog(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"):
|
||||
newEmail = nonEmptyOrNone(form.email.data)
|
||||
if newEmail and newEmail != 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 = randomString(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = current_user
|
||||
ver.token = token
|
||||
ver.email = newEmail
|
||||
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,
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
@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, optional=request.args.get("optional"),
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
@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"))
|
||||
|
||||
delta = (datetime.datetime.now() - ver.created_at)
|
||||
delta: datetime.timedelta
|
||||
if delta.total_seconds() > 12*60*60:
|
||||
flash(gettext("Token has expired"), "danger")
|
||||
db.session.delete(ver)
|
||||
db.session.commit()
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
user = ver.user
|
||||
|
||||
addAuditLog(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 = randomString(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")
|
|
@ -0,0 +1,116 @@
|
|||
# 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 . import bp
|
||||
from flask import redirect, render_template, session, request, flash, url_for
|
||||
from app.models import db, User, UserRank
|
||||
from app.utils import randomString, login_user_set_active, is_username_valid
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
from app.utils.phpbbparser import getProfile
|
||||
|
||||
|
||||
@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():
|
||||
username = request.args.get("username")
|
||||
if username is None:
|
||||
username = ""
|
||||
else:
|
||||
method = request.args.get("method")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.atLeast(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"), "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
else:
|
||||
return redirect(url_for("github.start"))
|
||||
|
||||
if "forum_token" in session:
|
||||
token = session["forum_token"]
|
||||
else:
|
||||
token = randomString(12)
|
||||
session["forum_token"] = token
|
||||
|
||||
if request.method == "POST":
|
||||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
elif ctype == "github":
|
||||
task = checkForumAccount.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.atLeast(UserRank.NEW_MEMBER):
|
||||
flash(gettext("That user has already been claimed!"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
# Get signature
|
||||
sig = None
|
||||
try:
|
||||
profile = getProfile("https://forum.minetest.net", 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)
|
|
@ -0,0 +1,251 @@
|
|||
# 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
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
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]):
|
||||
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))
|
||||
else:
|
||||
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])
|
||||
if top_rank == 1:
|
||||
title = gettext(u"Top %(type)s", type=top_type.text.lower())
|
||||
else:
|
||||
title = gettext(u"Top %(group)d %(type)s", group=top_rank, type=top_type.text.lower())
|
||||
if top_type == PackageType.MOD:
|
||||
icon = "fa-box"
|
||||
elif top_type == PackageType.GAME:
|
||||
icon = "fa-gamepad"
|
||||
else:
|
||||
icon = "fa-paint-brush"
|
||||
|
||||
description = gettext(u"%(display_name)s has a %(type)s placed at #%(place)d.",
|
||||
display_name=user.display_name, type=top_type.text.lower(), place=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.canAccessTodoList()):
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/check/", methods=["POST"])
|
||||
@login_required
|
||||
def user_check(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
|
||||
abort(403)
|
||||
|
||||
if user.forums_username is None:
|
||||
abort(404)
|
||||
|
||||
task = checkForumAccount.delay(user.forums_username)
|
||||
next_url = url_for("users.profile", username=username)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
|
@ -0,0 +1,368 @@
|
|||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
|
||||
from app.tasks.emails import send_verify_email
|
||||
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 current_user.rank.atLeast(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: nonEmptyOrNone(x)])
|
||||
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, user, username):
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
if user.checkPerm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
|
||||
user.display_name != form.display_name.data:
|
||||
if User.query.filter(User.id != user.id,
|
||||
or_(User.username == form.display_name.data,
|
||||
User.display_name.ilike(form.display_name.data))).count() > 0:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return None
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author == form.display_name.data)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return
|
||||
|
||||
user.display_name = form.display_name.data
|
||||
|
||||
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
|
||||
addAuditLog(severity, current_user, "Changed display name of {} to {}"
|
||||
.format(user.username, user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
|
||||
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):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("users.profile", username=username))
|
||||
|
||||
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.toName()
|
||||
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.toName()).data
|
||||
field_digest = getattr(form, "pref_" + notificationType.toName() + "_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.checkPerm(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 = randomString(32)
|
||||
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
|
||||
msg = "Changed email of {}".format(user.display_name)
|
||||
addAuditLog(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.checkPerm(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.toName()] = prefs.get_can_email(notificationType)
|
||||
data["pref_" + notificationType.toName() + "_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)
|
||||
|
||||
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
|
||||
|
||||
|
||||
@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.atLeast(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.atLeast(UserRank.ADMIN)):
|
||||
msg = "Deleted user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
if current_user.rank.atLeast(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:
|
||||
user.replies.delete()
|
||||
for thread in user.threads.all():
|
||||
db.session.delete(thread)
|
||||
user.email = None
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
|
||||
msg = "Deactivated user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(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.checkPerm(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
|
||||
addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
# Copy form fields to user_profile fields
|
||||
if user.checkPerm(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 = nonEmptyOrNone(form.forums_username.data)
|
||||
user.github_username = nonEmptyOrNone(form.github_username.data)
|
||||
|
||||
if user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
newRank = form["rank"].data
|
||||
if current_user.rank.atLeast(newRank):
|
||||
if newRank != user.rank:
|
||||
user.rank = form["rank"].data
|
||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
|
||||
addAuditLog(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(url_for("users.modtools", username=username))
|
||||
|
||||
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.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
user.email = request.form["email"]
|
||||
user.is_active = False
|
||||
|
||||
token = randomString(32)
|
||||
addAuditLog(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.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
user.rank = UserRank.BANNED
|
||||
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
|
||||
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))
|
|
@ -1,83 +1,115 @@
|
|||
# Content DB
|
||||
# Copyright (C) 2018 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
|
||||
# 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/>.
|
||||
from .models import *
|
||||
from .utils import make_flask_login_password
|
||||
|
||||
|
||||
import os, sys, datetime
|
||||
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)
|
||||
|
||||
if not "FLASK_CONFIG" in os.environ:
|
||||
os.environ["FLASK_CONFIG"] = "../config.cfg"
|
||||
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)
|
||||
|
||||
test_data = len(sys.argv) >= 2 and sys.argv[1].strip() == "-t"
|
||||
session.add(MinetestRelease("None", 0))
|
||||
session.add(MinetestRelease("0.4.16/17", 32))
|
||||
session.add(MinetestRelease("5.0", 37))
|
||||
session.add(MinetestRelease("5.1", 38))
|
||||
session.add(MinetestRelease("5.2", 39))
|
||||
session.add(MinetestRelease("5.3", 39))
|
||||
|
||||
from app.models import *
|
||||
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", "Featured"]:
|
||||
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 = MinetestRelease.query.filter_by(protocol=32).first()
|
||||
v50 = MinetestRelease.query.filter_by(protocol=37).first()
|
||||
v51 = MinetestRelease.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.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 +124,26 @@ awards.register_achievement("award_mesefind",{
|
|||
|
||||
rel = PackageRelease()
|
||||
rel.package = mod1
|
||||
rel.min_rel = v51
|
||||
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 +216,39 @@ 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.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,92 +256,127 @@ 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.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["MIT"]
|
||||
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.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():
|
||||
|
@ -323,47 +385,9 @@ Uses the CTF PvP Engine.
|
|||
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)
|
|
@ -1,4 +1,30 @@
|
|||
title: Help
|
||||
toc: False
|
||||
|
||||
* [Package Tags](package_tags)
|
||||
|
||||
## General Help
|
||||
|
||||
* [Frequently Asked Questions](faq)
|
||||
* [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)
|
||||
|
||||
## Help for Package Authors
|
||||
|
||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
||||
* [Git Update Detection](update_config)
|
||||
* [Creating Releases using Webhooks](release_webhooks)
|
||||
* [Package Configuration and Releases Guide](package_config)
|
||||
|
||||
## Help for Specific User Ranks
|
||||
|
||||
* [Editors](editors)
|
||||
|
||||
## APIs
|
||||
|
||||
* [API](api)
|
||||
* [Prometheus Metrics](metrics)
|
||||
|
|
|
@ -0,0 +1,398 @@
|
|||
title: API
|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_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.minetest.net/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.
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
* GET `/api/packages/` (List)
|
||||
* See [Package Queries](#package-queries)
|
||||
* GET `/api/packages/<username>/<name>/` (Read)
|
||||
* PUT `/api/packages/<author>/<name>/` (Update)
|
||||
* Requires authentication.
|
||||
* JSON dictionary 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`: Git repo URL.
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
|
||||
* 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 metapackage dependency (`name`), or a
|
||||
package dependency (`author/name`).
|
||||
* `optional_depends`: list of optional dependencies
|
||||
* Same as above.
|
||||
|
||||
You can download a package by building one of the two URLs:
|
||||
|
||||
```
|
||||
https://content.minetest.net/packages/${author}/${name}/download/`
|
||||
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Edit package
|
||||
curl -X PUT https://content.minetest.net/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.minetest.net/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
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
* `type`: Package types (`mod`, `game`, `txp`).
|
||||
* `q`: Query string.
|
||||
* `author`: Filter by author.
|
||||
* `tag`: Filter by tags.
|
||||
* `random`: When present, enable random ordering and ignore `sort`.
|
||||
* `limit`: Return at most `limit` packages.
|
||||
* `hide`: Hide content based on [Content Flags](/help/content_flags/).
|
||||
* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
|
||||
* `order`: Sort ascending (`asc`) or descending (`desc`).
|
||||
* `protocol_version`: Only show packages supported by this Minetest protocol version.
|
||||
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
|
||||
* `fmt`: How the response is formated.
|
||||
* `keys`: author/name only.
|
||||
* `short`: stuff needed for the Minetest client.
|
||||
|
||||
|
||||
## 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
|
||||
* `title`: human-readable title
|
||||
* `release_date`: Date released
|
||||
* `url`: download URL
|
||||
* `commit`: commit hash or null
|
||||
* `downloads`: number of downloads
|
||||
* `min_minetest_version`: dict or null, minimum supported minetest version (inclusive).
|
||||
* `max_minetest_version`: dict or null, minimum supported minetest version (inclusive).
|
||||
* `package`
|
||||
* `author`: author username
|
||||
* `name`: technical name
|
||||
* `type`: `mod`, `game`, or `txp`
|
||||
* 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.
|
||||
* 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 Minetest 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.minetest.net/api/packages/username/name/releases/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "method": "git", "title": "My Release", "ref": "master" }'
|
||||
|
||||
# Create release from zip upload
|
||||
curl -X POST https://content.minetest.net/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.minetest.net/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.minetest.net/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.minetest.net/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.minetest.net/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.minetest.net/api/packages/username/name/screenshots/3/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
|
||||
# Reorder screenshots
|
||||
curl -X POST https://content.minetest.net/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.minetest.net/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
|
||||
* `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`
|
||||
* Query arguments:
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `n`: number of results per page, max 100
|
||||
* `author`: filter by review author username
|
||||
* `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
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## 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.
|
||||
* `show_discarded`: Show topics marked as discarded.
|
||||
* `limit`: Return at most `limit` topics.
|
||||
|
||||
## Types
|
||||
|
||||
### Tags
|
||||
|
||||
* GET `/api/tags/` ([View](/api/tags/)): List of:
|
||||
* `name`: technical name.
|
||||
* `title`: human-readable title.
|
||||
* `description`: tag description or null.
|
||||
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
|
||||
* `views`: number of views of this tag.
|
||||
|
||||
### Content Warnings
|
||||
|
||||
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
|
||||
* `name`: technical name
|
||||
* `title`: human-readable title
|
||||
* `description`: tag description or null
|
||||
|
||||
### Licenses
|
||||
|
||||
* GET `/api/licenses/` ([View](/api/licenses/)): List of:
|
||||
* `name`
|
||||
* `is_foss`: whether the license is foss
|
||||
|
||||
### Minetest Versions
|
||||
|
||||
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
|
||||
* `name`: Version name.
|
||||
* `is_dev`: boolean, is dev version.
|
||||
* `protocol_version`: protocol version umber.
|
||||
|
||||
|
||||
## 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.
|
||||
* 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/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
|
||||
* `featured`: featured games
|
|
@ -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="https://rubenwardy.com/contact/" class="btn btn-primary">Contact the admin</a>
|
|
@ -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
|
||||
|
||||
Minetest 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.
|
||||
|
||||
* `bad_language`: swearing.
|
||||
* `drugs`: drugs or alcohol.
|
||||
* `gambling`
|
||||
* `gore`: blood, etc.
|
||||
* `horror`: shocking and scary content.
|
||||
* `violence`: non-cartoon violence.
|
|
@ -0,0 +1,34 @@
|
|||
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, but it also does have some
|
||||
requirements on minimum usefulness. 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.
|
||||
Editors can create the packages or "discard" them if they don't think it's worth adding them.
|
||||
|
||||
## 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.
|
|
@ -0,0 +1,50 @@
|
|||
title: Frequently Asked Questions
|
||||
|
||||
## 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 login, 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.
|
||||
|
||||
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.
|
||||
|
||||
If the email verification was sent using the Email settings tab, then you can just set a new 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 rubenwardy](https://rubenwardy.com/contact/) to
|
||||
remove your email from the blacklist.
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
### How can I create releases automatically?
|
||||
|
||||
There are a number of methods:
|
||||
|
||||
* [Git Update Detection](update_config): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
||||
* [Webhooks](release_webhooks): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
||||
* the [API](api): This is especially powerful when combined with CI/CD and other API endpoints.
|
||||
|
||||
|
||||
## How do I get help?
|
||||
|
||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
|
@ -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 Minetest client.
|
||||
|
||||
The purpose is to promote content that demonstrates a high quality of what is
|
||||
possible in Minetest. 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 Minetest 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 Minetest) 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.
|
|
@ -0,0 +1,23 @@
|
|||
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.
|
||||
|
||||
<p>
|
||||
<a class="btn btn-primary" href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">
|
||||
View ContentDB on Grafana
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Metrics
|
||||
|
||||
* `contentdb_packages` - Total packages (counter).
|
||||
* `contentdb_users` - Number of registered users (counter).
|
||||
* `contentdb_downloads` - Total downloads (counter).
|
||||
* `contentdb_score` - Total package score (gauge).
|
|
@ -0,0 +1,73 @@
|
|||
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.**
|
||||
|
||||
Minetest 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 Minetest avoid ending up in
|
||||
such a state. Licenses that prohibit redistribution or modification are
|
||||
completely banned from ContentDB and the Minetest 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 Minetest 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>
|
||||
|
||||
In the future, [the `platform_default` flag](/help/content_flags/) will be used to control what content
|
||||
each platforms shows - Android is significantly stricter about mature content.
|
||||
You may wish to remove all text from that setting completely, leaving it blank,
|
||||
if you wish to view all content when this happens. Currently, [mature content is
|
||||
not permitted on ContentDB](/policy_and_guidance/).
|
|
@ -0,0 +1,128 @@
|
|||
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:
|
||||
|
||||
name = mymod
|
||||
description = A short description to show in the client.
|
||||
|
||||
### Understood values
|
||||
|
||||
ContentDB understands the following information:
|
||||
|
||||
* `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 Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
|
||||
* `max_minetest_version` - The maximum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
|
||||
|
||||
and for mods only:
|
||||
|
||||
* `name` - the mod technical name.
|
||||
|
||||
|
||||
## .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`: Git repo URL.
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
|
||||
Use `null` 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 Minetest Versions
|
||||
|
||||
<a name="min_max_versions" />
|
||||
|
||||
When creating a release, the `.conf` file will be read to determine what Minetest
|
||||
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.
|
|
@ -3,24 +3,26 @@ 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.
|
||||
* **Members** - Trusted to change the meta data 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.
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
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.
|
||||
|
||||
<p class="alert alert-warning">
|
||||
"New commit" or "push" based webhooks will currently only work on branches named `master` or
|
||||
`main`.
|
||||
</p>
|
||||
|
||||
## 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.minetest.net/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.
|
||||
|
||||
### 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.minetest.net/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.
|
||||
|
||||
## 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 Minetest versions, which files are included,
|
||||
and update the package meta.
|
|
@ -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 5% (=1/20) 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/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).
|
|
@ -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 Minetest versions, which files are included,
|
||||
and update the package meta.
|
|
@ -0,0 +1,40 @@
|
|||
title: WTFPL is a terrible license
|
||||
toc: False
|
||||
|
||||
<div id="warning" class="alert alert-warning">
|
||||
<span class="icon_message"></span>
|
||||
|
||||
Please reconsider the choice of WTFPL as a license.
|
||||
|
||||
<script src="/static/libs/jquery.min.js"></script>
|
||||
<script>
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var r = params.get("r");
|
||||
if (r)
|
||||
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
|
||||
else
|
||||
$("#warning").hide();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
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/minutes20090304)
|
|
@ -0,0 +1,156 @@
|
|||
title: Package Inclusion Policy and Guidance
|
||||
|
||||
## 0. Overview
|
||||
|
||||
ContentDB is for the community, and as such listings should be useful to the
|
||||
community. To help with this, there are a few rules to improve the quality of
|
||||
the listings and to combat abuse.
|
||||
|
||||
* **No inappropriate content.** <sup>2.1</sup>
|
||||
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
|
||||
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
|
||||
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
|
||||
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
|
||||
* **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. <sup>1</sup>
|
||||
|
||||
|
||||
## 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. Acceptable Content
|
||||
|
||||
Sexually-orientated content is not permitted.
|
||||
If in doubt at what this means, [contact us by raising a report](/report/).
|
||||
|
||||
Mature content is permitted providing that it is labelled correctly.
|
||||
See [Content Flags](/help/content_flags/).
|
||||
|
||||
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.
|
||||
|
||||
### 2.2. State of Completion
|
||||
|
||||
ContentDB should only currently contain playable 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;
|
||||
MineClone 2 is a good example of a WIP package which may break between releases
|
||||
but still has value. Note that this doesn't mean that you should add a thing
|
||||
you started working on yesterday, it's worth adding all the basic stuff to
|
||||
make your package useful.
|
||||
|
||||
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
|
||||
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 them to be installed when a mod depends on it.
|
||||
|
||||
|
||||
## 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. Mod 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
|
||||
should 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.
|
||||
|
||||
|
||||
## 4. Licenses
|
||||
|
||||
### 4.1. Allowed Licenses
|
||||
|
||||
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
||||
that you have used in your package.
|
||||
|
||||
**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.
|
||||
|
||||
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.2. 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 being massively penalised in the search results and
|
||||
package lists. 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 meta data 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. Reporting Violations
|
||||
|
||||
Please click "Report" on the package page.
|
|
@ -0,0 +1,100 @@
|
|||
title: Privacy Policy
|
||||
|
||||
Last Updated: 2022-01-23
|
||||
([View updates](https://github.com/minetest/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
|
||||
* Response status code
|
||||
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
|
||||
|
||||
**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.
|
||||
* 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.
|
||||
* 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 Minetest 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 may be able to see the actions on a package in the future.
|
||||
* Preferred language can only be viewed by this with access to the database or a backup.
|
||||
* We may be required to share information with law enforcement.
|
||||
|
||||
## 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 as needed.
|
||||
|
||||
## Period of Retention
|
||||
|
||||
The server uses log rotation, meaning that any logged HTTP requests will be
|
||||
forgotten within a few weeks.
|
||||
|
||||
Usernames may be kept indefinitely, but other user information will be deleted if
|
||||
requested. See below.
|
||||
|
||||
## Removal Requests
|
||||
|
||||
Please [raise a report](https://content.minetest.net/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 email and
|
||||
via notices on the ContentDB website.
|
||||
|
||||
By continuing to use this service, you agree to the privacy policy.
|
|
@ -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,0 +1,188 @@
|
|||
# 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/>.
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
from typing import List, Dict, Optional, Iterator, Iterable
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
|
||||
|
||||
"""
|
||||
get_game_support(package):
|
||||
if package is a game:
|
||||
return [ package ]
|
||||
|
||||
for all hard dependencies:
|
||||
support = support AND get_meta_package_support(dep)
|
||||
|
||||
return support
|
||||
|
||||
get_meta_package_support(meta):
|
||||
for package implementing meta package:
|
||||
support = support OR get_game_support(package)
|
||||
|
||||
return support
|
||||
"""
|
||||
|
||||
|
||||
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 = {
|
||||
"repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
||||
"survivethedays"
|
||||
}
|
||||
|
||||
|
||||
class PackageSet:
|
||||
packages: Dict[str, Package]
|
||||
|
||||
def __init__(self, packages: Optional[Iterable[Package]] = None):
|
||||
self.packages = {}
|
||||
if packages:
|
||||
self.update(packages)
|
||||
|
||||
def update(self, packages: Iterable[Package]):
|
||||
for package in packages:
|
||||
key = package.getId()
|
||||
if key not in self.packages:
|
||||
self.packages[key] = package
|
||||
|
||||
def intersection_update(self, other):
|
||||
keys = set(self.packages.keys())
|
||||
keys.difference_update(set(other.packages.keys()))
|
||||
for key in keys:
|
||||
del self.packages[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.packages)
|
||||
|
||||
def __iter__(self):
|
||||
return self.packages.values().__iter__()
|
||||
|
||||
|
||||
class GameSupportResolver:
|
||||
checked_packages = set()
|
||||
checked_metapackages = set()
|
||||
resolved_packages: Dict[str, PackageSet] = {}
|
||||
resolved_metapackages: Dict[str, PackageSet] = {}
|
||||
|
||||
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> PackageSet:
|
||||
print(f"Resolving for {meta.name}", file=sys.stderr)
|
||||
|
||||
key = meta.name
|
||||
if key in self.resolved_metapackages:
|
||||
return self.resolved_metapackages.get(key)
|
||||
|
||||
if key in self.checked_metapackages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_metapackages.add(key)
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for package in meta.packages:
|
||||
if package.state != PackageState.APPROVED:
|
||||
continue
|
||||
|
||||
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
|
||||
continue
|
||||
|
||||
ret = self.resolve(package, history)
|
||||
if len(ret) == 0:
|
||||
retval = PackageSet()
|
||||
break
|
||||
|
||||
retval.update(ret)
|
||||
|
||||
self.resolved_metapackages[key] = retval
|
||||
return retval
|
||||
|
||||
def resolve(self, package: Package, history: List[str]) -> PackageSet:
|
||||
db.session.merge(package)
|
||||
|
||||
key = package.getId()
|
||||
print(f"Resolving for {key}", file=sys.stderr)
|
||||
|
||||
history = history.copy()
|
||||
history.append(key)
|
||||
|
||||
if package.type == PackageType.GAME:
|
||||
return PackageSet([package])
|
||||
|
||||
if key in self.resolved_packages:
|
||||
return self.resolved_packages.get(key)
|
||||
|
||||
if key in self.checked_packages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_packages.add(key)
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
raise LogicError(500, "Got non-mod")
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for dep in package.dependencies.filter_by(optional=False).all():
|
||||
ret = self.resolve_for_meta_package(dep.meta_package, history)
|
||||
if len(ret) == 0:
|
||||
continue
|
||||
elif len(retval) == 0:
|
||||
retval.update(ret)
|
||||
else:
|
||||
retval.intersection_update(ret)
|
||||
if len(retval) == 0:
|
||||
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
|
||||
|
||||
self.resolved_packages[key] = retval
|
||||
return retval
|
||||
|
||||
def update_all(self) -> None:
|
||||
for package in Package.query.filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
|
||||
def update(self, package: Package) -> None:
|
||||
previous_supported: Dict[str, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.getId()] = support
|
||||
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
assert game
|
||||
|
||||
lookup = previous_supported.pop(game.getId(), None)
|
||||
if lookup is None:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
elif lookup.confidence == 0:
|
||||
lookup.supports = True
|
||||
db.session.merge(lookup)
|
||||
|
||||
for game, support in previous_supported.items():
|
||||
if support.confidence == 0:
|
||||
db.session.remove(support)
|
|
@ -0,0 +1,196 @@
|
|||
# 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 re
|
||||
import validators
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
||||
License, UserRank, PackageDevState
|
||||
from app.utils import addAuditLog
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
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,
|
||||
}
|
||||
|
||||
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 not None:
|
||||
typ = ALLOWED_FIELDS.get(key)
|
||||
check(typ is not None, key + " is not a known field")
|
||||
if typ != AnyType:
|
||||
check(isinstance(value, typ), 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://"),
|
||||
key + " must start with http:// or https://")
|
||||
|
||||
check(validators.url(value, public=True), 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):
|
||||
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
|
||||
|
||||
if "name" in data and package.name != data["name"] and \
|
||||
not package.checkPerm(user, Permission.CHANGE_NAME):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
|
||||
|
||||
for alias, to in ALIASES.items():
|
||||
if alias in data:
|
||||
data[to] = data[alias]
|
||||
|
||||
validate(data)
|
||||
|
||||
if "type" in data:
|
||||
data["type"] = PackageType.coerce(data["type"])
|
||||
|
||||
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 "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", "type", "dev_state", "license", "media_license",
|
||||
"repo", "website", "issueTracker", "forums", "video_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:
|
||||
old_tags = list(package.tags)
|
||||
package.tags.clear()
|
||||
for tag_id in data["tags"]:
|
||||
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)
|
||||
|
||||
if not was_web and tag.is_protected:
|
||||
continue
|
||||
|
||||
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
|
||||
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
|
||||
|
||||
package.tags.append(tag)
|
||||
|
||||
if not was_web:
|
||||
for tag in old_tags:
|
||||
if tag.is_protected:
|
||||
package.tags.append(tag)
|
||||
|
||||
if "content_warnings" in data:
|
||||
package.content_warnings.clear()
|
||||
for warning_id in data["content_warnings"]:
|
||||
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)
|
||||
|
||||
if not was_new:
|
||||
if reason is None:
|
||||
msg = "Edited {}".format(package.title)
|
||||
else:
|
||||
msg = "Edited {} ({})".format(package.title, reason)
|
||||
|
||||
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
|
||||
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return package
|
|
@ -0,0 +1,98 @@
|
|||
# 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, re
|
||||
|
||||
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, MinetestRelease
|
||||
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
|
||||
from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
|
||||
|
||||
|
||||
def check_can_create_release(user: User, package: Package):
|
||||
if not package.checkPerm(user, Permission.MAKE_RELEASE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
count = package.releases.filter(PackageRelease.releaseDate > 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"))
|
||||
|
||||
|
||||
def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
|
||||
check_can_create_release(user, package)
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.title = title
|
||||
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)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
|
||||
|
||||
return rel
|
||||
|
||||
|
||||
def do_create_zip_release(user: User, package: Package, title: str, file,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
|
||||
commit_hash: str = None):
|
||||
check_can_create_release(user, package)
|
||||
|
||||
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.title = title
|
||||
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)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
|
||||
|
||||
return rel
|
|
@ -0,0 +1,87 @@
|
|||
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 addNotification, addAuditLog
|
||||
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 or JPG 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.checkPerm(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)
|
||||
|
||||
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("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 as e:
|
||||
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
|
||||
except (ValueError, TypeError) as e:
|
||||
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) as e:
|
||||
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")
|
|
@ -0,0 +1,63 @@
|
|||
# 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 imghdr
|
||||
import os
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import *
|
||||
from app.utils import randomString
|
||||
|
||||
|
||||
def get_extension(filename):
|
||||
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
|
||||
|
||||
ALLOWED_IMAGES = {"jpeg", "png"}
|
||||
def isAllowedImage(data):
|
||||
return imghdr.what(None, data) in ALLOWED_IMAGES
|
||||
|
||||
def upload_file(file, fileType, fileTypeDesc):
|
||||
if not file or file is None or file.filename == "":
|
||||
raise LogicError(400, "Expected file")
|
||||
|
||||
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
|
||||
|
||||
isImage = False
|
||||
if fileType == "image":
|
||||
allowedExtensions = ["jpg", "jpeg", "png"]
|
||||
isImage = True
|
||||
elif fileType == "zip":
|
||||
allowedExtensions = ["zip"]
|
||||
else:
|
||||
raise Exception("Invalid fileType")
|
||||
|
||||
ext = get_extension(file.filename)
|
||||
if ext is None or not ext in allowedExtensions:
|
||||
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
|
||||
|
||||
if isImage and not isAllowedImage(file.stream.read()):
|
||||
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
|
||||
|
||||
file.stream.seek(0)
|
||||
|
||||
filename = randomString(10) + "." + ext
|
||||
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
|
||||
file.save(filepath)
|
||||
|
||||
return "/uploads/" + filename, filepath
|
|
@ -0,0 +1,96 @@
|
|||
import logging
|
||||
|
||||
from app.tasks.emails import send_user_email
|
||||
|
||||
|
||||
def _has_newline(line):
|
||||
"""Used by has_bad_header to check for \\r or \\n"""
|
||||
if line and ("\r" in line or "\n" in line):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_bad_subject(subject):
|
||||
"""Copied from: flask_mail.py class Message def has_bad_headers"""
|
||||
if _has_newline(subject):
|
||||
for linenum, line in enumerate(subject.split("\r\n")):
|
||||
if not line:
|
||||
return True
|
||||
if linenum > 0 and line[0] not in "\t ":
|
||||
return True
|
||||
if _has_newline(line):
|
||||
return True
|
||||
if len(line.strip()) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FlaskMailSubjectFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
record.message = record.getMessage()
|
||||
if self.usesTime():
|
||||
record.asctime = self.formatTime(record, self.datefmt)
|
||||
s = self.formatMessage(record)
|
||||
return s
|
||||
|
||||
class FlaskMailTextFormatter(logging.Formatter):
|
||||
pass
|
||||
|
||||
class FlaskMailHTMLFormatter(logging.Formatter):
|
||||
def formatException(self, exc_info):
|
||||
formatted_exception = logging.Handler.formatException(self, exc_info)
|
||||
return "<pre>%s</pre>" % formatted_exception
|
||||
def formatStack(self, stack_info):
|
||||
return "<pre>%s</pre>" % stack_info
|
||||
|
||||
|
||||
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
|
||||
|
||||
class FlaskMailHandler(logging.Handler):
|
||||
def __init__(self, send_to, subject_template, level=logging.NOTSET):
|
||||
logging.Handler.__init__(self, level)
|
||||
self.send_to = send_to
|
||||
self.subject_template = subject_template
|
||||
|
||||
def setFormatter(self, text_fmt):
|
||||
"""
|
||||
Set the formatters for this handler. Provide at least one formatter.
|
||||
When no text_fmt is provided, no text-part is created for the email body.
|
||||
"""
|
||||
assert text_fmt != None, "At least one formatter should be provided"
|
||||
if type(text_fmt)==str:
|
||||
text_fmt = FlaskMailTextFormatter(text_fmt)
|
||||
self.formatter = text_fmt
|
||||
|
||||
def getSubject(self, record):
|
||||
fmt = FlaskMailSubjectFormatter(self.subject_template)
|
||||
subject = fmt.format(record)
|
||||
# Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
|
||||
if _is_bad_subject(subject):
|
||||
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
|
||||
return subject
|
||||
|
||||
def emit(self, record):
|
||||
subject = self.getSubject(record)
|
||||
text = self.format(record) if self.formatter else None
|
||||
html = "<pre>{}</pre>".format(text)
|
||||
|
||||
if "The recipient has exceeded message rate limit. Try again later" in subject:
|
||||
return
|
||||
|
||||
for email in self.send_to:
|
||||
send_user_email.delay(email, "en", subject, text, html)
|
||||
|
||||
|
||||
def build_handler(app):
|
||||
subject_template = "ContentDB %(message)s (%(module)s > %(funcName)s)"
|
||||
text_template = ("Message type: %(levelname)s\n"
|
||||
"Location: %(pathname)s:%(lineno)d\n"
|
||||
"Module: %(module)s\n"
|
||||
"Function: %(funcName)s\n"
|
||||
"Time: %(asctime)s\n"
|
||||
"Message: %(message)s\n\n")
|
||||
|
||||
mail_handler = FlaskMailHandler(app.config["MAIL_UTILS_ERROR_SEND_TO"], subject_template)
|
||||
mail_handler.setLevel(logging.ERROR)
|
||||
mail_handler.setFormatter(text_template)
|
||||
return mail_handler
|
|
@ -0,0 +1,179 @@
|
|||
from functools import partial
|
||||
|
||||
import bleach
|
||||
from bleach import Cleaner
|
||||
from bleach.linkifier import LinkifyFilter
|
||||
from bs4 import BeautifulSoup
|
||||
from markdown import Markdown
|
||||
from flask import Markup, url_for
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import SimpleTagInlineProcessor
|
||||
from markdown.inlinepatterns import Pattern
|
||||
from xml.etree import ElementTree
|
||||
|
||||
# Based on
|
||||
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
||||
#
|
||||
# License: MIT
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||
"ul", "ol", "li",
|
||||
"p",
|
||||
"br",
|
||||
"pre",
|
||||
"code",
|
||||
"blockquote",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"div", "span", "del", "s",
|
||||
]
|
||||
|
||||
ALLOWED_CSS = [
|
||||
"highlight", "codehilite",
|
||||
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
||||
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
||||
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
||||
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
||||
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
||||
]
|
||||
|
||||
|
||||
def allow_class(_tag, name, value):
|
||||
return name == "class" and value in ALLOWED_CSS
|
||||
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
"h1": ["id"],
|
||||
"h2": ["id"],
|
||||
"h3": ["id"],
|
||||
"h4": ["id"],
|
||||
"a": ["href", "title", "data-username"],
|
||||
"img": ["src", "title", "alt"],
|
||||
"code": allow_class,
|
||||
"div": allow_class,
|
||||
"span": allow_class,
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||
|
||||
md = None
|
||||
|
||||
|
||||
def render_markdown(source):
|
||||
html = md.convert(source)
|
||||
|
||||
cleaner = Cleaner(
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
|
||||
return cleaner.clean(html)
|
||||
|
||||
|
||||
class DelInsExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
|
||||
md.inlinePatterns.register(del_proc, "del", 200)
|
||||
|
||||
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
|
||||
md.inlinePatterns.register(ins_proc, "ins", 200)
|
||||
|
||||
|
||||
RE_PARTS = dict(
|
||||
USER=r"[A-Za-z0-9._-]*\b",
|
||||
REPO=r"[A-Za-z0-9_]+\b"
|
||||
)
|
||||
|
||||
|
||||
class MentionPattern(Pattern):
|
||||
ANCESTOR_EXCLUDES = ("a",)
|
||||
|
||||
def __init__(self, config, md):
|
||||
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
|
||||
super(MentionPattern, self).__init__(MENTION_RE, md)
|
||||
self.config = config
|
||||
|
||||
def handleMatch(self, m):
|
||||
from app.models import User
|
||||
|
||||
label = m.group(2)
|
||||
user = m.group(3)
|
||||
package_name = m.group(4)
|
||||
if package_name:
|
||||
el = ElementTree.Element("a")
|
||||
el.text = label
|
||||
el.set("href", url_for("packages.view", author=user, name=package_name))
|
||||
return el
|
||||
else:
|
||||
if User.query.filter_by(username=user).count() == 0:
|
||||
return None
|
||||
|
||||
el = ElementTree.Element("a")
|
||||
el.text = label
|
||||
el.set("href", url_for("users.profile", username=user))
|
||||
el.set("data-username", user)
|
||||
return el
|
||||
|
||||
|
||||
class MentionExtension(Extension):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MentionExtension, self).__init__(*args, **kwargs)
|
||||
|
||||
def extendMarkdown(self, md):
|
||||
md.ESCAPED_CHARS.append("@")
|
||||
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
|
||||
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
|
||||
MARKDOWN_EXTENSION_CONFIG = {
|
||||
"fenced_code": {},
|
||||
"tables": {},
|
||||
"codehilite": {
|
||||
"guess_lang": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def init_markdown(app):
|
||||
global md
|
||||
|
||||
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
|
||||
extension_configs=MARKDOWN_EXTENSION_CONFIG,
|
||||
output_format="html5")
|
||||
|
||||
@app.template_filter()
|
||||
def markdown(source):
|
||||
return Markup(render_markdown(source))
|
||||
|
||||
|
||||
def get_headings(html: str):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
headings = soup.find_all(["h1", "h2", "h3"])
|
||||
|
||||
root = []
|
||||
stack = []
|
||||
for heading in headings:
|
||||
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
|
||||
this_level = int(heading.name[1:]) - 1
|
||||
|
||||
while this_level <= len(stack):
|
||||
stack.pop()
|
||||
|
||||
if len(stack) > 0:
|
||||
stack[-1]["children"].append(this)
|
||||
else:
|
||||
root.append(this)
|
||||
|
||||
stack.append(this)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def get_user_mentions(html: str) -> set:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links = soup.select("a[data-username]")
|
||||
return set([x.get("data-username") for x in links])
|
669
app/models.py
669
app/models.py
|
@ -1,669 +0,0 @@
|
|||
# Content DB
|
||||
# Copyright (C) 2018 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
|
||||
# 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/>.
|
||||
|
||||
|
||||
from flask import Flask, url_for
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from urllib.parse import urlparse
|
||||
from app import app
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import validates
|
||||
from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter
|
||||
import enum
|
||||
|
||||
# Initialise database
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
|
||||
class UserRank(enum.Enum):
|
||||
BANNED = 0
|
||||
NOT_JOINED = 1
|
||||
NEW_MEMBER = 2
|
||||
MEMBER = 3
|
||||
TRUSTED_MEMBER = 4
|
||||
EDITOR = 5
|
||||
MODERATOR = 6
|
||||
ADMIN = 7
|
||||
|
||||
def atLeast(self, min):
|
||||
return self.value >= min.value
|
||||
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == UserRank else UserRank[item]
|
||||
|
||||
|
||||
class Permission(enum.Enum):
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
APPROVE_CHANGES = "APPROVE_CHANGES"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
MAKE_RELEASE = "MAKE_RELEASE"
|
||||
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
|
||||
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
|
||||
APPROVE_RELEASE = "APPROVE_RELEASE"
|
||||
APPROVE_NEW = "APPROVE_NEW"
|
||||
CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
|
||||
CHANGE_DNAME = "CHANGE_DNAME"
|
||||
CHANGE_RANK = "CHANGE_RANK"
|
||||
CHANGE_EMAIL = "CHANGE_EMAIL"
|
||||
EDIT_EDITREQUEST = "EDIT_EDITREQUEST"
|
||||
|
||||
# Only return true if the permission is valid for *all* contexts
|
||||
# See Package.checkPerm for package-specific contexts
|
||||
def check(self, user):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if self == Permission.APPROVE_NEW or \
|
||||
self == Permission.APPROVE_CHANGES or \
|
||||
self == Permission.APPROVE_RELEASE or \
|
||||
self == Permission.APPROVE_SCREENSHOT:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
else:
|
||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# User authentication information
|
||||
username = db.Column(db.String(50), nullable=False, unique=True)
|
||||
password = db.Column(db.String(255), nullable=False, server_default="")
|
||||
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
|
||||
|
||||
rank = db.Column(db.Enum(UserRank))
|
||||
|
||||
# Account linking
|
||||
github_username = db.Column(db.String(50), nullable=True, unique=True)
|
||||
forums_username = db.Column(db.String(50), nullable=True, unique=True)
|
||||
|
||||
# User email information
|
||||
email = db.Column(db.String(255), nullable=True, unique=True)
|
||||
confirmed_at = db.Column(db.DateTime())
|
||||
|
||||
# User information
|
||||
active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
||||
display_name = db.Column(db.String(100), nullable=False, server_default="")
|
||||
|
||||
# Content
|
||||
notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
|
||||
|
||||
# causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic")
|
||||
packages = db.relationship("Package", backref="author", lazy="dynamic")
|
||||
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
|
||||
|
||||
def __init__(self, username):
|
||||
import datetime
|
||||
|
||||
self.username = username
|
||||
self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
|
||||
self.display_name = username
|
||||
self.rank = UserRank.NOT_JOINED
|
||||
|
||||
def canAccessTodoList(self):
|
||||
return Permission.APPROVE_NEW.check(self) or \
|
||||
Permission.APPROVE_RELEASE.check(self) or \
|
||||
Permission.APPROVE_CHANGES.check(self)
|
||||
|
||||
def isClaimed(self):
|
||||
return self.rank.atLeast(UserRank.NEW_MEMBER)
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to User.checkPerm()")
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.CHANGE_AUTHOR:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_DNAME:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
elif perm == Permission.CHANGE_EMAIL:
|
||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
|
||||
else:
|
||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||
|
||||
class UserEmailVerification(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
email = db.Column(db.String(100))
|
||||
token = db.Column(db.String(32))
|
||||
user = db.relationship("User", foreign_keys=[user_id])
|
||||
|
||||
class Notification(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
user = db.relationship("User", foreign_keys=[user_id])
|
||||
causer = db.relationship("User", foreign_keys=[causer_id])
|
||||
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(200), nullable=True)
|
||||
|
||||
def __init__(self, us, cau, titl, ur):
|
||||
self.user = us
|
||||
self.causer = cau
|
||||
self.title = titl
|
||||
self.url = ur
|
||||
|
||||
|
||||
class License(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), nullable=False, unique=True)
|
||||
packages = db.relationship("Package", backref="license", lazy="dynamic")
|
||||
|
||||
def __init__(self, v):
|
||||
self.name = v
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class PackageType(enum.Enum):
|
||||
MOD = "Mod"
|
||||
GAME = "Game"
|
||||
TXP = "Texture Pack"
|
||||
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.value) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == PackageType else PackageType[item]
|
||||
|
||||
|
||||
class PackagePropertyKey(enum.Enum):
|
||||
name = "Name"
|
||||
title = "Title"
|
||||
shortDesc = "Short Description"
|
||||
desc = "Description"
|
||||
type = "Type"
|
||||
license = "License"
|
||||
tags = "Tags"
|
||||
provides = "Provides"
|
||||
repo = "Repository"
|
||||
website = "Website"
|
||||
issueTracker = "Issue Tracker"
|
||||
forums = "Forum Topic ID"
|
||||
|
||||
def convert(self, value):
|
||||
if self == PackagePropertyKey.tags:
|
||||
return ",".join([t.title for t in value])
|
||||
elif self == PackagePropertyKey.provides:
|
||||
return ",".join([t.name for t in value])
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
provides = db.Table("provides",
|
||||
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
|
||||
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
|
||||
)
|
||||
|
||||
tags = db.Table("tags",
|
||||
db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
|
||||
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
|
||||
)
|
||||
|
||||
class Dependency(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
depender_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id])
|
||||
meta_package_id = db.Column(db.Integer, db.ForeignKey("meta_package.id"), nullable=True)
|
||||
optional = db.Column(db.Boolean, nullable=False, default=False)
|
||||
__table_args__ = (db.UniqueConstraint('depender_id', 'package_id', 'meta_package_id', name='_dependency_uc'), )
|
||||
|
||||
def __init__(self, depender=None, package=None, meta=None):
|
||||
if depender is None:
|
||||
return
|
||||
|
||||
self.depender = depender
|
||||
|
||||
packageProvided = package is not None
|
||||
metaProvided = meta is not None
|
||||
|
||||
if packageProvided and not metaProvided:
|
||||
self.package = package
|
||||
elif metaProvided and not packageProvided:
|
||||
self.meta_package = meta
|
||||
else:
|
||||
raise Exception("Either meta or package must be given, but not both!")
|
||||
|
||||
def __str__(self):
|
||||
if self.package is not None:
|
||||
return self.package.author.username + "/" + self.package.name
|
||||
elif self.meta_package is not None:
|
||||
return self.meta_package.name
|
||||
else:
|
||||
raise Exception("Meta and package are both none!")
|
||||
|
||||
@staticmethod
|
||||
def SpecToList(depender, spec, cache={}):
|
||||
retval = []
|
||||
arr = spec.split(",")
|
||||
|
||||
import re
|
||||
pattern1 = re.compile("^([a-z0-9_]+)$")
|
||||
pattern2 = re.compile("^([A-Za-z0-9_]+)/([a-z0-9_]+)$")
|
||||
|
||||
for x in arr:
|
||||
x = x.strip()
|
||||
if x == "":
|
||||
continue
|
||||
|
||||
if pattern1.match(x):
|
||||
meta = MetaPackage.GetOrCreate(x, cache)
|
||||
retval.append(Dependency(depender, meta=meta))
|
||||
else:
|
||||
m = pattern2.match(x)
|
||||
username = m.group(1)
|
||||
name = m.group(2)
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
raise Exception("Unable to find user " + username)
|
||||
|
||||
package = Package.query.filter_by(author=user, name=name).first()
|
||||
if package is None:
|
||||
raise Exception("Unable to find package " + name + " by " + username)
|
||||
|
||||
retval.append(Dependency(depender, package=package))
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
|
||||
class Package(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Basic details
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
shortDesc = db.Column(db.String(200), nullable=False)
|
||||
desc = db.Column(db.Text, nullable=True)
|
||||
type = db.Column(db.Enum(PackageType))
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
license_id = db.Column(db.Integer, db.ForeignKey("license.id"))
|
||||
|
||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
soft_deleted = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
# Downloads
|
||||
repo = db.Column(db.String(200), nullable=True)
|
||||
website = db.Column(db.String(200), nullable=True)
|
||||
issueTracker = db.Column(db.String(200), nullable=True)
|
||||
forums = db.Column(db.Integer, nullable=True)
|
||||
|
||||
provides = db.relationship("MetaPackage", secondary=provides, lazy="subquery",
|
||||
backref=db.backref("packages", lazy=True))
|
||||
|
||||
dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
|
||||
|
||||
tags = db.relationship("Tag", secondary=tags, lazy="subquery",
|
||||
backref=db.backref("packages", lazy=True))
|
||||
|
||||
releases = db.relationship("PackageRelease", backref="package",
|
||||
lazy="dynamic", order_by=db.desc("package_release_releaseDate"))
|
||||
|
||||
screenshots = db.relationship("PackageScreenshot", backref="package",
|
||||
lazy="dynamic")
|
||||
|
||||
requests = db.relationship("EditRequest", backref="package",
|
||||
lazy="dynamic")
|
||||
|
||||
def __init__(self, package=None):
|
||||
if package is None:
|
||||
return
|
||||
|
||||
self.author_id = package.author_id
|
||||
self.created_at = package.created_at
|
||||
self.approved = package.approved
|
||||
|
||||
for e in PackagePropertyKey:
|
||||
setattr(self, e.name, getattr(package, e.name))
|
||||
|
||||
def getAsDictionary(self, base_url):
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"author": self.author.display_name,
|
||||
"shortDesc": self.shortDesc,
|
||||
"type": self.type.toName(),
|
||||
"license": self.license.name,
|
||||
"repo": self.repo,
|
||||
"url": base_url + self.getDownloadURL(),
|
||||
"release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None,
|
||||
"screenshots": [base_url + ss.url for ss in self.screenshots]
|
||||
}
|
||||
|
||||
def getDetailsURL(self):
|
||||
return url_for("package_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("create_edit_package_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getApproveURL(self):
|
||||
return url_for("approve_package_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getDeleteURL(self):
|
||||
return url_for("delete_package_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getNewScreenshotURL(self):
|
||||
return url_for("create_screenshot_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getCreateReleaseURL(self):
|
||||
return url_for("create_release_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getCreateEditRequestURL(self):
|
||||
return url_for("create_edit_editrequest_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getDownloadURL(self):
|
||||
return url_for("package_download_page",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getMainScreenshotURL(self):
|
||||
screenshot = self.screenshots.filter_by(approved=True).first()
|
||||
return screenshot.url if screenshot is not None else None
|
||||
|
||||
def getDownloadRelease(self):
|
||||
for rel in self.releases:
|
||||
if rel.approved:
|
||||
return rel
|
||||
|
||||
return None
|
||||
|
||||
def canImportScreenshot(self):
|
||||
if self.repo is None:
|
||||
return False
|
||||
|
||||
url = urlparse(self.repo)
|
||||
if url.netloc == "github.com":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def canMakeReleaseFromVCS(self):
|
||||
if self.repo is None:
|
||||
return False
|
||||
|
||||
url = urlparse(self.repo)
|
||||
if url.netloc == "github.com":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Package.checkPerm()")
|
||||
|
||||
isOwner = user == self.author
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
|
||||
return isOwner or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES:
|
||||
if isOwner:
|
||||
return user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
else:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
# Editors can change authors
|
||||
elif perm == Permission.CHANGE_AUTHOR:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
elif perm == Permission.APPROVE_NEW or perm == Permission.APPROVE_RELEASE \
|
||||
or perm == Permission.APPROVE_SCREENSHOT:
|
||||
return user.rank.atLeast(UserRank.TRUSTED_MEMBER if isOwner else UserRank.EDITOR)
|
||||
|
||||
# Moderators can delete packages
|
||||
elif perm == Permission.DELETE_PACKAGE or perm == Permission.CHANGE_RELEASE_URL:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to packages".format(perm.name))
|
||||
|
||||
class MetaPackage(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
dependencies = db.relationship("Dependency", backref="meta_package", lazy="dynamic")
|
||||
|
||||
def __init__(self, name=None):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@staticmethod
|
||||
def ListToSpec(list):
|
||||
return ",".join([str(x) for x in list])
|
||||
|
||||
@staticmethod
|
||||
def GetOrCreate(name, cache={}):
|
||||
mp = cache.get(name)
|
||||
if mp is None:
|
||||
mp = MetaPackage.query.filter_by(name=name).first()
|
||||
|
||||
if mp is None:
|
||||
mp = MetaPackage(name)
|
||||
db.session.add(mp)
|
||||
|
||||
cache[name] = mp
|
||||
return mp
|
||||
|
||||
@staticmethod
|
||||
def SpecToList(spec, cache={}):
|
||||
retval = []
|
||||
arr = spec.split(",")
|
||||
|
||||
import re
|
||||
pattern = re.compile("^([a-z0-9_]+)$")
|
||||
|
||||
for x in arr:
|
||||
x = x.strip()
|
||||
if x == "":
|
||||
continue
|
||||
|
||||
if not pattern.match(x):
|
||||
continue
|
||||
|
||||
retval.append(MetaPackage.GetOrCreate(x, cache))
|
||||
|
||||
return retval
|
||||
|
||||
class Tag(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
backgroundColor = db.Column(db.String(6), nullable=False)
|
||||
textColor = db.Column(db.String(6), nullable=False)
|
||||
|
||||
def __init__(self, title, backgroundColor="000000", textColor="ffffff"):
|
||||
self.title = title
|
||||
self.backgroundColor = backgroundColor
|
||||
self.textColor = textColor
|
||||
|
||||
import re
|
||||
regex = re.compile("[^a-z_]")
|
||||
self.name = regex.sub("", self.title.lower().replace(" ", "_"))
|
||||
|
||||
class PackageRelease(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
releaseDate = db.Column(db.DateTime, nullable=False)
|
||||
url = db.Column(db.String(200), nullable=False)
|
||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
task_id = db.Column(db.String(37), nullable=True)
|
||||
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("edit_release_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def __init__(self):
|
||||
self.releaseDate = datetime.now()
|
||||
|
||||
class PackageScreenshot(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(100), nullable=False)
|
||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("edit_screenshot_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def getThumbnailURL(self):
|
||||
return self.url # TODO
|
||||
|
||||
class EditRequest(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
desc = db.Column(db.String(1000), nullable=True)
|
||||
|
||||
# 0 - open
|
||||
# 1 - merged
|
||||
# 2 - rejected
|
||||
status = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
changes = db.relationship("EditRequestChange", backref="request",
|
||||
lazy="dynamic")
|
||||
|
||||
def getURL(self):
|
||||
return url_for("view_editrequest_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def getApproveURL(self):
|
||||
return url_for("approve_editrequest_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def getRejectURL(self):
|
||||
return url_for("reject_editrequest_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("create_edit_editrequest_page",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def applyAll(self, package):
|
||||
for change in self.changes:
|
||||
change.apply(package)
|
||||
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to EditRequest.checkPerm()")
|
||||
|
||||
isOwner = user == self.author
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.EDIT_EDITREQUEST:
|
||||
return isOwner or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to packages".format(perm.name))
|
||||
|
||||
|
||||
|
||||
|
||||
class EditRequestChange(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
request_id = db.Column(db.Integer, db.ForeignKey("edit_request.id"))
|
||||
key = db.Column(db.Enum(PackagePropertyKey), nullable=False)
|
||||
|
||||
# TODO: make diff instead
|
||||
oldValue = db.Column(db.Text, nullable=True)
|
||||
newValue = db.Column(db.Text, nullable=True)
|
||||
|
||||
def apply(self, package):
|
||||
if self.key == PackagePropertyKey.tags:
|
||||
package.tags.clear()
|
||||
for tagTitle in self.newValue.split(","):
|
||||
tag = Tag.query.filter_by(title=tagTitle.strip()).first()
|
||||
package.tags.append(tag)
|
||||
|
||||
else:
|
||||
setattr(package, self.key.name, self.newValue)
|
||||
|
||||
# Setup Flask-User
|
||||
db_adapter = SQLAlchemyAdapter(db, User) # Register the User model
|
||||
user_manager = UserManager(db_adapter, app) # Initialize Flask-User
|
|
@ -0,0 +1,177 @@
|
|||
# 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_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy_searchable import make_searchable
|
||||
|
||||
from app import app
|
||||
|
||||
# Initialise database
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
make_searchable(db.metadata)
|
||||
|
||||
|
||||
from .packages import *
|
||||
from .users import *
|
||||
from .threads import *
|
||||
|
||||
|
||||
class APIToken(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
access_token = db.Column(db.String(34), unique=True, nullable=False)
|
||||
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="tokens")
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
|
||||
|
||||
def canOperateOnPackage(self, package):
|
||||
if self.package and self.package != package:
|
||||
return False
|
||||
|
||||
return package.author == self.owner
|
||||
|
||||
|
||||
class AuditSeverity(enum.Enum):
|
||||
NORMAL = 0 # Normal user changes
|
||||
USER = 1 # Security user changes
|
||||
EDITOR = 2 # Editor changes
|
||||
MODERATION = 3 # Destructive / moderator changes
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == AuditSeverity else AuditSeverity[item.upper()]
|
||||
|
||||
|
||||
class AuditLogEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="audit_log_entries")
|
||||
|
||||
severity = db.Column(db.Enum(AuditSeverity), nullable=False)
|
||||
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(200), nullable=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="audit_log_entries")
|
||||
|
||||
description = db.Column(db.Text, nullable=True, default=None)
|
||||
|
||||
def __init__(self, causer, severity, title, url, package=None, description=None):
|
||||
if len(title) > 100:
|
||||
title = title[:99] + "…"
|
||||
|
||||
self.causer = causer
|
||||
self.severity = severity
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.package = package
|
||||
self.description = description
|
||||
|
||||
|
||||
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
|
||||
"minetest.net", "dropboxusercontent.com", "4shared.com",
|
||||
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
|
||||
"imageshack.com", "imgur.com"]
|
||||
|
||||
|
||||
class ForumTopic(db.Model):
|
||||
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="forum_topics")
|
||||
|
||||
wip = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discarded = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
type = db.Column(db.Enum(PackageType), nullable=False)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
name = db.Column(db.String(30), nullable=True)
|
||||
link = db.Column(db.String(200), nullable=True)
|
||||
|
||||
posts = db.Column(db.Integer, nullable=False)
|
||||
views = db.Column(db.Integer, nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def getRepoURL(self):
|
||||
if self.link is None:
|
||||
return None
|
||||
|
||||
for item in REPO_BLACKLIST:
|
||||
if item in self.link:
|
||||
return None
|
||||
|
||||
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
|
||||
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"name": self.name,
|
||||
"type": self.type.toName(),
|
||||
"title": self.title,
|
||||
"id": self.topic_id,
|
||||
"link": self.link,
|
||||
"posts": self.posts,
|
||||
"views": self.views,
|
||||
"is_wip": self.wip,
|
||||
"discarded": self.discarded,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to ForumTopic.checkPerm()")
|
||||
|
||||
if perm == Permission.TOPIC_DISCARD:
|
||||
return self.author == user or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to topics".format(perm.name))
|
||||
|
||||
|
||||
if app.config.get("LOG_SQL"):
|
||||
import logging
|
||||
logging.basicConfig()
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,240 @@
|
|||
# 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
|
||||
from typing import Tuple, List
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from . import db
|
||||
from .users import Permission, UserRank
|
||||
from .packages import Package
|
||||
|
||||
watchers = db.Table("watchers",
|
||||
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
|
||||
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Thread(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="threads")
|
||||
|
||||
is_review_thread = db.relationship("Package", foreign_keys=[Package.review_thread_id], back_populates="review_thread")
|
||||
|
||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True)
|
||||
review = db.relationship("PackageReview", foreign_keys=[review_id], cascade="all, delete")
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="threads", foreign_keys=[author_id])
|
||||
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
private = db.Column(db.Boolean, server_default="0", nullable=False)
|
||||
|
||||
locked = db.Column(db.Boolean, server_default="0", nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
replies = db.relationship("ThreadReply", back_populates="thread", lazy="dynamic",
|
||||
order_by=db.asc("thread_reply_id"), cascade="all, delete, delete-orphan")
|
||||
|
||||
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
||||
|
||||
def get_description(self):
|
||||
comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
|
||||
if len(comment) > 100:
|
||||
return comment[:97] + "..."
|
||||
else:
|
||||
return comment
|
||||
|
||||
def getViewURL(self, absolute=False):
|
||||
if absolute:
|
||||
from ..utils import abs_url_for
|
||||
return abs_url_for("threads.view", id=self.id)
|
||||
else:
|
||||
return url_for("threads.view", id=self.id, _external=False)
|
||||
|
||||
def getSubscribeURL(self):
|
||||
return url_for("threads.subscribe", id=self.id)
|
||||
|
||||
def getUnsubscribeURL(self):
|
||||
return url_for("threads.unsubscribe", id=self.id)
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return perm == Permission.SEE_THREAD and not self.private
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Thread.checkPerm()")
|
||||
|
||||
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
|
||||
if self.package:
|
||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
||||
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
if perm == Permission.SEE_THREAD:
|
||||
return canSee
|
||||
|
||||
elif perm == Permission.COMMENT_THREAD:
|
||||
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
|
||||
|
||||
elif perm == Permission.LOCK_THREAD:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
elif perm == Permission.DELETE_THREAD:
|
||||
from app.utils.models import get_system_user
|
||||
return (self.author == get_system_user() and self.package and
|
||||
user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
|
||||
def get_latest_reply(self):
|
||||
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
|
||||
|
||||
|
||||
class ThreadReply(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
|
||||
thread = db.relationship("Thread", back_populates="replies", foreign_keys=[thread_id])
|
||||
|
||||
comment = db.Column(db.String(2000), nullable=False)
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def get_url(self):
|
||||
return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
|
||||
|
||||
if perm == Permission.EDIT_REPLY:
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
|
||||
|
||||
elif perm == Permission.DELETE_REPLY:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
|
||||
|
||||
class PackageReview(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="reviews")
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
|
||||
|
||||
recommends = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
thread = db.relationship("Thread", uselist=False, back_populates="review")
|
||||
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
|
||||
|
||||
score = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
|
||||
votes: List[PackageReviewVote] = self.votes
|
||||
pos = sum([ 1 for vote in votes if vote.is_positive ])
|
||||
neg = sum([ 1 for vote in votes if not vote.is_positive])
|
||||
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
|
||||
return pos, neg, user_vote.is_positive if user_vote else None
|
||||
|
||||
def getAsDictionary(self, include_package=False):
|
||||
pos, neg, _user = self.get_totals()
|
||||
ret = {
|
||||
"is_positive": self.recommends,
|
||||
"user": {
|
||||
"username": self.author.username,
|
||||
"display_name": self.author.display_name,
|
||||
},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"votes": {
|
||||
"helpful": pos,
|
||||
"unhelpful": neg,
|
||||
},
|
||||
"title": self.thread.title,
|
||||
"comment": self.thread.replies[0].comment,
|
||||
}
|
||||
if include_package:
|
||||
ret["package"] = self.package.getAsDictionaryKey()
|
||||
return ret
|
||||
|
||||
def asSign(self):
|
||||
return 1 if self.recommends else -1
|
||||
|
||||
def getEditURL(self):
|
||||
return self.package.getURL("packages.review")
|
||||
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_review",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
reviewer=self.author.username)
|
||||
|
||||
def getVoteUrl(self, next_url=None):
|
||||
return url_for("packages.review_vote",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
review_id=self.id,
|
||||
r=next_url)
|
||||
|
||||
def update_score(self):
|
||||
(pos, neg, _) = self.get_totals()
|
||||
self.score = 3 * (pos - neg) + 1
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to PackageReview.checkPerm()")
|
||||
|
||||
if perm == Permission.DELETE_REVIEW:
|
||||
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to reviews".format(perm.name))
|
||||
|
||||
|
||||
class PackageReviewVote(db.Model):
|
||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
|
||||
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
|
||||
|
||||
is_positive = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
@ -0,0 +1,479 @@
|
|||
# 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 enum
|
||||
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import desc, text
|
||||
|
||||
from app import gravatar
|
||||
from . import db
|
||||
|
||||
|
||||
class UserRank(enum.Enum):
|
||||
BANNED = 0
|
||||
NOT_JOINED = 1
|
||||
NEW_MEMBER = 2
|
||||
MEMBER = 3
|
||||
TRUSTED_MEMBER = 4
|
||||
APPROVER = 5
|
||||
EDITOR = 6
|
||||
BOT = 7
|
||||
MODERATOR = 8
|
||||
ADMIN = 9
|
||||
|
||||
def atLeast(self, min):
|
||||
return self.value >= min.value
|
||||
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == UserRank else UserRank[item.upper()]
|
||||
|
||||
|
||||
class Permission(enum.Enum):
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
CHANGE_NAME = "CHANGE_NAME"
|
||||
MAKE_RELEASE = "MAKE_RELEASE"
|
||||
DELETE_RELEASE = "DELETE_RELEASE"
|
||||
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
|
||||
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
|
||||
APPROVE_RELEASE = "APPROVE_RELEASE"
|
||||
APPROVE_NEW = "APPROVE_NEW"
|
||||
EDIT_TAGS = "EDIT_TAGS"
|
||||
CREATE_TAG = "CREATE_TAG"
|
||||
CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
|
||||
CHANGE_USERNAMES = "CHANGE_USERNAMES"
|
||||
CHANGE_RANK = "CHANGE_RANK"
|
||||
CHANGE_EMAIL = "CHANGE_EMAIL"
|
||||
SEE_THREAD = "SEE_THREAD"
|
||||
CREATE_THREAD = "CREATE_THREAD"
|
||||
COMMENT_THREAD = "COMMENT_THREAD"
|
||||
LOCK_THREAD = "LOCK_THREAD"
|
||||
DELETE_THREAD = "DELETE_THREAD"
|
||||
DELETE_REPLY = "DELETE_REPLY"
|
||||
EDIT_REPLY = "EDIT_REPLY"
|
||||
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
|
||||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
||||
CREATE_TOKEN = "CREATE_TOKEN"
|
||||
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
|
||||
DELETE_REVIEW = "DELETE_REVIEW"
|
||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
||||
|
||||
# Only return true if the permission is valid for *all* contexts
|
||||
# See Package.checkPerm for package-specific contexts
|
||||
def check(self, user):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if self == Permission.APPROVE_NEW or \
|
||||
self == Permission.APPROVE_RELEASE or \
|
||||
self == Permission.APPROVE_SCREENSHOT or \
|
||||
self == Permission.SEE_THREAD:
|
||||
return user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
||||
|
||||
@staticmethod
|
||||
def checkPerm(user, perm):
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Permission.check")
|
||||
|
||||
return perm.check(user)
|
||||
|
||||
|
||||
def display_name_default(context):
|
||||
return context.get_current_parameters()["username"]
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
|
||||
|
||||
# User authentication information
|
||||
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
|
||||
password = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
|
||||
|
||||
def get_id(self):
|
||||
return self.username
|
||||
|
||||
rank = db.Column(db.Enum(UserRank), nullable=False)
|
||||
|
||||
# Account linking
|
||||
github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
|
||||
forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
|
||||
|
||||
# Access token for webhook setup
|
||||
github_access_token = db.Column(db.String(50), nullable=True, server_default=None)
|
||||
|
||||
# User email information
|
||||
email = db.Column(db.String(255), nullable=True, unique=True)
|
||||
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
|
||||
|
||||
locale = db.Column(db.String(10), nullable=True, default=None)
|
||||
|
||||
# User information
|
||||
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
||||
display_name = db.Column(db.String(100), nullable=False, default=display_name_default)
|
||||
|
||||
# Links
|
||||
website_url = db.Column(db.String(255), nullable=True, default=None)
|
||||
donate_url = db.Column(db.String(255), nullable=True, default=None)
|
||||
|
||||
# Content
|
||||
notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
|
||||
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
|
||||
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
|
||||
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
|
||||
cascade="all, delete, delete-orphan")
|
||||
|
||||
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
|
||||
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
|
||||
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
|
||||
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
|
||||
|
||||
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
|
||||
|
||||
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
|
||||
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
|
||||
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
|
||||
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
def __init__(self, username=None, active=False, email=None, password=None):
|
||||
self.username = username
|
||||
self.display_name = username
|
||||
self.is_active = active
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.rank = UserRank.NOT_JOINED
|
||||
|
||||
def canAccessTodoList(self):
|
||||
return Permission.APPROVE_NEW.check(self) or \
|
||||
Permission.APPROVE_RELEASE.check(self)
|
||||
|
||||
def isClaimed(self):
|
||||
return self.rank.atLeast(UserRank.NEW_MEMBER)
|
||||
|
||||
def getProfilePicURL(self):
|
||||
if self.profile_pic:
|
||||
return self.profile_pic
|
||||
elif self.rank == UserRank.BOT:
|
||||
return "/static/bot_avatar.png"
|
||||
else:
|
||||
return gravatar(self.email or f"{self.username}@content.minetest.net")
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to User.checkPerm()")
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.CHANGE_AUTHOR:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
elif perm == Permission.CHANGE_USERNAMES:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
elif perm == Permission.CHANGE_RANK:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
|
||||
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
|
||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
|
||||
elif perm == Permission.CHANGE_DISPLAY_NAME:
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
|
||||
elif perm == Permission.CREATE_TOKEN:
|
||||
if user == self:
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
else:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||
|
||||
def canCommentRL(self):
|
||||
from app.models import ThreadReply
|
||||
|
||||
factor = 1
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 2
|
||||
|
||||
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
||||
if ThreadReply.query.filter_by(author=self) \
|
||||
.filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
|
||||
return False
|
||||
|
||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
||||
if ThreadReply.query.filter_by(author=self) \
|
||||
.filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def canOpenThreadRL(self):
|
||||
from app.models import Thread
|
||||
|
||||
factor = 1
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 5
|
||||
|
||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
||||
return Thread.query.filter_by(author=self) \
|
||||
.filter(Thread.created_at > hour_ago).count() < 2 * factor
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
|
||||
if not self.is_authenticated or not other.is_authenticated:
|
||||
return False
|
||||
|
||||
assert self.id > 0
|
||||
return self.id == other.id
|
||||
|
||||
def can_see_edit_profile(self, current_user):
|
||||
return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
|
||||
self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
|
||||
self.checkPerm(current_user, Permission.CHANGE_RANK)
|
||||
|
||||
def can_delete(self):
|
||||
from app.models import ForumTopic
|
||||
return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
|
||||
|
||||
|
||||
class UserEmailVerification(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
email = db.Column(db.String(100), nullable=False)
|
||||
token = db.Column(db.String(32), nullable=True)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
|
||||
is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class EmailSubscription(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(100), nullable=False, unique=True)
|
||||
blacklisted = db.Column(db.Boolean, nullable=False, default=False)
|
||||
token = db.Column(db.String(32), nullable=True, default=None)
|
||||
|
||||
def __init__(self, email):
|
||||
self.email = email
|
||||
self.blacklisted = False
|
||||
self.token = None
|
||||
|
||||
|
||||
class NotificationType(enum.Enum):
|
||||
# Package / release / etc
|
||||
PACKAGE_EDIT = 1
|
||||
|
||||
# Approval review actions
|
||||
PACKAGE_APPROVAL = 2
|
||||
|
||||
# New thread
|
||||
NEW_THREAD = 3
|
||||
|
||||
# New Review
|
||||
NEW_REVIEW = 4
|
||||
|
||||
# Posted reply to subscribed thread
|
||||
THREAD_REPLY = 5
|
||||
|
||||
# A bot notification
|
||||
BOT = 6
|
||||
|
||||
# Added / removed as maintainer
|
||||
MAINTAINER = 7
|
||||
|
||||
# Editor misc
|
||||
EDITOR_ALERT = 8
|
||||
|
||||
# Editor misc
|
||||
EDITOR_MISC = 9
|
||||
|
||||
# Any other
|
||||
OTHER = 0
|
||||
|
||||
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def get_description(self):
|
||||
if self == NotificationType.PACKAGE_EDIT:
|
||||
return "When another user edits your packages, releases, etc."
|
||||
elif self == NotificationType.PACKAGE_APPROVAL:
|
||||
return "Notifications from editors related to the package approval process."
|
||||
elif self == NotificationType.NEW_THREAD:
|
||||
return "When a thread is created on your package."
|
||||
elif self == NotificationType.NEW_REVIEW:
|
||||
return "When a user posts a review on your package."
|
||||
elif self == NotificationType.THREAD_REPLY:
|
||||
return "When someone replies to a thread you're watching."
|
||||
elif self == NotificationType.BOT:
|
||||
return "From a bot - for example, update notifications."
|
||||
elif self == NotificationType.MAINTAINER:
|
||||
return "When your package's maintainers change."
|
||||
elif self == NotificationType.EDITOR_ALERT:
|
||||
return "For editors: Important alerts."
|
||||
elif self == NotificationType.EDITOR_MISC:
|
||||
return "For editors: Minor notifications, including new threads."
|
||||
elif self == NotificationType.OTHER:
|
||||
return "Minor notifications not important enough for a dedicated category."
|
||||
else:
|
||||
return ""
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.value < other.value
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == NotificationType else NotificationType[item.upper()]
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="notifications")
|
||||
|
||||
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="caused_notifications")
|
||||
|
||||
type = db.Column(db.Enum(NotificationType), nullable=False, default=NotificationType.OTHER)
|
||||
|
||||
emailed = db.Column(db.Boolean(), nullable=False, default=False)
|
||||
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(200), nullable=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="notifications")
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def __init__(self, user, causer, type, title, url, package=None):
|
||||
if len(title) > 100:
|
||||
title = title[:99] + "…"
|
||||
|
||||
self.user = user
|
||||
self.causer = causer
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.package = package
|
||||
|
||||
def can_send_email(self):
|
||||
prefs = self.user.notification_preferences
|
||||
return prefs and self.user.email and prefs.get_can_email(self.type)
|
||||
|
||||
def can_send_digest(self):
|
||||
prefs = self.user.notification_preferences
|
||||
return prefs and self.user.email and prefs.get_can_digest(self.type)
|
||||
|
||||
|
||||
class UserNotificationPreferences(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
user = db.relationship("User", back_populates="notification_preferences")
|
||||
|
||||
# 2 = immediate emails
|
||||
# 1 = daily digest emails
|
||||
# 0 = no emails
|
||||
|
||||
pref_package_edit = db.Column(db.Integer, nullable=False)
|
||||
pref_package_approval = db.Column(db.Integer, nullable=False)
|
||||
pref_new_thread = db.Column(db.Integer, nullable=False)
|
||||
pref_new_review = db.Column(db.Integer, nullable=False)
|
||||
pref_thread_reply = db.Column(db.Integer, nullable=False)
|
||||
pref_bot = db.Column(db.Integer, nullable=False)
|
||||
pref_maintainer = db.Column(db.Integer, nullable=False)
|
||||
pref_editor_alert = db.Column(db.Integer, nullable=False)
|
||||
pref_editor_misc = db.Column(db.Integer, nullable=False)
|
||||
pref_other = db.Column(db.Integer, nullable=False)
|
||||
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.pref_package_edit = 1
|
||||
self.pref_package_approval = 1
|
||||
self.pref_new_thread = 1
|
||||
self.pref_new_review = 1
|
||||
self.pref_thread_reply = 2
|
||||
self.pref_bot = 1
|
||||
self.pref_maintainer = 1
|
||||
self.pref_editor_alert = 1
|
||||
self.pref_editor_misc = 0
|
||||
self.pref_other = 0
|
||||
|
||||
def get_can_email(self, notification_type):
|
||||
return getattr(self, "pref_" + notification_type.toName()) == 2
|
||||
|
||||
def set_can_email(self, notification_type, value):
|
||||
value = 2 if value else 0
|
||||
setattr(self, "pref_" + notification_type.toName(), value)
|
||||
|
||||
def get_can_digest(self, notification_type):
|
||||
return getattr(self, "pref_" + notification_type.toName()) >= 1
|
||||
|
||||
def set_can_digest(self, notification_type, value):
|
||||
if self.get_can_email(notification_type):
|
||||
return
|
||||
|
||||
value = 1 if value else 0
|
||||
setattr(self, "pref_" + notification_type.toName(), value)
|
Binary file not shown.
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 846 B |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 980 B |
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,14 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Brands';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-display: auto;
|
||||
src: url("../webfonts/fa-brands-400.eot");
|
||||
src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); }
|
||||
|
||||
.fab {
|
||||
font-family: 'Font Awesome 5 Brands'; }
|
|
@ -0,0 +1,5 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:normal;font-display:auto;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,15 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: auto;
|
||||
src: url("../webfonts/fa-regular-400.eot");
|
||||
src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); }
|
||||
|
||||
.far {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 400; }
|
|
@ -0,0 +1,5 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-family:"Font Awesome 5 Free";font-weight:400}
|
|
@ -0,0 +1,16 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: auto;
|
||||
src: url("../webfonts/fa-solid-900.eot");
|
||||
src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); }
|
||||
|
||||
.fa,
|
||||
.fas {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900; }
|
|
@ -0,0 +1,5 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900}
|
|
@ -0,0 +1,371 @@
|
|||
/*!
|
||||
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/
|
||||
svg:not(:root).svg-inline--fa {
|
||||
overflow: visible; }
|
||||
|
||||
.svg-inline--fa {
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
vertical-align: -.125em; }
|
||||
.svg-inline--fa.fa-lg {
|
||||
vertical-align: -.225em; }
|
||||
.svg-inline--fa.fa-w-1 {
|
||||
width: 0.0625em; }
|
||||
.svg-inline--fa.fa-w-2 {
|
||||
width: 0.125em; }
|
||||
.svg-inline--fa.fa-w-3 {
|
||||
width: 0.1875em; }
|
||||
.svg-inline--fa.fa-w-4 {
|
||||
width: 0.25em; }
|
||||
.svg-inline--fa.fa-w-5 {
|
||||
width: 0.3125em; }
|
||||
.svg-inline--fa.fa-w-6 {
|
||||
width: 0.375em; }
|
||||
.svg-inline--fa.fa-w-7 {
|
||||
width: 0.4375em; }
|
||||
.svg-inline--fa.fa-w-8 {
|
||||
width: 0.5em; }
|
||||
.svg-inline--fa.fa-w-9 {
|
||||
width: 0.5625em; }
|
||||
.svg-inline--fa.fa-w-10 {
|
||||
width: 0.625em; }
|
||||
.svg-inline--fa.fa-w-11 {
|
||||
width: 0.6875em; }
|
||||
.svg-inline--fa.fa-w-12 {
|
||||
width: 0.75em; }
|
||||
.svg-inline--fa.fa-w-13 {
|
||||
width: 0.8125em; }
|
||||
.svg-inline--fa.fa-w-14 {
|
||||
width: 0.875em; }
|
||||
.svg-inline--fa.fa-w-15 {
|
||||
width: 0.9375em; }
|
||||
.svg-inline--fa.fa-w-16 {
|
||||
width: 1em; }
|
||||
.svg-inline--fa.fa-w-17 {
|
||||
width: 1.0625em; }
|
||||
.svg-inline--fa.fa-w-18 {
|
||||
width: 1.125em; }
|
||||
.svg-inline--fa.fa-w-19 {
|
||||
width: 1.1875em; }
|
||||
.svg-inline--fa.fa-w-20 {
|
||||
width: 1.25em; }
|
||||
.svg-inline--fa.fa-pull-left {
|
||||
margin-right: .3em;
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-pull-right {
|
||||
margin-left: .3em;
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-border {
|
||||
height: 1.5em; }
|
||||
.svg-inline--fa.fa-li {
|
||||
width: 2em; }
|
||||
.svg-inline--fa.fa-fw {
|
||||
width: 1.25em; }
|
||||
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0; }
|
||||
|
||||
.fa-layers {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: -.125em;
|
||||
width: 1em; }
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-text, .fa-layers-counter {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
text-align: center; }
|
||||
|
||||
.fa-layers-text {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-counter {
|
||||
background-color: #ff253a;
|
||||
border-radius: 1em;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
height: 1.5em;
|
||||
line-height: 1;
|
||||
max-width: 5em;
|
||||
min-width: 1.5em;
|
||||
overflow: hidden;
|
||||
padding: .25em;
|
||||
right: 0;
|
||||
text-overflow: ellipsis;
|
||||
top: 0;
|
||||
-webkit-transform: scale(0.25);
|
||||
transform: scale(0.25);
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-bottom-right {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
-webkit-transform: scale(0.25);
|
||||
transform: scale(0.25);
|
||||
-webkit-transform-origin: bottom right;
|
||||
transform-origin: bottom right; }
|
||||
|
||||
.fa-layers-bottom-left {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
top: auto;
|
||||
-webkit-transform: scale(0.25);
|
||||
transform: scale(0.25);
|
||||
-webkit-transform-origin: bottom left;
|
||||
transform-origin: bottom left; }
|
||||
|
||||
.fa-layers-top-right {
|
||||
right: 0;
|
||||
top: 0;
|
||||
-webkit-transform: scale(0.25);
|
||||
transform: scale(0.25);
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-top-left {
|
||||
left: 0;
|
||||
right: auto;
|
||||
top: 0;
|
||||
-webkit-transform: scale(0.25);
|
||||
transform: scale(0.25);
|
||||
-webkit-transform-origin: top left;
|
||||
transform-origin: top left; }
|
||||
|
||||
.fa-lg {
|
||||
font-size: 1.33333em;
|
||||
line-height: 0.75em;
|
||||
vertical-align: -.0667em; }
|
||||
|
||||
.fa-xs {
|
||||
font-size: .75em; }
|
||||
|
||||
.fa-sm {
|
||||
font-size: .875em; }
|
||||
|
||||
.fa-1x {
|
||||
font-size: 1em; }
|
||||
|
||||
.fa-2x {
|
||||
font-size: 2em; }
|
||||
|
||||
.fa-3x {
|
||||
font-size: 3em; }
|
||||
|
||||
.fa-4x {
|
||||
font-size: 4em; }
|
||||
|
||||
.fa-5x {
|
||||
font-size: 5em; }
|
||||
|
||||
.fa-6x {
|
||||
font-size: 6em; }
|
||||
|
||||
.fa-7x {
|
||||
font-size: 7em; }
|
||||
|
||||
.fa-8x {
|
||||
font-size: 8em; }
|
||||
|
||||
.fa-9x {
|
||||
font-size: 9em; }
|
||||
|
||||
.fa-10x {
|
||||
font-size: 10em; }
|
||||
|
||||
.fa-fw {
|
||||
text-align: center;
|
||||
width: 1.25em; }
|
||||
|
||||
.fa-ul {
|
||||
list-style-type: none;
|
||||
margin-left: 2.5em;
|
||||
padding-left: 0; }
|
||||
.fa-ul > li {
|
||||
position: relative; }
|
||||
|
||||
.fa-li {
|
||||
left: -2em;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
line-height: inherit; }
|
||||
|
||||
.fa-border {
|
||||
border: solid 0.08em #eee;
|
||||
border-radius: .1em;
|
||||
padding: .2em .25em .15em; }
|
||||
|
||||
.fa-pull-left {
|
||||
float: left; }
|
||||
|
||||
.fa-pull-right {
|
||||
float: right; }
|
||||
|
||||
.fa.fa-pull-left,
|
||||
.fas.fa-pull-left,
|
||||
.far.fa-pull-left,
|
||||
.fal.fa-pull-left,
|
||||
.fab.fa-pull-left {
|
||||
margin-right: .3em; }
|
||||
|
||||
.fa.fa-pull-right,
|
||||
.fas.fa-pull-right,
|
||||
.far.fa-pull-right,
|
||||
.fal.fa-pull-right,
|
||||
.fab.fa-pull-right {
|
||||
margin-left: .3em; }
|
||||
|
||||
.fa-spin {
|
||||
-webkit-animation: fa-spin 2s infinite linear;
|
||||
animation: fa-spin 2s infinite linear; }
|
||||
|
||||
.fa-pulse {
|
||||
-webkit-animation: fa-spin 1s infinite steps(8);
|
||||
animation: fa-spin 1s infinite steps(8); }
|
||||
|
||||
@-webkit-keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
.fa-rotate-90 {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg); }
|
||||
|
||||
.fa-rotate-180 {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";
|
||||
-webkit-transform: rotate(180deg);
|
||||
transform: rotate(180deg); }
|
||||
|
||||
.fa-rotate-270 {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";
|
||||
-webkit-transform: rotate(270deg);
|
||||
transform: rotate(270deg); }
|
||||
|
||||
.fa-flip-horizontal {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";
|
||||
-webkit-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1); }
|
||||
|
||||
.fa-flip-vertical {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
|
||||
-webkit-transform: scale(1, -1);
|
||||
transform: scale(1, -1); }
|
||||
|
||||
.fa-flip-both, .fa-flip-horizontal.fa-flip-vertical {
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
|
||||
-webkit-transform: scale(-1, -1);
|
||||
transform: scale(-1, -1); }
|
||||
|
||||
:root .fa-rotate-90,
|
||||
:root .fa-rotate-180,
|
||||
:root .fa-rotate-270,
|
||||
:root .fa-flip-horizontal,
|
||||
:root .fa-flip-vertical,
|
||||
:root .fa-flip-both {
|
||||
-webkit-filter: none;
|
||||
filter: none; }
|
||||
|
||||
.fa-stack {
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-stack-1x,
|
||||
.fa-stack-2x {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0; }
|
||||
|
||||
.svg-inline--fa.fa-stack-1x {
|
||||
height: 1em;
|
||||
width: 1.25em; }
|
||||
|
||||
.svg-inline--fa.fa-stack-2x {
|
||||
height: 2em;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-inverse {
|
||||
color: #fff; }
|
||||
|
||||
.sr-only {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px; }
|
||||
|
||||
.sr-only-focusable:active, .sr-only-focusable:focus {
|
||||
clip: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
position: static;
|
||||
width: auto; }
|
||||
|
||||
.svg-inline--fa .fa-primary {
|
||||
fill: var(--fa-primary-color, currentColor);
|
||||
opacity: 1;
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa .fa-secondary {
|
||||
fill: var(--fa-secondary-color, currentColor);
|
||||
opacity: 0.4;
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-primary {
|
||||
opacity: 0.4;
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-secondary {
|
||||
opacity: 1;
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa mask .fa-primary,
|
||||
.svg-inline--fa mask .fa-secondary {
|
||||
fill: black; }
|
||||
|
||||
.fad.fa-inverse {
|
||||
color: #fff; }
|
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue