Compare commits
699 Commits
difflayout
...
f0ckv2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6856f4109e | |||
| 4fa0ed7e58 | |||
| 2a5f8288d2 | |||
| a8af6427eb | |||
| b53ff15f91 | |||
| 3eb44c0949 | |||
| 84d4068e19 | |||
| b26b7518cb | |||
| 9a4aa4585c | |||
| ce03f9d17f | |||
| 98c8b8d097 | |||
| b7ba21c93a | |||
| 87905dd547 | |||
| 5ef0d5c62e | |||
| d13693de01 | |||
| a68c8c1618 | |||
| 3440f98bcd | |||
| 18a871976c | |||
| d09cb44fa7 | |||
| 298f12305e | |||
| ba36316fcb | |||
| 4ea3db078f | |||
| 7f171d04d5 | |||
| 6da7951b7c | |||
| a06beef458 | |||
| 5388813da9 | |||
| a1f1dd4523 | |||
| ca74655a0e | |||
| caebbbe101 | |||
| e66c4bf43b | |||
| e4c525cb7d | |||
| 48b86e8003 | |||
| cba0f77732 | |||
| 2ff53b6513 | |||
| 0c0f0fd2a9 | |||
| 5256a9eb07 | |||
| 41d18bbc54 | |||
| 7fe2192142 | |||
| 843acab8b3 | |||
| a6193c13fb | |||
| e219bc5573 | |||
| 32a7b1afac | |||
| 994b607ce7 | |||
| a10db31534 | |||
| 5d775fd00c | |||
| 8043830b88 | |||
| cfd9620be7 | |||
| 09ffe600d6 | |||
| 3601346895 | |||
| 2719ec5e57 | |||
| 7ae0392d09 | |||
| dd73ae0eb6 | |||
| 5f9453c6e5 | |||
| 09a2406b5a | |||
| 190d789618 | |||
| 1577e63e9e | |||
| c45870bf24 | |||
| 78aee03025 | |||
| db3da87c38 | |||
| 9f92841ea3 | |||
| ede9e63e8a | |||
| 2e9bd701b1 | |||
| 6bc882e75a | |||
| 36996d20d5 | |||
| 1e8fb4b2b6 | |||
| 04fa79b6bf | |||
| 2ce3e4535e | |||
| d9783abffa | |||
| b27d85aa27 | |||
| 266068b4b4 | |||
| bee5f00e76 | |||
| d005aa5d9c | |||
| 5d490cf134 | |||
| f6e9e13f4e | |||
| 8d9fa3fafd | |||
| 353fc382de | |||
| 9608398a8e | |||
| ce5dfa72c6 | |||
| 29d144920a | |||
| 91125372c8 | |||
| 75161e4957 | |||
| 813a0a066e | |||
| e416575419 | |||
| 286b6beaf8 | |||
| b370510ecd | |||
| 7e2ef4fc0f | |||
| 1a942066ec | |||
| 74d5ff2e03 | |||
| 7e5f7366d1 | |||
| 9c9530e8c9 | |||
| b19cf9ebca | |||
| e6562ecd90 | |||
| ad06ca5956 | |||
| 43742ecdb1 | |||
| 2350a97199 | |||
| b51b8aa11c | |||
| bec1d1c6d1 | |||
| 6aa58c0b74 | |||
| 21706d4a93 | |||
| 589046fed5 | |||
| 05ed241db0 | |||
| d44bc4d2bd | |||
| feb515b740 | |||
| 7c8cb2df78 | |||
| 63837fe86f | |||
| fc237ab71a | |||
| 360dc1db06 | |||
| 77741a55a8 | |||
| ef5fa4a6bc | |||
| bf66a6d276 | |||
| ce852544d1 | |||
| 6a117eeeb3 | |||
| a8584491b7 | |||
| 92c7fc4d6e | |||
| 6015befffc | |||
| 4be150ce44 | |||
| ae9bc17b4a | |||
| 8dc0854de2 | |||
| de7102ed03 | |||
| 11d4b5acf9 | |||
| 2513bf2db9 | |||
| 336a759d45 | |||
| 0af53bf08a | |||
| 4d19837645 | |||
| f740fdd404 | |||
| e2a8c86989 | |||
| 27ca317eb3 | |||
| 797b5acf86 | |||
| cd868eaea6 | |||
| 076da81f02 | |||
| 1a99e58d84 | |||
| 41ac99cd5c | |||
| efe3be141e | |||
| 4eba51c887 | |||
| 4daa7c2dfc | |||
| d04641cb3e | |||
| 6014c62ad8 | |||
| 31ed3b5db9 | |||
| a9b715165e | |||
| d29695bb7e | |||
| 8374c464cd | |||
| 514d6597cf | |||
| 6f8a809637 | |||
| 9fb0a24488 | |||
| e9f2929bf6 | |||
| b2f33cf384 | |||
| 4123c0b377 | |||
| 3aa1acde0a | |||
| 7b5ae3533f | |||
| d53794f9bf | |||
| 6a3b9675b8 | |||
| 9806052d5c | |||
| f48808237e | |||
| 6c07bdcb33 | |||
| 4ffe83fa9d | |||
| e2c277086f | |||
| bc44039fa9 | |||
| 2c169903f3 | |||
| 13fe1145d5 | |||
| 8e62d81d7b | |||
| 7d8a7587a7 | |||
| 468107867d | |||
| 87a95c7f93 | |||
| 56220e5b7e | |||
| bdbd60070c | |||
| a2875bf39b | |||
| 698f693700 | |||
| 93e8a39a40 | |||
| 0b22a1b590 | |||
| 5facee3ccd | |||
| e82471924a | |||
| aa6e76dad8 | |||
| 08d65532b1 | |||
| c5fd833790 | |||
| 48a2302053 | |||
| e94c4a439e | |||
| 64ca845ec5 | |||
| d3550f9648 | |||
| 789a18aef7 | |||
| 302f7c62ad | |||
| 494a990ad4 | |||
| 00a2f48fbf | |||
| a1f80d7407 | |||
| d94831db0f | |||
| 082f8669fb | |||
| f9943bd47a | |||
| 805c97b8e4 | |||
| a2b5a3f349 | |||
| 83c92262d8 | |||
| ab9f2daaf9 | |||
| 46d4b49157 | |||
| a7c811025e | |||
| afc776d948 | |||
| 8778dbf3da | |||
| c4ef2bfbca | |||
| bdb6be1702 | |||
| 2338858254 | |||
| ca0f6f19cc | |||
| c2cb897190 | |||
| 5a10649c57 | |||
| 563d90dbbc | |||
| d61b05195b | |||
| 3b5f646422 | |||
| 98c2aa3b7f | |||
| 5ef981d35b | |||
| d9b5b95f54 | |||
| 3bbee6029f | |||
| f057ea29d6 | |||
| d53a635495 | |||
| 9e9fd4adf3 | |||
| a613d01873 | |||
| c7edcfdfc2 | |||
| aae79eb56a | |||
| 782c50ff1a | |||
| 6a8a8a6bda | |||
| 2fa7e01b3a | |||
| 8b003c4d57 | |||
| f6f47fcbbe | |||
| ac7bb2e699 | |||
| 01475c42f5 | |||
| 71b06bc64a | |||
| b6e92cd6f2 | |||
| 581d35c983 | |||
| 9fc8741f9c | |||
| 24f0ec44ec | |||
| 7368339bae | |||
| 0ea1995de5 | |||
| 6d73cfcea9 | |||
| dd5f3dc8b9 | |||
| d80aa3b96b | |||
| 1c7bf20d6d | |||
| 92e6b7c450 | |||
| 1e11426043 | |||
| b5d688ec0d | |||
| 0c6132a2b9 | |||
| 9092bc8a98 | |||
| 8b18c4c27f | |||
| e05b60cee8 | |||
| d92004ba3a | |||
| 0129face77 | |||
| cdd7fa97d5 | |||
| 965573a80f | |||
| 4a491f404c | |||
| c5796744e7 | |||
| 78879bd028 | |||
| 4f4e3bada3 | |||
| ca39b3cfca | |||
| 0488b187fc | |||
| 56e17a8e27 | |||
| ef531bf6a2 | |||
| 0a979ddc78 | |||
| 6a8986b160 | |||
| bc85891027 | |||
| aeb9868507 | |||
| 55036fcc0d | |||
| 90994017b0 | |||
| d9c2f7cdcc | |||
| f486123cb7 | |||
| 9c6db72d5b | |||
| 763ef32275 | |||
| 0afd5bf7f6 | |||
| b7b799b17d | |||
| d3d7f2209f | |||
| e7f76af63d | |||
| 0731f607b1 | |||
| 3672ee8afb | |||
| 68fd1050c2 | |||
| 36d9dca211 | |||
| d75e5fb218 | |||
| 1c88a28520 | |||
| e4bac101e8 | |||
| ecb49d65e7 | |||
| 2c93fedaed | |||
| 7855db7b09 | |||
| d7af8a98d8 | |||
| db44e04dba | |||
| 656f3ea4b1 | |||
| 8c0a3bdaaa | |||
| 41a95c8e0d | |||
| fbacc9ae56 | |||
| f2e75389e6 | |||
| 93338f877b | |||
| a2dcc1d622 | |||
| a978ddcba6 | |||
| eb14192ee8 | |||
| d3aa1d0b69 | |||
| db08e5d3c9 | |||
| 1a314ce752 | |||
| 9473293ed2 | |||
| 5b85f5de2e | |||
| 3856f752bd | |||
| d4273ed515 | |||
| 9e579d2cdd | |||
| 8294d9626f | |||
| a9ca344955 | |||
| b06a8faabd | |||
| e0e6ebec29 | |||
| 9898a0704b | |||
| 38f5c9d6fe | |||
| 6c926a2374 | |||
| 4b0abfb551 | |||
| ca702c9e5d | |||
| 11bf73acc2 | |||
| 1762687fc8 | |||
| 587ba77f71 | |||
| ecdf54a894 | |||
| 2492ca8c25 | |||
| f7a6104896 | |||
| b96f22be8a | |||
| 59120ed96e | |||
| 6adfb55581 | |||
| 06ced46db1 | |||
| c48a644bb9 | |||
| 88b25ab251 | |||
| 8bb9a14773 | |||
| d424b24221 | |||
| ab1ea7b368 | |||
| 6f2afc992f | |||
| 81c0afc534 | |||
| 977cab8ad2 | |||
| f190ff073a | |||
| e30ead0174 | |||
| 17ee7a3b2d | |||
| 8a24564cd9 | |||
| 06564af203 | |||
| c051df18c2 | |||
| 78d08aa751 | |||
| a748cca833 | |||
| 1b8860d8ff | |||
| 4c742aaf66 | |||
| 6b6ed9d42b | |||
| 9df81105e2 | |||
| fb2489812e | |||
| d29edd735e | |||
| f06a7ffe55 | |||
| 8b29ee6722 | |||
| 34ed0e4621 | |||
| 83f3980300 | |||
| 7b599e3afa | |||
| 7dcd7005e4 | |||
| c093e9703d | |||
| 9ca60629a0 | |||
| 075adcc8c6 | |||
| 5f2fe0c732 | |||
| 28f35861c3 | |||
| 4e45e0fd66 | |||
| 69e90f8d2d | |||
| c615676465 | |||
| bdc78fc5a5 | |||
| 7fb049e1ed | |||
| 7f71285c80 | |||
| a6997bc4b4 | |||
| 219cab1b03 | |||
| 8c3556fd68 | |||
| c9892ec62f | |||
| 018428d236 | |||
| f4dcfe0e50 | |||
| 25a878cb7a | |||
| 2f048c0105 | |||
| e3822254a3 | |||
| 5b193bc001 | |||
| f36c10b428 | |||
| 4943a87e13 | |||
| a868e9f94b | |||
| e281484b8a | |||
| 39dfcad52f | |||
| fe3af86c87 | |||
| f5101da85c | |||
| d5de02511b | |||
| adc8431522 | |||
| d30642ca4a | |||
| 5bb86f7028 | |||
| 33411fc5ed | |||
| e72f9a6ef3 | |||
| 066fa97c43 | |||
| dceadf7113 | |||
| a0ef658684 | |||
| 2c88953d97 | |||
| 9d54e03ae0 | |||
| c20e54c11b | |||
| bbbb4397f0 | |||
| 0bbbf5ce13 | |||
| 7ebb74a295 | |||
| 0dd1f30777 | |||
| 2bc0f9c5fd | |||
| a8ef68fee6 | |||
| 39e6d58e18 | |||
| 1557d59300 | |||
| 1df8caa940 | |||
| 0f69365b02 | |||
| 8d8416e650 | |||
| 412995ece6 | |||
| df3405ac9b | |||
| 9146e37039 | |||
| bb6322e187 | |||
| 064bd51c64 | |||
| f6de7f72cf | |||
| d594ac2edd | |||
| 09fcf8d8ec | |||
| 1efd3b18ad | |||
| b2128ef0f8 | |||
| 8e4e40d92f | |||
| e0c29f203b | |||
| 994039370c | |||
| 08cdada5bc | |||
| 8a3a77d273 | |||
| 235f1b6d14 | |||
| 52a18acf40 | |||
| c8f0982f22 | |||
| 1f97701779 | |||
| f863580f20 | |||
| d299117eb0 | |||
| 62588a3a4b | |||
| d50a8b5965 | |||
| ec95f548ff | |||
| 379298acc7 | |||
| 7b80a3434c | |||
| 23427f009f | |||
| c4a571a714 | |||
| db6344d055 | |||
| f97de44a0c | |||
| 448efc69f8 | |||
| c2cb67c51c | |||
| 89548e1105 | |||
| 7c23c646fe | |||
| 7671e8c0cf | |||
| 7a57f4897f | |||
| 0b89e446e7 | |||
| 83bf04e965 | |||
| 69bf968a4b | |||
| 85cf4e0fc6 | |||
| 86a08bb76e | |||
| 73e328c0b6 | |||
| 57c12057d9 | |||
| 0ae82ce433 | |||
| 067f202c08 | |||
| 9177b993fc | |||
| 1a0a5d7679 | |||
| ae2a9b76cb | |||
| 47f1b5bb41 | |||
| 6fdfed5cae | |||
| c949453c9e | |||
| 0e8e26237e | |||
| 4a0784746d | |||
| c98e797d4f | |||
| 3ff61f4e36 | |||
| 26b4081984 | |||
| 80457014c1 | |||
| 8dd1ed22a2 | |||
| db0b28fe0c | |||
| 44bf46f02c | |||
| d9b49b1e21 | |||
| a7e4d5b0dd | |||
| 67643f17a9 | |||
| e0e0456768 | |||
| 7e7da4030d | |||
| 754fc95d56 | |||
| 9365cb21c8 | |||
| 264b6c3e6d | |||
| 78fe42ef3a | |||
| 45df561e9d | |||
| f38d77a4d8 | |||
| beb5460797 | |||
| 37460ba224 | |||
| f79e4d6f32 | |||
| 86085c435a | |||
| 697d62f89b | |||
| 18add9f21a | |||
| 5e298383a3 | |||
| d5f118f2fc | |||
| 63bb86defc | |||
| 066ca99dd3 | |||
| d1b0e3542c | |||
| a8978e232f | |||
| ddd87b6336 | |||
| 6be580dc92 | |||
| 420f58c85a | |||
| 62a6a345cb | |||
| 700e705bee | |||
| 80240ccb66 | |||
| eabf1585b9 | |||
| 4125db98ba | |||
| 98075423f0 | |||
| b8024acf12 | |||
| 4ed87cd331 | |||
| dcdea5e9ea | |||
| 1cfb001148 | |||
| 9a003be98f | |||
| 2a73a00e98 | |||
| 9804376d30 | |||
| 006ee727ec | |||
| 1fe506da65 | |||
| df997db8eb | |||
| 4d1d5d4332 | |||
| 03f751954d | |||
| 33abde6f79 | |||
| 3e427b7b58 | |||
| ac1d710811 | |||
| 93bb36883f | |||
| a6bf8fe6df | |||
| 090c0b8016 | |||
| 7593033ab9 | |||
| 991a31ff35 | |||
| aff3153a84 | |||
| a4cd858114 | |||
| ebaea76b90 | |||
| b705cd3a9c | |||
| 57a59dcda0 | |||
| 8ec4f1c1b3 | |||
| 181c30e9ee | |||
| ab8d751330 | |||
| 603b3f37b9 | |||
| 9f6215706d | |||
| 3e1657ec34 | |||
| 8909e02ddc | |||
| 61754e058f | |||
| 514dae7906 | |||
| ffb328ab96 | |||
| 8ddcff61e4 | |||
| c4c311c541 | |||
| b575a07921 | |||
| 236e540204 | |||
| ef08f85d25 | |||
| 635afe9f9f | |||
| 25dfa6c8a2 | |||
| cfd446597d | |||
| 9c42e8ea2b | |||
| 4f9fc25ef2 | |||
| 8693e16802 | |||
| 9732b30aa5 | |||
| 7c2619e492 | |||
| 0f7eced14d | |||
| 924dcc0641 | |||
| 54bdbad25e | |||
| 1174cd6947 | |||
| 6f0b62cf8d | |||
| ef3a9bd3b0 | |||
| dd4e56c8fb | |||
| 0ed609c8ce | |||
| 3f92e62820 | |||
| cb3bd4358c | |||
| be499ddb36 | |||
| df8797b92a | |||
| 6622ea93aa | |||
| cce4eb3d57 | |||
| 49a1365cf9 | |||
| 0dad6924b5 | |||
| 7ca88f6416 | |||
| f2cebddd4d | |||
| fda2ed36bd | |||
| 1adc0f4ee2 | |||
| 9b64feb8ed | |||
| c6dd2a7ff8 | |||
| 8977c072f2 | |||
| d4a68f59fd | |||
| 92cc474ca3 | |||
| 3a436304bd | |||
| 96b13db79c | |||
| b0d53d34ac | |||
| 107a184c04 | |||
| 29d33fe277 | |||
| 8257c8d021 | |||
| fa8ed5e354 | |||
| 95e37c1dd1 | |||
| 613f099a8b | |||
| 187f35227b | |||
| 8a679c2fe6 | |||
| b2584763ee | |||
| f9e45327bf | |||
| 096720c266 | |||
| 83fdada12d | |||
| 0714b0a68c | |||
| 78c28b4734 | |||
| 34fa51a6e9 | |||
| 060d73122b | |||
| 026bf4a421 | |||
| 6c85a86959 | |||
| 5ce2371b41 | |||
| 503c131f0b | |||
| 5bbcb5be41 | |||
| 126bf41d9a | |||
| 5ae397bb0c | |||
| d8a8626dae | |||
| 3abefe64de | |||
| d77936e58f | |||
| 88fc872df6 | |||
| 18e07e43b6 | |||
| f735a144af | |||
| fa350e8f1c | |||
| ad72c053d9 | |||
| 7e458e4450 | |||
| 4a155bc5f4 | |||
| 393db5fe2a | |||
| a5e79cca0c | |||
| cdd415a52f | |||
| 0b9b049f82 | |||
| ca4a722029 | |||
| bb4125601f | |||
| 375e1a85d4 | |||
| 18cac93bf1 | |||
| 2ab4ae06af | |||
| 2bce856153 | |||
| 8e6011785a | |||
| a87123cb43 | |||
| 28d5b9364f | |||
| 6688db6145 | |||
| a3bec09864 | |||
| 229cfacd5c | |||
| 0c246aab30 | |||
| a0ac4607cc | |||
| 9a9b787fd7 | |||
| e61654c567 | |||
| 6137545cab | |||
| e3ba7d3b10 | |||
| 0945f780a3 | |||
| ac98e292e9 | |||
| 4e10aec872 | |||
| 1aaa0493a9 | |||
| 7155b3a7da | |||
| 2f044a8d02 | |||
| 8c9e89c771 | |||
| d7377c108a | |||
| cbccac6e22 | |||
| 046ecf8321 | |||
| a2dd32989e | |||
| b608208cf9 | |||
| e0c435009b | |||
| 0f3b80f0c1 | |||
| c6ff4fa703 | |||
| bf92d53620 | |||
| dd6cda2b44 | |||
| dbe0859750 | |||
| c480b82db6 | |||
| 3e6298f81c | |||
| 97cc69b337 | |||
| 4b50e56eb8 | |||
| c488b93290 | |||
| aebb20b37f | |||
| fcfe73178b | |||
| 224af7e8cb | |||
| 29f73c9271 | |||
| 0e04e9f49f | |||
| ca9ccab697 | |||
| 74f1c31df2 | |||
| fdf54e4513 | |||
| 867304bcb1 | |||
| 3b5144f475 | |||
| 422f80cabd | |||
| f3b2887df3 | |||
| 1be9216624 | |||
| 0d85ff0535 | |||
| 1410200cf2 | |||
| 0745637229 | |||
| 832581ad68 | |||
| 748da77678 | |||
| fe1a29c2a6 | |||
| 013bdce1db | |||
| 615aacae9a | |||
| a1ef06e573 | |||
| 6ca29806b0 | |||
| 9694a560f7 | |||
| 0450d9e2ed | |||
| 71f292f243 | |||
| d44fb1ac05 | |||
| b836ce37f3 | |||
| 29551f9d27 | |||
| de2302b589 | |||
| 126923f2b7 | |||
| bc89c1d7a8 | |||
| e07fb589c5 | |||
| 3ec97f4451 | |||
| c569cde8cf | |||
| df312009b8 | |||
| 6c6764202e | |||
| b093f7618a | |||
| 7e00c090e2 | |||
| 9c46396a39 | |||
| 9ef3207cd4 | |||
| 72b8140f77 | |||
| 948e6461fd | |||
| eb209f6d27 | |||
| d0a014705b | |||
| 07edfcb71d | |||
| 0074355df8 | |||
| 3ac1489d1f | |||
| f87642341b | |||
| e445169456 | |||
| 8f8bda1d0d | |||
| bcb17dc48b | |||
| ab566fc126 | |||
| aabc33a6cd | |||
| 9c129b7a37 | |||
| 0393878c9f | |||
| 313cbeddc4 | |||
| e97698877d | |||
| ec8c423304 | |||
| ad325c085a | |||
| 385b731ee8 | |||
| 82574466ee |
13
.env.example
13
.env.example
@@ -1,3 +1,10 @@
|
|||||||
POSTGRES_USER=
|
POSTGRES_USER=f0ckm
|
||||||
POSTGRES_DB=
|
POSTGRES_DB=f0ckm
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=f0ckm
|
||||||
|
# --- Nginx & Let's Encrypt Configuration (Optional) ---
|
||||||
|
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
|
||||||
|
# COMPOSE_PROFILES=f0ckm-nginx
|
||||||
|
#
|
||||||
|
# VIRTUAL_HOST=yourdomain.com
|
||||||
|
# LETSENCRYPT_HOST=yourdomain.com
|
||||||
|
# LETSENCRYPT_EMAIL=your-email@example.com
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ public/tag_cache
|
|||||||
.env
|
.env
|
||||||
config.json
|
config.json
|
||||||
bundle.css
|
bundle.css
|
||||||
|
public/s/fonts/impact.woff
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,18 +1,30 @@
|
|||||||
# f0ckm
|
# f0ckm
|
||||||
|
|
||||||
|
Happy to finally bring you f0ckm! The long awaited imageboard solution that you can host yourself. It is designed to be a real alternative to big tech platforms that you can modify to suit your communities needs.
|
||||||
|
|
||||||
|
It features extensive tagging, searching, filtering and a variety of options.
|
||||||
|
|
||||||
|
The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
|
||||||
|
|
||||||
|
The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
|
||||||
|
|
||||||
|
## Software Requirements
|
||||||
|
|
||||||
|
- Docker (https://docs.docker.com/engine/install/debian/)
|
||||||
|
|
||||||
## prod
|
## prod
|
||||||
|
|
||||||
first things
|
first things
|
||||||
|
|
||||||
`cp .env.example .env`
|
`cp .env.example .env`
|
||||||
|
|
||||||
fill with for example: f0ckm
|
fill with for example: f0ckm (prefilled)
|
||||||
|
|
||||||
`cp config_example.json config.json`
|
`cp config_example.json config.json`
|
||||||
|
|
||||||
Edit to needs, for sql you can do this:
|
Edit to needs, for sql you can do this:
|
||||||
|
|
||||||
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname
|
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname, if you choose localhost you wont need to add the ENV `DB_HOST=localhost` and if you also change the port to be `DB_PORT=5454` you wont need this ENV either, but then Docker wont find the DB.
|
||||||
|
|
||||||
```
|
```
|
||||||
"sql": {
|
"sql": {
|
||||||
@@ -42,6 +54,8 @@ now vist http://localhost:1337 in your browser
|
|||||||
|
|
||||||
## dev
|
## dev
|
||||||
|
|
||||||
|
NOTE: when developing locally it might be necessary to run commands with `DB_HOST=localhost DB_PORT=5454`
|
||||||
|
|
||||||
`docker compose up -d f0ckm-db`
|
`docker compose up -d f0ckm-db`
|
||||||
|
|
||||||
on dev machine:
|
on dev machine:
|
||||||
@@ -49,8 +63,18 @@ on dev machine:
|
|||||||
`npm i`
|
`npm i`
|
||||||
`npm run dev`
|
`npm run dev`
|
||||||
|
|
||||||
|
Fill Database
|
||||||
|
|
||||||
|
`docker exec -i f0ckm-db psql -U f0ckm -d f0ckm < migrations/f0ckm_schema.sql`
|
||||||
|
|
||||||
|
`DB_HOST=localhost DB_PORT=5454 node scripts/seed.mjs`
|
||||||
|
|
||||||
Create admin user in dev env
|
Create admin user in dev env
|
||||||
|
|
||||||
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
||||||
|
|
||||||
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
||||||
|
|
||||||
|
## NGINX
|
||||||
|
|
||||||
|
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||||
|
|||||||
3
config/nginx/f0ck.conf
Normal file
3
config/nginx/f0ck.conf
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
client_max_body_size 10000M;
|
||||||
|
client_body_timeout 120s;
|
||||||
|
client_header_timeout 120s;
|
||||||
@@ -56,11 +56,15 @@
|
|||||||
"default_layout": "legacy",
|
"default_layout": "legacy",
|
||||||
"custom_favicon": "/s/img/favicon.gif",
|
"custom_favicon": "/s/img/favicon.gif",
|
||||||
"custom_brand_image": [],
|
"custom_brand_image": [],
|
||||||
|
"custom_navbar_brand_text": "",
|
||||||
"show_koepfe": false,
|
"show_koepfe": false,
|
||||||
"koepfe": [],
|
"koepfe": [],
|
||||||
"enable_global_chat": true,
|
"enable_global_chat": true,
|
||||||
"enable_danmaku": true,
|
"enable_danmaku": true,
|
||||||
"private_messages": true,
|
"private_messages": true,
|
||||||
|
"dm_attachments": true,
|
||||||
|
"dm_unencrypted": false,
|
||||||
|
"dm_attachment_expiry_days": 90,
|
||||||
"halls_enabled": true,
|
"halls_enabled": true,
|
||||||
"userhalls_enabled": true,
|
"userhalls_enabled": true,
|
||||||
"enable_userhall_image_upload": true,
|
"enable_userhall_image_upload": true,
|
||||||
@@ -68,12 +72,24 @@
|
|||||||
"meme_creator": true,
|
"meme_creator": true,
|
||||||
"enable_cleanup": false,
|
"enable_cleanup": false,
|
||||||
"enable_data_export": true,
|
"enable_data_export": true,
|
||||||
|
"inactivity_ban_days": 60,
|
||||||
|
"enable_user_api_keys": true,
|
||||||
|
"enable_user_invites": true,
|
||||||
|
"user_invite_slots": 2,
|
||||||
|
"invite_criteria": {
|
||||||
|
"uploads": 10,
|
||||||
|
"age_days": 10,
|
||||||
|
"comments": 10,
|
||||||
|
"tags": 10
|
||||||
|
},
|
||||||
"cleanup_timeframe_days": 30,
|
"cleanup_timeframe_days": 30,
|
||||||
"web_url_upload": true,
|
"web_url_upload": true,
|
||||||
"enable_youtube_upload": true,
|
"enable_youtube_upload": true,
|
||||||
"web_meta_extraction": true,
|
"web_meta_extraction": true,
|
||||||
"bypass_duplicate_check": true,
|
"bypass_duplicate_check": true,
|
||||||
"shitpost_mode": false,
|
"shitpost_mode": false,
|
||||||
|
"shitpost_require_rating": false,
|
||||||
|
"shitpost_min_tags": 0,
|
||||||
"protect_files": false,
|
"protect_files": false,
|
||||||
"enable_dynamic_thumbs": false,
|
"enable_dynamic_thumbs": false,
|
||||||
"allowed_comment_images": [
|
"allowed_comment_images": [
|
||||||
@@ -83,6 +99,14 @@
|
|||||||
],
|
],
|
||||||
"show_mime_picker": true,
|
"show_mime_picker": true,
|
||||||
"embed_youtube_in_comments": true,
|
"embed_youtube_in_comments": true,
|
||||||
|
"allow_fileupload_comments": true,
|
||||||
|
"allow_comment_deletion": false,
|
||||||
|
"enable_comment_polls": false,
|
||||||
|
"fileupload_comments_multifile": true,
|
||||||
|
"fileupload_comments_size": 104857600,
|
||||||
|
"fileupload_comments_max": 5,
|
||||||
|
"fileupload_comments_mode": "attachment",
|
||||||
|
"fileupload_comments_mimes": ["image", "video", "audio"],
|
||||||
"show_content_warning": true,
|
"show_content_warning": true,
|
||||||
"default_comment_display_mode": 1,
|
"default_comment_display_mode": 1,
|
||||||
"phrases": [
|
"phrases": [
|
||||||
@@ -94,12 +118,19 @@
|
|||||||
"enable_swiping": true,
|
"enable_swiping": true,
|
||||||
"enable_profile_description": true,
|
"enable_profile_description": true,
|
||||||
"user_alternative_infobox": false,
|
"user_alternative_infobox": false,
|
||||||
|
"user_banner_enabled": true,
|
||||||
|
"user_alternative_steuerung": false,
|
||||||
"enable_swf": false,
|
"enable_swf": false,
|
||||||
"swf_thumb": "/s/img/swf.png",
|
"swf_thumb": "/s/img/swf.png",
|
||||||
|
"enable_archive": true,
|
||||||
|
"archive_thumb": "/s/img/archive.webp",
|
||||||
|
"enable_item_title": true,
|
||||||
"open_registration": true,
|
"open_registration": true,
|
||||||
"open_registration_web_toggle": false,
|
"open_registration_web_toggle": false,
|
||||||
|
"open_registration_require_mail_andor_token": false,
|
||||||
"private_society": false,
|
"private_society": false,
|
||||||
"private_society_gate": "cloudflare",
|
"private_society_gate": "cloudflare",
|
||||||
|
"public_nsfw": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"images": "/b",
|
"images": "/b",
|
||||||
"thumbnails": "/t",
|
"thumbnails": "/t",
|
||||||
@@ -177,7 +208,16 @@
|
|||||||
"video/x-matroska": "mkv",
|
"video/x-matroska": "mkv",
|
||||||
"application/x-shockwave-flash": "swf",
|
"application/x-shockwave-flash": "swf",
|
||||||
"application/vnd.adobe.flash.movie": "swf",
|
"application/vnd.adobe.flash.movie": "swf",
|
||||||
"application/pdf": "pdf"
|
"application/pdf": "pdf",
|
||||||
|
"application/zip": "zip",
|
||||||
|
"application/x-zip-compressed": "zip",
|
||||||
|
"application/x-rar-compressed": "rar",
|
||||||
|
"application/vnd.rar": "rar",
|
||||||
|
"application/x-7z-compressed": "7z",
|
||||||
|
"application/x-tar": "tar",
|
||||||
|
"application/gzip": "gz",
|
||||||
|
"application/x-bzip2": "bz2",
|
||||||
|
"application/x-xz": "xz"
|
||||||
},
|
},
|
||||||
"apis": {},
|
"apis": {},
|
||||||
"smtp": {
|
"smtp": {
|
||||||
@@ -189,5 +229,10 @@
|
|||||||
"password": "smtp_password",
|
"password": "smtp_password",
|
||||||
"from": "admin@example.com",
|
"from": "admin@example.com",
|
||||||
"mail_reset_password": false
|
"mail_reset_password": false
|
||||||
|
},
|
||||||
|
"recaptcha": {
|
||||||
|
"enabled": false,
|
||||||
|
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
|
||||||
|
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,14 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- f0ckm-net
|
- f0ckm-net
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/opt/f0ckm/config.json:Z
|
- ./config.json:/opt/f0ckm/config.json:ro,Z
|
||||||
- ./src/:/opt/f0ckm/src/:Z
|
- ./src/:/opt/f0ckm/src/:Z
|
||||||
- ./views/:/opt/f0ckm/views/:Z
|
- ./views/:/opt/f0ckm/views/:Z
|
||||||
- ./scripts/:/opt/f0ckm/scripts/:Z
|
- ./scripts/:/opt/f0ckm/scripts/:Z
|
||||||
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
|
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
|
||||||
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
||||||
|
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
|
||||||
|
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
|
||||||
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
||||||
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
||||||
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
||||||
@@ -33,6 +35,10 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
- GIT_HASH=${f0ckm_TAG:-unknown}
|
- GIT_HASH=${f0ckm_TAG:-unknown}
|
||||||
|
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
|
||||||
|
- VIRTUAL_PORT=1337
|
||||||
|
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
||||||
|
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
||||||
ports:
|
ports:
|
||||||
- "1337:1337"
|
- "1337:1337"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -40,6 +46,16 @@ services:
|
|||||||
f0ckm-db:
|
f0ckm-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
tor:
|
||||||
|
image: dockurr/tor
|
||||||
|
container_name: tor
|
||||||
|
ports:
|
||||||
|
- "9050:9050"
|
||||||
|
- "8118:8118"
|
||||||
|
networks:
|
||||||
|
- f0ckm-net
|
||||||
|
restart: always
|
||||||
|
|
||||||
f0ckm-db:
|
f0ckm-db:
|
||||||
container_name: f0ckm-db
|
container_name: f0ckm-db
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -78,6 +94,49 @@ services:
|
|||||||
# - f0ckm-net
|
# - f0ckm-net
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
f0ckm-nginx:
|
||||||
|
image: nginxproxy/nginx-proxy:latest
|
||||||
|
container_name: f0ckm-nginx
|
||||||
|
profiles:
|
||||||
|
- f0ckm-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- vhost:/etc/nginx/vhost.d:rw
|
||||||
|
- html:/usr/share/nginx/html:rw
|
||||||
|
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||||
|
networks:
|
||||||
|
- f0ckm-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
acme-companion:
|
||||||
|
image: nginxproxy/acme-companion:latest
|
||||||
|
container_name: f0ckm-acme-companion
|
||||||
|
profiles:
|
||||||
|
- f0ckm-nginx
|
||||||
|
environment:
|
||||||
|
- NGINX_PROXY_CONTAINER=f0ckm-nginx
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- vhost:/etc/nginx/vhost.d:rw
|
||||||
|
- html:/usr/share/nginx/html:rw
|
||||||
|
- acme:/etc/acme.sh:rw
|
||||||
|
depends_on:
|
||||||
|
- f0ckm-nginx
|
||||||
|
networks:
|
||||||
|
- f0ckm-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
f0ckm-net:
|
f0ckm-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
certs:
|
||||||
|
vhost:
|
||||||
|
html:
|
||||||
|
acme:
|
||||||
|
|||||||
0
f0ckm-data/c/.gitkeep
Normal file
0
f0ckm-data/c/.gitkeep
Normal file
0
f0ckm-data/e/.gitkeep
Normal file
0
f0ckm-data/e/.gitkeep
Normal file
10
migrations/add_emoji_favorites.sql
Normal file
10
migrations/add_emoji_favorites.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- User emoji favorites — persisted per-user, cross-device
|
||||||
|
CREATE TABLE IF NOT EXISTS user_emoji_favorites (
|
||||||
|
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
emoji_name text NOT NULL,
|
||||||
|
emoji_url text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, emoji_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON user_emoji_favorites (user_id);
|
||||||
53
migrations/add_sticker_packs.sql
Normal file
53
migrations/add_sticker_packs.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration: Add sticker_packs table and link custom_emojis to packs
|
||||||
|
-- Run this against the f0ckm database (idempotent — safe to run multiple times)
|
||||||
|
|
||||||
|
-- Create sticker_packs table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
tg_name text UNIQUE,
|
||||||
|
tg_title text,
|
||||||
|
thumb_url text,
|
||||||
|
sticker_count integer DEFAULT 0,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
||||||
|
|
||||||
|
-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'custom_emojis'
|
||||||
|
AND column_name = 'pack_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.custom_emojis
|
||||||
|
ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
||||||
|
RAISE NOTICE 'pack_id column added to custom_emojis';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Add thumb_url column to sticker_packs if missing
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'sticker_packs'
|
||||||
|
AND column_name = 'thumb_url'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text;
|
||||||
|
RAISE NOTICE 'thumb_url column added to sticker_packs';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Index for fast pack lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
||||||
15
migrations/add_user_banner.sql
Normal file
15
migrations/add_user_banner.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add banner_file column to user_options for custom profile banners
|
||||||
|
-- Banners are displayed in the alternative infobox (user-infobox-block)
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner (e.g. cover, contain, or a percentage)';
|
||||||
@@ -20,6 +20,9 @@ SET client_min_messages = warning;
|
|||||||
SET row_security = off;
|
SET row_security = off;
|
||||||
|
|
||||||
DROP PUBLICATION IF EXISTS alltables;
|
DROP PUBLICATION IF EXISTS alltables;
|
||||||
|
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_user_id_fkey;
|
||||||
|
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_api_key_key;
|
||||||
|
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_pkey;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
|
||||||
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
|
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
|
||||||
@@ -76,7 +79,7 @@ CREATE OR REPLACE VIEW public.items_li AS
|
|||||||
SELECT
|
SELECT
|
||||||
NULL::integer AS id,
|
NULL::integer AS id,
|
||||||
NULL::character varying(255) AS src,
|
NULL::character varying(255) AS src,
|
||||||
NULL::character varying(40) AS dest,
|
NULL::character varying(60) AS dest,
|
||||||
NULL::character varying(100) AS mime,
|
NULL::character varying(100) AS mime,
|
||||||
NULL::integer AS size,
|
NULL::integer AS size,
|
||||||
NULL::character varying(255) AS checksum,
|
NULL::character varying(255) AS checksum,
|
||||||
@@ -91,6 +94,7 @@ DROP INDEX IF EXISTS public.idx_user_last_seen;
|
|||||||
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
|
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
|
||||||
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
|
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
|
||||||
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
|
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
|
||||||
|
DROP INDEX IF EXISTS public.idx_user_api_keys_api_key;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_userid;
|
DROP INDEX IF EXISTS public.idx_user_alias_userid;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
DROP INDEX IF EXISTS public.idx_user_alias_type;
|
||||||
DROP INDEX IF EXISTS public.idx_user_alias_alias;
|
DROP INDEX IF EXISTS public.idx_user_alias_alias;
|
||||||
@@ -191,6 +195,7 @@ DROP TABLE IF EXISTS public.user_halls_assign;
|
|||||||
DROP TABLE IF EXISTS public.user_halls;
|
DROP TABLE IF EXISTS public.user_halls;
|
||||||
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
DROP TABLE IF EXISTS public.user_dm_keyvault;
|
||||||
DROP TABLE IF EXISTS public.user_conversation_states;
|
DROP TABLE IF EXISTS public.user_conversation_states;
|
||||||
|
DROP TABLE IF EXISTS public.user_api_keys;
|
||||||
DROP TABLE IF EXISTS public.user_alias;
|
DROP TABLE IF EXISTS public.user_alias;
|
||||||
DROP TABLE IF EXISTS public."user";
|
DROP TABLE IF EXISTS public."user";
|
||||||
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
DROP SEQUENCE IF EXISTS public.user_id_seq;
|
||||||
@@ -588,7 +593,7 @@ CREATE TABLE public.comment_files (
|
|||||||
id integer NOT NULL,
|
id integer NOT NULL,
|
||||||
comment_id integer,
|
comment_id integer,
|
||||||
user_id integer NOT NULL,
|
user_id integer NOT NULL,
|
||||||
dest character varying(40) NOT NULL,
|
dest character varying(60) NOT NULL,
|
||||||
mime character varying(100) NOT NULL,
|
mime character varying(100) NOT NULL,
|
||||||
size integer NOT NULL,
|
size integer NOT NULL,
|
||||||
checksum character varying(255) NOT NULL,
|
checksum character varying(255) NOT NULL,
|
||||||
@@ -600,6 +605,8 @@ CREATE TABLE public.comment_files (
|
|||||||
|
|
||||||
ALTER TABLE public.comment_files OWNER TO f0ckm;
|
ALTER TABLE public.comment_files OWNER TO f0ckm;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.comment_files REPLICA IDENTITY DEFAULT;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -820,12 +827,25 @@ CREATE TABLE public.invite_tokens (
|
|||||||
used_by integer,
|
used_by integer,
|
||||||
is_used boolean DEFAULT false,
|
is_used boolean DEFAULT false,
|
||||||
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
||||||
created_by_matrix character varying(255) DEFAULT NULL::character varying
|
created_by_matrix character varying(255) DEFAULT NULL::character varying,
|
||||||
|
used_at timestamp with time zone DEFAULT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
|
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -869,7 +889,7 @@ ALTER SEQUENCE public.items_id_seq OWNER TO f0ckm;
|
|||||||
CREATE TABLE public.items (
|
CREATE TABLE public.items (
|
||||||
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
|
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
|
||||||
src character varying(255) NOT NULL,
|
src character varying(255) NOT NULL,
|
||||||
dest character varying(40) NOT NULL,
|
dest character varying(60) NOT NULL,
|
||||||
mime character varying(100) NOT NULL,
|
mime character varying(100) NOT NULL,
|
||||||
size integer NOT NULL,
|
size integer NOT NULL,
|
||||||
checksum character varying(255) NOT NULL,
|
checksum character varying(255) NOT NULL,
|
||||||
@@ -887,7 +907,10 @@ CREATE TABLE public.items (
|
|||||||
is_pinned boolean DEFAULT false,
|
is_pinned boolean DEFAULT false,
|
||||||
is_oc boolean DEFAULT false,
|
is_oc boolean DEFAULT false,
|
||||||
xd_score integer DEFAULT 0 NOT NULL,
|
xd_score integer DEFAULT 0 NOT NULL,
|
||||||
original_filename text
|
original_filename text,
|
||||||
|
title text,
|
||||||
|
width integer,
|
||||||
|
height integer
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -1326,6 +1349,21 @@ CREATE TABLE public."user" (
|
|||||||
|
|
||||||
ALTER TABLE public."user" OWNER TO f0ckm;
|
ALTER TABLE public."user" OWNER TO f0ckm;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: user_api_keys; Type: TABLE; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.user_api_keys (
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
api_key text NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT user_api_keys_pkey PRIMARY KEY (user_id),
|
||||||
|
CONSTRAINT user_api_keys_api_key_key UNIQUE (api_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.user_api_keys OWNER TO f0ckm;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
|
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -1455,6 +1493,7 @@ CREATE TABLE public.user_options (
|
|||||||
hide_koepfe boolean DEFAULT false NOT NULL,
|
hide_koepfe boolean DEFAULT false NOT NULL,
|
||||||
language text,
|
language text,
|
||||||
use_alternative_infobox boolean DEFAULT false,
|
use_alternative_infobox boolean DEFAULT false,
|
||||||
|
use_alternative_steuerung boolean DEFAULT NULL,
|
||||||
receive_system_notifications boolean DEFAULT true,
|
receive_system_notifications boolean DEFAULT true,
|
||||||
receive_user_notifications boolean DEFAULT true,
|
receive_user_notifications boolean DEFAULT true,
|
||||||
do_not_disturb boolean DEFAULT false,
|
do_not_disturb boolean DEFAULT false,
|
||||||
@@ -1586,6 +1625,13 @@ ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.au
|
|||||||
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
|
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.comment_files ALTER COLUMN id SET DEFAULT nextval('public.comment_files_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -1693,6 +1739,14 @@ ALTER TABLE ONLY public.comment_subscriptions
|
|||||||
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
|
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files comment_files_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.comment_files
|
||||||
|
ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2241,6 +2295,13 @@ CREATE INDEX idx_user_alias_type ON public.user_alias USING btree (type);
|
|||||||
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
|
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: idx_user_api_keys_api_key; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_api_keys_api_key ON public.user_api_keys USING btree (api_key);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
|
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2385,6 +2446,22 @@ ALTER TABLE ONLY public.comment_subscriptions
|
|||||||
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files comment_files_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.comment_files
|
||||||
|
ADD CONSTRAINT comment_files_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files comment_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.comment_files
|
||||||
|
ADD CONSTRAINT comment_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2721,6 +2798,14 @@ ALTER TABLE ONLY public.user_warnings
|
|||||||
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: user_api_keys user_api_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.user_api_keys
|
||||||
|
ADD CONSTRAINT user_api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
|
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
|
||||||
--
|
--
|
||||||
@@ -2759,4 +2844,104 @@ CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id);
|
|||||||
-- Add IP tracking to user_sessions for "current" IP view
|
-- Add IP tracking to user_sessions for "current" IP view
|
||||||
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
|
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
|
||||||
|
|
||||||
|
-- DM encrypted attachments (Migration 005)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.dm_attachments (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
sender_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
||||||
|
recipient_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
||||||
|
iv text NOT NULL,
|
||||||
|
file_path text NOT NULL,
|
||||||
|
original_name text NOT NULL DEFAULT '',
|
||||||
|
mime_hint text NOT NULL DEFAULT '',
|
||||||
|
size_bytes integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamp with time zone DEFAULT now(),
|
||||||
|
expires_at timestamp with time zone DEFAULT (now() + interval '90 days')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_att_sender ON public.dm_attachments(sender_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_att_recipient ON public.dm_attachments(recipient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_att_expires ON public.dm_attachments(expires_at);
|
||||||
|
|
||||||
|
-- DM message edit/delete support (Migration 006)
|
||||||
|
ALTER TABLE private_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS edited_at timestamp with time zone DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Wordfilter Table (Migration 009)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.wordfilter (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
word VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
replacement VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Comment Polls
|
||||||
|
CREATE TABLE IF NOT EXISTS public.comment_polls (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||||
|
UNIQUE(comment_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.comment_poll_options (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.comment_poll_votes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
|
||||||
|
option_id INTEGER NOT NULL REFERENCES public.comment_poll_options(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(poll_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comment_polls_comment_id ON public.comment_polls(comment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comment_poll_options_poll ON public.comment_poll_options(poll_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_poll ON public.comment_poll_votes(poll_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_user ON public.comment_poll_votes(user_id);
|
||||||
|
|
||||||
|
-- Sticker Packs (Migration 010)
|
||||||
|
-- sticker_packs table: holds imported Telegram (or custom) sticker pack metadata
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
tg_name text UNIQUE,
|
||||||
|
tg_title text,
|
||||||
|
thumb_url text,
|
||||||
|
sticker_count integer DEFAULT 0,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
||||||
|
|
||||||
|
-- Link custom_emojis to a sticker pack (NULL = standalone emoji)
|
||||||
|
ALTER TABLE public.custom_emojis
|
||||||
|
ADD COLUMN IF NOT EXISTS pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Ensure thumb_url exists even on databases created before this column was added
|
||||||
|
ALTER TABLE public.sticker_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS thumb_url text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
||||||
|
|
||||||
|
-- User Emoji Favorites (Migration 011)
|
||||||
|
-- Persists per-user sticker/emoji favorites for cross-device sync
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_emoji_favorites (
|
||||||
|
user_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
||||||
|
emoji_name text NOT NULL,
|
||||||
|
emoji_url text NOT NULL,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, emoji_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
|
||||||
|
|
||||||
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,13 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Impact';
|
font-family: 'Impact';
|
||||||
src: url('/s/fonts/impact.woff') format('woff');
|
src: url('/s/fonts/impact.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Impact';
|
||||||
|
src: url('/s/fonts/impact.woff') format('woff');
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meme Creator Styles */
|
/* Meme Creator Styles */
|
||||||
@@ -185,6 +192,8 @@ canvas#memeCanvas {
|
|||||||
.meme-layout-wrapper input[type="range"] {
|
.meme-layout-wrapper input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--accent, #9f0);
|
accent-color: var(--accent, #9f0);
|
||||||
|
touch-action: pan-x;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meme-layout-wrapper .layer-input-group {
|
.meme-layout-wrapper .layer-input-group {
|
||||||
@@ -234,7 +243,7 @@ canvas#memeCanvas {
|
|||||||
.meme-editor-layout {
|
.meme-editor-layout {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
grid-template-rows: 0.6fr 1fr !important;
|
grid-template-rows: 0.6fr auto !important;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.meme-controls {
|
.meme-controls {
|
||||||
@@ -260,3 +269,45 @@ canvas#memeCanvas {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Template Card & Drag Hover Styles */
|
||||||
|
.custom-template-card .template-image-wrapper {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-template-card:hover .template-image-wrapper {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-template-card i {
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-template-card:hover i {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper.drag-hover {
|
||||||
|
border-color: var(--accent, #9f0) !important;
|
||||||
|
box-shadow: 0 0 25px rgba(159, 255, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar space reservation for meme pages */
|
||||||
|
.meme-layout-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
transition: padding-right 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||||
|
.meme-layout-wrapper {
|
||||||
|
padding-right: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse reserved space when sidebar is hidden */
|
||||||
|
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* Stacked */
|
/* Stacked */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.3rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,19 +219,13 @@
|
|||||||
|
|
||||||
.upload-form:not(.shitpost-mode-active) .preview-media-small {
|
.upload-form:not(.shitpost-mode-active) .preview-media-small {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .preview-media-small {
|
|
||||||
width: 120px;
|
|
||||||
height: 80px;
|
|
||||||
object-fit: contain;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-media-small {
|
.preview-media-small {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -247,7 +241,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .file-meta-row-small {
|
.upload-form.shitpost-mode-active .file-name-small {
|
||||||
padding-right: 30px; /* Space for X button */
|
padding-right: 30px; /* Space for X button */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,8 +249,9 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-info-small {
|
.file-info-small {
|
||||||
@@ -403,56 +398,71 @@
|
|||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.sfw {
|
.item-rating-option input:checked + .item-rating-label.sfw {
|
||||||
background: #40c057;
|
background: var(--badge-sfw);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: #40c057;
|
border-color: var(--badge-sfw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.nsfw {
|
.item-rating-option input:checked + .item-rating-label.nsfw {
|
||||||
background: #fd7e14;
|
background: var(--badge-nsfw);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: #fd7e14;
|
border-color: var(--badge-nsfw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-rating-option input:checked + .item-rating-label.nsfl {
|
.item-rating-option input:checked + .item-rating-label.nsfl {
|
||||||
background: #fa5252;
|
background: var(--badge-nsfl);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: #fa5252;
|
border-color: var(--badge-nsfl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.item-rating-label:hover {
|
.item-rating-label:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bigger Previews in Shitpost Mode */
|
/* Bigger Previews in Shitpost Mode */
|
||||||
.file-preview-item {
|
.upload-form.shitpost-mode-active .file-preview-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
gap: 0;
|
gap: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-media-small {
|
.upload-form.shitpost-mode-active .preview-media-small {
|
||||||
flex: 0 0 50%;
|
flex: 0 0 50%;
|
||||||
width: 50% !important;
|
width: 50% !important;
|
||||||
height: auto !important;
|
max-width: 50% !important;
|
||||||
min-height: 120px;
|
height: 240px !important;
|
||||||
max-height: 350px;
|
max-height: 240px !important;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
|
overflow: hidden; /* Clip the video — Chrome cannot expand past this */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner video inside the wrapper fills the container and cannot resize it */
|
||||||
|
.upload-form.shitpost-mode-active .preview-video-wrapper > video {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-item Tags */
|
/* Per-item Tags */
|
||||||
.item-tags-container {
|
.item-tags-container {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-tags-list {
|
.item-tags-list {
|
||||||
@@ -549,6 +559,34 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-title-container {
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title-input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title-input::placeholder {
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.rating-options {
|
.rating-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -676,6 +714,29 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global title input (normal mode) — matches tag-input-container style */
|
||||||
|
.upload-title-input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title-input:focus {
|
||||||
|
border-color: var(--accent, #7c5cbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.tag-count {
|
.tag-count {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -735,9 +796,9 @@
|
|||||||
|
|
||||||
.upload-form .tag-suggestions {
|
.upload-form .tag-suggestions {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
min-width: 220px !important;
|
min-width: 220px;
|
||||||
max-width: 320px !important;
|
max-width: 320px;
|
||||||
max-height: 260px !important;
|
max-height: 260px;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
background: var(--dropdown-bg, #1a1a1a) !important;
|
background: var(--dropdown-bg, #1a1a1a) !important;
|
||||||
border: 1px solid var(--black, #000) !important;
|
border: 1px solid var(--black, #000) !important;
|
||||||
@@ -750,9 +811,7 @@
|
|||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form.shitpost-mode-active .tag-suggestions {
|
|
||||||
position: relative !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tagDropIn {
|
@keyframes tagDropIn {
|
||||||
from {
|
from {
|
||||||
@@ -1243,7 +1302,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 768px) {
|
||||||
.upload-form.shitpost-mode-active .file-preview-item {
|
.upload-form.shitpost-mode-active .file-preview-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -1253,6 +1312,7 @@
|
|||||||
.upload-form.shitpost-mode-active .preview-media-small,
|
.upload-form.shitpost-mode-active .preview-media-small,
|
||||||
.upload-form.shitpost-mode-active .item-media-col {
|
.upload-form.shitpost-mode-active .item-media-col {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
@@ -1260,4 +1320,341 @@
|
|||||||
.upload-form.shitpost-mode-active .item-media-col iframe {
|
.upload-form.shitpost-mode-active .item-media-col iframe {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep suggestions visible and position them above the tag input */
|
||||||
|
.upload-form.shitpost-mode-active .file-meta-row-small {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form.shitpost-mode-active .tag-suggestions {
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 100% !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
z-index: 200000 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video thumbnail loading animation */
|
||||||
|
.video-thumbnail-loading {
|
||||||
|
animation: thumbPulse 1.5s ease-in-out infinite;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thumbPulse {
|
||||||
|
0% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
100% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The container: CSS Grid with 3 rows — player | snapshot preview | button.
|
||||||
|
All rows are auto so the container grows naturally; no overflow clipping. */
|
||||||
|
.swf-upload-preview {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
max-height: none !important;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */
|
||||||
|
.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small {
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player,
|
||||||
|
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */
|
||||||
|
.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small {
|
||||||
|
width: 45% !important;
|
||||||
|
flex: 0 0 45% !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player,
|
||||||
|
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 220px !important;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder shown while Ruffle is loading */
|
||||||
|
.swf-upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
animation: thumbPulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swf-upload-placeholder-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swf-upload-placeholder-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */
|
||||||
|
.swf-upload-preview {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ruffle-snapshot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 200, 0, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(255, 200, 0, 0.2);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
color: rgba(255, 200, 0, 0.9);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ruffle-snapshot:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 200, 0, 0.15);
|
||||||
|
border-top-color: rgba(255, 200, 0, 0.4);
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ruffle-snapshot:active:not(:disabled) {
|
||||||
|
background: rgba(255, 200, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ruffle-snapshot:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Snapshot preview: sits in its own grid row, below the player */
|
||||||
|
.swf-upload-preview .ruffle-snapshot-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-top: 1px solid rgba(81, 207, 102, 0.3);
|
||||||
|
animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes snapPreviewIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(4px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
URL Upload Tracker — single persistent panel
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
#url-upload-tracker {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 9998;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--nav-bg, #1c1c1c);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: opacity 1.5s ease, transform 1.5s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#url-upload-tracker.uut-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────── */
|
||||||
|
.uut-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 10px 8px 12px;
|
||||||
|
background: rgba(var(--accent-rgb, 120,120,120), 0.07);
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-header > .fa-solid {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.75;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-actions { display: flex; gap: 2px; }
|
||||||
|
|
||||||
|
.uut-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
opacity: 0.3;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.uut-btn:hover { opacity: 0.8; background: rgba(var(--accent-rgb,120,120,120),0.12); }
|
||||||
|
|
||||||
|
/* ── Body (scrollable) ───────────────────────────── */
|
||||||
|
.uut-body {
|
||||||
|
max-height: 340px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(var(--accent-rgb,120,120,120), 0.25) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Active job rows ─────────────────────────────── */
|
||||||
|
.uut-job {
|
||||||
|
padding: 8px 12px 6px;
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.06);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-job--fading { opacity: 0; }
|
||||||
|
|
||||||
|
.uut-job-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-spinner {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 3px;
|
||||||
|
border: 1.5px solid rgba(var(--accent-rgb,120,120,120), 0.18);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: uutSpin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes uutSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.uut-job-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.uut-url {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
opacity: 0.65;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-stage {
|
||||||
|
font-size: 0.71rem;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-speed {
|
||||||
|
font-size: 0.67rem;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
opacity: 0.38;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.uut-bar {
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(var(--accent-rgb,120,120,120), 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uut-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.65s ease;
|
||||||
|
background-image: linear-gradient(90deg, var(--accent) 0%, rgba(255,255,255,0.4) 50%, var(--accent) 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: uutShimmer 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
|||||||
@@ -138,13 +138,11 @@
|
|||||||
transform: translateY(100%) translateY(-3px);
|
transform: translateY(100%) translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v0ck:hover .v0ck_player_controls,
|
|
||||||
.v0ck.v0ck_hover .v0ck_player_controls,
|
.v0ck.v0ck_hover .v0ck_player_controls,
|
||||||
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v0ck:hover .v0ck_progress,
|
|
||||||
.v0ck.v0ck_hover .v0ck_progress,
|
.v0ck.v0ck_hover .v0ck_progress,
|
||||||
.v0ck.v0ck_swf_active .v0ck_progress {
|
.v0ck.v0ck_swf_active .v0ck_progress {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -434,8 +432,9 @@
|
|||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
/* Hide images (emojis, inline imgs) inside unrevealed spoiler */
|
/* Hide images/videos (emojis, inline imgs, webm stickers) inside unrevealed spoiler */
|
||||||
.danmaku-pill .dpill-spoiler img {
|
.danmaku-pill .dpill-spoiler img,
|
||||||
|
.danmaku-pill .dpill-spoiler video {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -448,7 +447,9 @@
|
|||||||
-1px 1px 0 #000, 1px 1px 0 #000;
|
-1px 1px 0 #000, 1px 1px 0 #000;
|
||||||
}
|
}
|
||||||
.danmaku-pill .dpill-spoiler:hover img,
|
.danmaku-pill .dpill-spoiler:hover img,
|
||||||
.danmaku-pill .dpill-spoiler.revealed img {
|
.danmaku-pill .dpill-spoiler.revealed img,
|
||||||
|
.danmaku-pill .dpill-spoiler:hover video,
|
||||||
|
.danmaku-pill .dpill-spoiler.revealed video {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,3 +513,38 @@
|
|||||||
from { transform: translateX(calc(100vw + 100%)); }
|
from { transform: translateX(calc(100vw + 100%)); }
|
||||||
to { transform: translateX(calc(-100% - 200px)); }
|
to { transform: translateX(calc(-100% - 200px)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Speedup 2x HUD Pill */
|
||||||
|
.v0ck_speed_indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -10px);
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_speed_indicator:not(.v0ck_hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide mouse cursor on inactivity in fullscreen */
|
||||||
|
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
|
||||||
|
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
BIN
public/s/img/archive.webp
Normal file
BIN
public/s/img/archive.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
|
|
||||||
<defs>
|
|
||||||
<symbol id="heart_regular" viewBox="0 0 512 512"><path d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"/></symbol>
|
|
||||||
<symbol id="heart_solid" viewBox="0 0 512 512"><path d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"/></symbol>
|
|
||||||
<symbol id="cross" viewBox="0 0 512 512"><path d="M53.6,62.3 L458.4,458.4 M458.4,62.3 L53.6,458.4" style="stroke-linecap: round;stroke-width: 60;"/></symbol>
|
|
||||||
<symbol id="window-maximize" viewBox="0 0 512 512"><path d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z" style="fill: var(--maximize_button);"/></symbol>
|
|
||||||
<symbol id="window-minimize" viewBox="0 0 512 512"><path d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zm-96 464H48V256h320v208zm96-96h-48V144c0-26.5-21.5-48-48-48H144V48h320v320z" style="fill: var(--maximize_button);"/></symbol>
|
|
||||||
<symbol id="plus" viewBox="0 0 512 512"><path d="M256 80c0-8.84-7.16-16-16-16s-16 7.16-16 16v160H64c-8.84 0-16 7.16-16 16s7.16 16 16 16h160v160c0 8.84 7.16 16 16 16s16-7.16 16-16V272h160c8.84 0 16-7.16 16-16s-7.16-16-16-16H272V80z"/></symbol>
|
|
||||||
<symbol id="tag" viewBox="0 0 512 512"><path d="M0 252.118V48C0 21.49 21.49 0 48 0h204.118a48 48 0 0 1 33.941 14.059l211.882 211.882c18.745 18.745 18.745 49.137 0 67.882L293.823 497.941c-18.745 18.745-49.137 18.745-67.882 0L14.059 286.059A48 48 0 0 1 0 252.118zM112 64a48 48 0 1 0 48 48 48.055 48.055 0 0 0-48-48z"/></symbol>
|
|
||||||
<symbol id="shield" viewBox="0 0 512 512"><path d="M466.5 83.71c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91a27.3 27.3 0 0 1-31.4-18.41V28c0-15.46-12.54-28-28-28h-74.8c-15.46 0-28 12.54-28 28v37.39a27.3 27.3 0 0 1-31.4 18.41c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91A27.3 27.3 0 0 1 12 102.12V256c0 141.38 102.34 230.13 234.33 254.12a27.5 27.5 0 0 0 9.34 0C387.66 486.13 490 397.38 490 256V102.12a27.3 27.3 0 0 1-23.5-18.41z"/></symbol>
|
|
||||||
<symbol id="bell_solid" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></symbol>
|
|
||||||
<symbol id="bell_regular" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM44.75 330.4C63.2 312 96 281.3 96 208c0-61.94 43.15-112 112-112s112 50.06 112 112c0 73.3 32.8 104 51.25 122.4 2.8 2.8 3.5 6.9 1.75 10.4s-5.4 5.6-9.15 5.6H52.1c-3.75 0-7.35-2.1-9.1-5.6s-1.05-7.6 1.75-10.4z"/></symbol>
|
|
||||||
<symbol id="pin_solid" viewBox="0 0 24 24"><path d="M12 17h-7v-1.76a2 2 0 0 1 1.11-1.79l1.78-.9a2 0 0 0 1.11-1.76v-4.79a3 3 0 0 1 3-3 3 3 0 0 1 3 3v4.76a2 2 0 0 0 1.11 1.79l1.78.9a2 0 0 1 1.11 1.79v1.76h-7v5z" fill="currentColor" transform="rotate(45 12 12)"/></symbol>
|
|
||||||
<symbol id="pin_regular" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"></line><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path></g></symbol>
|
|
||||||
<symbol id="building" viewBox="0 0 512 512"><path d="M432 64H80c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16zm-32 352H112V96h288v320zM192 288h128v32H192zm0-64h128v32H192zm0-64h128v32H192z" fill="currentColor"/></symbol>
|
|
||||||
<symbol id="arrow-down" viewBox="0 0 448 512"><path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" fill="currentColor"/></symbol>
|
|
||||||
<symbol id="arrow-up" viewBox="0 0 448 512"><path d="M34.9 289.5l-22.2-22.2c-9.4-9.4-9.4-24.6 0-33.9L207 39c9.4-9.4 24.6-9.4 33.9 0l194.3 194.3c9.4 9.4 9.4 24.6 0 33.9l-22.2 22.2c-9.5 9.5-25 9.3-34.3-.4L264 168.6V456c0 13.3-10.7 24-24 24h-32c-13.3 0-24-10.7-24-24V168.6L69.2 289.1c-9.3 9.8-24.8 10-34.3.4z" fill="currentColor"/></symbol>
|
|
||||||
<symbol id="star_regular" viewBox="0 0 576 512"><path d="M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 137.4L288 384.8l-124.3 64.9 23.7-137.4L87.5 214.4l138-20.1L288 69.1l62.5 125.2 138 20.1-99.9 97.9z" fill="currentColor"/></symbol>
|
|
||||||
<symbol id="star_solid" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z" fill="currentColor"/></symbol>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -60,7 +60,7 @@
|
|||||||
a.textContent = tag.tag;
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge", "mr-2");
|
span.classList.add("badge");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,6 @@
|
|||||||
|
|
||||||
a.appendChild(img);
|
a.appendChild(img);
|
||||||
favcontainer.appendChild(a);
|
favcontainer.appendChild(a);
|
||||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
|
||||||
});
|
});
|
||||||
favcontainer.hidden = false;
|
favcontainer.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -379,40 +378,91 @@
|
|||||||
window.adminSetPassword = async (btn) => {
|
window.adminSetPassword = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
|
|
||||||
if (!password) return;
|
|
||||||
if (password.length < 20) return alert('Password must be at least 20 characters.');
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
try {
|
const hint =
|
||||||
|
'Set a new password for <strong>' + escHTML(name) + '</strong>. Must be at least 20 characters.<br><br>' +
|
||||||
|
'<input type="password" id="admin-pw-new" class="input" placeholder="New password (min 20 chars)" style="width:100%;margin-bottom:8px;" autocomplete="new-password">' +
|
||||||
|
'<input type="password" id="admin-pw-confirm" class="input" placeholder="Confirm new password" style="width:100%;" autocomplete="new-password">';
|
||||||
|
|
||||||
|
ModAction.confirm('Set Password', hint, async () => {
|
||||||
|
const password = document.getElementById('admin-pw-new')?.value || '';
|
||||||
|
const confirm = document.getElementById('admin-pw-confirm')?.value || '';
|
||||||
|
if (password.length < 20) throw new Error('Password must be at least 20 characters.');
|
||||||
|
if (password !== confirm) throw new Error('Passwords do not match.');
|
||||||
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.msg);
|
showFlash(data.msg, 'success');
|
||||||
} else {
|
} else {
|
||||||
alert(data.msg || 'Failed to set password');
|
throw new Error(data.msg || 'Failed to set password');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true });
|
||||||
alert('Network error');
|
};
|
||||||
|
|
||||||
|
window.adminRenameUser = async (btn) => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const currentName = btn.dataset.name;
|
||||||
|
const currentUsername = btn.dataset.username;
|
||||||
|
|
||||||
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
|
ModAction.confirm(
|
||||||
|
'Rename User',
|
||||||
|
'Enter a new login name for <strong>' + escHTML(currentName) + '</strong>.<br>' +
|
||||||
|
'<small style="color:#888;">Current login: <code>' + escHTML(currentUsername) + '</code> — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.</small>',
|
||||||
|
async (newUsername) => {
|
||||||
|
const data = await post('/api/v2/admin/users/rename', { user_id: id, new_username: newUsername });
|
||||||
|
if (data.success) {
|
||||||
|
showFlash(data.msg, 'success');
|
||||||
|
// Update the row in-place: links, text, and all button data attributes
|
||||||
|
const row = document.getElementById('user-row-' + id);
|
||||||
|
if (row) {
|
||||||
|
// Update the name link
|
||||||
|
const link = row.querySelector('.user-info-cell a');
|
||||||
|
if (link) {
|
||||||
|
link.href = '/user/' + data.new_login;
|
||||||
|
// Only overwrite text if there's no display_name (plain username link)
|
||||||
|
if (!link.querySelector('span[style*="accent"]')) {
|
||||||
|
link.textContent = data.new_user;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Update all buttons in the row with the new name/username
|
||||||
|
row.querySelectorAll('[data-username]').forEach(el => { el.dataset.username = data.new_login; });
|
||||||
|
row.querySelectorAll('[data-name]').forEach(el => { el.dataset.name = data.new_user; });
|
||||||
|
// Update activity stat links
|
||||||
|
row.querySelectorAll('a[href^="/user/"]').forEach(a => {
|
||||||
|
a.href = a.href.replace(/\/user\/[^/]+/, '/user/' + data.new_login);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(data.msg || 'Rename failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ hideReason: false, singleLine: true, confirmText: 'Rename', placeholder: 'new username' }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminDeleteUser = async (btn) => {
|
window.adminDeleteUser = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
try {
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
|
ModAction.confirm(
|
||||||
|
'Delete User',
|
||||||
|
'<strong style="color:#d9534f">CRITICAL ACTION</strong>: Permanently delete user <strong>' + escHTML(name) + '</strong>?<br><br>All their uploads and comments will be reassigned to <code>deleted_user</code>. <strong>This cannot be undone.</strong>',
|
||||||
|
async () => {
|
||||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.msg);
|
showFlash(data.msg, 'success');
|
||||||
document.getElementById(`user-row-${id}`)?.remove();
|
document.getElementById(`user-row-${id}`)?.remove();
|
||||||
} else {
|
} else {
|
||||||
alert(data.msg || 'Failed to delete user');
|
throw new Error(data.msg || 'Failed to delete user');
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Network error');
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ hideReason: true, confirmText: 'Delete User' }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminResetLoginAttempts = async (btn) => {
|
window.adminResetLoginAttempts = async (btn) => {
|
||||||
@@ -422,8 +472,13 @@
|
|||||||
try {
|
try {
|
||||||
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.msg);
|
showFlash(data.msg, 'success');
|
||||||
window.location.reload(); // Quickest way to refresh badges
|
// Remove the failed attempt badges and reset button from the row in-place
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
if (row) {
|
||||||
|
row.querySelectorAll('.status-badge[style*="ffcc00"], .status-badge[style*="ff4d4d"]').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
btn.remove();
|
||||||
} else {
|
} else {
|
||||||
alert(data.msg || 'Failed to reset attempts');
|
alert(data.msg || 'Failed to reset attempts');
|
||||||
}
|
}
|
||||||
@@ -435,18 +490,22 @@
|
|||||||
window.adminBulkDeleteHalls = async (btn) => {
|
window.adminBulkDeleteHalls = async (btn) => {
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
try {
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
|
ModAction.confirm(
|
||||||
|
'Delete All Halls',
|
||||||
|
'Permanently delete <strong>ALL halls</strong> for <strong>' + escHTML(name) + '</strong>? <strong>This cannot be undone.</strong>',
|
||||||
|
async () => {
|
||||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.msg);
|
showFlash(data.msg, 'success');
|
||||||
} else {
|
} else {
|
||||||
alert(data.msg || 'Failed to delete halls');
|
throw new Error(data.msg || 'Failed to delete halls');
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Network error');
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ hideReason: true, confirmText: 'Delete Everything' }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.adminReassignUploads = async (btn) => {
|
window.adminReassignUploads = async (btn) => {
|
||||||
@@ -473,7 +532,7 @@
|
|||||||
throw new Error(res.msg || 'Reassignment failed');
|
throw new Error(res.msg || 'Reassignment failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ hideReason: false, confirmText: 'Reassign', placeholder: 'target username' }
|
{ hideReason: false, singleLine: true, confirmText: 'Reassign', placeholder: 'target username' }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -533,7 +533,7 @@ class Danmaku {
|
|||||||
const raw = msg.dataset.rawText;
|
const raw = msg.dataset.rawText;
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
// Only re-render if the content is still plain text (no img children)
|
// Only re-render if the content is still plain text (no img children)
|
||||||
if (!msg.querySelector('img.dpill-emoji')) {
|
if (!msg.querySelector('.dpill-emoji')) {
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
msg.appendChild(this._renderContent(raw));
|
msg.appendChild(this._renderContent(raw));
|
||||||
}
|
}
|
||||||
@@ -600,11 +600,25 @@ class Danmaku {
|
|||||||
const code = match[3];
|
const code = match[3];
|
||||||
const url = emojiCache[code];
|
const url = emojiCache[code];
|
||||||
if (url) {
|
if (url) {
|
||||||
|
if (url.endsWith('.webm')) {
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.src = url;
|
||||||
|
vid.className = 'dpill-emoji';
|
||||||
|
vid.muted = true;
|
||||||
|
vid.loop = true;
|
||||||
|
vid.autoplay = true;
|
||||||
|
vid.playsInline = true;
|
||||||
|
vid.play().catch(() => {
|
||||||
|
vid.addEventListener('canplay', () => vid.play().catch(() => {}), { once: true });
|
||||||
|
});
|
||||||
|
parent.appendChild(vid);
|
||||||
|
} else {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = url;
|
img.src = url;
|
||||||
img.alt = `:${code}:`;
|
img.alt = `:${code}:`;
|
||||||
img.className = 'dpill-emoji';
|
img.className = 'dpill-emoji';
|
||||||
parent.appendChild(img);
|
parent.appendChild(img);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
parent.appendChild(document.createTextNode(match[0]));
|
parent.appendChild(document.createTextNode(match[0]));
|
||||||
}
|
}
|
||||||
|
|||||||
3261
public/s/js/f0ckm.js
3261
public/s/js/f0ckm.js
File diff suppressed because it is too large
Load Diff
@@ -634,8 +634,12 @@
|
|||||||
// Visual state: strike-through when disabled
|
// Visual state: strike-through when disabled
|
||||||
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
||||||
|
|
||||||
// Handle floating badge visibility
|
// Handle floating badge visibility — only show as fallback when on an item page
|
||||||
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
|
// with the primary player present but no in-player SWF button (e.g. v0ck not loaded).
|
||||||
|
// Never show it just because sidebar .webm stickers exist.
|
||||||
|
const primaryVideo = document.getElementById('my-video') ||
|
||||||
|
document.querySelector('video.viewer, video.v0ck_video');
|
||||||
|
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none';
|
||||||
|
|
||||||
// Style both (if they exist)
|
// Style both (if they exist)
|
||||||
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
||||||
|
|||||||
@@ -23,6 +23,22 @@
|
|||||||
let chatFocused = document.hasFocus();
|
let chatFocused = document.hasFocus();
|
||||||
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
||||||
|
|
||||||
|
// Shared IntersectionObserver for lazy-loading embedded images.
|
||||||
|
// Images are rendered with data-lazy-src; this observer sets the real src
|
||||||
|
// when the image is within 200px of the visible scroll area.
|
||||||
|
const lazyImgObserver = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
const img = entry.target;
|
||||||
|
const src = img.dataset.lazySrc;
|
||||||
|
if (src) { img.src = src; delete img.dataset.lazySrc; }
|
||||||
|
lazyImgObserver.unobserve(img);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: '200px', // start loading 200px before entering viewport
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
|
||||||
function updateBadge() {
|
function updateBadge() {
|
||||||
const badge = document.getElementById('gchat-badge');
|
const badge = document.getElementById('gchat-badge');
|
||||||
const bubble = document.getElementById('gchat-reopen-bubble');
|
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||||
@@ -187,7 +203,7 @@
|
|||||||
'gi'
|
'gi'
|
||||||
);
|
);
|
||||||
html = html.replace(imageRegex, url =>
|
html = html.replace(imageRegex, url =>
|
||||||
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6b. Raw video URLs from allowed hosts → <video>
|
// 6b. Raw video URLs from allowed hosts → <video>
|
||||||
@@ -270,7 +286,7 @@
|
|||||||
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
||||||
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||||
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
||||||
}
|
}
|
||||||
@@ -310,11 +326,73 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(force = false) {
|
function scrollToBottom(force = false, smooth = false) {
|
||||||
const el = document.getElementById('gchat-messages');
|
const el = document.getElementById('gchat-messages');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
if (force || nearBottom) el.scrollTop = el.scrollHeight;
|
if (!force && !nearBottom) return;
|
||||||
|
if (smooth) {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
// Double rAF ensures layout is committed before reading scrollHeight
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the message container and keep snapping to bottom for durationMs.
|
||||||
|
* Only stops if the user actively scrolls up via wheel / touch / keyboard.
|
||||||
|
* Same logic as the DM snapToBottomSticky.
|
||||||
|
*/
|
||||||
|
function startStickyScroll(durationMs = 8000) {
|
||||||
|
const el = document.getElementById('gchat-messages');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
let userScrolledUp = false;
|
||||||
|
|
||||||
|
const onWheel = (e) => { if (e.deltaY < 0) userScrolledUp = true; };
|
||||||
|
const onKey = (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) userScrolledUp = true; };
|
||||||
|
let touchStartY = 0;
|
||||||
|
const onTouchStart = (e) => { touchStartY = e.touches[0]?.clientY ?? 0; };
|
||||||
|
const onTouchMove = (e) => { if ((e.touches[0]?.clientY ?? 0) > touchStartY + 10) userScrolledUp = true; };
|
||||||
|
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: true });
|
||||||
|
el.addEventListener('keydown', onKey, { passive: true });
|
||||||
|
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
el.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||||
|
|
||||||
|
let ro;
|
||||||
|
const cleanup = () => {
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
el.removeEventListener('keydown', onKey);
|
||||||
|
el.removeEventListener('touchstart', onTouchStart);
|
||||||
|
el.removeEventListener('touchmove', onTouchMove);
|
||||||
|
if (ro) ro.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
// Debounce: batch rapid layout changes (e.g. progressive image renders)
|
||||||
|
// into a single smooth scroll instead of many jarring instant jumps.
|
||||||
|
let debounceTimer = null;
|
||||||
|
ro = new ResizeObserver(() => {
|
||||||
|
if (userScrolledUp) { cleanup(); return; }
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => scrollToBottom(true), 300);
|
||||||
|
setTimeout(() => scrollToBottom(true), 800);
|
||||||
|
setTimeout(() => scrollToBottom(true), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(cleanup, durationMs);
|
||||||
|
|
||||||
|
// First snap is instant (no animation — the panel just opened)
|
||||||
|
scrollToBottom(true);
|
||||||
|
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchYtOembed(cardEl) {
|
async function fetchYtOembed(cardEl) {
|
||||||
@@ -417,9 +495,19 @@
|
|||||||
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Embedded images: scroll to bottom when loaded + open modal on click
|
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
|
||||||
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
|
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
|
||||||
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
// Only snap to bottom on image load for NEW incoming messages, not history.
|
||||||
|
// History already scrolls once at the end of loadHistory; an extra scroll
|
||||||
|
// here is what causes the double jump.
|
||||||
|
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
||||||
|
img.addEventListener('click', () => openImgModal(img.src));
|
||||||
|
img.style.cursor = 'zoom-in';
|
||||||
|
lazyImgObserver.observe(img);
|
||||||
|
});
|
||||||
|
// Already-src'd images (avatars etc.) — same rule
|
||||||
|
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
|
||||||
|
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
||||||
img.addEventListener('click', () => openImgModal(img.src));
|
img.addEventListener('click', () => openImgModal(img.src));
|
||||||
img.style.cursor = 'zoom-in';
|
img.style.cursor = 'zoom-in';
|
||||||
});
|
});
|
||||||
@@ -444,11 +532,14 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
const container = document.getElementById('gchat-messages');
|
const container = document.getElementById('gchat-messages');
|
||||||
if (container) container.innerHTML = '';
|
if (!container) return;
|
||||||
(data.messages || []).forEach(m => appendMsg(m));
|
container.innerHTML = '';
|
||||||
scrollToBottom(true);
|
(data.messages || []).forEach(m => appendMsg(m, false));
|
||||||
// Also scroll after images have had time to paint
|
// Double rAF: wait for the browser to commit the layout (panel just became
|
||||||
setTimeout(() => scrollToBottom(true), 600);
|
// visible from display:none) before reading scrollHeight.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Chat] Failed to load history:', e);
|
console.error('[Chat] Failed to load history:', e);
|
||||||
}
|
}
|
||||||
@@ -552,7 +643,9 @@
|
|||||||
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
|
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
|
||||||
if (!isMinimized) {
|
if (!isMinimized) {
|
||||||
clearUnread();
|
clearUnread();
|
||||||
loadHistory();
|
// Wait one rAF so the panel transitions from display:none to its full
|
||||||
|
// height before loadHistory measures scrollHeight.
|
||||||
|
requestAnimationFrame(() => loadHistory());
|
||||||
if (!window.matchMedia('(pointer: coarse)').matches)
|
if (!window.matchMedia('(pointer: coarse)').matches)
|
||||||
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
|
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,69 @@
|
|||||||
let draggingLayer = null;
|
let draggingLayer = null;
|
||||||
let hoveredLayer = null;
|
let hoveredLayer = null;
|
||||||
let img = new Image();
|
let img = new Image();
|
||||||
|
let hasLoadedImage = window.memeTemplate.id !== 'custom' && window.memeTemplate.category !== 'Custom';
|
||||||
|
|
||||||
|
// Show the local file picker only when no pre-selected template exists
|
||||||
|
if (!hasLoadedImage) {
|
||||||
|
const customSelector = document.getElementById('customTemplateSelector');
|
||||||
|
if (customSelector) customSelector.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
const memeFont = 'Impact, Charcoal, sans-serif';
|
const memeFont = 'Impact, Charcoal, sans-serif';
|
||||||
|
const memeLetterSpacing = '0.03em';
|
||||||
|
|
||||||
|
function wrapText(ctx, text, maxWidth) {
|
||||||
|
const paragraphs = text.split('\n');
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
paragraphs.forEach(paragraph => {
|
||||||
|
if (paragraph.trim() === '') {
|
||||||
|
lines.push('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = paragraph.split(' ').filter(w => w !== '');
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
words.forEach(word => {
|
||||||
|
const testLine = currentLine ? currentLine + ' ' + word : word;
|
||||||
|
let metrics = ctx.measureText(testLine);
|
||||||
|
|
||||||
|
if (metrics.width <= maxWidth) {
|
||||||
|
currentLine = testLine;
|
||||||
|
} else {
|
||||||
|
if (currentLine !== '') {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics = ctx.measureText(word);
|
||||||
|
if (metrics.width <= maxWidth) {
|
||||||
|
currentLine = word;
|
||||||
|
} else {
|
||||||
|
let charLine = '';
|
||||||
|
for (let i = 0; i < word.length; i++) {
|
||||||
|
const char = word[i];
|
||||||
|
const testCharLine = charLine + char;
|
||||||
|
if (ctx.measureText(testCharLine).width > maxWidth && charLine !== '') {
|
||||||
|
lines.push(charLine);
|
||||||
|
charLine = char;
|
||||||
|
} else {
|
||||||
|
charLine = testCharLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentLine = charLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Image Setup
|
// Image Setup
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
@@ -29,11 +90,23 @@
|
|||||||
|
|
||||||
const defaultSize = 40;
|
const defaultSize = 40;
|
||||||
|
|
||||||
// Initial layers
|
// Initial layers - only set if we don't have any layers yet and we have loaded an image
|
||||||
|
if (textLayers.length === 0 && hasLoadedImage) {
|
||||||
textLayers = [
|
textLayers = [
|
||||||
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||||
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||||
];
|
];
|
||||||
|
} else if (hasLoadedImage) {
|
||||||
|
// Keep the text layers but adjust their coordinates to be in-bounds if they exceed new boundaries
|
||||||
|
textLayers.forEach(layer => {
|
||||||
|
if (layer.x > canvas.width) {
|
||||||
|
layer.x = canvas.width / 2;
|
||||||
|
}
|
||||||
|
if (layer.y > canvas.height) {
|
||||||
|
layer.y = canvas.height - 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderInputs();
|
renderInputs();
|
||||||
draw();
|
draw();
|
||||||
@@ -42,11 +115,65 @@
|
|||||||
|
|
||||||
// Ensure font is loaded before first draw
|
// Ensure font is loaded before first draw
|
||||||
if (document.fonts) {
|
if (document.fonts) {
|
||||||
|
// Force browser to load the Impact font
|
||||||
|
document.fonts.load('bold 40px Impact');
|
||||||
|
document.fonts.load('bold 80px Impact');
|
||||||
|
document.fonts.load('bold 150px Impact');
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSlider(container, min, max, initValue, onChange) {
|
||||||
|
container.style.cssText = 'position:relative;height:28px;display:flex;align-items:center;flex:1;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;';
|
||||||
|
|
||||||
|
const track = document.createElement('div');
|
||||||
|
track.style.cssText = 'position:absolute;left:8px;right:8px;height:4px;background:#333;border-radius:2px;top:50%;transform:translateY(-50%);';
|
||||||
|
|
||||||
|
const fill = document.createElement('div');
|
||||||
|
fill.style.cssText = 'position:absolute;left:0;height:100%;background:var(--accent,#9f0);border-radius:2px;pointer-events:none;';
|
||||||
|
|
||||||
|
const thumb = document.createElement('div');
|
||||||
|
thumb.style.cssText = 'position:absolute;width:18px;height:18px;background:var(--accent,#9f0);border-radius:50%;top:50%;transform:translate(-50%,-50%);box-shadow:0 0 6px rgba(0,0,0,.6);pointer-events:none;transition:transform .1s;';
|
||||||
|
|
||||||
|
const setRatio = (r) => {
|
||||||
|
fill.style.width = (r * 100) + '%';
|
||||||
|
thumb.style.left = (r * 100) + '%';
|
||||||
|
};
|
||||||
|
setRatio((initValue - min) / (max - min));
|
||||||
|
|
||||||
|
track.appendChild(fill);
|
||||||
|
track.appendChild(thumb);
|
||||||
|
container.appendChild(track);
|
||||||
|
|
||||||
|
const valueFromClientX = (clientX) => {
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const r = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
setRatio(r);
|
||||||
|
return Math.round(min + r * (max - min));
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault(); // block keyboard + scroll takeover
|
||||||
|
container.setPointerCapture(e.pointerId);
|
||||||
|
thumb.style.transform = 'translate(-50%,-50%) scale(1.25)';
|
||||||
|
onChange(valueFromClientX(e.clientX));
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
container.addEventListener('pointermove', (e) => {
|
||||||
|
if (!container.hasPointerCapture(e.pointerId)) return;
|
||||||
|
onChange(valueFromClientX(e.clientX));
|
||||||
|
});
|
||||||
|
|
||||||
|
const onEnd = (e) => {
|
||||||
|
if (!container.hasPointerCapture(e.pointerId)) return;
|
||||||
|
container.releasePointerCapture(e.pointerId);
|
||||||
|
thumb.style.transform = 'translate(-50%,-50%) scale(1)';
|
||||||
|
};
|
||||||
|
container.addEventListener('pointerup', onEnd);
|
||||||
|
container.addEventListener('pointercancel', onEnd);
|
||||||
|
}
|
||||||
|
|
||||||
function renderInputs() {
|
function renderInputs() {
|
||||||
layersContainer.innerHTML = '';
|
layersContainer.innerHTML = '';
|
||||||
textLayers.forEach((layer, index) => {
|
textLayers.forEach((layer, index) => {
|
||||||
@@ -64,7 +191,7 @@
|
|||||||
|
|
||||||
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
||||||
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
|
<div class="layer-fs-slider" style="flex: 1;"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -74,11 +201,11 @@
|
|||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fsInput = div.querySelector('.layer-fs-input');
|
const fsSlider = div.querySelector('.layer-fs-slider');
|
||||||
const fsVal = div.querySelector('.layer-fs-val');
|
const fsVal = div.querySelector('.layer-fs-val');
|
||||||
fsInput.addEventListener('input', (e) => {
|
createSlider(fsSlider, 10, 200, layer.fontSize, (val) => {
|
||||||
layer.fontSize = parseInt(e.target.value);
|
layer.fontSize = val;
|
||||||
fsVal.textContent = layer.fontSize;
|
fsVal.textContent = val;
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +221,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
addTextBtn.addEventListener('click', () => {
|
addTextBtn.addEventListener('click', () => {
|
||||||
|
if (!hasLoadedImage) {
|
||||||
|
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
textLayers.push({
|
textLayers.push({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
text: 'NEW TEXT',
|
text: 'NEW TEXT',
|
||||||
@@ -125,10 +256,11 @@
|
|||||||
|
|
||||||
const fontSize = layer.fontSize || 40;
|
const fontSize = layer.fontSize || 40;
|
||||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||||
|
ctx.letterSpacing = memeLetterSpacing;
|
||||||
|
|
||||||
let displayStr = layer.text.toUpperCase();
|
let displayStr = layer.text.toUpperCase();
|
||||||
const lines = displayStr.split('\n');
|
const lines = wrapText(ctx, displayStr, canvas.width * 0.9);
|
||||||
const h = lines.length * fontSize * 1.1;
|
const h = lines.length * fontSize * 1.25;
|
||||||
const w = canvas.width * 0.9;
|
const w = canvas.width * 0.9;
|
||||||
|
|
||||||
// Box for the dragged/hovered layer (top-most layer gets preference)
|
// Box for the dragged/hovered layer (top-most layer gets preference)
|
||||||
@@ -142,12 +274,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
const yOffset = i * (fontSize * 1.1);
|
const yOffset = i * (fontSize * 1.25);
|
||||||
const renderX = Math.round(layer.x);
|
const renderX = Math.round(layer.x);
|
||||||
const renderY = Math.round(layer.y + yOffset);
|
const renderY = Math.round(layer.y + yOffset);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
||||||
|
ctx.letterSpacing = memeLetterSpacing;
|
||||||
ctx.strokeStyle = 'black';
|
ctx.strokeStyle = 'black';
|
||||||
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
||||||
ctx.strokeText(line, renderX, renderY);
|
ctx.strokeText(line, renderX, renderY);
|
||||||
@@ -173,9 +306,13 @@
|
|||||||
const isInsideText = (pt, layer) => {
|
const isInsideText = (pt, layer) => {
|
||||||
if (!layer.text) return false;
|
if (!layer.text) return false;
|
||||||
const fontSize = layer.fontSize || 40;
|
const fontSize = layer.fontSize || 40;
|
||||||
const lines = layer.text.split('\n');
|
ctx.save();
|
||||||
|
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||||
|
ctx.letterSpacing = memeLetterSpacing;
|
||||||
|
const lines = wrapText(ctx, layer.text.toUpperCase(), canvas.width * 0.9);
|
||||||
|
ctx.restore();
|
||||||
const w = canvas.width * 0.95;
|
const w = canvas.width * 0.95;
|
||||||
const h = lines.length * fontSize * 1.2;
|
const h = lines.length * fontSize * 1.25;
|
||||||
|
|
||||||
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
||||||
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
||||||
@@ -232,16 +369,22 @@
|
|||||||
canvas.addEventListener('mousedown', onStart);
|
canvas.addEventListener('mousedown', onStart);
|
||||||
// Upload
|
// Upload
|
||||||
uploadBtn.addEventListener('click', async () => {
|
uploadBtn.addEventListener('click', async () => {
|
||||||
|
if (!hasLoadedImage) {
|
||||||
|
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
||||||
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
||||||
|
const templateId = (window.memeTemplate && window.memeTemplate.id) ? window.memeTemplate.id.toLowerCase() : '';
|
||||||
|
|
||||||
const isOrakelVon10 = subCategory === 'von10';
|
const isOrakelVon10 = subCategory === 'von10';
|
||||||
const isOrakelUser = subCategory === 'user';
|
const isOrakelUser = subCategory === 'user';
|
||||||
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
|
const isOrakelBingoApu = subCategory === 'bingoapu' || templateId === 'bingoapu';
|
||||||
|
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10 && !isOrakelBingoApu;
|
||||||
|
|
||||||
let uploadCanvas = canvas;
|
let uploadCanvas = canvas;
|
||||||
|
|
||||||
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
|
if (isOrakelNormal || isOrakelUser || isOrakelVon10 || isOrakelBingoApu) {
|
||||||
// Create an off-screen canvas to apply the orakel answer silently
|
// Create an off-screen canvas to apply the orakel answer silently
|
||||||
uploadCanvas = document.createElement('canvas');
|
uploadCanvas = document.createElement('canvas');
|
||||||
uploadCanvas.width = canvas.width;
|
uploadCanvas.width = canvas.width;
|
||||||
@@ -252,6 +395,7 @@
|
|||||||
uCtx.drawImage(canvas, 0, 0);
|
uCtx.drawImage(canvas, 0, 0);
|
||||||
|
|
||||||
let result = '';
|
let result = '';
|
||||||
|
let selectedCorner = null;
|
||||||
if (isOrakelNormal) {
|
if (isOrakelNormal) {
|
||||||
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
||||||
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
||||||
@@ -265,11 +409,35 @@
|
|||||||
}
|
}
|
||||||
} else if (isOrakelVon10) {
|
} else if (isOrakelVon10) {
|
||||||
result = Math.floor(Math.random() * 11).toString();
|
result = Math.floor(Math.random() * 11).toString();
|
||||||
|
} else if (isOrakelBingoApu) {
|
||||||
|
const corners = [
|
||||||
|
{ x: 60, y: 100, label: 'YES' },
|
||||||
|
{ x: 573, y: 100, label: 'NO' },
|
||||||
|
{ x: 60, y: 615, label: 'NO' },
|
||||||
|
{ x: 573, y: 615, label: 'YES' }
|
||||||
|
];
|
||||||
|
selectedCorner = corners[Math.floor(Math.random() * corners.length)];
|
||||||
|
result = selectedCorner.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Orakel result on the hidden canvas
|
// Draw Orakel result on the hidden canvas
|
||||||
uCtx.save();
|
uCtx.save();
|
||||||
|
if (isOrakelBingoApu) {
|
||||||
|
// Draw "KOPS" in normal meme text style on the selected corner
|
||||||
|
uCtx.font = `bold 28px ${memeFont}`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
|
uCtx.textAlign = 'center';
|
||||||
|
uCtx.textBaseline = 'middle';
|
||||||
|
uCtx.fillStyle = '#fff';
|
||||||
|
uCtx.strokeStyle = '#000';
|
||||||
|
uCtx.lineWidth = 4;
|
||||||
|
uCtx.miterLimit = 2;
|
||||||
|
|
||||||
|
uCtx.strokeText('KOPS', selectedCorner.x, selectedCorner.y);
|
||||||
|
uCtx.fillText('KOPS', selectedCorner.x, selectedCorner.y);
|
||||||
|
} else {
|
||||||
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
uCtx.textAlign = 'center';
|
uCtx.textAlign = 'center';
|
||||||
uCtx.textBaseline = 'middle';
|
uCtx.textBaseline = 'middle';
|
||||||
|
|
||||||
@@ -312,11 +480,13 @@
|
|||||||
|
|
||||||
let currentFontSize = 74;
|
let currentFontSize = 74;
|
||||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
|
|
||||||
// First attempt: Shrink entire text on one line down to 58px if needed
|
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||||
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||||
currentFontSize -= 2;
|
currentFontSize -= 2;
|
||||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||||
@@ -328,30 +498,36 @@
|
|||||||
// Two lines — auto-fit just the name, ID below
|
// Two lines — auto-fit just the name, ID below
|
||||||
let nameFontSize = 74;
|
let nameFontSize = 74;
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||||
nameFontSize -= 2;
|
nameFontSize -= 2;
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||||
const lineGap = Math.round(nameFontSize * 0.65);
|
const lineGap = Math.round(nameFontSize * 1.25);
|
||||||
const nameY = Math.round(yPos - lineGap / 2);
|
const nameY = Math.round(yPos - lineGap / 2);
|
||||||
const idY = Math.round(yPos + lineGap / 2) + 2;
|
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||||
|
|
||||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
uCtx.fillText(namePart, xPos, nameY);
|
uCtx.fillText(namePart, xPos, nameY);
|
||||||
|
|
||||||
if (idPart) {
|
if (idPart) {
|
||||||
uCtx.font = `bold ${idFontSize}px Impact`;
|
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
uCtx.fillText(idPart, xPos, idY);
|
uCtx.fillText(idPart, xPos, idY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal / von10 — single line as before
|
// Normal / von10 — single line as before
|
||||||
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||||
|
uCtx.letterSpacing = memeLetterSpacing;
|
||||||
uCtx.strokeText(result, xPos, yPos);
|
uCtx.strokeText(result, xPos, yPos);
|
||||||
uCtx.fillText(result, xPos, yPos);
|
uCtx.fillText(result, xPos, yPos);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
uCtx.restore();
|
uCtx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +560,14 @@
|
|||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (result.manual_approval) {
|
||||||
|
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
|
||||||
|
if (window.loadPageAjax) {
|
||||||
|
window.loadPageAjax('/');
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const dest = result.redirect || '/meme';
|
const dest = result.redirect || '/meme';
|
||||||
if (window.loadItemAjax) {
|
if (window.loadItemAjax) {
|
||||||
window.loadItemAjax(dest);
|
window.loadItemAjax(dest);
|
||||||
@@ -393,6 +577,7 @@
|
|||||||
window.location.href = dest;
|
window.location.href = dest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
window.flashMessage('Error: ' + result.msg, 3000, 'error');
|
window.flashMessage('Error: ' + result.msg, 3000, 'error');
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
@@ -404,6 +589,79 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom Local Image Selector logic
|
||||||
|
const fileInput = document.getElementById('customTemplateFile');
|
||||||
|
const selectFileBtn = document.getElementById('selectCustomFileBtn');
|
||||||
|
const canvasWrapper = document.querySelector('.canvas-wrapper');
|
||||||
|
|
||||||
|
const loadLocalImage = (file) => {
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
hasLoadedImage = true;
|
||||||
|
img.src = event.target.result;
|
||||||
|
|
||||||
|
// Update template metadata
|
||||||
|
window.memeTemplate.name = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
||||||
|
// Update header title dynamically
|
||||||
|
const headerTitle = document.querySelector('.meme-title');
|
||||||
|
if (headerTitle) {
|
||||||
|
const baseTitle = window.f0ckI18n?.meme?.create_meme || 'Create Meme:';
|
||||||
|
headerTitle.innerHTML = `${baseTitle} ${window.memeTemplate.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tags input value if tags are present
|
||||||
|
const tagsInput = document.getElementById('tags');
|
||||||
|
if (tagsInput) {
|
||||||
|
tagsInput.value = `meme, Custom, ${window.memeTemplate.name}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectFileBtn && fileInput) {
|
||||||
|
selectFileBtn.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
loadLocalImage(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML5 Drag & Drop Support
|
||||||
|
if (canvas && canvasWrapper) {
|
||||||
|
const preventDefaults = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
canvasWrapper.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
canvasWrapper.addEventListener(eventName, () => {
|
||||||
|
canvasWrapper.classList.add('drag-hover');
|
||||||
|
}, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
canvasWrapper.addEventListener(eventName, () => {
|
||||||
|
canvasWrapper.classList.remove('drag-hover');
|
||||||
|
}, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasWrapper.addEventListener('drop', (e) => {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const file = dt.files[0];
|
||||||
|
loadLocalImage(file);
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
// Initial draw
|
// Initial draw
|
||||||
setTimeout(draw, 300);
|
setTimeout(draw, 300);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -209,6 +209,12 @@
|
|||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
function decodeHtmlEntities(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const txt = document.createElement('textarea');
|
||||||
|
txt.innerHTML = String(str);
|
||||||
|
return txt.value;
|
||||||
|
}
|
||||||
function timeAgo(iso) {
|
function timeAgo(iso) {
|
||||||
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||||
const i = window.f0ckI18n || {};
|
const i = window.f0ckI18n || {};
|
||||||
@@ -229,28 +235,33 @@
|
|||||||
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
|
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
|
||||||
}
|
}
|
||||||
function hashId() {
|
function hashId() {
|
||||||
// Strip the leading '#' and allow numeric IDs or board/postid format
|
// Check path first /abyss/1234 or /abyss/gif/1234
|
||||||
|
const pathClean = location.pathname.replace(/\/$/, '');
|
||||||
|
const pathMatch = pathClean.match(/\/abyss\/([a-zA-Z0-9_\/-]+)$/);
|
||||||
|
if (pathMatch) return pathMatch[1];
|
||||||
|
|
||||||
|
// Fallback to hash
|
||||||
const raw = location.hash.replace(/^#/, '').trim();
|
const raw = location.hash.replace(/^#/, '').trim();
|
||||||
if (/^\d+$/.test(raw)) return raw;
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
let lastPushedHash = location.hash;
|
let lastPushedUrl = location.pathname + location.hash;
|
||||||
function pushHash(id) {
|
function pushHash(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const newHash = '#' + id;
|
const newUrl = '/abyss/' + id;
|
||||||
if (newHash === lastPushedHash) return;
|
if (newUrl === lastPushedUrl) return;
|
||||||
lastPushedHash = newHash;
|
lastPushedUrl = newUrl;
|
||||||
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
|
history.pushState({ scrollerId: id }, '', newUrl);
|
||||||
updateCacheActiveId(id);
|
updateCacheActiveId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle back/forward within abyss — scroll to the target slide
|
// Handle back/forward within abyss — scroll to the target slide
|
||||||
window.addEventListener('popstate', (e) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (!document.body.classList.contains('scroller-active')) return;
|
if (!document.body.classList.contains('scroller-active')) return;
|
||||||
const id = e.state?.scrollerId || location.hash.replace('#', '');
|
const id = e.state?.scrollerId || hashId();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
lastPushedHash = '#' + id;
|
lastPushedUrl = '/abyss/' + id;
|
||||||
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
|
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -707,9 +718,12 @@
|
|||||||
const id = contextLink.dataset.id;
|
const id = contextLink.dataset.id;
|
||||||
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
|
// Clear any previous persistent highlight
|
||||||
|
commentsList.querySelectorAll('.comment-linked').forEach(el => el.classList.remove('comment-linked'));
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
target.classList.add('highlight-comment');
|
// Flash the animation for attention, then keep the persistent highlight
|
||||||
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
|
target.classList.add('highlight-comment', 'comment-linked');
|
||||||
|
setTimeout(() => target.classList.remove('highlight-comment'), 2500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1366,18 +1380,15 @@
|
|||||||
${window.scrollerLoggedIn ? `
|
${window.scrollerLoggedIn ? `
|
||||||
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||||||
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
||||||
<span class="scroll-btn-label"></span>
|
|
||||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
||||||
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
||||||
<span class="scroll-btn-label"></span>
|
|
||||||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
${window.scrollerLoggedIn ? `
|
${window.scrollerLoggedIn ? `
|
||||||
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
||||||
<span class="scroll-btn-label"></span>
|
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
||||||
@@ -1385,7 +1396,7 @@
|
|||||||
</button>
|
</button>
|
||||||
${item.is_external ? (
|
${item.is_external ? (
|
||||||
item.local_id
|
item.local_id
|
||||||
? `<a class="scroll-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
||||||
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
||||||
</a>`
|
</a>`
|
||||||
@@ -1546,24 +1557,13 @@
|
|||||||
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
|
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
|
||||||
if (rBadge) {
|
if (rBadge) {
|
||||||
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
|
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
|
||||||
rBadge.addEventListener('click', async e => {
|
rBadge.addEventListener('click', e => {
|
||||||
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
|
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const slideEl = rBadge.closest('.scroll-slide');
|
const slideEl = rBadge.closest('.scroll-slide');
|
||||||
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
|
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
|
||||||
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
|
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
|
||||||
try {
|
cycleRatingOptimistic(rBadge, id, false);
|
||||||
const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
rBadge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
|
||||||
rBadge.textContent = data.rating_label;
|
|
||||||
rBadge.dataset.rating = data.rating_class;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1694,7 +1694,8 @@
|
|||||||
is_audio: false,
|
is_audio: false,
|
||||||
comment_count: p.replies || 0,
|
comment_count: p.replies || 0,
|
||||||
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
||||||
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
|
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged'),
|
||||||
|
original_filename: p.filename ? `${decodeHtmlEntities(p.filename)}${ext}` : null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -1825,7 +1826,7 @@
|
|||||||
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
|
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
|
||||||
const first = feed.querySelector('.scroll-slide:not([data-lock])');
|
const first = feed.querySelector('.scroll-slide:not([data-lock])');
|
||||||
const toActivate = target || first;
|
const toActivate = target || first;
|
||||||
if (toActivate) setTimeout(() => { activateSlide(toActivate); hideLoader(); }, 200);
|
if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); }, 200);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SCROLLER] Fetch error:', err);
|
console.error('[SCROLLER] Fetch error:', err);
|
||||||
@@ -1859,7 +1860,8 @@
|
|||||||
url: item.external_media_url || item.dest,
|
url: item.external_media_url || item.dest,
|
||||||
rating: rating,
|
rating: rating,
|
||||||
tags: '4chan',
|
tags: '4chan',
|
||||||
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`
|
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
|
||||||
|
...(item.original_filename ? { original_filename: item.original_filename } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -1885,7 +1887,7 @@
|
|||||||
// Update button to link to the new site-internal post
|
// Update button to link to the new site-internal post
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.outerHTML = `
|
btn.outerHTML = `
|
||||||
<a href="/${data.item_id}" target="_blank" class="scroll-btn success" style="text-decoration:none;">
|
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
||||||
<span class="scroll-btn-label">View</span>
|
<span class="scroll-btn-label">View</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -1925,6 +1927,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cycleRatingOptimistic(badge, id, showToast = false) {
|
||||||
|
if (!badge || !id || isNaN(id)) return;
|
||||||
|
|
||||||
|
let currentRating = badge.dataset.rating || '';
|
||||||
|
if (!currentRating) {
|
||||||
|
if (badge.classList.contains('sfw')) currentRating = 'sfw';
|
||||||
|
else if (badge.classList.contains('nsfw')) currentRating = 'nsfw';
|
||||||
|
else if (badge.classList.contains('nsfl')) currentRating = 'nsfl';
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextRating = 'sfw';
|
||||||
|
if (currentRating === 'sfw') nextRating = 'nsfw';
|
||||||
|
else if (currentRating === 'nsfw') nextRating = 'nsfl';
|
||||||
|
|
||||||
|
const mapping = {
|
||||||
|
sfw: { label: 'SFW', cls: 'sfw', toast: '🛡 SFW' },
|
||||||
|
nsfw: { label: 'NSFW', cls: 'nsfw', toast: '🔥 NSFW' },
|
||||||
|
nsfl: { label: 'NSFL', cls: 'nsfl', toast: '💀 NSFL' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = mapping[nextRating];
|
||||||
|
|
||||||
|
const oldClassName = badge.className;
|
||||||
|
const oldTextContent = badge.textContent;
|
||||||
|
const oldDatasetRating = badge.dataset.rating;
|
||||||
|
|
||||||
|
// Track active request ID to ignore out-of-order race conditions on rapid keypresses
|
||||||
|
const reqId = (badge._lastCycleReqId || 0) + 1;
|
||||||
|
badge._lastCycleReqId = reqId;
|
||||||
|
|
||||||
|
// Optimistically apply new state
|
||||||
|
badge.className = `scroll-rating ${info.cls}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||||
|
badge.textContent = info.label;
|
||||||
|
badge.dataset.rating = info.cls;
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
showShareToast(info.toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
||||||
|
if (!data.success) {
|
||||||
|
revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Verify we match actual server result
|
||||||
|
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||||
|
badge.textContent = data.rating_label;
|
||||||
|
badge.dataset.rating = data.rating_class;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
||||||
|
revert();
|
||||||
|
});
|
||||||
|
|
||||||
|
function revert() {
|
||||||
|
badge.className = oldClassName;
|
||||||
|
badge.textContent = oldTextContent;
|
||||||
|
badge.dataset.rating = oldDatasetRating;
|
||||||
|
showShareToast('⚠️ Failed to update rating');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reloadFeed() {
|
function reloadFeed() {
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -2736,7 +2805,7 @@
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
username: window.scrollerUsername,
|
username: window.scrollerUsername,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
content: content,
|
content: data.comment?.content ?? content,
|
||||||
created_at: null
|
created_at: null
|
||||||
}, !!window.scrollerLoggedIn);
|
}, !!window.scrollerLoggedIn);
|
||||||
// Set avatar from global
|
// Set avatar from global
|
||||||
@@ -3136,26 +3205,12 @@
|
|||||||
else if (e.key === 'p' || e.key === 'P') {
|
else if (e.key === 'p' || e.key === 'P') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentSlide || !window.scrollerLoggedIn) return;
|
if (!currentSlide || !window.scrollerLoggedIn) return;
|
||||||
const itemId = currentSlide.dataset.id;
|
const itemId = currentSlide.dataset.localId || currentSlide.dataset.id;
|
||||||
if (!itemId) return;
|
if (!itemId) return;
|
||||||
fetch(`/api/v2/tags/${itemId}/cycle-rating`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success) return;
|
|
||||||
// Update the badge on the slide immediately
|
|
||||||
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
cycleRatingOptimistic(badge, itemId, true);
|
||||||
badge.textContent = data.rating_label;
|
|
||||||
badge.dataset.rating = data.rating_class;
|
|
||||||
}
|
}
|
||||||
const labels = { sfw: '🛡 SFW', nsfw: '🔥 NSFW', nsfl: '💀 NSFL' };
|
|
||||||
showShareToast(labels[data.rating_class] ?? data.rating_label);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
||||||
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
||||||
@@ -3441,7 +3496,7 @@
|
|||||||
|
|
||||||
// Tab type arrays
|
// Tab type arrays
|
||||||
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||||
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
|
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
||||||
let sActiveTab = 'user';
|
let sActiveTab = 'user';
|
||||||
let sCachedNotifs = [];
|
let sCachedNotifs = [];
|
||||||
|
|
||||||
@@ -3545,6 +3600,9 @@
|
|||||||
} else if (n.type === 'report') {
|
} else if (n.type === 'report') {
|
||||||
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
|
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
|
||||||
msg = i18n.notif_new_report || 'New user report';
|
msg = i18n.notif_new_report || 'New user report';
|
||||||
|
} else if (n.type === 'warning') {
|
||||||
|
link = `/notifications?tab=system#notif-${n.id}`; user = i18n.notif_system || 'System';
|
||||||
|
msg = (i18n.account_warning && i18n.account_warning.title) || 'Account Warning';
|
||||||
} else {
|
} else {
|
||||||
link = `/${n.item_id}#c${n.reference_id}`;
|
link = `/${n.item_id}#c${n.reference_id}`;
|
||||||
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
|
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
|
||||||
@@ -3552,8 +3610,13 @@
|
|||||||
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
|
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
|
||||||
else msg = i18n.notif_commented || 'commented';
|
else msg = i18n.notif_commented || 'commented';
|
||||||
}
|
}
|
||||||
|
let thumb;
|
||||||
|
if (n.type === 'warning') {
|
||||||
|
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
|
||||||
|
} else {
|
||||||
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
||||||
const thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
||||||
|
}
|
||||||
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="notif-content">
|
<div class="notif-content">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,19 @@
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
||||||
|
let lastRenderedIds = ''; // track rendered comment IDs to skip needless re-renders
|
||||||
// Shared cache for activity across AJAX loads
|
// Shared cache for activity across AJAX loads
|
||||||
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
||||||
|
|
||||||
const loadEmojis = async () => {
|
const loadEmojis = async () => {
|
||||||
|
// Prefer CommentSystem's already-fetched cache to avoid a redundant request
|
||||||
|
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache)
|
||||||
|
|| null;
|
||||||
|
if (csCache && Object.keys(csCache).length > 0) {
|
||||||
|
Object.assign(customEmojis, csCache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No shared cache yet — fetch directly
|
||||||
if (Object.keys(customEmojis).length > 0) return;
|
if (Object.keys(customEmojis).length > 0) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v2/emojis');
|
const res = await fetch('/api/v2/emojis');
|
||||||
@@ -25,7 +34,11 @@
|
|||||||
|
|
||||||
const renderEmoji = (match, name) => {
|
const renderEmoji = (match, name) => {
|
||||||
if (customEmojis[name]) {
|
if (customEmojis[name]) {
|
||||||
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
|
const url = customEmojis[name];
|
||||||
|
if (url.endsWith('.webm')) {
|
||||||
|
return `<video class="sidebar-comment-img emoji" src="${url}" title=":${name}:" autoplay loop muted playsinline></video>`;
|
||||||
|
}
|
||||||
|
return `<img class="sidebar-comment-img emoji" src="${url}" alt="${name}" title=":${name}:" loading="lazy">`;
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
};
|
};
|
||||||
@@ -37,9 +50,19 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playSidebarEmojiVideos = (container) => {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('video.emoji').forEach(v => {
|
||||||
|
v.play().catch(() => {
|
||||||
|
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ytOembedCache = new Map(); // videoId -> meta object
|
const ytOembedCache = new Map(); // videoId -> meta object
|
||||||
const ytOembedPending = new Map(); // videoId -> Promise
|
const ytOembedPending = new Map(); // videoId -> Promise
|
||||||
|
|
||||||
|
|
||||||
const fetchSidebarYoutubeTitles = async (container) => {
|
const fetchSidebarYoutubeTitles = async (container) => {
|
||||||
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
|
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
|
||||||
if (links.length === 0) return;
|
if (links.length === 0) return;
|
||||||
@@ -57,8 +80,40 @@
|
|||||||
meta = await ytOembedPending.get(videoId);
|
meta = await ytOembedPending.get(videoId);
|
||||||
} else {
|
} else {
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
|
||||||
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
||||||
|
// 1. Try client-side oEmbed first (avoids Tor/proxy consent walls)
|
||||||
|
try {
|
||||||
|
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`;
|
||||||
|
const clientResp = await fetch(oembedUrl);
|
||||||
|
if (clientResp.ok) {
|
||||||
|
const clientData = await clientResp.json();
|
||||||
|
if (clientData.title) {
|
||||||
|
const metaObj = {
|
||||||
|
title: clientData.title,
|
||||||
|
site_name: 'youtube.com',
|
||||||
|
author: clientData.author_name || 'Unknown'
|
||||||
|
};
|
||||||
|
// Cache client-side
|
||||||
|
ytOembedCache.set(videoId, metaObj);
|
||||||
|
// Push to server cache so other clients/server benefit
|
||||||
|
try {
|
||||||
|
const csrf = window.f0ckSession?.csrf_token;
|
||||||
|
fetch('/api/v2/meta/cache', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(csrf ? { 'X-CSRF-Token': csrf } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: ytUrl, meta: metaObj })
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (_) {}
|
||||||
|
return metaObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
// 2. Fall back to backend meta fetch
|
||||||
|
try {
|
||||||
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
|
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
@@ -83,6 +138,12 @@
|
|||||||
titleSpan.textContent = 'YouTube Video';
|
titleSpan.textContent = 'YouTube Video';
|
||||||
}
|
}
|
||||||
titleSpan.dataset.loaded = 'true';
|
titleSpan.dataset.loaded = 'true';
|
||||||
|
|
||||||
|
// Re-check overflow since the new text might cause wrapping
|
||||||
|
const inner = link.closest('.comment-content-inner');
|
||||||
|
if (inner && typeof checkOverflow === 'function') {
|
||||||
|
checkOverflow(inner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,10 +189,10 @@
|
|||||||
const hostsRegexPart = allowedHosts.join('|');
|
const hostsRegexPart = allowedHosts.join('|');
|
||||||
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
||||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?<!\\S)(?=\\/[a-zA-Z0-9_\\-]))`;
|
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
||||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
renderer.blockquote = function (quote) {
|
renderer.blockquote = function (quote) {
|
||||||
@@ -222,9 +283,13 @@
|
|||||||
let processedLine = line;
|
let processedLine = line;
|
||||||
|
|
||||||
// Handle Mentions
|
// Handle Mentions
|
||||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
const mentionStore = [];
|
||||||
const user = g1 || g2;
|
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
|
||||||
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
const user = g1 || g2 || g4;
|
||||||
|
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||||
|
const idx = mentionStore.length;
|
||||||
|
mentionStore.push(html);
|
||||||
|
return `\x02MNT${idx}\x03`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle Comment Context Links (>>ID)
|
// Handle Comment Context Links (>>ID)
|
||||||
@@ -247,13 +312,33 @@
|
|||||||
return `[video](${fullUrl})`;
|
return `[video](${fullUrl})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use marked for each line individually
|
// Use marked for each line individually.
|
||||||
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
|
// Protect URLs and already-formed Markdown link/image tokens from the
|
||||||
const bs = String.fromCharCode(92);
|
// italic-prevention pass so that underscores in query params
|
||||||
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
|
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
|
||||||
|
const mdProtected = [];
|
||||||
|
// Match [text](url) /  tokens AND bare http(s) URLs
|
||||||
|
let mdSafe = processedLine.replace(
|
||||||
|
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
|
||||||
|
(match) => {
|
||||||
|
const idx = mdProtected.length;
|
||||||
|
mdProtected.push(match);
|
||||||
|
return `\x02MDURL${idx}\x03`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Escape * and _ only in the non-URL portions
|
||||||
|
mdSafe = mdSafe
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\*/g, '\\*')
|
||||||
|
.replace(/_/g, '\\_');
|
||||||
|
// Restore protected URLs/tokens
|
||||||
|
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
|
||||||
|
|
||||||
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
|
// Restore Mentions
|
||||||
|
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
|
||||||
|
|
||||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
@@ -290,6 +375,14 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Abyss label replacement
|
||||||
|
md = md.replace(
|
||||||
|
/<a\s[^>]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||||
|
(match, abyssId) => {
|
||||||
|
return `<a href="/abyss/${abyssId}" class="sidebar-abyss-link" data-abyss-id="${abyssId}"><i class="fa-solid fa-dice-d6"></i> /abyss/${abyssId}</a>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Build regex for allowed media hosters (video/audio)
|
// Build regex for allowed media hosters (video/audio)
|
||||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const mediaHosts = [escapedSiteHost];
|
const mediaHosts = [escapedSiteHost];
|
||||||
@@ -367,9 +460,49 @@
|
|||||||
const SIDEBAR_MAX_CHARS = 200;
|
const SIDEBAR_MAX_CHARS = 200;
|
||||||
const SIDEBAR_MAX_EMOJIS = 12;
|
const SIDEBAR_MAX_EMOJIS = 12;
|
||||||
|
|
||||||
|
const renderCommentAttachments = (files, content = '') => {
|
||||||
|
if (!files || files.length === 0) return '';
|
||||||
|
const items = files.map(f => {
|
||||||
|
const url = `/c/${f.dest}`;
|
||||||
|
if (content.includes(url)) return ''; // Skip if already rendered in content
|
||||||
|
if (f.mime.startsWith('image/')) {
|
||||||
|
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" class="sidebar-comment-img" alt="${escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
||||||
|
} else if (f.mime.startsWith('video/')) {
|
||||||
|
return `<div class="cf-attachment cf-video"><video src="${url}" class="sidebar-comment-img" controls preload="metadata"></video></div>`;
|
||||||
|
} else if (f.mime.startsWith('audio/')) {
|
||||||
|
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSidebarPoll = (poll, commentId, itemId) => {
|
||||||
|
if (!poll) return '';
|
||||||
|
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
|
||||||
|
return `<a class="sidebar-poll-preview" href="${href}"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(poll.question)}</a>`;
|
||||||
|
};
|
||||||
|
|
||||||
const renderActivityItem = (c) => {
|
const renderActivityItem = (c) => {
|
||||||
const rawContent = c.content || c.body || '';
|
const rawContent = c.content || c.body || '';
|
||||||
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||||
|
|
||||||
|
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
||||||
|
|
||||||
|
// Robust fallback for is_long if server didn't provide it, plus inline media checks
|
||||||
|
let isLong = c.is_long;
|
||||||
|
if (isLong === undefined) {
|
||||||
|
isLong = rawContent.length > 120
|
||||||
|
|| rawContent.split('\n').length > 2
|
||||||
|
|| (c.files && c.files.length > 0);
|
||||||
|
}
|
||||||
|
// Always force isLong if there are inline media tags that expand vertically
|
||||||
|
if (!isLong && (/\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
|
||||||
|
isLong = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||||
|
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
||||||
|
|
||||||
// Build avatar URL — same priority as the rest of the app
|
// Build avatar URL — same priority as the rest of the app
|
||||||
let avatarSrc = '/a/default.png';
|
let avatarSrc = '/a/default.png';
|
||||||
@@ -383,71 +516,151 @@
|
|||||||
const timeStr = c.created_at
|
const timeStr = c.created_at
|
||||||
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
||||||
: (c.timeago || 'just now');
|
: (c.timeago || 'just now');
|
||||||
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
|
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}" data-iso="${escapeHtml(c.created_at)}"` : '';
|
||||||
|
const fullDate = c.created_at
|
||||||
|
? (window.f0ckFormatDateFull ? window.f0ckFormatDateFull(c.created_at) : new Date(c.created_at).toISOString())
|
||||||
|
: '';
|
||||||
|
|
||||||
let itemPreview = '';
|
let itemPreview = '';
|
||||||
if (c.item_id) {
|
if (c.item_id) {
|
||||||
let mediaHtml = '';
|
let mediaHtml = '';
|
||||||
|
const rClass = c.item_rating_class || 'untagged';
|
||||||
|
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
||||||
|
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
||||||
|
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
||||||
|
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
||||||
|
|
||||||
|
let isBlurred = false;
|
||||||
|
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
|
||||||
|
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
|
||||||
|
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
|
||||||
|
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
|
||||||
|
|
||||||
let thumbUrl = `/t/${c.item_id}.webp`;
|
let thumbUrl = `/t/${c.item_id}.webp`;
|
||||||
|
if (isBlurred) {
|
||||||
|
thumbUrl = `/t/${c.item_id}_blur.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
||||||
|
|
||||||
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
|
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
|
||||||
|
|
||||||
itemPreview = `
|
itemPreview = `
|
||||||
<div class="item-preview">
|
<div class="item-preview">
|
||||||
<a href="/${c.item_id}">${mediaHtml}</a>
|
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="comment" id="sc${c.id}">
|
<div class="comment" id="sc${c.id}">
|
||||||
<div class="comment-body">
|
<div class="comment-body">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<div class="comment-header-left">
|
<div class="comment-header-left">
|
||||||
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
||||||
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
|
<img src="${avatarSrc}" class="sidebar-avatar" loading="eager" onload="this.classList.add('loaded')" onerror="this.classList.add('loaded');this.src='/a/default.png'" />
|
||||||
</a>
|
</a>
|
||||||
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
<span class="comment-time timeago" tooltip="${fullDate}" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
<div class="comment-content${isLong ? ' has-overflow' : ''}"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn"${isLong ? ' style="display:block"' : ''}>${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||||
${itemPreview}
|
${itemPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkOverflow = () => {
|
|
||||||
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
|
const checkOverflow = (targetElement) => {
|
||||||
|
const targets = targetElement
|
||||||
|
? (targetElement.classList.contains('comment-content-inner') ? [targetElement] : targetElement.querySelectorAll('.comment-content-inner'))
|
||||||
|
: document.querySelectorAll('.sidebar-activity .comment-content-inner');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
// Read phase: batch layout reads first
|
||||||
|
targets.forEach(inner => {
|
||||||
const container = inner.parentElement;
|
const container = inner.parentElement;
|
||||||
const btn = container.querySelector('.read-more-btn');
|
const btn = container ? container.querySelector('.read-more-btn') : null;
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
// If expanded, always show "see less"
|
const isExpanded = container.classList.contains('expanded');
|
||||||
if (container.classList.contains('expanded')) {
|
const scrollHeight = inner.scrollHeight;
|
||||||
btn.style.display = 'block';
|
const clientHeight = inner.clientHeight;
|
||||||
|
const hasUnloadedImages = Array.from(inner.querySelectorAll('img:not(.emoji), video')).some(
|
||||||
|
media => {
|
||||||
|
if (media.tagName === 'IMG') {
|
||||||
|
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
|
||||||
|
}
|
||||||
|
if (media.tagName === 'VIDEO') {
|
||||||
|
return media.readyState < 1 && media.dataset.error !== 'true'; // HAVE_METADATA
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
container,
|
||||||
|
btn,
|
||||||
|
isExpanded,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
hasUnloadedImages
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write phase: perform DOM updates after all reads are completed.
|
||||||
|
// Visibility of the button is controlled entirely by the CSS rule on
|
||||||
|
// .comment-content.has-overflow > .read-more-btn — no btn.style.display here.
|
||||||
|
results.forEach(({ container, btn, isExpanded, scrollHeight, clientHeight, hasUnloadedImages }) => {
|
||||||
|
// Clear any leftover inline display style (e.g. from the HTML template's
|
||||||
|
// style="display:block") so the CSS class-based rule is the single source of truth.
|
||||||
|
btn.style.display = '';
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
|
if (scrollHeight > clientHeight + 2) { // 2px buffer for rounding
|
||||||
btn.style.display = 'block';
|
// Content overflows — ensure clamped state (may already be set from HTML default)
|
||||||
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
||||||
container.classList.add('has-overflow');
|
container.classList.add('has-overflow');
|
||||||
|
} else if (hasUnloadedImages) {
|
||||||
|
// Images haven't loaded yet — their height is 0 so we can't tell if content
|
||||||
|
// will overflow. Keep has-overflow set; attachMediaLoadListeners will
|
||||||
|
// re-run checkOverflow once images finish loading.
|
||||||
} else {
|
} else {
|
||||||
btn.style.display = 'none';
|
// Content fits and all images are loaded — safe to remove clamped state
|
||||||
container.classList.remove('has-overflow');
|
container.classList.remove('has-overflow');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachMediaLoadListeners = (element) => {
|
const attachMediaLoadListeners = (element) => {
|
||||||
element.querySelectorAll('img, video').forEach(media => {
|
// Only target images and videos inside comment-content-inner.
|
||||||
|
// Avatars, emojis, and item preview thumbnails have fixed sizes and do not affect text overflow.
|
||||||
|
element.querySelectorAll('.comment-content-inner img:not(.emoji), .comment-content-inner video').forEach(media => {
|
||||||
if (media.dataset.loadListenerBound) return;
|
if (media.dataset.loadListenerBound) return;
|
||||||
media.dataset.loadListenerBound = 'true';
|
media.dataset.loadListenerBound = 'true';
|
||||||
|
|
||||||
media.addEventListener('load', checkOverflow, { once: true });
|
const inner = media.closest('.comment-content-inner');
|
||||||
media.addEventListener('loadedmetadata', checkOverflow, { once: true });
|
if (inner) {
|
||||||
|
const handler = () => checkOverflow(inner);
|
||||||
|
const errorHandler = () => {
|
||||||
|
media.dataset.error = 'true';
|
||||||
|
checkOverflow(inner);
|
||||||
|
};
|
||||||
|
media.addEventListener('load', handler, { once: true });
|
||||||
|
media.addEventListener('error', errorHandler, { once: true });
|
||||||
|
media.addEventListener('loadedmetadata', handler, { once: true });
|
||||||
|
|
||||||
|
// Also use ResizeObserver to catch layout changes (e.g. cached image layout deferred)
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
const ro = new ResizeObserver(() => checkOverflow(inner));
|
||||||
|
ro.observe(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -459,7 +672,7 @@
|
|||||||
const contentDiv = readBtn.closest('.comment-content');
|
const contentDiv = readBtn.closest('.comment-content');
|
||||||
if (contentDiv) {
|
if (contentDiv) {
|
||||||
contentDiv.classList.toggle('expanded');
|
contentDiv.classList.toggle('expanded');
|
||||||
checkOverflow(); // Re-sync button text and visibility
|
checkOverflow(contentDiv); // Re-sync button text and visibility for this element
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -470,43 +683,28 @@
|
|||||||
const showSkeletons = () => {
|
const showSkeletons = () => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const variants = [
|
container.innerHTML = `
|
||||||
`<div class="skeleton-line skeleton-text-long"></div>
|
<div class="sidebar-loading-state">
|
||||||
<div class="skeleton-line skeleton-text-medium"></div>
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
<div class="skeleton-line skeleton-text-short"></div>`,
|
<span>${window.f0ckI18n?.sidebar_loading_activity || 'Loading activity...'}</span>
|
||||||
`<div class="skeleton-line skeleton-text-long"></div>
|
|
||||||
<div class="skeleton-line skeleton-text-short"></div>`,
|
|
||||||
`<div class="skeleton-line skeleton-text-medium"></div>
|
|
||||||
<div class="skeleton-line skeleton-text-long"></div>
|
|
||||||
<div class="skeleton-line skeleton-text-short"></div>`,
|
|
||||||
`<div class="skeleton-line skeleton-text-long"></div>
|
|
||||||
<div class="skeleton-line skeleton-text-medium"></div>`,
|
|
||||||
`<div class="skeleton-line skeleton-text-short"></div>
|
|
||||||
<div class="skeleton-line skeleton-text-long"></div>`,
|
|
||||||
];
|
|
||||||
let html = '';
|
|
||||||
for (let i = 0; i < SIDEBAR_SKELETON_COUNT; i++) {
|
|
||||||
html += `
|
|
||||||
<div class="sidebar-skeleton-item">
|
|
||||||
<div class="skeleton-header">
|
|
||||||
<div class="skeleton-avatar"></div>
|
|
||||||
<div class="skeleton-meta">
|
|
||||||
<div class="skeleton-line skeleton-name"></div>
|
|
||||||
<div class="skeleton-line skeleton-time"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`;
|
||||||
${variants[i % 5]}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
// Auto-play converted GIF videos
|
|
||||||
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFromCache = () => {
|
const renderFromCache = (force = false) => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
if (!container || window._sidebarActivityCache.length === 0) return false;
|
if (!container || window._sidebarActivityCache.length === 0) return false;
|
||||||
|
|
||||||
|
const currentIds = window._sidebarActivityCache.map(c => String(c.id)).join(',');
|
||||||
|
// If the same comments are already rendered, leave the DOM completely untouched.
|
||||||
|
// This keeps video stickers playing and avoids any overflow state churn.
|
||||||
|
// force=true bypasses this guard for cases where content changed without ID changes
|
||||||
|
// (e.g. emojis just loaded and need to replace raw :codes: in existing comments).
|
||||||
|
if (!force && currentIds === lastRenderedIds && container.querySelector('.comment')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
lastRenderedIds = currentIds;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
window._sidebarActivityCache.forEach(c => {
|
window._sidebarActivityCache.forEach(c => {
|
||||||
html += renderActivityItem(c);
|
html += renderActivityItem(c);
|
||||||
@@ -518,15 +716,35 @@
|
|||||||
container.appendChild(ioSentinel);
|
container.appendChild(ioSentinel);
|
||||||
}
|
}
|
||||||
attachMediaLoadListeners(container);
|
attachMediaLoadListeners(container);
|
||||||
|
// has-overflow is now set per-comment by the server (via is_long) so the
|
||||||
|
// correct clamped/unclamped state is baked into the HTML on first paint.
|
||||||
|
// checkOverflow still runs deferred to handle two edge cases:
|
||||||
|
// 1. Comments with images whose heights are 0 at inject time — the server
|
||||||
|
// correctly marks them is_long:true, but images that load extra-tall
|
||||||
|
// still need a re-check after load (handled by attachMediaLoadListeners).
|
||||||
|
// 2. Any mis-estimates: content that the heuristic got wrong is corrected
|
||||||
|
// after layout so measurements are accurate.
|
||||||
|
// Run checkOverflow synchronously immediately after setting innerHTML.
|
||||||
|
// This forces a synchronous layout (reflow) but guarantees the browser
|
||||||
|
// will not paint the intermediate state, completely eliminating any "flash"
|
||||||
|
// of the button popping in or out.
|
||||||
checkOverflow();
|
checkOverflow();
|
||||||
|
|
||||||
|
// checkOverflow still runs deferred to handle image loads (attachMediaLoadListeners)
|
||||||
|
// and a fallback 1s timeout for any incredibly slow layout edge cases.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(checkOverflow, 1000);
|
||||||
|
});
|
||||||
fetchSidebarYoutubeTitles(container);
|
fetchSidebarYoutubeTitles(container);
|
||||||
// Auto-play converted GIF videos
|
// Auto-play converted GIF videos and webm emoji stickers
|
||||||
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
|
playSidebarEmojiVideos(container);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_PAGE_LIMIT = 15;
|
const SIDEBAR_PAGE_LIMIT = 15;
|
||||||
const SIDEBAR_INITIAL_LIMIT = 15;
|
const SIDEBAR_INITIAL_LIMIT = 15;
|
||||||
|
const SIDEBAR_MAX_COMMENTS = 20;
|
||||||
|
|
||||||
const loadActivity = async (silent = false) => {
|
const loadActivity = async (silent = false) => {
|
||||||
const container = document.getElementById('sidebar-activity-container');
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
@@ -552,22 +770,21 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success && data.comments && data.comments.length > 0) {
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
window._sidebarActivityCache = data.comments.map(c => ({
|
window._sidebarActivityCache = data.comments.slice(0, SIDEBAR_MAX_COMMENTS).map(c => ({
|
||||||
...c,
|
...c,
|
||||||
body: c.content || c.body
|
body: c.content || c.body
|
||||||
}));
|
}));
|
||||||
hasMore = data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT;
|
hasMore = (data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT)
|
||||||
renderFromCache();
|
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
|
||||||
// Also check after a delay to account for image/emoji loading shifts
|
renderFromCache(); // no-op if IDs unchanged (renderFromCache self-guards)
|
||||||
setTimeout(checkOverflow, 500);
|
|
||||||
} else if (!hasCache) {
|
} else if (!hasCache) {
|
||||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_no_activity || 'No recent activity.') + '</div>';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Sidebar Activity: Failed to load activity", e);
|
console.error("Sidebar Activity: Failed to load activity", e);
|
||||||
if (!hasCache) {
|
if (!hasCache) {
|
||||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.') + '</div>';
|
||||||
}
|
}
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -589,7 +806,7 @@
|
|||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'sidebar-load-more-sentinel';
|
sentinel.id = 'sidebar-load-more-sentinel';
|
||||||
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
||||||
sentinel.textContent = 'Loading…';
|
sentinel.textContent = window.f0ckI18n?.sidebar_loading_more || 'Loading…';
|
||||||
container.appendChild(sentinel);
|
container.appendChild(sentinel);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -605,40 +822,65 @@
|
|||||||
|
|
||||||
if (data.success && data.comments && data.comments.length > 0) {
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
currentPage++;
|
currentPage++;
|
||||||
hasMore = data.hasMore === true;
|
|
||||||
|
|
||||||
// Append only comments not already in the cache
|
// Append only comments not already in the cache, up to the global cap
|
||||||
|
const remaining = SIDEBAR_MAX_COMMENTS - window._sidebarActivityCache.length;
|
||||||
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
||||||
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
|
const newComments = data.comments
|
||||||
...c,
|
.filter(c => !existingIds.has(String(c.id)))
|
||||||
body: c.content || c.body
|
.slice(0, remaining)
|
||||||
}));
|
.map(c => ({ ...c, body: c.content || c.body }));
|
||||||
|
|
||||||
window._sidebarActivityCache.push(...newComments);
|
window._sidebarActivityCache.push(...newComments);
|
||||||
|
|
||||||
|
// Stop loading more if the hard cap is reached
|
||||||
|
hasMore = data.hasMore === true
|
||||||
|
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
|
||||||
|
|
||||||
// Append new items to DOM
|
// Append new items to DOM
|
||||||
let html = '';
|
let html = '';
|
||||||
newComments.forEach(c => { html += renderActivityItem(c); });
|
newComments.forEach(c => { html += renderActivityItem(c); });
|
||||||
if (html) {
|
if (html) {
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = html;
|
temp.innerHTML = html;
|
||||||
|
const addedNodes = [];
|
||||||
while (temp.firstElementChild) {
|
while (temp.firstElementChild) {
|
||||||
container.appendChild(temp.firstElementChild);
|
const node = temp.firstElementChild;
|
||||||
|
container.appendChild(node);
|
||||||
|
addedNodes.push(node);
|
||||||
}
|
}
|
||||||
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
||||||
if (ioSentinel) container.appendChild(ioSentinel);
|
if (ioSentinel) container.appendChild(ioSentinel);
|
||||||
attachMediaLoadListeners(container);
|
|
||||||
checkOverflow();
|
addedNodes.forEach(node => {
|
||||||
fetchSidebarYoutubeTitles(container);
|
attachMediaLoadListeners(node);
|
||||||
// Auto-play converted GIF videos
|
fetchSidebarYoutubeTitles(node);
|
||||||
|
playSidebarEmojiVideos(node);
|
||||||
|
});
|
||||||
|
// Defer overflow checks to after layout so measurements are accurate.
|
||||||
|
addedNodes.forEach(node => checkOverflow(node));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// Deferred re-check for image comments — mirrors renderFromCache behaviour.
|
||||||
|
// Images in newly appended nodes may not be laid out yet at append time.
|
||||||
|
setTimeout(() => addedNodes.forEach(node => checkOverflow(node)), 1000);
|
||||||
|
});
|
||||||
|
// Auto-play converted GIF videos and webm emoji stickers
|
||||||
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
// Show end-of-feed indicator
|
||||||
|
const end = document.createElement('div');
|
||||||
|
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
||||||
|
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
|
||||||
|
container.appendChild(end);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
// Show end-of-feed indicator
|
// Show end-of-feed indicator
|
||||||
const end = document.createElement('div');
|
const end = document.createElement('div');
|
||||||
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
||||||
end.textContent = '─ end of activity ─';
|
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
|
||||||
container.appendChild(end);
|
container.appendChild(end);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -659,7 +901,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
|
// 2. Update cache (prepend, capped at SIDEBAR_MAX_COMMENTS)
|
||||||
const newItem = {
|
const newItem = {
|
||||||
...data,
|
...data,
|
||||||
body: data.body || data.content,
|
body: data.body || data.content,
|
||||||
@@ -667,9 +909,22 @@
|
|||||||
};
|
};
|
||||||
window._sidebarActivityCache.unshift(newItem);
|
window._sidebarActivityCache.unshift(newItem);
|
||||||
|
|
||||||
|
// Trim cache to the hard cap and remove the evicted DOM node (if any)
|
||||||
|
if (window._sidebarActivityCache.length > SIDEBAR_MAX_COMMENTS) {
|
||||||
|
const evicted = window._sidebarActivityCache.pop();
|
||||||
|
if (container && evicted) {
|
||||||
|
const evictedEl = document.getElementById('sc' + evicted.id);
|
||||||
|
if (evictedEl) evictedEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update DOM if visible
|
// Update DOM if visible
|
||||||
if (container) {
|
if (container) {
|
||||||
const html = renderActivityItem(newItem);
|
// is_long may be provided by the server (SSE payload) or absent for legacy events.
|
||||||
|
// Default to true so the button is always visible; checkOverflow will hide it
|
||||||
|
// if the rendered content is actually short.
|
||||||
|
const itemWithLong = { ...newItem, is_long: newItem.is_long !== false };
|
||||||
|
const html = renderActivityItem(itemWithLong);
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = html;
|
temp.innerHTML = html;
|
||||||
const node = temp.firstElementChild;
|
const node = temp.firstElementChild;
|
||||||
@@ -677,15 +932,26 @@
|
|||||||
node.classList.add('new-item-fade');
|
node.classList.add('new-item-fade');
|
||||||
container.prepend(node);
|
container.prepend(node);
|
||||||
attachMediaLoadListeners(node);
|
attachMediaLoadListeners(node);
|
||||||
checkOverflow();
|
checkOverflow(node);
|
||||||
fetchSidebarYoutubeTitles(container);
|
fetchSidebarYoutubeTitles(container);
|
||||||
|
playSidebarEmojiVideos(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await loadEmojis();
|
// Run emoji loading and activity fetching in parallel — avatars appear
|
||||||
loadActivity();
|
// immediately without waiting for the emoji API to respond first.
|
||||||
|
// After both settle, if emojis finished after the initial render, re-render
|
||||||
|
// from cache so custom emoji images show on first page load.
|
||||||
|
const emojiPromise = loadEmojis();
|
||||||
|
const activityPromise = loadActivity();
|
||||||
|
await activityPromise;
|
||||||
|
await emojiPromise;
|
||||||
|
// If emojis were not yet available when activity first rendered, re-render now.
|
||||||
|
if (Object.keys(customEmojis).length > 0 && window._sidebarActivityCache.length > 0) {
|
||||||
|
renderFromCache(true); // force: emojis are now loaded, content changed
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for live activity from f0ckm.js
|
// Listen for live activity from f0ckm.js
|
||||||
@@ -718,7 +984,7 @@
|
|||||||
void el.offsetWidth;
|
void el.offsetWidth;
|
||||||
el.classList.add('new-item-fade');
|
el.classList.add('new-item-fade');
|
||||||
attachMediaLoadListeners(inner);
|
attachMediaLoadListeners(inner);
|
||||||
checkOverflow();
|
requestAnimationFrame(() => checkOverflow(inner));
|
||||||
fetchSidebarYoutubeTitles(el);
|
fetchSidebarYoutubeTitles(el);
|
||||||
// Auto-play converted GIF videos
|
// Auto-play converted GIF videos
|
||||||
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
|
||||||
@@ -732,6 +998,20 @@
|
|||||||
handleLiveEdit(e.detail);
|
handleLiveEdit(e.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When emojis are refreshed (e.g. after sticker pack import), sync the sidebar
|
||||||
|
// cache and re-render so newly imported stickers appear in existing entries.
|
||||||
|
window.addEventListener('f0ck:emojis_ready', () => {
|
||||||
|
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
|
||||||
|
if (!csCache || Object.keys(csCache).length === 0) return;
|
||||||
|
// Merge new emojis into local cache (additive — never removes)
|
||||||
|
const hadBefore = Object.keys(customEmojis).length;
|
||||||
|
Object.assign(customEmojis, csCache);
|
||||||
|
const hasNew = Object.keys(customEmojis).length > hadBefore;
|
||||||
|
if (hasNew && window._sidebarActivityCache.length > 0) {
|
||||||
|
renderFromCache(true); // force: new emoji images available, re-render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||||
|
|
||||||
// Handle AJAX item loads
|
// Handle AJAX item loads
|
||||||
@@ -744,6 +1024,7 @@
|
|||||||
|
|
||||||
if (modeChanged) {
|
if (modeChanged) {
|
||||||
window._sidebarActivityCache = [];
|
window._sidebarActivityCache = [];
|
||||||
|
lastRenderedIds = '';
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false); // Force reload with loading state
|
loadActivity(false); // Force reload with loading state
|
||||||
@@ -765,6 +1046,7 @@
|
|||||||
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||||
lastBoundMode = e.detail.mode;
|
lastBoundMode = e.detail.mode;
|
||||||
window._sidebarActivityCache = [];
|
window._sidebarActivityCache = [];
|
||||||
|
lastRenderedIds = '';
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false);
|
loadActivity(false);
|
||||||
|
|||||||
@@ -71,11 +71,13 @@ window.TagAutocomplete = (() => {
|
|||||||
|
|
||||||
// Flag to prevent focusout from destroying dropdown while touching it
|
// Flag to prevent focusout from destroying dropdown while touching it
|
||||||
let dropdownTouching = false;
|
let dropdownTouching = false;
|
||||||
|
// Flag set when we intentionally blur to dismiss the keyboard on mobile
|
||||||
|
let keyboardDismissed = false;
|
||||||
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
||||||
dropdown.addEventListener('touchend', () => {
|
dropdown.addEventListener('touchend', () => {
|
||||||
dropdownTouching = false;
|
dropdownTouching = false;
|
||||||
// Re-focus input so user can keep typing after scrolling
|
// Note: do NOT re-focus input here — that would reopen the mobile keyboard.
|
||||||
input.focus();
|
// The keyboard only comes back when the user explicitly taps the input.
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
||||||
|
|
||||||
@@ -267,18 +269,51 @@ window.TagAutocomplete = (() => {
|
|||||||
open(opts);
|
open(opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close when clicking/tapping outside
|
// Close when clicking/tapping outside.
|
||||||
const onDocClick = (e) => {
|
// Desktop (mousedown): close immediately.
|
||||||
|
// Mobile (touchstart): first tap outside dismisses the keyboard only (blur),
|
||||||
|
// leaving suggestions visible so the user can scroll up to see them.
|
||||||
|
// A second tap outside within 500 ms actually closes the dropdown.
|
||||||
|
let outsideTapCount = 0;
|
||||||
|
let outsideTapTimer = null;
|
||||||
|
|
||||||
|
const onDocMousedown = (e) => {
|
||||||
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
||||||
document.removeEventListener('mousedown', onDocClick);
|
document.removeEventListener('mousedown', onDocMousedown);
|
||||||
document.removeEventListener('touchstart', onDocClick);
|
|
||||||
destroy();
|
destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Delay attaching to avoid capturing the opening click
|
|
||||||
|
const onDocTouchstart = (e) => {
|
||||||
|
if (wrapper.contains(e.target) || e.target === anchorEl) return;
|
||||||
|
|
||||||
|
outsideTapCount++;
|
||||||
|
|
||||||
|
if (outsideTapCount === 1) {
|
||||||
|
// First outside tap — just blur to dismiss the keyboard.
|
||||||
|
// Suggestions remain visible so the user can scroll up to see them.
|
||||||
|
keyboardDismissed = true;
|
||||||
|
input.blur();
|
||||||
|
// Reset the flag after the blur event has fired.
|
||||||
|
setTimeout(() => { keyboardDismissed = false; }, 100);
|
||||||
|
|
||||||
|
// Reset the counter after the double-tap window expires.
|
||||||
|
outsideTapTimer = setTimeout(() => {
|
||||||
|
outsideTapCount = 0;
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// Second outside tap — close the dropdown.
|
||||||
|
clearTimeout(outsideTapTimer);
|
||||||
|
outsideTapCount = 0;
|
||||||
|
document.removeEventListener('touchstart', onDocTouchstart);
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay attaching to avoid capturing the opening touch.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('mousedown', onDocClick);
|
document.addEventListener('mousedown', onDocMousedown);
|
||||||
document.addEventListener('touchstart', onDocClick, { passive: true });
|
document.addEventListener('touchstart', onDocTouchstart, { passive: true });
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Click on the wrapper area should refocus the input
|
// Click on the wrapper area should refocus the input
|
||||||
@@ -293,6 +328,7 @@ window.TagAutocomplete = (() => {
|
|||||||
// Delay to allow suggestion tap/scroll to complete first
|
// Delay to allow suggestion tap/scroll to complete first
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (dropdownTouching) return; // user is interacting with dropdown
|
if (dropdownTouching) return; // user is interacting with dropdown
|
||||||
|
if (keyboardDismissed) return; // intentional blur to hide mobile keyboard
|
||||||
// Don't close if focus is still within the wrapper
|
// Don't close if focus is still within the wrapper
|
||||||
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
||||||
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@
|
|||||||
a.textContent = tag.tag;
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.classList.add("badge", "mr-2");
|
span.classList.add("badge");
|
||||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
span.classList.add('new-tag-glow');
|
span.classList.add('new-tag-glow');
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,6 @@
|
|||||||
|
|
||||||
a.appendChild(img);
|
a.appendChild(img);
|
||||||
favcontainer.appendChild(a);
|
favcontainer.appendChild(a);
|
||||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
|
||||||
});
|
});
|
||||||
favcontainer.hidden = false;
|
favcontainer.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class UserCommentSystem {
|
if (!window.UserCommentSystem) {
|
||||||
|
window.UserCommentSystem = class UserCommentSystem {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.container = document.getElementById('user-comments-container');
|
this.container = document.getElementById('user-comments-container');
|
||||||
this.username = this.container ? this.container.dataset.user : null;
|
this.username = this.container ? this.container.dataset.user : null;
|
||||||
@@ -8,6 +9,8 @@ class UserCommentSystem {
|
|||||||
this.userColor = null;
|
this.userColor = null;
|
||||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.icons = {
|
this.icons = {
|
||||||
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
|
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
|
||||||
@@ -23,12 +26,16 @@ class UserCommentSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleLiveEdit(data) {
|
handleLiveEdit(data) {
|
||||||
if (!this.container) return;
|
if (!this.container || !document.body.contains(this.container)) {
|
||||||
|
window.removeEventListener('f0ck:comment_edited', this.editListener);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const el = document.getElementById('c' + data.comment_id);
|
const el = document.getElementById('c' + data.comment_id);
|
||||||
if (el && this.container.contains(el)) {
|
if (el && this.container.contains(el)) {
|
||||||
const contentEl = el.querySelector('.comment-content');
|
const contentEl = el.querySelector('.comment-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
||||||
|
this.playEmojiVideos(contentEl);
|
||||||
el.classList.remove('new-item-fade');
|
el.classList.remove('new-item-fade');
|
||||||
void el.offsetWidth;
|
void el.offsetWidth;
|
||||||
el.classList.add('new-item-fade');
|
el.classList.add('new-item-fade');
|
||||||
@@ -37,7 +44,7 @@ class UserCommentSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.loadEmojis();
|
await this.loadEmojis();
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
@@ -112,6 +119,7 @@ class UserCommentSystem {
|
|||||||
const html = this.renderComment(c);
|
const html = this.renderComment(c);
|
||||||
this.container.insertAdjacentHTML('beforeend', html);
|
this.container.insertAdjacentHTML('beforeend', html);
|
||||||
});
|
});
|
||||||
|
this.playEmojiVideos(this.container);
|
||||||
this.page++;
|
this.page++;
|
||||||
} else {
|
} else {
|
||||||
this.finished = true;
|
this.finished = true;
|
||||||
@@ -127,13 +135,89 @@ class UserCommentSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playEmojiVideos(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('video.emoji').forEach(v => {
|
||||||
|
v.play().catch(() => {
|
||||||
|
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderEmoji(match, name) {
|
renderEmoji(match, name) {
|
||||||
if (this.customEmojis && this.customEmojis[name]) {
|
if (this.customEmojis && this.customEmojis[name]) {
|
||||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
const url = this.customEmojis[name];
|
||||||
|
if (url.endsWith('.webm')) {
|
||||||
|
return `<video src="${url}" class="emoji" autoplay loop muted playsinline title=":${name}:"></video>`;
|
||||||
|
}
|
||||||
|
return `<img src="${url}" class="emoji" alt="${match}" title="${match}">`;
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderCommentAttachments(files, content = '') {
|
||||||
|
if (!files || files.length === 0) return '';
|
||||||
|
const items = files.map(f => {
|
||||||
|
const url = `/c/${f.dest}`;
|
||||||
|
if (content && content.includes(url)) return ''; // Skip if already rendered in content
|
||||||
|
if (f.mime && f.mime.startsWith('image/')) {
|
||||||
|
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
||||||
|
} else if (f.mime && f.mime.startsWith('video/')) {
|
||||||
|
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
|
||||||
|
} else if (f.mime && f.mime.startsWith('audio/')) {
|
||||||
|
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCommentPoll(poll, commentId) {
|
||||||
|
if (!poll) return '';
|
||||||
|
const i18n = window.f0ckI18n || {};
|
||||||
|
const session = window.f0ckSession || {};
|
||||||
|
const total = poll.total_votes || 0;
|
||||||
|
const voted = !!poll.user_vote_option_id;
|
||||||
|
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
|
||||||
|
const isAnon = poll.is_anonymous !== false;
|
||||||
|
|
||||||
|
const optionsHtml = (poll.options || []).map(opt => {
|
||||||
|
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
|
||||||
|
const isVoted = poll.user_vote_option_id === opt.id;
|
||||||
|
const clickable = session.logged_in && !expired && !voted;
|
||||||
|
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
|
||||||
|
? `<div class="poll-option-voters">${opt.voters.map(v => {
|
||||||
|
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
|
||||||
|
const name = String(u.username || '');
|
||||||
|
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
|
||||||
|
return name ? `<a href="/user/${this.escapeHtml(name)}" title="${this.escapeHtml(name)}"><img class="poll-voter-avatar" src="${src}" alt="${this.escapeHtml(name)}" loading="lazy"></a>` : '';
|
||||||
|
}).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
return `<div class="poll-option ${isVoted ? 'poll-option-voted' : ''} ${clickable ? 'poll-option-clickable' : ''}"
|
||||||
|
data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
|
||||||
|
<div class="poll-option-bar" style="width:${pct}%"></div>
|
||||||
|
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
|
||||||
|
<span class="poll-option-pct">${pct}%</span>
|
||||||
|
${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
|
||||||
|
${voterAvatars}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const anonBadge = isAnon
|
||||||
|
? `<span class="poll-anon-badge" title="${i18n.poll_anonymous || 'Anonymous'}"><i class="fa-solid fa-user-secret"></i></span>`
|
||||||
|
: `<span class="poll-anon-badge poll-public-badge" title="${i18n.poll_public || 'Public votes'}"><i class="fa-solid fa-eye"></i></span>`;
|
||||||
|
|
||||||
|
return `<div class="comment-poll" data-poll-id="${poll.id}" data-is-anonymous="${isAnon ? '1' : '0'}">
|
||||||
|
<div class="poll-question">${this.escapeHtml(poll.question)}</div>
|
||||||
|
<div class="poll-options">${optionsHtml}</div>
|
||||||
|
<div class="poll-footer">
|
||||||
|
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span>
|
||||||
|
${anonBadge}
|
||||||
|
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
renderCommentContent(content, itemId = null) {
|
renderCommentContent(content, itemId = null) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
@@ -152,7 +236,7 @@ class UserCommentSystem {
|
|||||||
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||||
|
|
||||||
// 2. Mentions
|
// 2. Mentions
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
|
||||||
|
|
||||||
const siteOrigin = window.location.origin;
|
const siteOrigin = window.location.origin;
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
@@ -191,6 +275,32 @@ class UserCommentSystem {
|
|||||||
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderer.image = (href, title, text) => {
|
||||||
|
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||||
|
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
|
||||||
|
if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) {
|
||||||
|
const filename = src.substring(3); // Remove '/c/'
|
||||||
|
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
|
||||||
|
}
|
||||||
|
return imgHtml;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-compile regexes for image/video/audio embeds matching comments.js
|
||||||
|
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const allowedHosts = [escapedSiteHost];
|
||||||
|
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||||
|
window.f0ckAllowedImages.forEach(h => {
|
||||||
|
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hostsRegexPart = allowedHosts.join('|');
|
||||||
|
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||||
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
|
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
|
||||||
|
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||||
|
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
||||||
|
|
||||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||||
const renderedLines = escaped.split('\n').map(line => {
|
const renderedLines = escaped.split('\n').map(line => {
|
||||||
const trimmed = line.trimStart();
|
const trimmed = line.trimStart();
|
||||||
@@ -203,7 +313,17 @@ class UserCommentSystem {
|
|||||||
if (line.length > 10000) return line;
|
if (line.length > 10000) return line;
|
||||||
if (!line.trim()) return ' ';
|
if (!line.trim()) return ' ';
|
||||||
|
|
||||||
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
let processedLine = line;
|
||||||
|
|
||||||
|
// Handle Mentions
|
||||||
|
const mentionStore = [];
|
||||||
|
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
|
||||||
|
const user = g1 || g2 || g4;
|
||||||
|
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||||
|
const idx = mentionStore.length;
|
||||||
|
mentionStore.push(html);
|
||||||
|
return `\x02MNT${idx}\x03`;
|
||||||
|
});
|
||||||
|
|
||||||
// Handle Comment Context Links (>>ID)
|
// Handle Comment Context Links (>>ID)
|
||||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||||
@@ -211,9 +331,34 @@ class UserCommentSystem {
|
|||||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle Image Embeds
|
||||||
|
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||||
|
let fullUrl = url;
|
||||||
|
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||||
|
fullUrl = '//' + url;
|
||||||
|
}
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Raw Video/Audio links so Marked converts them to <a>
|
||||||
|
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
|
||||||
|
let fullUrl = url;
|
||||||
|
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||||
|
return `[video](${fullUrl})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
|
||||||
|
let fullUrl = url;
|
||||||
|
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||||
|
return `[audio](${fullUrl})`;
|
||||||
|
});
|
||||||
|
|
||||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
|
// Restore Mentions
|
||||||
|
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
|
||||||
|
|
||||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
@@ -264,35 +409,7 @@ class UserCommentSystem {
|
|||||||
const fullDate = new Date(c.created_at).toISOString();
|
const fullDate = new Date(c.created_at).toISOString();
|
||||||
const content = this.renderCommentContent(c.content, c.item_id);
|
const content = this.renderCommentContent(c.content, c.item_id);
|
||||||
|
|
||||||
// Replicating the structure of comments.js but adapting for the list view
|
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||||
// We add a header indicating which item this comment belongs to
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="comment" id="c${c.id}">
|
|
||||||
<div class="comment-avatar">
|
|
||||||
<a href="/${c.item_id}">
|
|
||||||
<img src="/t/${c.item_id}.webp" alt="">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="comment-body">
|
|
||||||
<div class="comment-header">
|
|
||||||
<div class="comment-header-left">
|
|
||||||
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
|
||||||
</div>
|
|
||||||
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
|
||||||
</div>
|
|
||||||
<div class="comment-content">${content}</div>
|
|
||||||
<div class="comment-footer">
|
|
||||||
<div class="comment-footer-right">
|
|
||||||
<div class="comment-actions">
|
|
||||||
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startLiveTimestamps() {
|
startLiveTimestamps() {
|
||||||
@@ -336,15 +453,21 @@ class UserCommentSystem {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initializer for AJAX and standard load
|
// Initializer for AJAX and standard load
|
||||||
window.initUserComments = () => {
|
window.initUserComments = () => {
|
||||||
// Prevent multiple instances if already running on this container
|
const container = document.getElementById('user-comments-container');
|
||||||
if (document.getElementById('user-comments-container')) {
|
if (container && !container.dataset.initialized) {
|
||||||
|
container.dataset.initialized = 'true';
|
||||||
new UserCommentSystem();
|
new UserCommentSystem();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
if (document.readyState === 'loading') {
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
window.initUserComments();
|
window.initUserComments();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
window.initUserComments();
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,13 +53,23 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
||||||
|
<div class="v0ck_speed_indicator v0ck_hidden">
|
||||||
|
<svg viewBox="0 0 24 24" style="width: 16px; height: 16px; fill: currentColor; display: inline-block; vertical-align: middle; margin-right: 6px;">
|
||||||
|
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>
|
||||||
|
</svg>
|
||||||
|
<span>2X Speed</span>
|
||||||
|
</div>
|
||||||
<div class="v0ck_overlay">
|
<div class="v0ck_overlay">
|
||||||
<svg style="width: 60px; height: 60px;">
|
<svg style="width: 60px; height: 60px;">
|
||||||
<use href="${svg}#play"></use>
|
<use href="${svg}#play"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="v0ck_hud v0ck_hidden">
|
<div class="v0ck_hud v0ck_hidden">
|
||||||
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
|
<svg>
|
||||||
|
<use class="v0ck_hud_icon v0ck_hud_volume_full" href="${svg}#volume_full"></use>
|
||||||
|
<use class="v0ck_hud_icon v0ck_hud_volume_mid v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||||
|
<use class="v0ck_hud_icon v0ck_hud_volume_mute v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||||
|
</svg>
|
||||||
<div class="v0ck_hud_bar_container">
|
<div class="v0ck_hud_bar_container">
|
||||||
<div class="v0ck_hud_bar"></div>
|
<div class="v0ck_hud_bar"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +121,7 @@ class v0ck {
|
|||||||
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
|
|
||||||
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
||||||
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
||||||
@@ -157,18 +164,35 @@ class v0ck {
|
|||||||
const playtime = player.querySelector('.v0ck_playtime');
|
const playtime = player.querySelector('.v0ck_playtime');
|
||||||
const overlay = player.querySelector('.v0ck_overlay');
|
const overlay = player.querySelector('.v0ck_overlay');
|
||||||
const volumeButton = player.querySelector('.v0ck_volume');
|
const volumeButton = player.querySelector('.v0ck_volume');
|
||||||
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
|
const volumeSymbols = volumeButton.querySelectorAll('use');
|
||||||
|
|
||||||
const defaultVolume = 0.5;
|
const defaultVolume = 0.5;
|
||||||
let mousedown = false;
|
let mousedown = false;
|
||||||
let _volume;
|
let _volume;
|
||||||
|
|
||||||
|
// Hold to speedup (2x) states
|
||||||
|
let speedUpTimeout;
|
||||||
|
let isSpeedingUp = false;
|
||||||
|
let restorePlaybackRate = 1;
|
||||||
|
let ignoreNextClick = false;
|
||||||
|
let wasPausedWhenStarted = false;
|
||||||
|
// Mobile tap-to-show-controls: true when this touch revealed the controls bar
|
||||||
|
let controlsJustShown = false;
|
||||||
|
const speedIndicator = player.querySelector('.v0ck_speed_indicator');
|
||||||
|
|
||||||
|
// (mouse position is now tracked via docMouseX/docMouseY in resetControlsTimer block)
|
||||||
|
|
||||||
function handleVolumeButton(vol) {
|
function handleVolumeButton(vol) {
|
||||||
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
|
[...volumeSymbols].forEach(s => s.classList.add('v0ck_hidden'));
|
||||||
switch (true) {
|
let targetId = 'v0ck_svg_volume_full';
|
||||||
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
|
if (vol === 0) {
|
||||||
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
|
targetId = 'v0ck_svg_volume_mute';
|
||||||
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
|
} else if (vol <= 0.5) {
|
||||||
|
targetId = 'v0ck_svg_volume_mid';
|
||||||
|
}
|
||||||
|
const activeSymbol = [...volumeSymbols].find(s => s.id === targetId);
|
||||||
|
if (activeSymbol) {
|
||||||
|
activeSymbol.classList.remove('v0ck_hidden');
|
||||||
}
|
}
|
||||||
localStorage.setItem("volume", vol);
|
localStorage.setItem("volume", vol);
|
||||||
}
|
}
|
||||||
@@ -262,14 +286,31 @@ class v0ck {
|
|||||||
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: on touchstart, record whether controls were hidden so the
|
||||||
|
// subsequent click can decide whether to show controls or toggle play.
|
||||||
|
player.addEventListener('touchstart', () => {
|
||||||
|
if (isMobile) {
|
||||||
|
controlsJustShown = !player.classList.contains('v0ck_hover');
|
||||||
|
}
|
||||||
|
}, { passive: true, capture: true });
|
||||||
|
|
||||||
player.addEventListener('click', e => {
|
player.addEventListener('click', e => {
|
||||||
|
if (ignoreNextClick) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
ignoreNextClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const path = e.path || (e.composedPath && e.composedPath());
|
const path = e.path || (e.composedPath && e.composedPath());
|
||||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||||
if (!isControls) {
|
if (!isControls) {
|
||||||
if (isMobile && !player.classList.contains('v0ck_hover')) {
|
if (isMobile && controlsJustShown) {
|
||||||
|
// First tap: controls were just revealed by this touch — don't toggle play
|
||||||
|
controlsJustShown = false;
|
||||||
player.classList.add('v0ck_hover');
|
player.classList.add('v0ck_hover');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
controlsJustShown = false;
|
||||||
togglePlay(e);
|
togglePlay(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -301,9 +342,18 @@ class v0ck {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loader events
|
// Loader events with debounce to prevent flicker on fast starts/seeking
|
||||||
const showLoader = () => loader.classList.remove('v0ck_hidden');
|
let loaderTimeout = null;
|
||||||
const hideLoader = () => loader.classList.add('v0ck_hidden');
|
const showLoader = () => {
|
||||||
|
if (loaderTimeout) clearTimeout(loaderTimeout);
|
||||||
|
loaderTimeout = setTimeout(() => {
|
||||||
|
loader.classList.remove('v0ck_hidden');
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
const hideLoader = () => {
|
||||||
|
if (loaderTimeout) clearTimeout(loaderTimeout);
|
||||||
|
loader.classList.add('v0ck_hidden');
|
||||||
|
};
|
||||||
video.addEventListener('waiting', showLoader);
|
video.addEventListener('waiting', showLoader);
|
||||||
video.addEventListener('stalled', showLoader);
|
video.addEventListener('stalled', showLoader);
|
||||||
video.addEventListener('canplay', hideLoader);
|
video.addEventListener('canplay', hideLoader);
|
||||||
@@ -325,11 +375,21 @@ class v0ck {
|
|||||||
hud.classList.remove('v0ck_hidden');
|
hud.classList.remove('v0ck_hidden');
|
||||||
hudBar.style.width = `${vol * 100}%`;
|
hudBar.style.width = `${vol * 100}%`;
|
||||||
|
|
||||||
// Update HUD icon based on volume
|
// Update HUD icon based on volume by toggling hidden class
|
||||||
let icon = 'volume_full';
|
const hudSymbols = hud.querySelectorAll('.v0ck_hud_icon');
|
||||||
if (vol === 0) icon = 'volume_mute';
|
hudSymbols.forEach(s => s.classList.add('v0ck_hidden'));
|
||||||
else if (vol <= 0.5) icon = 'volume_mid';
|
|
||||||
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
|
let targetClass = 'v0ck_hud_volume_full';
|
||||||
|
if (vol === 0) {
|
||||||
|
targetClass = 'v0ck_hud_volume_mute';
|
||||||
|
} else if (vol <= 0.5) {
|
||||||
|
targetClass = 'v0ck_hud_volume_mid';
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSymbol = [...hudSymbols].find(s => s.classList.contains(targetClass));
|
||||||
|
if (activeSymbol) {
|
||||||
|
activeSymbol.classList.remove('v0ck_hidden');
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(hudTimer);
|
clearTimeout(hudTimer);
|
||||||
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
||||||
@@ -348,7 +408,7 @@ class v0ck {
|
|||||||
startY = touch.clientY;
|
startY = touch.clientY;
|
||||||
startVol = video.volume;
|
startVol = video.volume;
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: false });
|
||||||
|
|
||||||
player.addEventListener('touchmove', e => {
|
player.addEventListener('touchmove', e => {
|
||||||
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
||||||
@@ -361,6 +421,8 @@ class v0ck {
|
|||||||
if (gestureType === 'none') {
|
if (gestureType === 'none') {
|
||||||
if (dy > dx && dy > 5) {
|
if (dy > dx && dy > 5) {
|
||||||
gestureType = 'volume';
|
gestureType = 'volume';
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
endSpeedUp();
|
||||||
} else if (dx > dy && dx > 5) {
|
} else if (dx > dy && dx > 5) {
|
||||||
gestureType = 'other'; // Probably seeking or horizontal swipe
|
gestureType = 'other'; // Probably seeking or horizontal swipe
|
||||||
return;
|
return;
|
||||||
@@ -370,6 +432,9 @@ class v0ck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gestureType === 'volume') {
|
if (gestureType === 'volume') {
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
endSpeedUp();
|
||||||
|
|
||||||
const deltaY = startY - touch.clientY; // swipe up is positive
|
const deltaY = startY - touch.clientY; // swipe up is positive
|
||||||
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
||||||
let newVol = startVol + (deltaY / sensitivity);
|
let newVol = startVol + (deltaY / sensitivity);
|
||||||
@@ -384,9 +449,86 @@ class v0ck {
|
|||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Desktop mouse volume gesture support (clicking and dragging vertically on the player)
|
||||||
|
let activeMouseGesture = false;
|
||||||
|
|
||||||
|
player.addEventListener('mousedown', e => {
|
||||||
|
if (isMobile) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const path = e.path || (e.composedPath && e.composedPath());
|
||||||
|
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||||
|
if (isControls) return;
|
||||||
|
|
||||||
|
gestureType = 'none';
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
startVol = video.volume;
|
||||||
|
activeMouseGesture = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
if (!activeMouseGesture || gestureType === 'other') return;
|
||||||
|
|
||||||
|
const dx = Math.abs(e.clientX - startX);
|
||||||
|
const dy = Math.abs(e.clientY - startY);
|
||||||
|
|
||||||
|
if (gestureType === 'none') {
|
||||||
|
if (dy > dx && dy > 5) {
|
||||||
|
gestureType = 'volume';
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
endSpeedUp();
|
||||||
|
} else if (dx > dy && dx > 5) {
|
||||||
|
gestureType = 'other';
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gestureType === 'volume') {
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
endSpeedUp();
|
||||||
|
ignoreNextClick = true;
|
||||||
|
|
||||||
|
const deltaY = startY - e.clientY; // swipe up is positive
|
||||||
|
const sensitivity = 200;
|
||||||
|
let newVol = startVol + (deltaY / sensitivity);
|
||||||
|
newVol = Math.max(0, Math.min(1, newVol));
|
||||||
|
|
||||||
|
video.volume = newVol;
|
||||||
|
volumeSlider.value = newVol;
|
||||||
|
_volume = newVol;
|
||||||
|
handleVolumeButton(newVol);
|
||||||
|
showHUD(newVol);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
if (activeMouseGesture) {
|
||||||
|
activeMouseGesture = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
ignoreNextClick = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
skipButtons.forEach(button => button.addEventListener('click', skip));
|
skipButtons.forEach(button => button.addEventListener('click', skip));
|
||||||
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
||||||
|
ranges.forEach(range => range.addEventListener('input', handleRangeUpdate));
|
||||||
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
||||||
|
|
||||||
|
// Prevent touch events on the volume slider from bubbling to the player container (avoiding gesture conflicts and page scrolls)
|
||||||
|
if (volumeSlider) {
|
||||||
|
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(evt => {
|
||||||
|
volumeSlider.addEventListener(evt, e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
progress.addEventListener('mousedown', scrub);
|
progress.addEventListener('mousedown', scrub);
|
||||||
progress.addEventListener('touchstart', scrub, { passive: false });
|
progress.addEventListener('touchstart', scrub, { passive: false });
|
||||||
progress.addEventListener('touchmove', scrub, { passive: false });
|
progress.addEventListener('touchmove', scrub, { passive: false });
|
||||||
@@ -394,11 +536,197 @@ class v0ck {
|
|||||||
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
||||||
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
||||||
|
|
||||||
|
let keyHoldTimer = null;
|
||||||
|
let isHoldingKey = false;
|
||||||
|
let heldKey = null;
|
||||||
|
let reverseInterval = null;
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (!document.body.contains(video)) {
|
||||||
|
cleanupKeyboardListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block interaction if content warning is visible
|
||||||
|
const cwModal = document.getElementById('content-warning-modal');
|
||||||
|
if (cwModal && cwModal.style.display !== 'none') return;
|
||||||
|
|
||||||
|
// Don't fire if user is typing in an input, textarea or editing content
|
||||||
|
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
let newVol = video.volume;
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
newVol = Math.min(1, newVol + 0.05);
|
||||||
|
} else {
|
||||||
|
newVol = Math.max(0, newVol - 0.05);
|
||||||
|
}
|
||||||
|
video.volume = newVol;
|
||||||
|
if (volumeSlider) {
|
||||||
|
volumeSlider.value = newVol;
|
||||||
|
}
|
||||||
|
_volume = newVol;
|
||||||
|
handleVolumeButton(newVol);
|
||||||
|
showHUD(newVol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "." || e.key === ",") {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!Number.isFinite(video.currentTime)) return;
|
||||||
|
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
||||||
|
const direction = e.key === "." ? 1 : -1;
|
||||||
|
|
||||||
|
if (heldKey && heldKey !== e.key) return;
|
||||||
|
const isCurrentHold = heldKey === e.key;
|
||||||
|
|
||||||
|
if (video.paused || isCurrentHold) {
|
||||||
|
if (!e.repeat) {
|
||||||
|
heldKey = e.key;
|
||||||
|
isHoldingKey = false;
|
||||||
|
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||||
|
keyHoldTimer = setTimeout(() => {
|
||||||
|
isHoldingKey = true;
|
||||||
|
if (heldKey === ".") {
|
||||||
|
video.play();
|
||||||
|
} else if (heldKey === ",") {
|
||||||
|
if (reverseInterval) clearInterval(reverseInterval);
|
||||||
|
reverseInterval = setInterval(() => {
|
||||||
|
if (video.currentTime > 0) {
|
||||||
|
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
||||||
|
} else {
|
||||||
|
clearInterval(reverseInterval);
|
||||||
|
reverseInterval = null;
|
||||||
|
}
|
||||||
|
}, 40);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
if (keyHoldTimer && !isHoldingKey) {
|
||||||
|
clearTimeout(keyHoldTimer);
|
||||||
|
keyHoldTimer = null;
|
||||||
|
isHoldingKey = true;
|
||||||
|
if (heldKey === ".") {
|
||||||
|
video.play();
|
||||||
|
} else if (heldKey === ",") {
|
||||||
|
if (reverseInterval) clearInterval(reverseInterval);
|
||||||
|
reverseInterval = setInterval(() => {
|
||||||
|
if (video.currentTime > 0) {
|
||||||
|
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
||||||
|
} else {
|
||||||
|
clearInterval(reverseInterval);
|
||||||
|
reverseInterval = null;
|
||||||
|
}
|
||||||
|
}, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * 5));
|
||||||
|
}
|
||||||
|
showControlsAndReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(e) {
|
||||||
|
if (!document.body.contains(video)) {
|
||||||
|
cleanupKeyboardListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "." || e.key === ",") {
|
||||||
|
if (heldKey === e.key) {
|
||||||
|
if (keyHoldTimer) {
|
||||||
|
clearTimeout(keyHoldTimer);
|
||||||
|
keyHoldTimer = null;
|
||||||
|
}
|
||||||
|
if (isHoldingKey) {
|
||||||
|
if (heldKey === ".") {
|
||||||
|
video.pause();
|
||||||
|
} else if (heldKey === ",") {
|
||||||
|
if (reverseInterval) {
|
||||||
|
clearInterval(reverseInterval);
|
||||||
|
reverseInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Number.isFinite(video.currentTime)) {
|
||||||
|
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
||||||
|
const direction = heldKey === "." ? 1 : -1;
|
||||||
|
const frameTime = 1 / 30;
|
||||||
|
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * frameTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heldKey = null;
|
||||||
|
isHoldingKey = false;
|
||||||
|
showControlsAndReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (!document.body.contains(video)) {
|
||||||
|
cleanupKeyboardListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||||
|
if (reverseInterval) clearInterval(reverseInterval);
|
||||||
|
keyHoldTimer = null;
|
||||||
|
reverseInterval = null;
|
||||||
|
if (isHoldingKey) {
|
||||||
|
if (heldKey === ".") {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heldKey = null;
|
||||||
|
isHoldingKey = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupKeyboardListeners() {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
window.removeEventListener('blur', handleBlur);
|
||||||
|
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||||
|
if (reverseInterval) clearInterval(reverseInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
|
window.addEventListener('blur', handleBlur);
|
||||||
|
|
||||||
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
||||||
handleVolumeButton(video.volume);
|
handleVolumeButton(video.volume);
|
||||||
|
|
||||||
|
const mediaObj = player.closest('.media-object');
|
||||||
|
let isBlurredDetail = false;
|
||||||
|
if (mediaObj && localStorage.getItem('blurDetail') !== 'false') {
|
||||||
|
const mode = mediaObj.getAttribute('data-mode');
|
||||||
|
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
||||||
|
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
||||||
|
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
||||||
|
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
||||||
|
|
||||||
|
let shouldBlurThis = false;
|
||||||
|
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
|
||||||
|
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
|
||||||
|
else if (mode === 'sfw') shouldBlurThis = blurSfw;
|
||||||
|
else if (mode === 'untagged') shouldBlurThis = blurUntagged;
|
||||||
|
|
||||||
|
if (shouldBlurThis && !mediaObj.classList.contains('revealed')) {
|
||||||
|
isBlurredDetail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade preload from 'metadata' → 'auto' for video elements so the browser
|
||||||
|
// starts buffering data immediately during player setup. With just 'metadata',
|
||||||
|
// play() triggers a brand-new range request and the user sees the loader spinner
|
||||||
|
// until enough data arrives. 'auto' closes that gap for both autoplay and manual play.
|
||||||
|
if (video.tagName === 'VIDEO' && video.preload !== 'auto') {
|
||||||
|
video.preload = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt autoplay and show overlay if blocked
|
// Attempt autoplay and show overlay if blocked
|
||||||
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
|
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
|
||||||
if (shouldAutoplay) {
|
if (shouldAutoplay) {
|
||||||
const playPromise = togglePlay();
|
const playPromise = togglePlay();
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
@@ -541,6 +869,162 @@ class v0ck {
|
|||||||
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controls auto-hide logic (auto hide controls after 2.5 seconds of inactivity)
|
||||||
|
let controlsTimer;
|
||||||
|
// True while the cursor is physically inside .v0ck_player_controls
|
||||||
|
let mouseIsOverControls = false;
|
||||||
|
|
||||||
|
// Track real mouse position at document level — completely independent of
|
||||||
|
// any element animation or synthetic events.
|
||||||
|
let docMouseX = -1;
|
||||||
|
let docMouseY = -1;
|
||||||
|
const onDocMouseMove = (e) => {
|
||||||
|
docMouseX = e.clientX;
|
||||||
|
docMouseY = e.clientY;
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
|
||||||
|
|
||||||
|
function resetControlsTimer() {
|
||||||
|
clearTimeout(controlsTimer);
|
||||||
|
// Never schedule auto-hide while the user is mousing over the controls bar
|
||||||
|
if (mouseIsOverControls) return;
|
||||||
|
const isFullscreen = player.classList.contains('v0ck_fullscreen');
|
||||||
|
if (!video.paused || isFullscreen) {
|
||||||
|
controlsTimer = setTimeout(() => {
|
||||||
|
player.classList.remove('v0ck_hover');
|
||||||
|
if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) {
|
||||||
|
settingsMenu.classList.add('v0ck_hidden');
|
||||||
|
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showControlsAndReset(e) {
|
||||||
|
// Ignore synthetic pointer events fired by the browser during CSS animations
|
||||||
|
// (element shifts under stationary cursor). We compare against docMouseX/Y
|
||||||
|
// which is only ever updated by genuine user mouse movement.
|
||||||
|
if (e && e.clientX !== undefined && e.clientY !== undefined) {
|
||||||
|
if (e.clientX === docMouseX && e.clientY === docMouseY &&
|
||||||
|
player.classList.contains('v0ck_hover')) {
|
||||||
|
// Coordinates unchanged and controls already visible — synthetic event, skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
docMouseX = e.clientX;
|
||||||
|
docMouseY = e.clientY;
|
||||||
|
}
|
||||||
|
player.classList.add('v0ck_hover');
|
||||||
|
resetControlsTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events that should show controls and reset/extend the auto-hide timer
|
||||||
|
const resetEvents = ['touchstart', 'touchmove', 'touchend', 'click', 'mousemove', 'mouseenter'];
|
||||||
|
resetEvents.forEach(evt => {
|
||||||
|
player.addEventListener(evt, showControlsAndReset, { capture: true, passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// While hovering the controls bar: freeze the auto-hide timer completely.
|
||||||
|
const controlsBar = player.querySelector('.v0ck_player_controls');
|
||||||
|
if (controlsBar) {
|
||||||
|
controlsBar.addEventListener('mouseenter', () => {
|
||||||
|
mouseIsOverControls = true;
|
||||||
|
clearTimeout(controlsTimer); // cancel any countdown already in progress
|
||||||
|
}, { passive: true });
|
||||||
|
controlsBar.addEventListener('mouseleave', (e) => {
|
||||||
|
mouseIsOverControls = false;
|
||||||
|
// Only restart the timer if the cursor re-entered the player area
|
||||||
|
// (not when it left the player entirely — the player mouseleave handles that)
|
||||||
|
const r = player.getBoundingClientRect();
|
||||||
|
const stillInPlayer = e.clientX >= r.left && e.clientX <= r.right &&
|
||||||
|
e.clientY >= r.top && e.clientY <= r.bottom;
|
||||||
|
if (stillInPlayer) resetControlsTimer();
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide when cursor leaves the player entirely.
|
||||||
|
// NO capture:true — that would incorrectly intercept mouseleave events from
|
||||||
|
// child elements (e.g. the progress bar animating away from the cursor).
|
||||||
|
// Without capture, this only fires when the mouse truly leaves .v0ck itself.
|
||||||
|
player.addEventListener('mouseleave', (e) => {
|
||||||
|
const r = player.getBoundingClientRect();
|
||||||
|
// Grace zone: the controls bar peeks ~3px below the player when hidden.
|
||||||
|
// If the cursor is still in that strip, don't hide.
|
||||||
|
if (e.clientX >= r.left && e.clientX <= r.right &&
|
||||||
|
e.clientY > r.bottom && e.clientY <= r.bottom + 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the cursor moved into the controls bar itself (or any child of it),
|
||||||
|
// keep the controls visible — the user is interacting with them.
|
||||||
|
const controls = player.querySelector('.v0ck_player_controls');
|
||||||
|
if (controls && e.relatedTarget && controls.contains(e.relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
docMouseX = -1;
|
||||||
|
docMouseY = -1;
|
||||||
|
player.classList.remove('v0ck_hover');
|
||||||
|
clearTimeout(controlsTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('play', resetControlsTimer);
|
||||||
|
video.addEventListener('playing', resetControlsTimer);
|
||||||
|
video.addEventListener('pause', () => clearTimeout(controlsTimer));
|
||||||
|
|
||||||
|
// Speedup 2x on Hold logic
|
||||||
|
function startSpeedUp(e) {
|
||||||
|
if (e.type === 'mousedown' && isMobile) return;
|
||||||
|
// Only left mouse click or touch triggers speedup
|
||||||
|
if (e.type === 'mousedown' && e.button !== 0) return;
|
||||||
|
|
||||||
|
// Don't speed up if clicking on controls or settings panel
|
||||||
|
const path = e.path || (e.composedPath && e.composedPath());
|
||||||
|
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||||
|
if (isControls) return;
|
||||||
|
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
speedUpTimeout = setTimeout(() => {
|
||||||
|
isSpeedingUp = true;
|
||||||
|
ignoreNextClick = true;
|
||||||
|
wasPausedWhenStarted = video.paused;
|
||||||
|
restorePlaybackRate = video.playbackRate;
|
||||||
|
video.playbackRate = 2.0;
|
||||||
|
|
||||||
|
if (wasPausedWhenStarted) {
|
||||||
|
video.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speedIndicator) {
|
||||||
|
speedIndicator.classList.remove('v0ck_hidden');
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSpeedUp(e) {
|
||||||
|
clearTimeout(speedUpTimeout);
|
||||||
|
if (isSpeedingUp) {
|
||||||
|
isSpeedingUp = false;
|
||||||
|
video.playbackRate = restorePlaybackRate;
|
||||||
|
if (wasPausedWhenStarted) {
|
||||||
|
video.pause();
|
||||||
|
wasPausedWhenStarted = false;
|
||||||
|
}
|
||||||
|
if (speedIndicator) {
|
||||||
|
speedIndicator.classList.add('v0ck_hidden');
|
||||||
|
}
|
||||||
|
// Brief timeout before allowing normal clicking again to bypass the immediate click event
|
||||||
|
setTimeout(() => {
|
||||||
|
ignoreNextClick = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.addEventListener('mousedown', startSpeedUp);
|
||||||
|
player.addEventListener('touchstart', startSpeedUp, { passive: true });
|
||||||
|
player.addEventListener('mouseup', endSpeedUp);
|
||||||
|
player.addEventListener('mouseleave', endSpeedUp);
|
||||||
|
player.addEventListener('touchend', endSpeedUp);
|
||||||
|
player.addEventListener('touchcancel', endSpeedUp);
|
||||||
|
|
||||||
this.toggleFullScreen = toggleFullScreen;
|
this.toggleFullScreen = toggleFullScreen;
|
||||||
this.enterFullScreen = enterFullScreen;
|
this.enterFullScreen = enterFullScreen;
|
||||||
|
|
||||||
|
|||||||
162
scripts/backfill_dimensions.mjs
Normal file
162
scripts/backfill_dimensions.mjs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Backfill script: populate width/height for existing image and video items.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/backfill_dimensions.mjs
|
||||||
|
* node scripts/backfill_dimensions.mjs --dry-run (print without writing)
|
||||||
|
* node scripts/backfill_dimensions.mjs --limit 500 (process first N items)
|
||||||
|
*
|
||||||
|
* Skips: audio, flash, PDF, YouTube items (width/height left as NULL).
|
||||||
|
* Skips: items where the physical file is missing.
|
||||||
|
* Safe to run multiple times — only processes rows where width IS NULL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn as _spawn } from 'child_process';
|
||||||
|
import db from '../src/inc/sql.mjs';
|
||||||
|
import cfg from '../src/inc/config.mjs';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const isDryRun = process.argv.includes('--dry-run');
|
||||||
|
const limitArg = process.argv.indexOf('--limit');
|
||||||
|
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
||||||
|
const BATCH = 50;
|
||||||
|
|
||||||
|
// ---- tiny spawn helper (no shell) ----
|
||||||
|
const spawn = (cmd, args, opts = {}) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const child = _spawn(cmd, args, opts);
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
child.stdout?.on('data', d => { out += d; });
|
||||||
|
child.stderr?.on('data', d => { err += d; });
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code !== 0 && !opts.ignoreExitCode) {
|
||||||
|
const e = new Error(`${cmd} exited ${code}`);
|
||||||
|
e.stderr = err;
|
||||||
|
return reject(e);
|
||||||
|
}
|
||||||
|
resolve(out);
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- probe helpers ----
|
||||||
|
async function getDimsImage(filePath) {
|
||||||
|
try {
|
||||||
|
// magick identify -format "%wx%h" reports raw pixel dimensions
|
||||||
|
const out = await spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { ignoreExitCode: true });
|
||||||
|
// Take the first line (multi-frame GIF etc. may have many)
|
||||||
|
const line = out.trim().split('\n')[0];
|
||||||
|
const match = line.match(/^(\d+)x(\d+)$/);
|
||||||
|
if (match) return { w: parseInt(match[1], 10), h: parseInt(match[2], 10) };
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDimsVideo(filePath) {
|
||||||
|
try {
|
||||||
|
const out = await spawn('ffprobe', [
|
||||||
|
'-v', 'error', '-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height',
|
||||||
|
'-of', 'csv=p=0', filePath
|
||||||
|
], { ignoreExitCode: true });
|
||||||
|
const parts = out.trim().split(',');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
const w = parseInt(parts[0], 10);
|
||||||
|
const h = parseInt(parts[1], 10);
|
||||||
|
if (w > 0 && h > 0) return { w, h };
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- main ----
|
||||||
|
async function run() {
|
||||||
|
console.log(`[BACKFILL] Starting dimension backfill${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (limit: ${limit})` : ''}`);
|
||||||
|
|
||||||
|
// Count pending
|
||||||
|
const [{ count }] = await db`
|
||||||
|
SELECT count(*) FROM items
|
||||||
|
WHERE width IS NULL
|
||||||
|
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
||||||
|
`;
|
||||||
|
const total = parseInt(count, 10);
|
||||||
|
const toProcess = limit ? Math.min(total, limit) : total;
|
||||||
|
console.log(`[BACKFILL] ${total} items need backfill${limit ? `, processing up to ${toProcess}` : ''}`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// NOTE: Always query at OFFSET 0 — updated rows leave the WHERE width IS NULL set,
|
||||||
|
// so the result set shrinks naturally each batch. Using a moving OFFSET would skip
|
||||||
|
// as many rows as were just updated (pagination-with-mutation bug).
|
||||||
|
while (processed < toProcess) {
|
||||||
|
const batchSize = limit ? Math.min(BATCH, toProcess - processed) : BATCH;
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, dest, mime FROM items
|
||||||
|
WHERE width IS NULL
|
||||||
|
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ${batchSize}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (rows.length === 0) break;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (processed >= toProcess) break;
|
||||||
|
processed++;
|
||||||
|
|
||||||
|
const filePath = path.join(cfg.paths.b, row.dest);
|
||||||
|
|
||||||
|
// Check file exists (may be deleted/purged)
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch {
|
||||||
|
console.log(`[BACKFILL] SKIP #${row.id} — file missing: ${row.dest}`);
|
||||||
|
skipped++;
|
||||||
|
// Mark with 0 so it doesn't re-appear in future runs
|
||||||
|
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dims = null;
|
||||||
|
try {
|
||||||
|
if (row.mime.startsWith('image/')) {
|
||||||
|
dims = await getDimsImage(filePath);
|
||||||
|
} else if (row.mime.startsWith('video/')) {
|
||||||
|
dims = await getDimsVideo(filePath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[BACKFILL] PROBE ERROR #${row.id}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dims) {
|
||||||
|
console.log(`[BACKFILL] SKIP #${row.id} — no dimensions found (${row.mime})`);
|
||||||
|
skipped++;
|
||||||
|
// Mark with 0 so it doesn't re-appear in future runs and cause infinite loops
|
||||||
|
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] ${isDryRun ? '[DRY]' : 'UPDATE'} #${row.id} → ${dims.w}×${dims.h}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await db`UPDATE items SET width = ${dims.w}, height = ${dims.h} WHERE id = ${row.id}`;
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Progress: ${processed} / ${toProcess} (updated=${updated}, skipped=${skipped}, failed=${failed})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Done. updated=${updated}, skipped=${skipped}, failed=${failed}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('[BACKFILL] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
248
scripts/backfill_uuid_filenames.mjs
Normal file
248
scripts/backfill_uuid_filenames.mjs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import db from '../src/inc/sql.mjs';
|
||||||
|
import cfg from '../src/inc/config.mjs';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const isDryRun = process.argv.includes('--dry-run');
|
||||||
|
const limitArg = process.argv.indexOf('--limit');
|
||||||
|
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(`[BACKFILL] Starting long UUID migration & backfill script${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (Limit: ${limit})` : ''}`);
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
console.log('[MIGRATION] Applying database schema migrations...');
|
||||||
|
// Drop views first because they depend on columns we want to alter
|
||||||
|
await db`DROP VIEW IF EXISTS public.items_sfw CASCADE`;
|
||||||
|
await db`DROP VIEW IF EXISTS public.items_li CASCADE`;
|
||||||
|
|
||||||
|
// Alter dest columns in items and comment_files
|
||||||
|
await db`ALTER TABLE public.items ALTER COLUMN dest TYPE character varying(255)`;
|
||||||
|
await db`ALTER TABLE public.comment_files ALTER COLUMN dest TYPE character varying(255)`;
|
||||||
|
|
||||||
|
// Recreate public.items_li view
|
||||||
|
await db`
|
||||||
|
CREATE OR REPLACE VIEW public.items_li AS
|
||||||
|
SELECT items.id,
|
||||||
|
items.src,
|
||||||
|
items.dest,
|
||||||
|
items.mime,
|
||||||
|
items.size,
|
||||||
|
items.checksum,
|
||||||
|
items.username,
|
||||||
|
items.userchannel,
|
||||||
|
items.usernetwork,
|
||||||
|
items.stamp
|
||||||
|
FROM ((public.items
|
||||||
|
JOIN public.tags_assign ta1 ON (((ta1.tag_id = 1) AND (ta1.item_id = items.id))))
|
||||||
|
JOIN public.tags_assign ta2 ON (((NOT (ta2.tag_id IN ( SELECT tags_nsfp.id
|
||||||
|
FROM public.tags_nsfp))) AND (ta2.item_id = items.id))))
|
||||||
|
WHERE items.active
|
||||||
|
GROUP BY items.id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Recreate public.items_sfw view
|
||||||
|
await db`
|
||||||
|
CREATE OR REPLACE VIEW public.items_sfw AS
|
||||||
|
SELECT ( SELECT
|
||||||
|
CASE
|
||||||
|
WHEN (tags_assign.tag_id > 0) THEN tags_assign.tag_id
|
||||||
|
ELSE 0
|
||||||
|
END AS "case"
|
||||||
|
FROM public.tags_assign
|
||||||
|
WHERE ((tags_assign.tag_id = ANY (ARRAY[1, 2])) AND (tags_assign.item_id = items.id))) AS sfw,
|
||||||
|
( SELECT
|
||||||
|
CASE
|
||||||
|
WHEN (tags_assign.tag_id > 0) THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS "case"
|
||||||
|
FROM public.tags_assign
|
||||||
|
WHERE ((tags_assign.tag_id IN ( SELECT tags_nsfp.id
|
||||||
|
FROM public.tags_nsfp)) AND (tags_assign.item_id = items.id))
|
||||||
|
LIMIT 1) AS nsfp,
|
||||||
|
id,
|
||||||
|
src,
|
||||||
|
dest,
|
||||||
|
mime,
|
||||||
|
size,
|
||||||
|
checksum,
|
||||||
|
username,
|
||||||
|
userchannel,
|
||||||
|
usernetwork,
|
||||||
|
stamp,
|
||||||
|
active
|
||||||
|
FROM public.items;
|
||||||
|
`;
|
||||||
|
console.log('[MIGRATION] Schema changes applied and views successfully recreated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to keep track of all renamed files: old_filename -> new_filename
|
||||||
|
const renameMap = new Map();
|
||||||
|
|
||||||
|
// 1. Process items
|
||||||
|
console.log('[BACKFILL] Fetching items to process...');
|
||||||
|
const items = await db`SELECT id, dest, mime FROM items`;
|
||||||
|
console.log(`[BACKFILL] Found ${items.length} items in DB.`);
|
||||||
|
|
||||||
|
let itemsProcessed = 0;
|
||||||
|
let itemsRenamedCount = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (limit && itemsProcessed >= limit) break;
|
||||||
|
itemsProcessed++;
|
||||||
|
|
||||||
|
// YouTube embeds store dest as "yt:VIDEO_ID" — not a real file, skip entirely
|
||||||
|
if (item.mime === 'video/youtube') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(item.dest);
|
||||||
|
const base = path.basename(item.dest, ext);
|
||||||
|
|
||||||
|
// If name is already 48 characters long, skip
|
||||||
|
if (base.length === 48) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUuid = crypto.randomBytes(24).toString('hex');
|
||||||
|
const newDest = `${newUuid}${ext}`;
|
||||||
|
renameMap.set(item.dest, newDest);
|
||||||
|
|
||||||
|
// Physical files could be in active, pending or deleted folders
|
||||||
|
const pathsToCheck = [
|
||||||
|
path.join(cfg.paths.b, item.dest),
|
||||||
|
path.join(cfg.paths.pending, 'b', item.dest),
|
||||||
|
path.join(cfg.paths.deleted, 'b', item.dest)
|
||||||
|
];
|
||||||
|
|
||||||
|
let foundPath = null;
|
||||||
|
for (const p of pathsToCheck) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(p);
|
||||||
|
if (!stat.isSymbolicLink()) {
|
||||||
|
foundPath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundPath) {
|
||||||
|
const newPath = path.join(path.dirname(foundPath), newDest);
|
||||||
|
console.log(`[BACKFILL] Renaming item file: ${foundPath} -> ${newPath}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await fs.rename(foundPath, newPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[BACKFILL] [WARNING] Item physical file not found/is symlink for: ${item.dest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
await db`UPDATE items SET dest = ${newDest} WHERE id = ${item.id}`;
|
||||||
|
}
|
||||||
|
itemsRenamedCount++;
|
||||||
|
|
||||||
|
if (itemsRenamedCount % BATCH_SIZE === 0) {
|
||||||
|
console.log(`[BACKFILL] Processed ${itemsRenamedCount} item renames...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process comment_files
|
||||||
|
console.log('[BACKFILL] Fetching comment files to process...');
|
||||||
|
const commentFiles = await db`SELECT id, dest FROM comment_files`;
|
||||||
|
console.log(`[BACKFILL] Found ${commentFiles.length} comment files in DB.`);
|
||||||
|
|
||||||
|
let commentsProcessed = 0;
|
||||||
|
let commentsRenamedCount = 0;
|
||||||
|
|
||||||
|
for (const cf of commentFiles) {
|
||||||
|
if (limit && commentsProcessed >= limit) break;
|
||||||
|
commentsProcessed++;
|
||||||
|
|
||||||
|
const ext = path.extname(cf.dest);
|
||||||
|
const base = path.basename(cf.dest, ext);
|
||||||
|
|
||||||
|
if (base.length === 48) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUuid = crypto.randomBytes(24).toString('hex');
|
||||||
|
const newDest = `${newUuid}${ext}`;
|
||||||
|
renameMap.set(cf.dest, newDest);
|
||||||
|
|
||||||
|
const oldPath = path.join(cfg.paths.c, cf.dest);
|
||||||
|
const newPath = path.join(cfg.paths.c, newDest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(oldPath);
|
||||||
|
if (!stat.isSymbolicLink()) {
|
||||||
|
console.log(`[BACKFILL] Renaming comment file: ${oldPath} -> ${newPath}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Rename corresponding thumbnail in t/ (cf_${old_uuid}.webp -> cf_${new_uuid}.webp)
|
||||||
|
const oldThumbPath = path.join(cfg.paths.t, `cf_${base}.webp`);
|
||||||
|
const newThumbPath = path.join(cfg.paths.t, `cf_${newUuid}.webp`);
|
||||||
|
try {
|
||||||
|
await fs.access(oldThumbPath);
|
||||||
|
console.log(`[BACKFILL] Renaming comment thumbnail: ${oldThumbPath} -> ${newThumbPath}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await fs.rename(oldThumbPath, newThumbPath);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
await db`UPDATE comment_files SET dest = ${newDest} WHERE id = ${cf.id}`;
|
||||||
|
}
|
||||||
|
commentsRenamedCount++;
|
||||||
|
|
||||||
|
if (commentsRenamedCount % BATCH_SIZE === 0) {
|
||||||
|
console.log(`[BACKFILL] Processed ${commentsRenamedCount} comment file renames...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update Symlinks in c/
|
||||||
|
console.log('[BACKFILL] Scanning /c/ directory for symlinks to update...');
|
||||||
|
try {
|
||||||
|
const filesInC = await fs.readdir(cfg.paths.c);
|
||||||
|
for (const f of filesInC) {
|
||||||
|
const filePath = path.join(cfg.paths.c, f);
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(filePath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
const target = await fs.readlink(filePath);
|
||||||
|
const targetBasename = path.basename(target);
|
||||||
|
if (renameMap.has(targetBasename)) {
|
||||||
|
const newTargetBasename = renameMap.get(targetBasename);
|
||||||
|
// Re-construct the symlink target path, pointing to public/b or public/c
|
||||||
|
const resolvedTargetAbs = path.resolve(path.dirname(filePath), target);
|
||||||
|
const resolvedTargetNewAbs = path.join(path.dirname(resolvedTargetAbs), newTargetBasename);
|
||||||
|
const relativeNewTarget = path.relative(path.dirname(filePath), resolvedTargetNewAbs);
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Updating symlink: ${filePath} -> ${relativeNewTarget}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
await fs.symlink(relativeNewTarget, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BACKFILL] [ERROR] Failed processing file/symlink ${filePath}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BACKFILL] Error reading /c/ directory:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Completed. Items renamed: ${itemsRenamedCount}. Comment files renamed: ${commentsRenamedCount}.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('[BACKFILL] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import db from "../src/inc/sql.mjs";
|
import db from "../src/inc/sql.mjs";
|
||||||
import lib from "../src/inc/lib.mjs";
|
import lib from "../src/inc/lib.mjs";
|
||||||
import cfg from "../src/inc/config.mjs";
|
import cfg from "../src/inc/config.mjs";
|
||||||
import { getDefaultLayout } from "../src/inc/settings.mjs";
|
|
||||||
|
|
||||||
const [username, password] = process.argv.slice(2);
|
const [username, password] = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ async function createAdmin() {
|
|||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||||
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${getDefaultLayout() === 'modern'}, false, false)
|
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${(cfg.websrv.default_layout ?? 'modern') !== 'legacy'}, false, false)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`--- Admin User ${username} Created Successfully ---`);
|
console.log(`--- Admin User ${username} Created Successfully ---`);
|
||||||
|
|||||||
83
scripts/fix_youtube_dest.mjs
Normal file
83
scripts/fix_youtube_dest.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* fix_youtube_dest.mjs
|
||||||
|
*
|
||||||
|
* One-time fix: the backfill_uuid_filenames.mjs script failed to exclude
|
||||||
|
* YouTube items, which store dest as "yt:VIDEO_ID" rather than a real file.
|
||||||
|
* As a result, those dest values were replaced with random UUIDs, breaking
|
||||||
|
* embed URLs like youtube.com/embed/<uuid>.
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. Finds all items with mime = 'video/youtube'
|
||||||
|
* 2. For each, re-extracts the video ID from the src column
|
||||||
|
* (src holds the original YouTube watch URL: https://www.youtube.com/watch?v=VIDEO_ID)
|
||||||
|
* 3. Restores dest to "yt:VIDEO_ID"
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/fix_youtube_dest.mjs # live run
|
||||||
|
* node scripts/fix_youtube_dest.mjs --dry-run # preview only, no DB changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from '../src/inc/sql.mjs';
|
||||||
|
|
||||||
|
const isDryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(`[FIX-YT-DEST] Starting${isDryRun ? ' (DRY RUN)' : ''}...`);
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, dest, src
|
||||||
|
FROM items
|
||||||
|
WHERE mime = 'video/youtube'
|
||||||
|
ORDER BY id
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[FIX-YT-DEST] Found ${rows.length} YouTube item(s) to check.`);
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Already correct format
|
||||||
|
if (row.dest && row.dest.startsWith('yt:')) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract video ID from src URL
|
||||||
|
const match = row.src && row.src.match(ytRegex);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`[FIX-YT-DEST] [WARN] Item ${row.id}: cannot extract video ID from src="${row.src}", dest="${row.dest}" — skipping`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = match[1];
|
||||||
|
const newDest = `yt:${videoId}`;
|
||||||
|
|
||||||
|
console.log(`[FIX-YT-DEST] Item ${row.id}: "${row.dest}" → "${newDest}" (src: ${row.src})`);
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
await db`UPDATE items SET dest = ${newDest} WHERE id = ${row.id}`;
|
||||||
|
}
|
||||||
|
fixed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n[FIX-YT-DEST] Done.`);
|
||||||
|
console.log(` Fixed: ${fixed}`);
|
||||||
|
console.log(` Skipped: ${skipped} (already correct)`);
|
||||||
|
console.log(` Failed: ${failed} (no video ID recoverable from src)`);
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
console.log('\n[FIX-YT-DEST] DRY RUN — no changes were written to the database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('[FIX-YT-DEST] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
* node regen.mjs --all - Regenerate ALL items
|
* node regen.mjs --all - Regenerate ALL items
|
||||||
* node regen.mjs --audio - Regenerate all audio items
|
* node regen.mjs --audio - Regenerate all audio items
|
||||||
* node regen.mjs --pdf - Regenerate all PDF items
|
* node regen.mjs --pdf - Regenerate all PDF items
|
||||||
|
* node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items
|
||||||
|
*
|
||||||
|
* Flash (SWF) items are always excluded — their thumbnails are set via the
|
||||||
|
* Ruffle snapshot mechanism and must never be touched by this script.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import db from "../src/inc/sql.mjs";
|
import db from "../src/inc/sql.mjs";
|
||||||
@@ -26,14 +30,40 @@ if (args.length === 0) {
|
|||||||
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
||||||
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
|
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
|
||||||
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
|
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
|
||||||
|
console.log(' node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flash mime types — never regenerate these
|
||||||
|
const FLASH_MIMES = [
|
||||||
|
'application/x-shockwave-flash',
|
||||||
|
'application/vnd.adobe.flash.movie',
|
||||||
|
];
|
||||||
|
const isFlash = (mime) => FLASH_MIMES.includes(mime?.toLowerCase());
|
||||||
|
|
||||||
const THUMB_SIZE = 512;
|
const THUMB_SIZE = 512;
|
||||||
|
const blurOnly = args.includes('--blur');
|
||||||
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
|
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
|
||||||
|
|
||||||
const regen = async (item) => {
|
const regen = async (item) => {
|
||||||
const { id, dest, mime, src } = item;
|
const { id, dest, mime, src } = item;
|
||||||
|
|
||||||
|
if (isFlash(mime)) {
|
||||||
|
console.log(`[${id}] Skipped (Flash/SWF — thumbnail managed by Ruffle snapshot)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blurOnly) {
|
||||||
|
console.log(`[${id}] Regenerating blurred thumbnail only: ${dest}`);
|
||||||
|
try {
|
||||||
|
await queue.genBlurredThumbnail(id, false);
|
||||||
|
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -49,23 +79,23 @@ const regen = async (item) => {
|
|||||||
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate blurred thumbnail if item has NSFW tag
|
// Regenerate blurred thumbnail unconditionally
|
||||||
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
|
|
||||||
if (nsfw.length > 0) {
|
|
||||||
await queue.genBlurredThumbnail(id, false);
|
await queue.genBlurredThumbnail(id, false);
|
||||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared NOT IN clause for Flash exclusion
|
||||||
|
const flashExclude = db`mime NOT IN (${db(FLASH_MIMES)})`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let items;
|
let items;
|
||||||
|
|
||||||
if (args.includes('--all')) {
|
if (args.includes('--all')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND ${flashExclude} ORDER BY id`;
|
||||||
console.log(`Regenerating ALL ${items.length} items...\n`);
|
console.log(`Regenerating ALL ${items.length} non-Flash items...\n`);
|
||||||
} else if (args.includes('--audio')) {
|
} else if (args.includes('--audio')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
|
||||||
console.log(`Regenerating ${items.length} audio items...\n`);
|
console.log(`Regenerating ${items.length} audio items...\n`);
|
||||||
@@ -75,6 +105,14 @@ try {
|
|||||||
} else if (args.includes('--youtube')) {
|
} else if (args.includes('--youtube')) {
|
||||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
|
||||||
console.log(`Regenerating ${items.length} YouTube items...\n`);
|
console.log(`Regenerating ${items.length} YouTube items...\n`);
|
||||||
|
} else if (blurOnly) {
|
||||||
|
items = await db`
|
||||||
|
SELECT id, dest, mime, src
|
||||||
|
FROM items
|
||||||
|
WHERE active = true AND is_deleted = false AND ${flashExclude}
|
||||||
|
ORDER BY id
|
||||||
|
`;
|
||||||
|
console.log(`Regenerating ONLY blurred thumbnails for all ${items.length} non-Flash items...\n`);
|
||||||
} else {
|
} else {
|
||||||
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
|
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@@ -97,3 +135,4 @@ try {
|
|||||||
console.error('Fatal error:', err);
|
console.error('Fatal error:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const sendJson = (res, data, code = 200) => {
|
|||||||
|
|
||||||
// Generate UUID using the same method as video uploads
|
// Generate UUID using the same method as video uploads
|
||||||
const genuuid = async () => {
|
const genuuid = async () => {
|
||||||
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||||
|
return raw.substring(0, 48);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAvatarUpload = async (req, res) => {
|
export const handleAvatarUpload = async (req, res) => {
|
||||||
@@ -161,6 +162,14 @@ export const handleAvatarUpload = async (req, res) => {
|
|||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
avatar_file: finalFilename,
|
||||||
|
avatar: null
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
||||||
return sendJson(res, {
|
return sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -224,6 +233,13 @@ export const handleAvatarDelete = async (req, res) => {
|
|||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
avatar_file: null
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
console.log('[AVATAR HANDLER] Delete complete');
|
console.log('[AVATAR HANDLER] Delete complete');
|
||||||
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
289
src/banner_handler.mjs
Normal file
289
src/banner_handler.mjs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import { execFile as _execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
|
// Helper for JSON response
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate UUID using the same method as video uploads
|
||||||
|
const genuuid = async () => {
|
||||||
|
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||||
|
return raw.substring(0, 48);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBannerUpload = async (req, res) => {
|
||||||
|
console.log('[BANNER HANDLER] Upload started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
console.log('[BANNER HANDLER] Unauthorized - No valid session found');
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
console.log('[BANNER HANDLER] Authorized:', req.session.user);
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
console.log('[BANNER HANDLER] No boundary');
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Collecting body...');
|
||||||
|
const body = await collectBody(req);
|
||||||
|
console.log('[BANNER HANDLER] Body collected, size:', body.length);
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Parsing multipart...');
|
||||||
|
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||||
|
const file = parts.file;
|
||||||
|
console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts));
|
||||||
|
|
||||||
|
if (!file || !file.data) {
|
||||||
|
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB max)
|
||||||
|
const maxSize = 5 * 1024 * 1024;
|
||||||
|
if (file.data.length > maxSize) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed MIME types
|
||||||
|
const allowedMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate MIME type from content-type header
|
||||||
|
let mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
|
||||||
|
console.log('[BANNER HANDLER] File MIME from header:', mime);
|
||||||
|
if (!allowedMimes.includes(mime)) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to tmp and verify with file magic
|
||||||
|
console.log('[BANNER HANDLER] Generating UUID...');
|
||||||
|
const uuid = await genuuid();
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`);
|
||||||
|
const finalFilename = `banner_${uuid}.webp`;
|
||||||
|
const finalPath = path.join(cfg.paths.a, finalFilename);
|
||||||
|
|
||||||
|
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||||
|
await fs.mkdir(cfg.paths.a, { recursive: true });
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Writing tmp file:', tmpPath);
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify MIME with file magic
|
||||||
|
console.log('[BANNER HANDLER] Checking MIME with file magic...');
|
||||||
|
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||||
|
console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim());
|
||||||
|
const allowedActualMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to webp using ImageMagick — landscape banner crop (1200x400)
|
||||||
|
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
||||||
|
console.log('[BANNER HANDLER] Running magick...');
|
||||||
|
try {
|
||||||
|
await execFile('magick', [`${tmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalPath]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500);
|
||||||
|
}
|
||||||
|
console.log('[BANNER HANDLER] Magick done, output:', finalPath);
|
||||||
|
|
||||||
|
const file_orig = parts.file_orig;
|
||||||
|
if (file_orig && file_orig.data) {
|
||||||
|
console.log('[BANNER HANDLER] Processing original file...');
|
||||||
|
const origTmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_orig_tmp`);
|
||||||
|
const finalOrigPath = path.join(cfg.paths.a, `banner_${uuid}_orig.webp`);
|
||||||
|
await fs.writeFile(origTmpPath, file_orig.data);
|
||||||
|
try {
|
||||||
|
await execFile('magick', [`${origTmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalOrigPath]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BANNER HANDLER] Magick error on original:', err.message);
|
||||||
|
}
|
||||||
|
await fs.unlink(origTmpPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current banner_file to delete old one (after magick succeeds)
|
||||||
|
let currentBanner = null;
|
||||||
|
try {
|
||||||
|
currentBanner = (await db`
|
||||||
|
select banner_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.banner_file;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tmp file
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
|
||||||
|
// Delete old banner file if exists
|
||||||
|
if (currentBanner) {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
||||||
|
await fs.unlink(oldOrigPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
console.log('[BANNER HANDLER] Updating database...');
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set banner_file = ${finalFilename},
|
||||||
|
banner_position = ${parts.banner_position || 'center'},
|
||||||
|
banner_size = ${parts.banner_size || 'cover'}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: +req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
banner_file: finalFilename,
|
||||||
|
banner_position: parts.banner_position || 'center',
|
||||||
|
banner_size: parts.banner_size || 'cover'
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[BANNER HANDLER] DB update failed:', dbErr.message);
|
||||||
|
await fs.unlink(finalPath).catch(() => {});
|
||||||
|
return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Upload complete:', finalFilename);
|
||||||
|
return sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
banner_file: finalFilename,
|
||||||
|
msg: 'Banner uploaded successfully'
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'BODY_TOO_LARGE') {
|
||||||
|
return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413);
|
||||||
|
}
|
||||||
|
console.error('[BANNER HANDLER ERROR]', err.message, err.stack);
|
||||||
|
try {
|
||||||
|
return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBannerDelete = async (req, res) => {
|
||||||
|
console.log('[BANNER HANDLER] Delete started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentBanner = (await db`
|
||||||
|
select banner_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.banner_file;
|
||||||
|
|
||||||
|
if (currentBanner) {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
||||||
|
await fs.unlink(oldOrigPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set banner_file = null
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: +req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
banner_file: null
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Delete complete');
|
||||||
|
return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BANNER DELETE ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,7 +12,6 @@ const sendJson = (res, data, code = 200) => {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
// One-time migration: ensure comment_files table exists
|
|
||||||
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||||
@@ -25,8 +24,12 @@ db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
|||||||
original_filename TEXT,
|
original_filename TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`.catch(() => { });
|
)`.catch(() => { });
|
||||||
|
db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { });
|
||||||
|
db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { });
|
||||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
||||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
||||||
|
db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { });
|
||||||
|
db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse multipart form data supporting multiple files with the same field name.
|
* Parse multipart form data supporting multiple files with the same field name.
|
||||||
@@ -92,12 +95,19 @@ const parseMultipartFiles = (buffer, boundary) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
|
* Build the allowed MIME list for comment uploads.
|
||||||
* Filters from cfg.mimes, excluding PDF, SWF, etc.
|
* Respects cfg.websrv.fileupload_comments_mimes (e.g. ["image", "video", "audio"]) to
|
||||||
|
* allow a different set of categories than the global allowedMimes used for page uploads.
|
||||||
|
* Falls back to image/video/audio if the setting is absent.
|
||||||
*/
|
*/
|
||||||
const getAllowedCommentMimes = () => {
|
const getAllowedCommentMimes = () => {
|
||||||
|
const allowedCats = Array.isArray(cfg.websrv.fileupload_comments_mimes)
|
||||||
|
? cfg.websrv.fileupload_comments_mimes.map(c => c.toLowerCase())
|
||||||
|
: ['image', 'video', 'audio'];
|
||||||
return Object.keys(cfg.mimes).filter(mime =>
|
return Object.keys(cfg.mimes).filter(mime =>
|
||||||
mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
|
allowedCats.some(cat =>
|
||||||
|
cat.includes('/') ? mime === cat : mime.startsWith(`${cat}/`)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +184,7 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const allowedMimes = getAllowedCommentMimes();
|
const allowedMimes = getAllowedCommentMimes();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@@ -374,14 +385,10 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
phash = await queue.generatePHash(tmpPath);
|
phash = await queue.generatePHash(tmpPath);
|
||||||
if (phash && !linkedToExisting) {
|
if (phash && !linkedToExisting) {
|
||||||
// Check comment_files for visual duplicate
|
// Check comment_files for visual duplicate using fast SQL query
|
||||||
const cfItems = await db`
|
const commentMatch = await queue.checkcommentrepostphash(phash);
|
||||||
SELECT id, phash, dest FROM comment_files
|
if (commentMatch) {
|
||||||
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
const existingAbsPath = path.join(cfg.paths.c, commentMatch.dest);
|
||||||
`;
|
|
||||||
for (const cf of cfItems) {
|
|
||||||
if (isPhashMatch(phash, cf.phash)) {
|
|
||||||
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
|
|
||||||
try {
|
try {
|
||||||
const realTarget = await fs.realpath(existingAbsPath);
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
const destPath = path.join(cfg.paths.c, filename);
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
@@ -392,11 +399,9 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check items table for visual duplicate
|
// Also check items table for visual duplicate using fast SQL query
|
||||||
if (!linkedToExisting) {
|
if (!linkedToExisting) {
|
||||||
const phashMatch = await queue.checkrepostphash(phash);
|
const phashMatch = await queue.checkrepostphash(phash);
|
||||||
if (phashMatch) {
|
if (phashMatch) {
|
||||||
@@ -478,6 +483,74 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v2/comments/upload/:id
|
||||||
|
* Called by the client when the user removes a staged (not-yet-posted) attachment
|
||||||
|
* from the compose area. Only deletes if the row still has comment_id = NULL
|
||||||
|
* (i.e. it was never linked to a real comment) and belongs to the requesting user.
|
||||||
|
*/
|
||||||
|
export const handleCommentUploadCancel = async (req, res, fileId) => {
|
||||||
|
// Manual session lookup (same pattern as handleCommentUpload)
|
||||||
|
if (req.cookies?.session) {
|
||||||
|
try {
|
||||||
|
const user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
if (user.length > 0) {
|
||||||
|
req.session = user[0];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Session lookup failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parseInt(fileId, 10);
|
||||||
|
if (!id || isNaN(id)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only allow deletion of own unlinked files
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, dest FROM comment_files
|
||||||
|
WHERE id = ${id}
|
||||||
|
AND user_id = ${req.session.id}
|
||||||
|
AND comment_id IS NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
// Either doesn't exist, belongs to someone else, or already linked — silently OK
|
||||||
|
return sendJson(res, { success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dest } = rows[0];
|
||||||
|
await db`DELETE FROM comment_files WHERE id = ${id}`;
|
||||||
|
|
||||||
|
// Delete file and thumbnail from disk
|
||||||
|
const filePath = path.join(cfg.paths.c, dest);
|
||||||
|
const uuid = dest.split('.')[0];
|
||||||
|
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
|
||||||
|
await fs.unlink(filePath).catch(() => {});
|
||||||
|
await fs.unlink(thumbPath).catch(() => {});
|
||||||
|
|
||||||
|
console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`);
|
||||||
|
return sendJson(res, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[COMMENT_UPLOAD] Cancel error:', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Delete failed' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate thumbnail for a comment file.
|
* Generate thumbnail for a comment file.
|
||||||
* Outputs to /t/cf_<uuid>.webp
|
* Outputs to /t/cf_<uuid>.webp
|
||||||
@@ -501,7 +574,33 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
|||||||
const ffThumbSize = Math.max(size, 512);
|
const ffThumbSize = Math.max(size, 512);
|
||||||
const seeks = ['20%', '40%', '60%', '80%'];
|
const seeks = ['20%', '40%', '60%', '80%'];
|
||||||
for (const seek of seeks) {
|
for (const seek of seeks) {
|
||||||
|
try {
|
||||||
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[COMMENT_UPLOAD] ffmpegthumbnailer failed at ${seek} for ${filename}, trying ffmpeg fallback: ${err.message}`);
|
||||||
|
let seekSeconds = 0;
|
||||||
|
try {
|
||||||
|
const durationStr = (await queue.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', realSource])).stdout.trim();
|
||||||
|
const duration = parseFloat(durationStr);
|
||||||
|
if (!isNaN(duration) && duration > 0) {
|
||||||
|
const pct = parseFloat(seek) / 100;
|
||||||
|
seekSeconds = duration * pct;
|
||||||
|
}
|
||||||
|
} catch (probeErr) {
|
||||||
|
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
|
||||||
|
}
|
||||||
|
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
|
||||||
|
await queue.spawn('ffmpeg', [
|
||||||
|
'-y',
|
||||||
|
'-ss', String(seekSeconds),
|
||||||
|
'-color_trc', '1',
|
||||||
|
'-i', realSource,
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-update', '1',
|
||||||
|
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
|
||||||
|
tmpFile
|
||||||
|
]);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||||
@@ -541,41 +640,4 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
|||||||
await fs.unlink(tmpFile).catch(() => { });
|
await fs.unlink(tmpFile).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PHash matching helper (same logic as queue.checkrepostphash)
|
|
||||||
*/
|
|
||||||
function isPhashMatch(newHash, dbHash) {
|
|
||||||
if (!newHash || !dbHash) return false;
|
|
||||||
const newHashes = newHash.split('_');
|
|
||||||
const dbHashes = dbHash.split('_');
|
|
||||||
const THRESHOLD = 15;
|
|
||||||
|
|
||||||
const getHammingDistance = (h1, h2) => {
|
|
||||||
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
|
||||||
let distance = 0;
|
|
||||||
for (let i = 0; i < h1.length; i += 2) {
|
|
||||||
const v1 = parseInt(h1.substr(i, 2), 16);
|
|
||||||
const v2 = parseInt(h2.substr(i, 2), 16);
|
|
||||||
let xor = v1 ^ v2;
|
|
||||||
while (xor) {
|
|
||||||
distance += xor & 1;
|
|
||||||
xor >>= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return distance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
|
||||||
let matches = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < framesToCompare; i++) {
|
|
||||||
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
|
||||||
if (dist <= THRESHOLD) matches++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (framesToCompare >= 3 && matches >= 2) return true;
|
|
||||||
if (framesToCompare === 1 && matches === 1) return true;
|
|
||||||
if (framesToCompare === 2 && matches >= 2) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|||||||
262
src/dm_attachment_handler.mjs
Normal file
262
src/dm_attachment_handler.mjs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* dm_attachment_handler.mjs — Server-side handler for encrypted DM attachments.
|
||||||
|
*
|
||||||
|
* All uploaded content is opaque AES-GCM ciphertext — the server cannot read it.
|
||||||
|
* Files are stored at /e/<id> (configured via DM_ATTACHMENT_DIR env or /e/).
|
||||||
|
*
|
||||||
|
* Routes (registered as bypass middlewares in index.mjs):
|
||||||
|
* POST /api/dm/attachment/upload/:recipientId — receive ciphertext blob, store on disk
|
||||||
|
* GET /api/dm/attachment/:id — stream ciphertext back (auth required)
|
||||||
|
* DELETE /api/dm/attachment/:id — delete (sender only or admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import db from './inc/sql.mjs';
|
||||||
|
import lib from './inc/lib.mjs';
|
||||||
|
import cfg from './inc/config.mjs';
|
||||||
|
import { collectBody } from './inc/multipart.mjs';
|
||||||
|
import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs';
|
||||||
|
|
||||||
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ATTACHMENT_DIR = process.env.DM_ATTACHMENT_DIR || cfg.paths.e;
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB plaintext; ciphertext is ~1.33× due to base64url
|
||||||
|
|
||||||
|
// Ensure storage dir exists at startup
|
||||||
|
fs.mkdir(ATTACHMENT_DIR, { recursive: true }).catch(e =>
|
||||||
|
console.error('[DM_ATT] Failed to create attachment dir:', e.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function cleanupExpiredAttachments() {
|
||||||
|
try {
|
||||||
|
const expired = await db`
|
||||||
|
SELECT id, file_path FROM dm_attachments
|
||||||
|
WHERE expires_at < now()
|
||||||
|
`;
|
||||||
|
if (!expired.length) return;
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
for (const row of expired) {
|
||||||
|
await fs.unlink(row.file_path).catch(() => {}); // ignore already-missing files
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = expired.map(r => r.id);
|
||||||
|
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
|
||||||
|
|
||||||
|
console.log(`[DM_ATT] Cleanup: removed ${deleted} expired attachment(s)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DM_ATT] Cleanup error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once at startup, then every 6 hours
|
||||||
|
cleanupExpiredAttachments();
|
||||||
|
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function resolveSession(req) {
|
||||||
|
if (!req.cookies?.session) return null;
|
||||||
|
try {
|
||||||
|
const rows = await db`
|
||||||
|
SELECT u.id, u.login, u.user, u.admin, u.is_moderator, s.csrf_token
|
||||||
|
FROM user_sessions s
|
||||||
|
LEFT JOIN "user" u ON u.id = s.user_id
|
||||||
|
WHERE s.session = ${lib.sha256(req.cookies.session)}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, data, code = 200) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCsrf(req, session) {
|
||||||
|
const token = req.headers['x-csrf-token'];
|
||||||
|
return session?.csrf_token && token && token === session.csrf_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Multipart parser (minimal — only parses fields + one binary file part) ───
|
||||||
|
|
||||||
|
function parseAttachmentMultipart(buffer, boundary) {
|
||||||
|
const boundaryBuf = Buffer.from(`--${boundary}`);
|
||||||
|
const segments = [];
|
||||||
|
let start = 0, idx;
|
||||||
|
while ((idx = buffer.indexOf(boundaryBuf, start)) !== -1) {
|
||||||
|
if (start !== 0) segments.push(buffer.slice(start, idx - 2));
|
||||||
|
start = idx + boundaryBuf.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { fields: {}, file: null };
|
||||||
|
for (const seg of segments) {
|
||||||
|
const hdrEnd = seg.indexOf('\r\n\r\n');
|
||||||
|
if (hdrEnd === -1) continue;
|
||||||
|
const headers = seg.slice(0, hdrEnd).toString();
|
||||||
|
const body = seg.slice(hdrEnd + 4);
|
||||||
|
const nameM = headers.match(/name="([^"]+)"/);
|
||||||
|
if (!nameM) continue;
|
||||||
|
const hasFile = headers.includes('filename=') || headers.includes('filename*=');
|
||||||
|
if (hasFile && !result.file) {
|
||||||
|
const ctM = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
|
result.file = {
|
||||||
|
data: body,
|
||||||
|
contentType: ctM ? ctM[1].trim() : 'application/octet-stream'
|
||||||
|
};
|
||||||
|
} else if (!hasFile) {
|
||||||
|
result.fields[nameM[1]] = body.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upload handler ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleDmAttachmentUpload(req, res, recipientId) {
|
||||||
|
if (!getDmAttachments()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const session = await resolveSession(req);
|
||||||
|
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||||
|
|
||||||
|
const rid = parseInt(recipientId, 10);
|
||||||
|
if (!rid || rid === session.id) return sendJson(res, { success: false, msg: 'Invalid recipient' }, 400);
|
||||||
|
|
||||||
|
// Check recipient exists
|
||||||
|
const recip = await db`SELECT id FROM "user" WHERE id = ${rid} LIMIT 1`;
|
||||||
|
if (!recip.length) return sendJson(res, { success: false, msg: 'Recipient not found' }, 404);
|
||||||
|
|
||||||
|
const ct = req.headers['content-type'] || '';
|
||||||
|
const bndM = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||||
|
if (!ct.includes('multipart/form-data') || !bndM) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Expected multipart/form-data' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
// MAX_BYTES * 1.4 overhead for base64url encoding + multipart framing
|
||||||
|
rawBody = await collectBody(req, Math.ceil(MAX_BYTES * 1.4) + 4096);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'BODY_TOO_LARGE') return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = bndM[1] || bndM[2];
|
||||||
|
const { fields, file } = parseAttachmentMultipart(rawBody, boundary);
|
||||||
|
|
||||||
|
if (!file) return sendJson(res, { success: false, msg: 'No file part found' }, 400);
|
||||||
|
|
||||||
|
const iv = (fields.iv || '').trim();
|
||||||
|
const originalName = (fields.original_name || '').slice(0, 255);
|
||||||
|
const mimeHint = (fields.mime_hint || '').slice(0, 100);
|
||||||
|
const sizeBytes = parseInt(fields.size_bytes || '0', 10) || 0;
|
||||||
|
|
||||||
|
if (!iv || iv.length > 32) return sendJson(res, { success: false, msg: 'Missing or invalid iv' }, 400);
|
||||||
|
if (file.data.length > Math.ceil(MAX_BYTES * 1.4)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Insert DB record first to get an ID
|
||||||
|
const expiresAt = new Date(Date.now() + getDmAttachmentExpiryDays() * 86400000);
|
||||||
|
const [row] = await db`
|
||||||
|
INSERT INTO dm_attachments ${db({
|
||||||
|
sender_id: session.id,
|
||||||
|
recipient_id: rid,
|
||||||
|
iv,
|
||||||
|
file_path: '',
|
||||||
|
original_name: originalName,
|
||||||
|
mime_hint: mimeHint,
|
||||||
|
size_bytes: sizeBytes,
|
||||||
|
expires_at: expiresAt
|
||||||
|
})}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const filePath = path.join(ATTACHMENT_DIR, String(row.id));
|
||||||
|
await fs.writeFile(filePath, file.data);
|
||||||
|
|
||||||
|
// Update file_path now that we know the ID
|
||||||
|
await db`UPDATE dm_attachments SET file_path = ${filePath} WHERE id = ${row.id}`;
|
||||||
|
|
||||||
|
return sendJson(res, { success: true, id: String(row.id) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM_ATT] Upload error:', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Download handler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleDmAttachmentDownload(req, res, attachmentId) {
|
||||||
|
const session = await resolveSession(req);
|
||||||
|
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const id = parseInt(attachmentId, 10);
|
||||||
|
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
SELECT id, sender_id, recipient_id, iv, file_path, original_name, mime_hint
|
||||||
|
FROM dm_attachments
|
||||||
|
WHERE id = ${id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const att = rows[0];
|
||||||
|
// Only sender or recipient may download
|
||||||
|
if (att.sender_id !== session.id && att.recipient_id !== session.id && !session.admin) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(att.file_path);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(data.length),
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
'X-DM-IV': att.iv,
|
||||||
|
'X-Original-Name': encodeURIComponent(att.original_name || 'attachment'),
|
||||||
|
'X-Mime-Hint': att.mime_hint || ''
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM_ATT] Download error:', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'File not found on disk' }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete handler ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleDmAttachmentDelete(req, res, attachmentId) {
|
||||||
|
const session = await resolveSession(req);
|
||||||
|
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||||
|
|
||||||
|
const id = parseInt(attachmentId, 10);
|
||||||
|
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
||||||
|
|
||||||
|
const rows = await db`SELECT id, sender_id, file_path FROM dm_attachments WHERE id = ${id} LIMIT 1`;
|
||||||
|
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const att = rows[0];
|
||||||
|
if (att.sender_id !== session.id && !session.admin) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(att.file_path).catch(() => {});
|
||||||
|
await db`DELETE FROM dm_attachments WHERE id = ${id}`;
|
||||||
|
return sendJson(res, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM_ATT] Delete error:', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { execFile as _execFile } from "child_process";
|
import { execFile as _execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
const execFile = promisify(_execFile);
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
@@ -80,11 +81,11 @@ export const handleEmojiUpload = async (req, res) => {
|
|||||||
|
|
||||||
const file = parts.file;
|
const file = parts.file;
|
||||||
if (file && file.data && file.data.length > 0) {
|
if (file && file.data && file.data.length > 0) {
|
||||||
const randSuffix = Math.random().toString(36).substring(7);
|
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||||
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||||
|
|
||||||
const webpFilename = `${name}_${randSuffix}.webp`;
|
const webpFilename = `${randSuffix}.webp`;
|
||||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||||
|
|
||||||
if (originalExt === 'webp') {
|
if (originalExt === 'webp') {
|
||||||
@@ -135,3 +136,133 @@ export const handleEmojiUpload = async (req, res) => {
|
|||||||
return sendJson(res, { success: false, message: err.message }, 500);
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleEmojiEdit = async (req, res) => {
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0 || !user[0].admin) {
|
||||||
|
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked emoji edit for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = boundaryMatch[1].trim();
|
||||||
|
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||||
|
boundary = boundary.substring(1, boundary.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyBuffer = await collectBody(req);
|
||||||
|
const parts = parseMultipart(bodyBuffer, boundary);
|
||||||
|
|
||||||
|
const name = (parts.name || '').trim().toLowerCase();
|
||||||
|
let url = (parts.url || '').trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the current emoji record
|
||||||
|
const current = await db`SELECT id, name, url FROM custom_emojis WHERE id = ${id} LIMIT 1`;
|
||||||
|
if (current.length === 0) {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name collision (allow keeping the same name)
|
||||||
|
if (name !== current[0].name) {
|
||||||
|
const conflict = await db`SELECT id FROM custom_emojis WHERE name = ${name} AND id != ${id} LIMIT 1`;
|
||||||
|
if (conflict.length > 0) {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parts.file;
|
||||||
|
if (file && file.data && file.data.length > 0) {
|
||||||
|
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||||
|
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||||
|
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||||
|
|
||||||
|
const webpFilename = `${randSuffix}.webp`;
|
||||||
|
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||||
|
|
||||||
|
if (originalExt === 'webp') {
|
||||||
|
await fs.writeFile(webpPath, file.data);
|
||||||
|
} else {
|
||||||
|
const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`;
|
||||||
|
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
try {
|
||||||
|
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(webpPath);
|
||||||
|
if (!stat || stat.size === 0) throw new Error('File write/conversion verification failed');
|
||||||
|
|
||||||
|
// Delete the old local file if it was a hosted emoji
|
||||||
|
if (current[0].url && current[0].url.startsWith('/s/emojis/')) {
|
||||||
|
const oldFilename = path.basename(current[0].url);
|
||||||
|
const oldPath = path.join(cfg.paths.emojis, oldFilename);
|
||||||
|
await fs.unlink(oldPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
url = `/s/emojis/${webpFilename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no new file and no new URL, keep the existing URL
|
||||||
|
if (!url) {
|
||||||
|
url = current[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db`
|
||||||
|
UPDATE custom_emojis
|
||||||
|
SET name = ${name}, url = ${url}
|
||||||
|
WHERE id = ${id}
|
||||||
|
RETURNING id, name, url
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return sendJson(res, { success: true, emoji: updated[0] });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||||
|
}
|
||||||
|
console.error('[EMOJI EDIT ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const handleHallUpdate = async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/v2/admin/halls — create a new hall
|
// POST /api/v2/admin/halls — create a new hall
|
||||||
export const handleHallCreate = async (req, res) => {
|
export const handleHallCreate = async (req, res) => {
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
|
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
|
||||||
if (!token || token !== session.csrf_token) {
|
if (!token || token !== session.csrf_token) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ config.paths = {
|
|||||||
emojis: resolvePath('public/s/emojis'),
|
emojis: resolvePath('public/s/emojis'),
|
||||||
koepfe: resolvePath('public/s/koepfe'),
|
koepfe: resolvePath('public/s/koepfe'),
|
||||||
memes: resolvePath('public/memes'),
|
memes: resolvePath('public/memes'),
|
||||||
|
e: resolvePath('e'),
|
||||||
pending: resolvePath('pending'),
|
pending: resolvePath('pending'),
|
||||||
deleted: resolvePath('deleted'),
|
deleted: resolvePath('deleted'),
|
||||||
logs: resolvePath('logs'),
|
logs: resolvePath('logs'),
|
||||||
|
|||||||
120
src/inc/lib.mjs
120
src/inc/lib.mjs
@@ -81,6 +81,37 @@ export default new class {
|
|||||||
}
|
}
|
||||||
return tmp;
|
return tmp;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a multi-rating SQL WHERE clause fragment from an array of rating strings.
|
||||||
|
* Supported values: 'sfw', 'nsfw', 'nsfl', 'untagged'
|
||||||
|
* Returns null if the ratings array is empty or contains all possible values (treat as ALL).
|
||||||
|
*/
|
||||||
|
getMultiRatingMode(ratings) {
|
||||||
|
if (!Array.isArray(ratings) || ratings.length === 0) return null;
|
||||||
|
const valid = ['sfw', 'nsfw', 'nsfl', 'untagged'];
|
||||||
|
const filtered = ratings.filter(r => valid.includes(r));
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
// If all 4 are selected, treat as ALL
|
||||||
|
if (filtered.includes('sfw') && filtered.includes('nsfw') && filtered.includes('untagged') &&
|
||||||
|
(!cfg.enable_nsfl || filtered.includes('nsfl'))) return '1 = 1';
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (filtered.includes('sfw')) {
|
||||||
|
parts.push('items.id in (select item_id from tags_assign where tag_id = 1)');
|
||||||
|
}
|
||||||
|
if (filtered.includes('nsfw')) {
|
||||||
|
parts.push('items.id in (select item_id from tags_assign where tag_id = 2)');
|
||||||
|
}
|
||||||
|
if (filtered.includes('nsfl') && cfg.enable_nsfl) {
|
||||||
|
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
|
||||||
|
}
|
||||||
|
if (filtered.includes('untagged')) {
|
||||||
|
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return '(' + parts.join(' OR ') + ')';
|
||||||
|
};
|
||||||
createID() {
|
createID() {
|
||||||
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
|
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
|
||||||
};
|
};
|
||||||
@@ -102,8 +133,14 @@ export default new class {
|
|||||||
// Build suffix with query params
|
// Build suffix with query params
|
||||||
let suffix = env.strict ? '?strict=1' : '';
|
let suffix = env.strict ? '?strict=1' : '';
|
||||||
|
|
||||||
|
// mainDisplay: decoded for human-readable display (e.g. div.location)
|
||||||
|
// main: keeps percent-encoding for use in href attributes
|
||||||
|
let mainDisplay = tmp;
|
||||||
|
try { mainDisplay = decodeURIComponent(tmp); } catch (_) {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
main: tmp,
|
main: tmp,
|
||||||
|
mainDisplay,
|
||||||
path: env.path ? env.path : '',
|
path: env.path ? env.path : '',
|
||||||
suffix: suffix
|
suffix: suffix
|
||||||
};
|
};
|
||||||
@@ -183,10 +220,30 @@ export default new class {
|
|||||||
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
||||||
};
|
};
|
||||||
async verify(str, hash) {
|
async verify(str, hash) {
|
||||||
const [salt, key] = hash.substring(6).split(":");
|
if (typeof hash !== 'string') return false;
|
||||||
|
|
||||||
|
if (hash.startsWith("$f0ck$")) {
|
||||||
|
const parts = hash.substring(6).split(":");
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [salt, key] = parts;
|
||||||
|
try {
|
||||||
const keyBuffer = Buffer.from(key, "hex");
|
const keyBuffer = Buffer.from(key, "hex");
|
||||||
const derivedKey = await scrypt(str, salt, 64);
|
const derivedKey = await scrypt(str, salt, 64);
|
||||||
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash.length === 32) {
|
||||||
|
return this.md5(str) === hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash.length === 64) {
|
||||||
|
return this.sha256(str) === hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
async getTags(itemid) {
|
async getTags(itemid) {
|
||||||
const tags = await db`
|
const tags = await db`
|
||||||
@@ -314,6 +371,67 @@ export default new class {
|
|||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Middleware: authenticate via X-Api-Key header (upload-only)
|
||||||
|
async apiKeyAuth(req, res, next) {
|
||||||
|
const key = req.headers['x-api-key'];
|
||||||
|
if (!key) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: JSON.stringify({ success: false, msg: 'API key required' }),
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
const rows = await db`
|
||||||
|
SELECT
|
||||||
|
u.id, u.user, u.login, u.admin, u.is_moderator, u.banned,
|
||||||
|
uo.display_name, uo.mode, uo.theme, uo.avatar, uo.avatar_file,
|
||||||
|
uo.username_color, uo.show_motd, uo.disable_autoplay,
|
||||||
|
uo.disable_swiping, uo.use_new_layout, uo.excluded_tags,
|
||||||
|
uo.ruffle_background, uo.ruffle_volume, uo.quote_emojis,
|
||||||
|
uo.embed_youtube_in_comments, uo.hide_koepfe,
|
||||||
|
uo.use_alternative_infobox, uo.language, uo.comment_display_mode,
|
||||||
|
uo.force_comment_display_mode, uo.min_xd_score, uo.show_background,
|
||||||
|
uo.font, uo.receive_system_notifications, uo.receive_user_notifications,
|
||||||
|
uo.do_not_disturb, uo.description
|
||||||
|
FROM user_api_keys k
|
||||||
|
JOIN "user" u ON u.id = k.user_id
|
||||||
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
|
WHERE k.api_key = ${key}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
row = rows[0];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API KEY AUTH] DB error:', err);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
body: JSON.stringify({ success: false, msg: 'Internal server error' }),
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: JSON.stringify({ success: false, msg: 'Invalid API key' }),
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.banned) {
|
||||||
|
return res.reply({
|
||||||
|
code: 403,
|
||||||
|
body: JSON.stringify({ success: false, msg: 'Account banned' }),
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = { ...row, api_key_auth: true };
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
getCookieOptions(expires = null, httpOnly = true) {
|
getCookieOptions(expires = null, httpOnly = true) {
|
||||||
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
||||||
let options = "Path=/; SameSite=Lax";
|
let options = "Path=/; SameSite=Lax";
|
||||||
|
|||||||
@@ -70,7 +70,17 @@
|
|||||||
"btn_add_urls": "URL(s) hinzufügen",
|
"btn_add_urls": "URL(s) hinzufügen",
|
||||||
"tags_required_shitpost": "Alle Beiträge benötigen Tags",
|
"tags_required_shitpost": "Alle Beiträge benötigen Tags",
|
||||||
"url_queued_background": "URL Verarbeitung im Hintergrund gestartet.",
|
"url_queued_background": "URL Verarbeitung im Hintergrund gestartet.",
|
||||||
"add_more": "Mehr hinzufügen"
|
"add_more": "Mehr hinzufügen",
|
||||||
|
"url_tracker_title": "URL-Uploads",
|
||||||
|
"url_tracker_queued": "Warteschlange",
|
||||||
|
"url_tracker_downloading": "Wird heruntergeladen",
|
||||||
|
"url_tracker_analyzing": "Wird analysiert",
|
||||||
|
"url_tracker_saving": "Wird gespeichert",
|
||||||
|
"url_tracker_processing": "Wird verarbeitet",
|
||||||
|
"url_tracker_extracting": "Wird extrahiert",
|
||||||
|
"url_tracker_complete": "Fertig!",
|
||||||
|
"url_tracker_failed": "Upload fehlgeschlagen",
|
||||||
|
"url_tracker_view": "Ansehen →"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"registering": "Wird registriert...",
|
"registering": "Wird registriert...",
|
||||||
@@ -120,6 +130,7 @@
|
|||||||
"switching": "Wird umgeschaltet...",
|
"switching": "Wird umgeschaltet...",
|
||||||
"generating": "Wird generiert...",
|
"generating": "Wird generiert...",
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
|
"profile": "Profil",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"current_avatar": "Aktueller Avatar",
|
"current_avatar": "Aktueller Avatar",
|
||||||
"upload_custom_avatar": "Eigenen Avatar hochladen",
|
"upload_custom_avatar": "Eigenen Avatar hochladen",
|
||||||
@@ -128,26 +139,46 @@
|
|||||||
"no_file_selected": "Keine Datei ausgewählt",
|
"no_file_selected": "Keine Datei ausgewählt",
|
||||||
"upload_btn": "Hochladen",
|
"upload_btn": "Hochladen",
|
||||||
"remove_custom": "Eigenen entfernen",
|
"remove_custom": "Eigenen entfernen",
|
||||||
|
"current_banner": "Aktuelles Banner",
|
||||||
|
"banner_hint": "Max. 5 MB. Erlaubt: gif, jpg, png, webp",
|
||||||
|
"no_banner_set": "Kein Banner gesetzt",
|
||||||
"custom_description": "Eigene Beschreibung",
|
"custom_description": "Eigene Beschreibung",
|
||||||
"description_placeholder": "Beschreibung zum Profil hinzufügen",
|
"description_placeholder": "Beschreibung zum Profil hinzufügen",
|
||||||
"save_description": "Beschreibung speichern",
|
"save_description": "Beschreibung speichern",
|
||||||
"clear": "Löschen",
|
"clear": "Löschen",
|
||||||
"preferences": "Einstellungen",
|
"preferences": "Einstellungen",
|
||||||
"ui_section": "Benutzeroberfläche",
|
"ui_section": "Benutzeroberfläche",
|
||||||
|
"content_preferences_section": "Inhaltseinstellungen",
|
||||||
"appearance_section": "Erscheinungsbild",
|
"appearance_section": "Erscheinungsbild",
|
||||||
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
||||||
"modern_layout": "Modernes Layout",
|
"modern_layout": "Modernes Layout",
|
||||||
"modern_layout_hint": "3-Spalten-Layout",
|
"modern_layout_hint": "3-Spalten-Layout",
|
||||||
"alternative_infobox": "Alternativer Autor-Infoblock",
|
"alternative_infobox": "Alternativer Autor-Infoblock",
|
||||||
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
||||||
|
"alternative_steuerung": "Icon-Navigationsstil",
|
||||||
|
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
|
||||||
|
"f_for_fullscreen": "F is for Fullscreen",
|
||||||
|
"f_for_fullscreen_hint": "Das Drücken von F schaltet das Vollbild im Videoplayer statt den Beitrag zu favorisieren.",
|
||||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||||
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
|
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
|
||||||
|
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||||
|
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||||
"disable_swiping": "Wischen deaktivieren",
|
"disable_swiping": "Wischen deaktivieren",
|
||||||
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
||||||
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
||||||
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
||||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||||
"enable_bg_blur_hint": "Unscharfen Hintergrund bei Beiträgen anzeigen",
|
"enable_bg_blur_hint": "Unscharfen Hintergrund bei Beiträgen anzeigen",
|
||||||
|
"blur_nsfw": "NSFW weichzeichnen",
|
||||||
|
"blur_nsfw_hint": "Zeichnet NSFW-Vorschaubilder weich.",
|
||||||
|
"blur_nsfl": "NSFL weichzeichnen",
|
||||||
|
"blur_nsfl_hint": "Zeichnet NSFL-Vorschaubilder weich.",
|
||||||
|
"blur_sfw": "SFW weichzeichnen",
|
||||||
|
"blur_sfw_hint": "Zeichnet SFW-Vorschaubilder weich.",
|
||||||
|
"blur_untagged": "Unmarkierte weichzeichnen",
|
||||||
|
"blur_untagged_hint": "Zeichnet unmarkierte Vorschaubilder weich.",
|
||||||
|
"blur_detail": "Beiträge weichzeichnen",
|
||||||
|
"blur_detail_hint": "Erfordert das Anklicken zum Anzeigen von weichgezeichneten Medien auf der Beitragsseite.",
|
||||||
"render_emojis": "Emojis in Zitatantworten anzeigen",
|
"render_emojis": "Emojis in Zitatantworten anzeigen",
|
||||||
"render_emojis_hint": ":emoji:-Bilder in >zitierten Zeilen anzeigen",
|
"render_emojis_hint": ":emoji:-Bilder in >zitierten Zeilen anzeigen",
|
||||||
"embed_yt": "YouTube-Videos in Kommentaren einbetten",
|
"embed_yt": "YouTube-Videos in Kommentaren einbetten",
|
||||||
@@ -156,7 +187,7 @@
|
|||||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||||
"comment_display_linear": "Linear / Flach (4chan-Stil)",
|
"comment_display_linear": "Linear",
|
||||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||||
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
|
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
@@ -262,7 +293,9 @@
|
|||||||
"toggle_rating": "Beitragsbewertung umschalten",
|
"toggle_rating": "Beitragsbewertung umschalten",
|
||||||
"play_pause": "Abspielen/Pause",
|
"play_pause": "Abspielen/Pause",
|
||||||
"next_prev": "Weiter/Zurück",
|
"next_prev": "Weiter/Zurück",
|
||||||
|
"volume": "Lautstärke",
|
||||||
"scroll_nav": "Weiter/Zurück",
|
"scroll_nav": "Weiter/Zurück",
|
||||||
|
"frame_skip": "Einzelbildsteuerung (pausiert) / 5s Sprung",
|
||||||
"show_help": "Hilfe anzeigen"
|
"show_help": "Hilfe anzeigen"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -299,7 +332,10 @@
|
|||||||
"scroll_for_more": "Scrollen für mehr..."
|
"scroll_for_more": "Scrollen für mehr..."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"label": "Fehler"
|
"label": "Fehler",
|
||||||
|
"post_not_visible": "Dieser Beitrag ist derzeit leider nicht sichtbar.",
|
||||||
|
"filter_hint": "Dein aktueller Filter ist auf {mode} gesetzt.",
|
||||||
|
"filter_hint_link": "Um diesen Inhalt zu sehen, ändere deinen Filter entsprechend."
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"drop_anywhere": "Überall ablegen zum Hochladen"
|
"drop_anywhere": "Überall ablegen zum Hochladen"
|
||||||
@@ -311,7 +347,20 @@
|
|||||||
"attach_file": "Datei anhängen",
|
"attach_file": "Datei anhängen",
|
||||||
"uploading_file": "Wird hochgeladen...",
|
"uploading_file": "Wird hochgeladen...",
|
||||||
"remove_file": "Datei entfernen",
|
"remove_file": "Datei entfernen",
|
||||||
"file_too_large": "Datei zu groß"
|
"file_too_large": "Datei zu groß",
|
||||||
|
"poll_btn_title": "Umfrage erstellen",
|
||||||
|
"poll_question_placeholder": "Umfragefrage...",
|
||||||
|
"poll_option_placeholder": "Option...",
|
||||||
|
"poll_add_option": "Option hinzufügen",
|
||||||
|
"poll_remove": "Umfrage entfernen",
|
||||||
|
"poll_vote": "Abstimmen",
|
||||||
|
"poll_voted": "Abgestimmt",
|
||||||
|
"poll_votes": "Stimmen",
|
||||||
|
"poll_vote_single": "Stimme",
|
||||||
|
"poll_delete": "Umfrage löschen",
|
||||||
|
"poll_expired": "Umfrage geschlossen",
|
||||||
|
"poll_anonymous": "Anonym",
|
||||||
|
"poll_public": "Öffentliche Stimmen"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Datei auswählen",
|
"select_file": "Datei auswählen",
|
||||||
@@ -406,6 +455,10 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"loading_activity": "Aktivität wird geladen...",
|
"loading_activity": "Aktivität wird geladen...",
|
||||||
|
"no_activity": "Keine kürzliche Aktivität.",
|
||||||
|
"failed_to_load": "Laden fehlgeschlagen.",
|
||||||
|
"loading_more": "Laden…",
|
||||||
|
"end_of_activity": "─ Ende der Aktivität ─",
|
||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
"read_more": "mehr sehen",
|
"read_more": "mehr sehen",
|
||||||
"see_less": "weniger anzeigen",
|
"see_less": "weniger anzeigen",
|
||||||
@@ -430,13 +483,13 @@
|
|||||||
},
|
},
|
||||||
"ranking": {
|
"ranking": {
|
||||||
"title": "Rangliste",
|
"title": "Rangliste",
|
||||||
"top_contributors": "Top-Mitwirkende",
|
"top_contributors": "Größte Etikettierer",
|
||||||
"col_rank": "Rang",
|
"col_rank": "Rang",
|
||||||
"col_avatar": "Avatar",
|
"col_avatar": "Avatar",
|
||||||
"col_username": "Nutzername",
|
"col_username": "Nutzername",
|
||||||
"col_tagged": "Markiert",
|
"col_tagged": "Markiert",
|
||||||
"tag_stats": "Statistiken",
|
"tag_stats": "Statistiken",
|
||||||
"stat_total": "Gesamt",
|
"stat_total": "Gesamt Inhalte",
|
||||||
"stat_tagged": "Markiert",
|
"stat_tagged": "Markiert",
|
||||||
"stat_untagged": "Unmarkiert",
|
"stat_untagged": "Unmarkiert",
|
||||||
"stat_sfw": "SFW-Inhalte",
|
"stat_sfw": "SFW-Inhalte",
|
||||||
@@ -446,6 +499,7 @@
|
|||||||
"stat_comments": "Gesamt Kommentare",
|
"stat_comments": "Gesamt Kommentare",
|
||||||
"stat_favs": "Gesamt Favoriten",
|
"stat_favs": "Gesamt Favoriten",
|
||||||
"stat_disk_usage": "Dateigröße Gesamt",
|
"stat_disk_usage": "Dateigröße Gesamt",
|
||||||
|
"stat_users": "Gesamt Benutzer",
|
||||||
"most_favorited": "Meiste Favs",
|
"most_favorited": "Meiste Favs",
|
||||||
"favs": "Favs",
|
"favs": "Favs",
|
||||||
"top_xd": "Top xD-Score"
|
"top_xd": "Top xD-Score"
|
||||||
@@ -518,6 +572,24 @@
|
|||||||
"found": "Gefundene Metadaten:",
|
"found": "Gefundene Metadaten:",
|
||||||
"no_results": "Keine weiteren Metadaten in dieser Datei gefunden."
|
"no_results": "Keine weiteren Metadaten in dieser Datei gefunden."
|
||||||
},
|
},
|
||||||
|
"info_modal": {
|
||||||
|
"title": "Post- & Datei-Informationen",
|
||||||
|
"button_title": "Post- & Datei-Informationen",
|
||||||
|
"id": "Post-ID",
|
||||||
|
"source": "Quelle",
|
||||||
|
"uploader": "Hochgeladen von",
|
||||||
|
"uploaded_at": "Hochgeladen am",
|
||||||
|
"file_size": "Dateigröße",
|
||||||
|
"mime_type": "MIME-Typ",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"oc": "Originaler Inhalt (OC)",
|
||||||
|
"no": "Nein",
|
||||||
|
"direct_url": "Direkt-Link",
|
||||||
|
"view_file": "Datei anzeigen",
|
||||||
|
"metadata": "Metadaten",
|
||||||
|
"sha256": "SHA-256-Hash",
|
||||||
|
"dimensions": "Abmessungen"
|
||||||
|
},
|
||||||
"meme": {
|
"meme": {
|
||||||
"add_text_layer": "Textebene hinzufügen",
|
"add_text_layer": "Textebene hinzufügen",
|
||||||
"tags_label": "Tags (kommagetrennt)",
|
"tags_label": "Tags (kommagetrennt)",
|
||||||
@@ -528,7 +600,12 @@
|
|||||||
"text_layer": "Textebene",
|
"text_layer": "Textebene",
|
||||||
"enter_text": "Text eingeben...",
|
"enter_text": "Text eingeben...",
|
||||||
"size_label": "Größe",
|
"size_label": "Größe",
|
||||||
"create_meme": "Meme erstellen:"
|
"create_meme": "Meme erstellen:",
|
||||||
|
"custom_template_title": "Eigene Vorlage",
|
||||||
|
"custom_template_tag": "Lokale Datei",
|
||||||
|
"select_image": "Eigenes Bild auswählen",
|
||||||
|
"choose_file_btn": "Bild aussuchen",
|
||||||
|
"choose_image_first": "Bitte wählen Sie zuerst ein Bild aus!"
|
||||||
},
|
},
|
||||||
"timeago": {
|
"timeago": {
|
||||||
"just_now": "gerade eben",
|
"just_now": "gerade eben",
|
||||||
@@ -693,5 +770,30 @@
|
|||||||
"left_hand_desc": "Du weißt bescheid.",
|
"left_hand_desc": "Du weißt bescheid.",
|
||||||
"replying_to": "Antwort an {user}",
|
"replying_to": "Antwort an {user}",
|
||||||
"reply": "Antworten"
|
"reply": "Antworten"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"section_title": "Einladungen",
|
||||||
|
"section_desc": "Lade neue Nutzer ein. Du musst alle unten stehenden Kriterien erfüllen, um Einladungstokens zu generieren.",
|
||||||
|
"eligible": "✓ Du bist berechtigt, Einladungen zu generieren.",
|
||||||
|
"not_eligible": "✗ Du erfüllst noch nicht alle Kriterien.",
|
||||||
|
"slots_used": "{used} / {total} Einladungsslots genutzt",
|
||||||
|
"criteria_uploads": "Uploads",
|
||||||
|
"criteria_age": "Kontoalter",
|
||||||
|
"criteria_comments": "Kommentare",
|
||||||
|
"criteria_tags": "Vergebene Tags",
|
||||||
|
"criteria_days": " Tage",
|
||||||
|
"generate_btn": "Einladung generieren",
|
||||||
|
"generating": "Wird generiert…",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"no_invites": "Noch keine Einladungstokens generiert.",
|
||||||
|
"status_unused": "Ungenutzt",
|
||||||
|
"status_used_by": "Genutzt von {user}",
|
||||||
|
"copy_btn": "Kopieren",
|
||||||
|
"copied": "Kopiert!",
|
||||||
|
"delete_btn": "Löschen",
|
||||||
|
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||||
|
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||||
|
"slot_refreshed": "Slot erneuert",
|
||||||
|
"admin_desc": "Du bist Admin, leg los."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,13 +64,23 @@
|
|||||||
"remove_file": "Remove File",
|
"remove_file": "Remove File",
|
||||||
"cancel_upload": "Cancel Upload",
|
"cancel_upload": "Cancel Upload",
|
||||||
"shitpost_success": "Successfully shitposted {n} items!",
|
"shitpost_success": "Successfully shitposted {n} items!",
|
||||||
"shitposting_status": "Shitposting",
|
"shitposting_status": "Uploading",
|
||||||
"item_comment_placeholder": "Comment (optional)...",
|
"item_comment_placeholder": "Write a Comment...",
|
||||||
"item_tags_placeholder": "Tags...",
|
"item_tags_placeholder": "Tags...",
|
||||||
"btn_add_urls": "Add URL(s)",
|
"btn_add_urls": "Add URL(s)",
|
||||||
"tags_required_shitpost": "All items need tags",
|
"tags_required_shitpost": "All items need tags",
|
||||||
"url_queued_background": "URL processing started in background.",
|
"url_queued_background": "URL processing started in background.",
|
||||||
"add_more": "Add more"
|
"add_more": "Add more",
|
||||||
|
"url_tracker_title": "URL Uploads",
|
||||||
|
"url_tracker_queued": "Queued",
|
||||||
|
"url_tracker_downloading": "Downloading",
|
||||||
|
"url_tracker_analyzing": "Analyzing",
|
||||||
|
"url_tracker_saving": "Saving",
|
||||||
|
"url_tracker_processing": "Processing",
|
||||||
|
"url_tracker_extracting": "Extracting",
|
||||||
|
"url_tracker_complete": "Complete!",
|
||||||
|
"url_tracker_failed": "Upload failed",
|
||||||
|
"url_tracker_view": "View →"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"registering": "Registering...",
|
"registering": "Registering...",
|
||||||
@@ -120,6 +130,7 @@
|
|||||||
"switching": "Switching...",
|
"switching": "Switching...",
|
||||||
"generating": "Generating...",
|
"generating": "Generating...",
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
"profile": "Profile",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"current_avatar": "Current Avatar",
|
"current_avatar": "Current Avatar",
|
||||||
"upload_custom_avatar": "Upload Custom Avatar",
|
"upload_custom_avatar": "Upload Custom Avatar",
|
||||||
@@ -128,26 +139,46 @@
|
|||||||
"no_file_selected": "No file selected",
|
"no_file_selected": "No file selected",
|
||||||
"upload_btn": "Upload",
|
"upload_btn": "Upload",
|
||||||
"remove_custom": "Remove Custom",
|
"remove_custom": "Remove Custom",
|
||||||
|
"current_banner": "Current Banner",
|
||||||
|
"banner_hint": "Max 5MB. Allowed: gif, jpg, png, webp",
|
||||||
|
"no_banner_set": "No banner set",
|
||||||
"custom_description": "Custom Description",
|
"custom_description": "Custom Description",
|
||||||
"description_placeholder": "Add a description to your profile",
|
"description_placeholder": "Add a description to your profile",
|
||||||
"save_description": "Save Description",
|
"save_description": "Save Description",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"ui_section": "User Interface",
|
"ui_section": "User Interface",
|
||||||
|
"content_preferences_section": "Content Preferences",
|
||||||
"appearance_section": "Appearance",
|
"appearance_section": "Appearance",
|
||||||
"show_motd": "Show Message of the Day (MOTD)",
|
"show_motd": "Show Message of the Day (MOTD)",
|
||||||
"modern_layout": "Modern layout",
|
"modern_layout": "Modern layout",
|
||||||
"modern_layout_hint": "3 Column Layout",
|
"modern_layout_hint": "3 Column Layout",
|
||||||
"alternative_infobox": "Alternative Author Infobox",
|
"alternative_infobox": "Alternative Author Infobox",
|
||||||
"alternative_infobox_hint": "Show a rich author card with avatar and bio on item pages",
|
"alternative_infobox_hint": "Show a rich author card with avatar and bio on item pages",
|
||||||
|
"alternative_steuerung": "Icon nav style",
|
||||||
|
"alternative_steuerung_hint": "Replace text navigation (← prev | random | next →) with compact chevron icons",
|
||||||
|
"f_for_fullscreen": "F is for Fullscreen",
|
||||||
|
"f_for_fullscreen_hint": "Pressing F will toggle fullscreen in the video player instead of favoriting the post.",
|
||||||
"disable_autoplay": "Disable Autoplay",
|
"disable_autoplay": "Disable Autoplay",
|
||||||
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
|
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
|
||||||
|
"hide_item_ratings": "Hide Item Ratings",
|
||||||
|
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
|
||||||
"disable_swiping": "Disable Swiping",
|
"disable_swiping": "Disable Swiping",
|
||||||
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
||||||
"image_expand_on_click": "Expand images inline on click",
|
"image_expand_on_click": "Expand images inline on click",
|
||||||
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
||||||
"enable_bg_blur": "Enable Background blur",
|
"enable_bg_blur": "Enable Background blur",
|
||||||
"enable_bg_blur_hint": "Show blurred background on items",
|
"enable_bg_blur_hint": "Show blurred background on items",
|
||||||
|
"blur_nsfw": "Blur NSFW",
|
||||||
|
"blur_nsfw_hint": "Blur NSFW-rated thumbnails.",
|
||||||
|
"blur_nsfl": "Blur NSFL",
|
||||||
|
"blur_nsfl_hint": "Blur NSFL-rated/shock thumbnails.",
|
||||||
|
"blur_sfw": "Blur SFW",
|
||||||
|
"blur_sfw_hint": "Blur SFW-rated thumbnails.",
|
||||||
|
"blur_untagged": "Blur Untagged",
|
||||||
|
"blur_untagged_hint": "Blur thumbnails with no tags or rating.",
|
||||||
|
"blur_detail": "Click to reveal item on detail page",
|
||||||
|
"blur_detail_hint": "Require clicking to reveal blurred media on the post detail page.",
|
||||||
"render_emojis": "Render emojis in quote replies",
|
"render_emojis": "Render emojis in quote replies",
|
||||||
"render_emojis_hint": "Show :emoji: images inside >quoted lines",
|
"render_emojis_hint": "Show :emoji: images inside >quoted lines",
|
||||||
"embed_yt": "Embed YouTube links in comments",
|
"embed_yt": "Embed YouTube links in comments",
|
||||||
@@ -156,7 +187,7 @@
|
|||||||
"hide_koepfe_hint": "Disable the Köpfe",
|
"hide_koepfe_hint": "Disable the Köpfe",
|
||||||
"comment_display_mode": "Comment Display Mode",
|
"comment_display_mode": "Comment Display Mode",
|
||||||
"comment_display_tree": "Reply Tree (Default)",
|
"comment_display_tree": "Reply Tree (Default)",
|
||||||
"comment_display_linear": "Linear / Flat (4chan style)",
|
"comment_display_linear": "Linear",
|
||||||
"comment_display_mode_hint": "Choose how you want comments to be displayed.",
|
"comment_display_mode_hint": "Choose how you want comments to be displayed.",
|
||||||
"forced_mode_notice": "This setting is managed by an administrator.",
|
"forced_mode_notice": "This setting is managed by an administrator.",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -262,7 +293,9 @@
|
|||||||
"toggle_rating": "toggle item rating",
|
"toggle_rating": "toggle item rating",
|
||||||
"play_pause": "play/pause",
|
"play_pause": "play/pause",
|
||||||
"next_prev": "next/prev",
|
"next_prev": "next/prev",
|
||||||
|
"volume": "volume",
|
||||||
"scroll_nav": "next/prev",
|
"scroll_nav": "next/prev",
|
||||||
|
"frame_skip": "frame step (paused) / 5s skip",
|
||||||
"show_help": "show help"
|
"show_help": "show help"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -299,7 +332,10 @@
|
|||||||
"scroll_for_more": "Scroll for more..."
|
"scroll_for_more": "Scroll for more..."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"label": "Error"
|
"label": "Error",
|
||||||
|
"post_not_visible": "Sorry, this post is currently not visible.",
|
||||||
|
"filter_hint": "Your current filter is set to {mode}.",
|
||||||
|
"filter_hint_link": "To view this content, change your filter accordingly."
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"drop_anywhere": "Drop anywhere to upload"
|
"drop_anywhere": "Drop anywhere to upload"
|
||||||
@@ -311,7 +347,20 @@
|
|||||||
"attach_file": "Attach file",
|
"attach_file": "Attach file",
|
||||||
"uploading_file": "Uploading...",
|
"uploading_file": "Uploading...",
|
||||||
"remove_file": "Remove file",
|
"remove_file": "Remove file",
|
||||||
"file_too_large": "File too large"
|
"file_too_large": "File too large",
|
||||||
|
"poll_btn_title": "Create poll",
|
||||||
|
"poll_question_placeholder": "Poll question...",
|
||||||
|
"poll_option_placeholder": "Option...",
|
||||||
|
"poll_add_option": "Add option",
|
||||||
|
"poll_remove": "Remove poll",
|
||||||
|
"poll_vote": "Vote",
|
||||||
|
"poll_voted": "You voted",
|
||||||
|
"poll_votes": "votes",
|
||||||
|
"poll_vote_single": "vote",
|
||||||
|
"poll_delete": "Delete poll",
|
||||||
|
"poll_expired": "Poll closed",
|
||||||
|
"poll_anonymous": "Anonymous",
|
||||||
|
"poll_public": "Public votes"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Select a file",
|
"select_file": "Select a file",
|
||||||
@@ -410,6 +459,10 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"loading_activity": "Loading activity...",
|
"loading_activity": "Loading activity...",
|
||||||
|
"no_activity": "No recent activity.",
|
||||||
|
"failed_to_load": "Failed to load.",
|
||||||
|
"loading_more": "Loading…",
|
||||||
|
"end_of_activity": "─ end of activity ─",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"read_more": "read more",
|
"read_more": "read more",
|
||||||
"see_less": "see less",
|
"see_less": "see less",
|
||||||
@@ -450,6 +503,7 @@
|
|||||||
"stat_comments": "Total Comments",
|
"stat_comments": "Total Comments",
|
||||||
"stat_favs": "Total Favorites",
|
"stat_favs": "Total Favorites",
|
||||||
"stat_disk_usage": "Total File Size",
|
"stat_disk_usage": "Total File Size",
|
||||||
|
"stat_users": "Total Users",
|
||||||
"most_favorited": "Most Favorited",
|
"most_favorited": "Most Favorited",
|
||||||
"favs": "favs",
|
"favs": "favs",
|
||||||
"top_xd": "Top xD Scores"
|
"top_xd": "Top xD Scores"
|
||||||
@@ -522,6 +576,24 @@
|
|||||||
"found": "Found in metadata:",
|
"found": "Found in metadata:",
|
||||||
"no_results": "No additional metadata fields found in this file."
|
"no_results": "No additional metadata fields found in this file."
|
||||||
},
|
},
|
||||||
|
"info_modal": {
|
||||||
|
"title": "Post & File Information",
|
||||||
|
"button_title": "Post & File Info",
|
||||||
|
"id": "Post ID",
|
||||||
|
"source": "Source",
|
||||||
|
"uploader": "Uploader",
|
||||||
|
"uploaded_at": "Uploaded At",
|
||||||
|
"file_size": "File Size",
|
||||||
|
"mime_type": "MIME Type",
|
||||||
|
"rating": "Rating",
|
||||||
|
"oc": "Original Content (OC)",
|
||||||
|
"no": "No",
|
||||||
|
"direct_url": "Direct URL",
|
||||||
|
"view_file": "View File",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"sha256": "SHA-256 Hash",
|
||||||
|
"dimensions": "Dimensions"
|
||||||
|
},
|
||||||
"meme": {
|
"meme": {
|
||||||
"add_text_layer": "Add Text Layer",
|
"add_text_layer": "Add Text Layer",
|
||||||
"tags_label": "Tags (comma separated)",
|
"tags_label": "Tags (comma separated)",
|
||||||
@@ -532,7 +604,12 @@
|
|||||||
"text_layer": "Text Layer",
|
"text_layer": "Text Layer",
|
||||||
"enter_text": "Enter text...",
|
"enter_text": "Enter text...",
|
||||||
"size_label": "Size",
|
"size_label": "Size",
|
||||||
"create_meme": "Create Meme:"
|
"create_meme": "Create Meme:",
|
||||||
|
"custom_template_title": "Use Own Template",
|
||||||
|
"custom_template_tag": "Local File",
|
||||||
|
"select_image": "Select Custom Image",
|
||||||
|
"choose_file_btn": "Choose Image",
|
||||||
|
"choose_image_first": "Please select an image first!"
|
||||||
},
|
},
|
||||||
"timeago": {
|
"timeago": {
|
||||||
"just_now": "just now",
|
"just_now": "just now",
|
||||||
@@ -695,5 +772,30 @@
|
|||||||
"left_hand_desc": "You know why.",
|
"left_hand_desc": "You know why.",
|
||||||
"replying_to": "Replying to {user}",
|
"replying_to": "Replying to {user}",
|
||||||
"reply": "Reply"
|
"reply": "Reply"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"section_title": "Invites",
|
||||||
|
"section_desc": "Invite new users to join. You must meet all criteria below to generate invite tokens.",
|
||||||
|
"eligible": "✓ You are eligible to generate invites.",
|
||||||
|
"not_eligible": "✗ You do not yet meet all criteria.",
|
||||||
|
"slots_used": "{used} / {total} invite slots used",
|
||||||
|
"criteria_uploads": "Uploads",
|
||||||
|
"criteria_age": "Account age",
|
||||||
|
"criteria_comments": "Comments",
|
||||||
|
"criteria_tags": "Tags assigned",
|
||||||
|
"criteria_days": " days",
|
||||||
|
"generate_btn": "Generate invite",
|
||||||
|
"generating": "Generating…",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"no_invites": "No invite tokens generated yet.",
|
||||||
|
"status_unused": "Unused",
|
||||||
|
"status_used_by": "Used by {user}",
|
||||||
|
"copy_btn": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"delete_btn": "Delete",
|
||||||
|
"delete_confirm": "Delete this invite token?",
|
||||||
|
"slot_refreshes_on": "slot refreshes on {date}",
|
||||||
|
"slot_refreshed": "slot refreshed",
|
||||||
|
"admin_desc": "You are an admin, go ahead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,17 @@
|
|||||||
"btn_add_urls": "URL(s) toevoegen",
|
"btn_add_urls": "URL(s) toevoegen",
|
||||||
"tags_required_shitpost": "Alle items hebben tags nodig",
|
"tags_required_shitpost": "Alle items hebben tags nodig",
|
||||||
"url_queued_background": "URL wordt op de achtergrond verwerkt.",
|
"url_queued_background": "URL wordt op de achtergrond verwerkt.",
|
||||||
"add_more": "Meer toevoegen"
|
"add_more": "Meer toevoegen",
|
||||||
|
"url_tracker_title": "URL Uploads",
|
||||||
|
"url_tracker_queued": "In wachtrij",
|
||||||
|
"url_tracker_downloading": "Downloaden",
|
||||||
|
"url_tracker_analyzing": "Analyseren",
|
||||||
|
"url_tracker_saving": "Opslaan",
|
||||||
|
"url_tracker_processing": "Verwerken",
|
||||||
|
"url_tracker_extracting": "Extraheren",
|
||||||
|
"url_tracker_complete": "Klaar!",
|
||||||
|
"url_tracker_failed": "Upload mislukt",
|
||||||
|
"url_tracker_view": "Bekijken →"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"registering": "Registreren...",
|
"registering": "Registreren...",
|
||||||
@@ -120,6 +130,7 @@
|
|||||||
"switching": "Omschakelen...",
|
"switching": "Omschakelen...",
|
||||||
"generating": "Genereren...",
|
"generating": "Genereren...",
|
||||||
"title": "Instellingen",
|
"title": "Instellingen",
|
||||||
|
"profile": "Profiel",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"current_avatar": "Huidige Avatar",
|
"current_avatar": "Huidige Avatar",
|
||||||
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
|
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
|
||||||
@@ -128,26 +139,46 @@
|
|||||||
"no_file_selected": "Geen bestand geselecteerd",
|
"no_file_selected": "Geen bestand geselecteerd",
|
||||||
"upload_btn": "Uploaden",
|
"upload_btn": "Uploaden",
|
||||||
"remove_custom": "Aangepaste Verwijderen",
|
"remove_custom": "Aangepaste Verwijderen",
|
||||||
|
"current_banner": "Huidige Banner",
|
||||||
|
"banner_hint": "Max 5MB. Toegestaan: gif, jpg, png, webp",
|
||||||
|
"no_banner_set": "Geen banner ingesteld",
|
||||||
"custom_description": "Aangepaste Beschrijving",
|
"custom_description": "Aangepaste Beschrijving",
|
||||||
"description_placeholder": "Voeg een beschrijving toe aan je profiel",
|
"description_placeholder": "Voeg een beschrijving toe aan je profiel",
|
||||||
"save_description": "Beschrijving Opslaan",
|
"save_description": "Beschrijving Opslaan",
|
||||||
"clear": "Wissen",
|
"clear": "Wissen",
|
||||||
"preferences": "Voorkeuren",
|
"preferences": "Voorkeuren",
|
||||||
"ui_section": "Gebruikersinterface",
|
"ui_section": "Gebruikersinterface",
|
||||||
|
"content_preferences_section": "Inhoudsvoorkeuren",
|
||||||
"appearance_section": "Uiterlijk",
|
"appearance_section": "Uiterlijk",
|
||||||
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
||||||
"modern_layout": "Moderne layout",
|
"modern_layout": "Moderne layout",
|
||||||
"modern_layout_hint": "Indeling met 3 kolommen",
|
"modern_layout_hint": "Indeling met 3 kolommen",
|
||||||
"alternative_infobox": "Alternatief auteur-informatievak",
|
"alternative_infobox": "Alternatief auteur-informatievak",
|
||||||
"alternative_infobox_hint": "Toont een uitgebreide auteurkaart met avatar en bio op itempagina's",
|
"alternative_infobox_hint": "Toont een uitgebreide auteurkaart met avatar en bio op itempagina's",
|
||||||
|
"alternative_steuerung": "Icoon-navigatiestijl",
|
||||||
|
"alternative_steuerung_hint": "Vervangt tekstnavigatie (← terug | willekeurig | verder →) door compacte chevron-iconen",
|
||||||
|
"f_for_fullscreen": "F is for Fullscreen",
|
||||||
|
"f_for_fullscreen_hint": "Drukken op F schakelt het volledige scherm in de videospeler in plaats van de post te favorieten.",
|
||||||
"disable_autoplay": "Automatisch afspelen uitschakelen",
|
"disable_autoplay": "Automatisch afspelen uitschakelen",
|
||||||
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
|
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
|
||||||
|
"hide_item_ratings": "Itembeoordelingen verbergen",
|
||||||
|
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
||||||
"disable_swiping": "Swipen uitschakelen",
|
"disable_swiping": "Swipen uitschakelen",
|
||||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||||
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
||||||
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
||||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||||
"enable_bg_blur_hint": "Vervaagde achtergrond tonen bij items",
|
"enable_bg_blur_hint": "Vervaagde achtergrond tonen bij items",
|
||||||
|
"blur_nsfw": "NSFW vervagen",
|
||||||
|
"blur_nsfw_hint": "Vervaagt NSFW-thumbnails.",
|
||||||
|
"blur_nsfl": "NSFL vervagen",
|
||||||
|
"blur_nsfl_hint": "Vervaagt NSFL-thumbnails.",
|
||||||
|
"blur_sfw": "SFW vervagen",
|
||||||
|
"blur_sfw_hint": "Vervaagt SFW-thumbnails.",
|
||||||
|
"blur_untagged": "Ongetagde vervagen",
|
||||||
|
"blur_untagged_hint": "Vervaagt ongetagde thumbnails.",
|
||||||
|
"blur_detail": "Details weigeren direct te tonen (vervagen)",
|
||||||
|
"blur_detail_hint": "Vereist klikken om vervaagde media op de detailpagina te onthullen.",
|
||||||
"render_emojis": "Emoji's weergeven in antwoorden",
|
"render_emojis": "Emoji's weergeven in antwoorden",
|
||||||
"render_emojis_hint": "Toon :emoji: afbeeldingen binnen >geciteerde regels",
|
"render_emojis_hint": "Toon :emoji: afbeeldingen binnen >geciteerde regels",
|
||||||
"embed_yt": "YouTube-links insluiten in opmerkingen",
|
"embed_yt": "YouTube-links insluiten in opmerkingen",
|
||||||
@@ -156,7 +187,7 @@
|
|||||||
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
||||||
"comment_display_mode": "Reactie-weergavemodus",
|
"comment_display_mode": "Reactie-weergavemodus",
|
||||||
"comment_display_tree": "Antwoordboom (Standaard)",
|
"comment_display_tree": "Antwoordboom (Standaard)",
|
||||||
"comment_display_linear": "Lineair / Vlak (4chan-stijl)",
|
"comment_display_linear": "Lineair",
|
||||||
"comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
|
"comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
|
||||||
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
|
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
|
||||||
"language": "Taal",
|
"language": "Taal",
|
||||||
@@ -262,7 +293,9 @@
|
|||||||
"toggle_rating": "itembeoordeling omschakelen",
|
"toggle_rating": "itembeoordeling omschakelen",
|
||||||
"play_pause": "afspelen/pauzeren",
|
"play_pause": "afspelen/pauzeren",
|
||||||
"next_prev": "volgende/vorige",
|
"next_prev": "volgende/vorige",
|
||||||
|
"volume": "volume",
|
||||||
"scroll_nav": "next/prev",
|
"scroll_nav": "next/prev",
|
||||||
|
"frame_skip": "frame-stap (gepauzeerd) / 5s overslaan",
|
||||||
"show_help": "help tonen"
|
"show_help": "help tonen"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -299,7 +332,10 @@
|
|||||||
"scroll_for_more": "Scroll voor meer..."
|
"scroll_for_more": "Scroll voor meer..."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"label": "Fout"
|
"label": "Fout",
|
||||||
|
"post_not_visible": "Sorry, deze post is momenteel niet zichtbaar.",
|
||||||
|
"filter_hint": "Je huidige filter is ingesteld op {mode}.",
|
||||||
|
"filter_hint_link": "Om deze inhoud te bekijken, pas je filter aan."
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"drop_anywhere": "Overal slepen om te uploaden"
|
"drop_anywhere": "Overal slepen om te uploaden"
|
||||||
@@ -311,7 +347,20 @@
|
|||||||
"attach_file": "Bestand bijvoegen",
|
"attach_file": "Bestand bijvoegen",
|
||||||
"uploading_file": "Uploaden...",
|
"uploading_file": "Uploaden...",
|
||||||
"remove_file": "Bestand verwijderen",
|
"remove_file": "Bestand verwijderen",
|
||||||
"file_too_large": "Bestand te groot"
|
"file_too_large": "Bestand te groot",
|
||||||
|
"poll_btn_title": "Peiling aanmaken",
|
||||||
|
"poll_question_placeholder": "Peilingvraag...",
|
||||||
|
"poll_option_placeholder": "Optie...",
|
||||||
|
"poll_add_option": "Optie toevoegen",
|
||||||
|
"poll_remove": "Peiling verwijderen",
|
||||||
|
"poll_vote": "Stemmen",
|
||||||
|
"poll_voted": "Je hebt gestemd",
|
||||||
|
"poll_votes": "stemmen",
|
||||||
|
"poll_vote_single": "stem",
|
||||||
|
"poll_delete": "Peiling verwijderen",
|
||||||
|
"poll_expired": "Peiling gesloten",
|
||||||
|
"poll_anonymous": "Anoniem",
|
||||||
|
"poll_public": "Openbare stemmen"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Selecteer een bestand",
|
"select_file": "Selecteer een bestand",
|
||||||
@@ -406,6 +455,10 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"loading_activity": "Activiteit laden...",
|
"loading_activity": "Activiteit laden...",
|
||||||
|
"no_activity": "Geen recente activiteit.",
|
||||||
|
"failed_to_load": "Laden mislukt.",
|
||||||
|
"loading_more": "Laden…",
|
||||||
|
"end_of_activity": "─ einde van activiteit ─",
|
||||||
"view": "Bekijken",
|
"view": "Bekijken",
|
||||||
"read_more": "lees meer",
|
"read_more": "lees meer",
|
||||||
"see_less": "zie minder",
|
"see_less": "zie minder",
|
||||||
@@ -446,6 +499,7 @@
|
|||||||
"stat_comments": "Totaal aantal reacties",
|
"stat_comments": "Totaal aantal reacties",
|
||||||
"stat_favs": "Totaal aantal favorieten",
|
"stat_favs": "Totaal aantal favorieten",
|
||||||
"stat_disk_usage": "Totale Bestandsgrootte",
|
"stat_disk_usage": "Totale Bestandsgrootte",
|
||||||
|
"stat_users": "Totaal Gebruikers",
|
||||||
"most_favorited": "Meest Gefavoriet",
|
"most_favorited": "Meest Gefavoriet",
|
||||||
"favs": "favorieten",
|
"favs": "favorieten",
|
||||||
"top_xd": "Top xD-scores"
|
"top_xd": "Top xD-scores"
|
||||||
@@ -518,6 +572,24 @@
|
|||||||
"found": "Gevonden in metadata:",
|
"found": "Gevonden in metadata:",
|
||||||
"no_results": "Geen extra metadata-velden gevonden in dit bestand."
|
"no_results": "Geen extra metadata-velden gevonden in dit bestand."
|
||||||
},
|
},
|
||||||
|
"info_modal": {
|
||||||
|
"title": "Post- & Bestandsinformatie",
|
||||||
|
"button_title": "Post- & Bestandsinformatie",
|
||||||
|
"id": "Post-ID",
|
||||||
|
"source": "Bron",
|
||||||
|
"uploader": "Uploader",
|
||||||
|
"uploaded_at": "Geüpload op",
|
||||||
|
"file_size": "Bestandsgrootte",
|
||||||
|
"mime_type": "MIME-type",
|
||||||
|
"rating": "Beoordeling",
|
||||||
|
"oc": "Originele Content (OC)",
|
||||||
|
"no": "Nee",
|
||||||
|
"direct_url": "Directe URL",
|
||||||
|
"view_file": "Bestand bekijken",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"sha256": "SHA-256 Hash",
|
||||||
|
"dimensions": "Afmetingen"
|
||||||
|
},
|
||||||
"meme": {
|
"meme": {
|
||||||
"add_text_layer": "Tekstlaag Toevoegen",
|
"add_text_layer": "Tekstlaag Toevoegen",
|
||||||
"tags_label": "Tags (gescheiden door komma's)",
|
"tags_label": "Tags (gescheiden door komma's)",
|
||||||
@@ -528,7 +600,12 @@
|
|||||||
"text_layer": "Tekstlaag",
|
"text_layer": "Tekstlaag",
|
||||||
"enter_text": "Voer tekst in...",
|
"enter_text": "Voer tekst in...",
|
||||||
"size_label": "Grootte",
|
"size_label": "Grootte",
|
||||||
"create_meme": "Meme Maken:"
|
"create_meme": "Meme Maken:",
|
||||||
|
"custom_template_title": "Eigen sjabloon gebruiken",
|
||||||
|
"custom_template_tag": "Lokaal bestand",
|
||||||
|
"select_image": "Selecteer eigen afbeelding",
|
||||||
|
"choose_file_btn": "Kies afbeelding",
|
||||||
|
"choose_image_first": "Selecteer eerst een afbeelding!"
|
||||||
},
|
},
|
||||||
"timeago": {
|
"timeago": {
|
||||||
"just_now": "zojuist",
|
"just_now": "zojuist",
|
||||||
@@ -691,5 +768,30 @@
|
|||||||
"left_hand_desc": "Je weet wel waarom.",
|
"left_hand_desc": "Je weet wel waarom.",
|
||||||
"replying_to": "Antwoord aan {user}",
|
"replying_to": "Antwoord aan {user}",
|
||||||
"reply": "Antwoorden"
|
"reply": "Antwoorden"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"section_title": "Uitnodigingen",
|
||||||
|
"section_desc": "Nodig nieuwe gebruikers uit. Je moet aan alle onderstaande criteria voldoen om uitnodigingstokens te genereren.",
|
||||||
|
"eligible": "✓ Je bent bevoegd om uitnodigingen te genereren.",
|
||||||
|
"not_eligible": "✗ Je voldoet nog niet aan alle criteria.",
|
||||||
|
"slots_used": "{used} / {total} uitnodigingsslots gebruikt",
|
||||||
|
"criteria_uploads": "Uploads",
|
||||||
|
"criteria_age": "Accountleeftijd",
|
||||||
|
"criteria_comments": "Opmerkingen",
|
||||||
|
"criteria_tags": "Toegewezen tags",
|
||||||
|
"criteria_days": " dagen",
|
||||||
|
"generate_btn": "Uitnodiging genereren",
|
||||||
|
"generating": "Genereren…",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"no_invites": "Nog geen uitnodigingstokens gegenereerd.",
|
||||||
|
"status_unused": "Ongebruikt",
|
||||||
|
"status_used_by": "Gebruikt door {user}",
|
||||||
|
"copy_btn": "Kopiëren",
|
||||||
|
"copied": "Gekopieërd!",
|
||||||
|
"delete_btn": "Verwijderen",
|
||||||
|
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
||||||
|
"slot_refreshes_on": "slot vernieuwd op {date}",
|
||||||
|
"slot_refreshed": "slot vernieuwd",
|
||||||
|
"admin_desc": "Je bent admin, ga je gang."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,17 @@
|
|||||||
"btn_add_urls": "Elfe hinzufügen",
|
"btn_add_urls": "Elfe hinzufügen",
|
||||||
"tags_required_shitpost": "Alle Fetzen brauchen Etiketten",
|
"tags_required_shitpost": "Alle Fetzen brauchen Etiketten",
|
||||||
"url_queued_background": "Elfen-Hintergrundverarbeitung gestartet.",
|
"url_queued_background": "Elfen-Hintergrundverarbeitung gestartet.",
|
||||||
"add_more": "Meer hinzufügen"
|
"add_more": "Meer hinzufügen",
|
||||||
|
"url_tracker_title": "Elfen-Aufladierungen",
|
||||||
|
"url_tracker_queued": "Warteschlange",
|
||||||
|
"url_tracker_downloading": "Wird hinunterladiert",
|
||||||
|
"url_tracker_analyzing": "Wird analysiert",
|
||||||
|
"url_tracker_saving": "Wird gespeichert",
|
||||||
|
"url_tracker_processing": "Wird verarbeitet",
|
||||||
|
"url_tracker_extracting": "Wird extrahiert",
|
||||||
|
"url_tracker_complete": "Fertig!",
|
||||||
|
"url_tracker_failed": "Aufladierung fehlgeschlagen",
|
||||||
|
"url_tracker_view": "Ansehen →"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"registering": "Registrierung wird in die Wege geleitet...",
|
"registering": "Registrierung wird in die Wege geleitet...",
|
||||||
@@ -89,7 +99,7 @@
|
|||||||
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
||||||
"confirm_password": "Kennwort bestätigen",
|
"confirm_password": "Kennwort bestätigen",
|
||||||
"email_placeholder": "E-Post",
|
"email_placeholder": "E-Post",
|
||||||
"invite_token": "Einladungskennzeichen",
|
"invite_token": "Einladungskots",
|
||||||
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
|
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
|
||||||
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
||||||
"tos_terms": "Nutzungsbedingungen",
|
"tos_terms": "Nutzungsbedingungen",
|
||||||
@@ -120,6 +130,7 @@
|
|||||||
"switching": "Umschaltung wird vorgenommen...",
|
"switching": "Umschaltung wird vorgenommen...",
|
||||||
"generating": "Generierung wird angestoßen...",
|
"generating": "Generierung wird angestoßen...",
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
|
"profile": "Profil",
|
||||||
"avatar": "Profilbild",
|
"avatar": "Profilbild",
|
||||||
"current_avatar": "Aktuelles Profilbild",
|
"current_avatar": "Aktuelles Profilbild",
|
||||||
"upload_custom_avatar": "Benutzerdefiniertes Profilbild aufladieren",
|
"upload_custom_avatar": "Benutzerdefiniertes Profilbild aufladieren",
|
||||||
@@ -128,26 +139,44 @@
|
|||||||
"no_file_selected": "Keine Datei ausgewählt",
|
"no_file_selected": "Keine Datei ausgewählt",
|
||||||
"upload_btn": "Aufladieren",
|
"upload_btn": "Aufladieren",
|
||||||
"remove_custom": "Benutzerdefiniertes entfernen",
|
"remove_custom": "Benutzerdefiniertes entfernen",
|
||||||
|
"current_banner": "Aktuelles Banner",
|
||||||
|
"banner_hint": "Max 5MB. Erlaubt: gif, jpg, png, webp",
|
||||||
|
"no_banner_set": "Kein Banner gesetzt",
|
||||||
"custom_description": "Benutzerdefinierte Beschreibung",
|
"custom_description": "Benutzerdefinierte Beschreibung",
|
||||||
"description_placeholder": "Fügen Sie Ihrem Profil eine Beschreibung hinzu",
|
"description_placeholder": "Fügen Sie Ihrem Profil eine Beschreibung hinzu",
|
||||||
"save_description": "Beschreibung speichern",
|
"save_description": "Beschreibung speichern",
|
||||||
"clear": "Leeren",
|
"clear": "Leeren",
|
||||||
"preferences": "Präferenzen",
|
"preferences": "Präferenzen",
|
||||||
"ui_section": "Benutzeroberfläche",
|
"ui_section": "Benutzeroberfläche",
|
||||||
|
"content_preferences_section": "Inhaltseinstellungen",
|
||||||
"appearance_section": "Erscheinungsbild",
|
"appearance_section": "Erscheinungsbild",
|
||||||
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
||||||
"modern_layout": "Modernes Layout",
|
"modern_layout": "Modernes Layout",
|
||||||
"modern_layout_hint": "3-Spalten-Layout",
|
"modern_layout_hint": "3-Spalten-Layout",
|
||||||
"alternative_infobox": "Alternativer Autor-Infoblock",
|
"alternative_infobox": "Alternativer Autor-Infoblock",
|
||||||
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
||||||
|
"alternative_steuerung": "Icon-Navigationsstil",
|
||||||
|
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
|
||||||
|
"f_for_fullscreen": "F is for Fullscreen",
|
||||||
|
"f_for_fullscreen_hint": "Das Drücken der F-Taste leitet die Vollbildlichkeit des Abspielers ein, statt den Pfosten zu favorisieren.",
|
||||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||||
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
|
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
|
||||||
|
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||||
|
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||||
"disable_swiping": "Wischen deaktivieren",
|
"disable_swiping": "Wischen deaktivieren",
|
||||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||||
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
||||||
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
||||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||||
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
||||||
|
"blur_nsfw": "NSFW verwischen",
|
||||||
|
"blur_nsfw_hint": "Verwischt NSFW-Vorschaubilder.",
|
||||||
|
"blur_nsfl": "NSFL verwischen",
|
||||||
|
"blur_nsfl_hint": "Verwischt NSFL-Vorschaubilder.",
|
||||||
|
"blur_sfw": "SFW verwischen",
|
||||||
|
"blur_sfw_hint": "Verwischt SFW-Vorschaubilder.",
|
||||||
|
"blur_untagged": "Unmarkierte verwischen",
|
||||||
|
"blur_untagged_hint": "Verwischt unmarkierte Vorschaubilder.",
|
||||||
"render_emojis": "Emojis in Zitatantworten darstellen",
|
"render_emojis": "Emojis in Zitatantworten darstellen",
|
||||||
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
||||||
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
|
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
|
||||||
@@ -156,7 +185,7 @@
|
|||||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||||
"comment_display_linear": "Linear / Flach (Vierkanal-Stil)",
|
"comment_display_linear": "Linear",
|
||||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||||
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
|
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
@@ -262,7 +291,9 @@
|
|||||||
"toggle_rating": "Elementbewertung umschalten",
|
"toggle_rating": "Elementbewertung umschalten",
|
||||||
"play_pause": "Abspielen/Pause",
|
"play_pause": "Abspielen/Pause",
|
||||||
"next_prev": "nächster/vorheriger",
|
"next_prev": "nächster/vorheriger",
|
||||||
|
"volume": "Lautstärke",
|
||||||
"scroll_nav": "nächster/vorheriger",
|
"scroll_nav": "nächster/vorheriger",
|
||||||
|
"frame_skip": "Einzelbildrücken (pausiert) / 5s Hüpfen",
|
||||||
"show_help": "Hilfe anzeigen"
|
"show_help": "Hilfe anzeigen"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -299,7 +330,10 @@
|
|||||||
"scroll_for_more": "Scrollen für mehr..."
|
"scroll_for_more": "Scrollen für mehr..."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"label": "Fehler"
|
"label": "Fehler",
|
||||||
|
"post_not_visible": "Bedauerlicher Weise ist dieser Pfosten derzeit nicht sichtbar.",
|
||||||
|
"filter_hint": "Ihr aktueller Filter ist auf {mode} eingestellt.",
|
||||||
|
"filter_hint_link": "Um diesen Inhalt zu betrachten, stellen Sie Ihren Filter entsprechend um."
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"drop_anywhere": "Überall ablegen zum Aufladieren"
|
"drop_anywhere": "Überall ablegen zum Aufladieren"
|
||||||
@@ -311,7 +345,20 @@
|
|||||||
"attach_file": "Datei anflanschen",
|
"attach_file": "Datei anflanschen",
|
||||||
"uploading_file": "Wird aufladiert...",
|
"uploading_file": "Wird aufladiert...",
|
||||||
"remove_file": "Datei entfernen",
|
"remove_file": "Datei entfernen",
|
||||||
"file_too_large": "Datei zu voluminös"
|
"file_too_large": "Datei zu voluminös",
|
||||||
|
"poll_btn_title": "Umfrage erstellen",
|
||||||
|
"poll_question_placeholder": "Frage",
|
||||||
|
"poll_option_placeholder": "Option",
|
||||||
|
"poll_add_option": "Option hinzufügen",
|
||||||
|
"poll_remove": "Umfrage entfernen",
|
||||||
|
"poll_vote": "Abstimmen",
|
||||||
|
"poll_voted": "Abgestimmt",
|
||||||
|
"poll_votes": "Stimmen",
|
||||||
|
"poll_vote_single": "Stimme",
|
||||||
|
"poll_delete": "Umfrage löschen",
|
||||||
|
"poll_expired": "Umfrage geschlossen",
|
||||||
|
"poll_anonymous": "Anonym",
|
||||||
|
"poll_public": "Öffentliche Stimmen"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Datei auswählen",
|
"select_file": "Datei auswählen",
|
||||||
@@ -409,6 +456,10 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"loading_activity": "Aktivität wird geladen...",
|
"loading_activity": "Aktivität wird geladen...",
|
||||||
|
"no_activity": "Noch keine Aktivität",
|
||||||
|
"failed_to_load": "Ladung gescheitert.",
|
||||||
|
"loading_more": "Ladung wird aufbereitet…",
|
||||||
|
"end_of_activity": "─ Ende des Aktivitätsfadens ─",
|
||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
"read_more": "mehr sehen",
|
"read_more": "mehr sehen",
|
||||||
"see_less": "weniger sehen",
|
"see_less": "weniger sehen",
|
||||||
@@ -449,6 +500,7 @@
|
|||||||
"stat_comments": "Gesamtanzahl Kommentare",
|
"stat_comments": "Gesamtanzahl Kommentare",
|
||||||
"stat_favs": "Gesamtanzahl Favoriten",
|
"stat_favs": "Gesamtanzahl Favoriten",
|
||||||
"stat_disk_usage": "Dateigröße Gesamt",
|
"stat_disk_usage": "Dateigröße Gesamt",
|
||||||
|
"stat_users": "Gesamt Benutzer",
|
||||||
"most_favorited": "Am häufigsten favorisiert",
|
"most_favorited": "Am häufigsten favorisiert",
|
||||||
"favs": "Favoriten",
|
"favs": "Favoriten",
|
||||||
"top_xd": "Beste xD-Punktestände"
|
"top_xd": "Beste xD-Punktestände"
|
||||||
@@ -521,6 +573,24 @@
|
|||||||
"found": "In den Metadaten gefunden:",
|
"found": "In den Metadaten gefunden:",
|
||||||
"no_results": "Keine weiteren Metadatenfelder in dieser Datei gefunden."
|
"no_results": "Keine weiteren Metadatenfelder in dieser Datei gefunden."
|
||||||
},
|
},
|
||||||
|
"info_modal": {
|
||||||
|
"title": "Pfosten- & Datei-Informationen",
|
||||||
|
"button_title": "Pfosten- & Datei-Informationen",
|
||||||
|
"id": "Pfosten-ID",
|
||||||
|
"source": "Quelle",
|
||||||
|
"uploader": "Hochgeladen von",
|
||||||
|
"uploaded_at": "Hochgeladen am",
|
||||||
|
"file_size": "Dateigröße",
|
||||||
|
"mime_type": "MIME-Typ",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"oc": "Originaler Inhalt (OC)",
|
||||||
|
"no": "Nein",
|
||||||
|
"direct_url": "Direktelfe",
|
||||||
|
"view_file": "Datei betrachten",
|
||||||
|
"metadata": "Metadaten",
|
||||||
|
"sha256": "SHA-256-Streuwert",
|
||||||
|
"dimensions": "Abmessungen"
|
||||||
|
},
|
||||||
"meme": {
|
"meme": {
|
||||||
"add_text_layer": "Textebene hinzufügen",
|
"add_text_layer": "Textebene hinzufügen",
|
||||||
"tags_label": "Etiketten (kommagetrennt)",
|
"tags_label": "Etiketten (kommagetrennt)",
|
||||||
@@ -531,7 +601,12 @@
|
|||||||
"text_layer": "Textebene",
|
"text_layer": "Textebene",
|
||||||
"enter_text": "Text eingeben...",
|
"enter_text": "Text eingeben...",
|
||||||
"size_label": "Größe",
|
"size_label": "Größe",
|
||||||
"create_meme": "Memel erstellen:"
|
"create_meme": "Memel erstellen:",
|
||||||
|
"custom_template_title": "Eigene Vorlage nutzen",
|
||||||
|
"custom_template_tag": "Lokale Datei",
|
||||||
|
"select_image": "Eigenes Bild auswählen",
|
||||||
|
"choose_file_btn": "Bild aussuchen",
|
||||||
|
"choose_image_first": "Zuerst Vorlage auswählen"
|
||||||
},
|
},
|
||||||
"timeago": {
|
"timeago": {
|
||||||
"just_now": "gerade eben",
|
"just_now": "gerade eben",
|
||||||
@@ -696,5 +771,30 @@
|
|||||||
"left_hand_desc": "Sie wissen schon wieso.",
|
"left_hand_desc": "Sie wissen schon wieso.",
|
||||||
"replying_to": "Antwort an {user}",
|
"replying_to": "Antwort an {user}",
|
||||||
"reply": "Antworten"
|
"reply": "Antworten"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"section_title": "Einladungswesen",
|
||||||
|
"section_desc": "Laden Sie neue Nutzer ein, beizutreten. Sie müssen alle nachstehenden Kriterien erfüllen, um Einladungskots zu erzeugen.",
|
||||||
|
"eligible": "✓ Sie sind berechtigt, Einladungen zu erzeugen.",
|
||||||
|
"not_eligible": "✗ Sie erfüllen noch nicht alle Kriterien.",
|
||||||
|
"slots_used": "{used} / {total} Einladungsplätze in Benutzung",
|
||||||
|
"criteria_uploads": "Aufladierungen",
|
||||||
|
"criteria_age": "Kontoalter",
|
||||||
|
"criteria_comments": "Kommentare",
|
||||||
|
"criteria_tags": "Vergebene Etiketten",
|
||||||
|
"criteria_days": " Tage",
|
||||||
|
"generate_btn": "Einladung erzeugen",
|
||||||
|
"generating": "Erzeugung wird durchgeführt…",
|
||||||
|
"loading": "Ladung wird aufbereitet…",
|
||||||
|
"no_invites": "Noch keine Einladungskots erzeugt.",
|
||||||
|
"status_unused": "Ungebraucht",
|
||||||
|
"status_used_by": "Gebraucht von {user}",
|
||||||
|
"copy_btn": "Kopieren",
|
||||||
|
"copied": "Kopiert!",
|
||||||
|
"delete_btn": "Löschen",
|
||||||
|
"delete_confirm": "Diesen Einladungskot löschen?",
|
||||||
|
"slot_refreshes_on": "Platz erneuert sich am {date}",
|
||||||
|
"slot_refreshed": "Platz erneuert",
|
||||||
|
"admin_desc": "Du bist Admin, mach weiter."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,28 @@ import cfg from "./config.mjs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
|
||||||
|
function isFlatFrame(buffer) {
|
||||||
|
if (!buffer || buffer.length !== 1056) return true;
|
||||||
|
let min = 255;
|
||||||
|
let max = 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
const val = buffer[i];
|
||||||
|
if (val < min) min = val;
|
||||||
|
if (val > max) max = val;
|
||||||
|
sum += val;
|
||||||
|
}
|
||||||
|
const mean = sum / buffer.length;
|
||||||
|
if (mean < 15 || mean > 240) return true;
|
||||||
|
|
||||||
|
let sqDiffSum = 0;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
sqDiffSum += Math.pow(buffer[i] - mean, 2);
|
||||||
|
}
|
||||||
|
const variance = sqDiffSum / buffer.length;
|
||||||
|
return variance < 10 || (max - min) < 15;
|
||||||
|
}
|
||||||
|
|
||||||
export default new class queue {
|
export default new class queue {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -85,31 +107,52 @@ export default new class queue {
|
|||||||
async generatePHash(source) {
|
async generatePHash(source) {
|
||||||
try {
|
try {
|
||||||
// Temporal dHash implementation:
|
// Temporal dHash implementation:
|
||||||
// 1. Get duration.
|
// 1. Check if source is image/video and get duration.
|
||||||
// 2. Extract 3 frames: 10%, 50%, 90%.
|
// 2. For videos: Extract 3 frames (10%, 50%, 90% of duration).
|
||||||
// 3. Generate dHash for each.
|
// For static images: Extract 1 frame.
|
||||||
// 4. Return combined hash "hash1_hash2_hash3".
|
// 3. Generate dHash for each valid non-flat frame.
|
||||||
|
// 4. Return combined hash "hash1_hash2_hash3" or single "hash".
|
||||||
|
|
||||||
// Skip ffprobe for PDFs (which would fail with "Invalid data")
|
// Skip ffprobe for PDFs (which would fail with "Invalid data")
|
||||||
if (source.toLowerCase().endsWith('.pdf')) {
|
if (source.toLowerCase().endsWith('.pdf')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isVideo = true;
|
||||||
|
let timestamps = [];
|
||||||
|
|
||||||
|
try {
|
||||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||||
const duration = parseFloat(durationStr);
|
const duration = parseFloat(durationStr);
|
||||||
if (isNaN(duration) || duration <= 0) return null;
|
if (isNaN(duration) || duration <= 0) {
|
||||||
|
isVideo = false;
|
||||||
|
} else {
|
||||||
|
timestamps = [duration * 0.1, duration * 0.5, duration * 0.9];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
isVideo = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVideo) {
|
||||||
|
timestamps = [0]; // Process static image as single frame
|
||||||
|
}
|
||||||
|
|
||||||
const timestamps = [duration * 0.1, duration * 0.5, duration * 0.9];
|
|
||||||
const hashes = [];
|
const hashes = [];
|
||||||
|
|
||||||
for (const ts of timestamps) {
|
for (const ts of timestamps) {
|
||||||
let buffer;
|
let buffer;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await this.spawn('ffmpeg', ['-ss', ts.toString(), '-v', 'error', '-i', source, '-vf', 'thumbnail,scale=33:32,format=gray', '-frames:v', '1', '-f', 'rawvideo', 'pipe:1'], { encoding: 'buffer', quiet: true });
|
const vf = isVideo ? 'thumbnail,scale=33:32,format=gray' : 'scale=33:32,format=gray';
|
||||||
|
const args = [];
|
||||||
|
if (isVideo) {
|
||||||
|
args.push('-ss', ts.toString());
|
||||||
|
}
|
||||||
|
args.push('-v', 'error', '-i', source, '-vf', vf, '-frames:v', '1', '-f', 'rawvideo', 'pipe:1');
|
||||||
|
|
||||||
|
const { stdout } = await this.spawn('ffmpeg', args, { encoding: 'buffer', quiet: true });
|
||||||
buffer = stdout;
|
buffer = stdout;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[PHASH] Failed to extract frame at ${ts}s for ${source}: ${err.message}`);
|
console.warn(`[PHASH] Failed to extract frame at ${ts}s for ${source}: ${err.message}`);
|
||||||
// Buffer remains undefined, triggering fallback below
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!buffer || buffer.length !== 1056) {
|
if (!buffer || buffer.length !== 1056) {
|
||||||
@@ -117,6 +160,12 @@ export default new class queue {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out flat/black frames (e.g. solid color backgrounds, fade-to-black)
|
||||||
|
if (isFlatFrame(buffer)) {
|
||||||
|
console.log(`[PHASH] Ignored flat/black frame at ${ts}s for ${source}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let hash = '';
|
let hash = '';
|
||||||
let currentByte = 0;
|
let currentByte = 0;
|
||||||
let bitCount = 0;
|
let bitCount = 0;
|
||||||
@@ -151,72 +200,122 @@ export default new class queue {
|
|||||||
|
|
||||||
async checkrepostphash(newHash) {
|
async checkrepostphash(newHash) {
|
||||||
if (!newHash) return false;
|
if (!newHash) return false;
|
||||||
const newHashes = newHash.split('_');
|
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||||
if (newHashes.length === 0) return false;
|
if (newHashes.length === 0) return false;
|
||||||
|
|
||||||
// Fetch all phashes, filtering out "all zero" failed hashes
|
const h1 = newHashes[0] || '';
|
||||||
const items = await db`
|
const h2 = newHashes[1] || '';
|
||||||
SELECT id, phash FROM items
|
const h3 = newHashes[2] || '';
|
||||||
WHERE phash IS NOT NULL
|
|
||||||
AND phash != ''
|
const results = await db`
|
||||||
AND phash NOT LIKE '00000000%'
|
SELECT id FROM items
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND phash IS NOT NULL AND phash != '' AND phash != 'ERROR' AND phash != 'MISSING' AND phash NOT LIKE '00000000%'
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
) >= (
|
||||||
|
CASE
|
||||||
|
WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN 2
|
||||||
|
WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Configurable threshold: max Hamming distance per 256-bit dHash frame.
|
return results.length > 0 ? results[0].id : false;
|
||||||
// A value of 15 means < 6% bit difference — tight enough to only match true duplicates.
|
|
||||||
const THRESHOLD = 15;
|
|
||||||
|
|
||||||
const getHammingDistance = (h1, h2) => {
|
|
||||||
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
|
||||||
let distance = 0;
|
|
||||||
for (let i = 0; i < h1.length; i += 2) {
|
|
||||||
const v1 = parseInt(h1.substr(i, 2), 16);
|
|
||||||
const v2 = parseInt(h2.substr(i, 2), 16);
|
|
||||||
let xor = v1 ^ v2;
|
|
||||||
while (xor) {
|
|
||||||
distance += xor & 1;
|
|
||||||
xor >>= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return distance;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// We want at least 2 out of 3 frames to match
|
async findallrepostphash(newHash, excludeId = null) {
|
||||||
const REQUIRED_MATCHES = 2;
|
if (!newHash) return [];
|
||||||
|
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||||
|
if (newHashes.length === 0) return [];
|
||||||
|
|
||||||
for (const item of items) {
|
const h1 = newHashes[0] || '';
|
||||||
// Handle legacy single hashes vs new multi-hashes
|
const h2 = newHashes[1] || '';
|
||||||
const dbHashes = item.phash.split('_');
|
const h3 = newHashes[2] || '';
|
||||||
|
|
||||||
let matches = 0;
|
const results = await db`
|
||||||
// Compare corresponding frames: 0vs0, 1vs1, 2vs2
|
SELECT id, username, stamp FROM items
|
||||||
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
WHERE is_deleted = false
|
||||||
|
AND phash IS NOT NULL AND phash != '' AND phash != 'ERROR' AND phash != 'MISSING' AND phash NOT LIKE '00000000%'
|
||||||
|
${excludeId ? db`AND id != ${excludeId}` : db``}
|
||||||
|
AND (
|
||||||
|
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
>= 1
|
||||||
|
)
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
for (let i = 0; i < framesToCompare; i++) {
|
return results.map(r => ({ id: r.id, username: r.username, stamp: r.stamp }));
|
||||||
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
};
|
||||||
if (dist <= THRESHOLD) {
|
|
||||||
matches++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have 3 frames, require 2 out of 3 matches.
|
async checkcommentrepostphash(newHash) {
|
||||||
// If we are comparing against a legacy 1-frame hash, require that single frame to match.
|
if (!newHash) return false;
|
||||||
if (framesToCompare >= 3 && matches >= REQUIRED_MATCHES) {
|
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||||
return item.id;
|
if (newHashes.length === 0) return false;
|
||||||
} else if (framesToCompare === 1 && matches === 1) {
|
|
||||||
return item.id;
|
|
||||||
} else if (framesToCompare === 2 && matches >= 2) {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
const h1 = newHashes[0] || '';
|
||||||
|
const h2 = newHashes[1] || '';
|
||||||
|
const h3 = newHashes[2] || '';
|
||||||
|
|
||||||
|
const results = await db`
|
||||||
|
SELECT id, dest FROM comment_files
|
||||||
|
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
+
|
||||||
|
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||||
|
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||||
|
ELSE false END::int
|
||||||
|
) >= (
|
||||||
|
CASE
|
||||||
|
WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN 2
|
||||||
|
WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
return results.length > 0 ? results[0] : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
async genuuid() {
|
async genuuid() {
|
||||||
return (await db`
|
const raw = (await db`
|
||||||
select gen_random_uuid() as uuid
|
select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid
|
||||||
`)[0].uuid.substring(0, 8);
|
`)[0].uuid;
|
||||||
|
return raw.substring(0, 48);
|
||||||
};
|
};
|
||||||
|
|
||||||
async checkrepostlink(link) {
|
async checkrepostlink(link) {
|
||||||
@@ -273,6 +372,10 @@ export default new class queue {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
const outPath = path.join(tDir, itemid + '.webp');
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
if (mime === 'video/youtube') {
|
if (mime === 'video/youtube') {
|
||||||
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
|
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
@@ -306,15 +409,51 @@ export default new class queue {
|
|||||||
const ffThumbSize = Math.max(thumbSize, 512);
|
const ffThumbSize = Math.max(thumbSize, 512);
|
||||||
const seeks = ['20%', '40%', '60%', '80%'];
|
const seeks = ['20%', '40%', '60%', '80%'];
|
||||||
for (const seek of seeks) {
|
for (const seek of seeks) {
|
||||||
|
try {
|
||||||
await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[QUEUE] ffmpegthumbnailer failed at ${seek} for ${itemid}, trying ffmpeg fallback: ${err.message}`);
|
||||||
|
let seekSeconds = 0;
|
||||||
|
try {
|
||||||
|
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', sourcePath])).stdout.trim();
|
||||||
|
const duration = parseFloat(durationStr);
|
||||||
|
if (!isNaN(duration) && duration > 0) {
|
||||||
|
const pct = parseFloat(seek) / 100;
|
||||||
|
seekSeconds = duration * pct;
|
||||||
|
}
|
||||||
|
} catch (probeErr) {
|
||||||
|
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
|
||||||
|
}
|
||||||
|
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
|
||||||
|
await this.spawn('ffmpeg', [
|
||||||
|
'-y',
|
||||||
|
'-ss', String(seekSeconds),
|
||||||
|
'-color_trc', '1',
|
||||||
|
'-i', sourcePath,
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-update', '1',
|
||||||
|
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
|
||||||
|
tmpFile
|
||||||
|
]);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||||
} catch (e) { break; }
|
} catch (e) { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (mime.startsWith('image/') && mime != 'image/gif')
|
else if (mime.startsWith('image/') && mime != 'image/gif') {
|
||||||
await this.spawn('magick', [sourcePath + '[0]', tmpFile]);
|
if (mime === 'image/avif') {
|
||||||
|
try {
|
||||||
|
await this.spawn('ffmpeg', ['-i', sourcePath, '-frames:v', '1', '-update', '1', tmpFile]);
|
||||||
|
} catch (err) {
|
||||||
|
// If ffmpeg fails, fallback to magick
|
||||||
|
await this.spawn('magick', [sourcePath + '[0]', '-auto-orient', tmpFile]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.spawn('magick', [sourcePath + '[0]', '-auto-orient', tmpFile]);
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (mime.startsWith('audio/')) {
|
else if (mime.startsWith('audio/')) {
|
||||||
let coverExtracted = false;
|
let coverExtracted = false;
|
||||||
this._lastCoverExtracted = false; // Reset state for this call
|
this._lastCoverExtracted = false; // Reset state for this call
|
||||||
@@ -421,11 +560,83 @@ export default new class queue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (mime.startsWith('application/') && cfg.mimes[mime] && !['swf', 'pdf'].includes(cfg.mimes[mime])) {
|
||||||
|
// Any application/* MIME registered in config that isn't swf or pdf is treated as an archive.
|
||||||
|
// Both the detection and the label come straight from cfg.mimes — no hardcoded list needed.
|
||||||
|
let customThumb = cfg.websrv && cfg.websrv.archive_thumb;
|
||||||
|
if (customThumb && customThumb.startsWith('/')) {
|
||||||
|
customThumb = path.join(path.resolve(), 'public', customThumb);
|
||||||
|
}
|
||||||
|
let usedCustom = false;
|
||||||
|
if (customThumb) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(customThumb).catch(() => null);
|
||||||
|
if (stat && stat.size > 0) {
|
||||||
|
await this.spawn('magick', [customThumb, tmpFile]);
|
||||||
|
usedCustom = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!usedCustom) {
|
||||||
|
const archiveLabel = (cfg.mimes[mime] || 'arc').toUpperCase();
|
||||||
|
await this.spawn('magick', [
|
||||||
|
'-size', thumbSpec, 'xc:#1a2e1a',
|
||||||
|
'-gravity', 'center',
|
||||||
|
'-fill', '#66bb6a',
|
||||||
|
'-pointsize', '48',
|
||||||
|
'-annotate', '0', archiveLabel,
|
||||||
|
tmpFile
|
||||||
|
]).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.spawn('magick', [tmpFile, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', path.join(tDir, itemid + '.webp')]);
|
|
||||||
|
const isTransparentMime = mime === 'image/png' || mime === 'image/webp' || mime === 'image/avif' || mime === 'image/gif';
|
||||||
|
if (isTransparentMime) {
|
||||||
|
// Build a grey/white checkerboard via explicit xc: squares (no pattern replacement tricks):
|
||||||
|
// row1 = [white | grey], row2 = row1 flipped → stack into a 2x2 tile → tile to thumbSpec
|
||||||
|
const sq = 16;
|
||||||
|
const tmpRow1 = path.join(os.tmpdir(), `${itemid}_r1.png`);
|
||||||
|
const tmpRow2 = path.join(os.tmpdir(), `${itemid}_r2.png`);
|
||||||
|
const tmpTile = path.join(os.tmpdir(), `${itemid}_tile.png`);
|
||||||
|
const tmpBg = path.join(os.tmpdir(), `${itemid}_bg.png`);
|
||||||
|
const tmpResized = path.join(os.tmpdir(), `${itemid}_rs.png`);
|
||||||
|
try {
|
||||||
|
await this.spawn('magick', ['-size', `${sq}x${sq}`, 'xc:white', '-size', `${sq}x${sq}`, 'xc:#cccccc', '+append', tmpRow1]);
|
||||||
|
await this.spawn('magick', [tmpRow1, '-flop', tmpRow2]);
|
||||||
|
await this.spawn('magick', [tmpRow1, tmpRow2, '-append', tmpTile]);
|
||||||
|
await this.spawn('magick', ['-size', thumbSpec, `tile:${tmpTile}`, '-define', 'png:color-type=2', tmpBg]);
|
||||||
|
// Resize/crop source image preserving alpha channel; force sRGB so palette images retain color
|
||||||
|
await this.spawn('magick', [tmpFile, '-auto-orient', '-colorspace', 'sRGB', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', tmpResized]);
|
||||||
|
// Composite: Over operator — image (with alpha) on top of opaque checkerboard bg
|
||||||
|
await this.spawn('magick', [tmpBg, tmpResized, '-composite', outPath]);
|
||||||
|
} finally {
|
||||||
|
for (const f of [tmpRow1, tmpRow2, tmpTile, tmpBg, tmpResized]) {
|
||||||
|
await fs.promises.unlink(f).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.spawn('magick', [tmpFile, '-auto-orient', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', outPath]);
|
||||||
|
}
|
||||||
await fs.promises.unlink(tmpFile).catch(_ => { });
|
await fs.promises.unlink(tmpFile).catch(_ => { });
|
||||||
await fs.promises.unlink(tmpJpg).catch(_ => { });
|
await fs.promises.unlink(tmpJpg).catch(_ => { });
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[QUEUE] genThumbnail failed for item ${itemid} (${mime}):`, err.message || err);
|
||||||
|
// Cleanup temp files
|
||||||
|
await fs.promises.unlink(tmpFile).catch(() => {});
|
||||||
|
await fs.promises.unlink(tmpJpg).catch(() => {});
|
||||||
|
// Fallback: copy 404.gif as the thumbnail
|
||||||
|
const fallback404 = path.join(cfg.paths.s, 'img', '404.gif');
|
||||||
|
try {
|
||||||
|
await this.spawn('magick', [fallback404, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', outPath]);
|
||||||
|
console.warn(`[QUEUE] Used 404.gif fallback thumbnail for item ${itemid}`);
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error(`[QUEUE] Even fallback thumbnail failed for item ${itemid}:`, fallbackErr.message || fallbackErr);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -434,7 +645,7 @@ export default new class queue {
|
|||||||
const src = path.join(tDir, `${itemid}.webp`);
|
const src = path.join(tDir, `${itemid}.webp`);
|
||||||
const dst = path.join(tDir, `${itemid}_blur.webp`);
|
const dst = path.join(tDir, `${itemid}_blur.webp`);
|
||||||
try {
|
try {
|
||||||
await this.spawn('magick', [src, '-blur', '0x20', dst]);
|
await this.spawn('magick', [src, '-blur', '0x48', dst]);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[QUEUE] Failed to generate blurred thumbnail for ${itemid}:`, err);
|
console.error(`[QUEUE] Failed to generate blurred thumbnail for ${itemid}:`, err);
|
||||||
|
|||||||
@@ -2,14 +2,55 @@ import db from "../sql.mjs";
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { updateHallsCache } from "../halls_cache.mjs";
|
import { updateHallsCache } from "../halls_cache.mjs";
|
||||||
|
import queue from "../queue.mjs";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
|
|
||||||
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
|
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||||
|
|
||||||
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
|
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
|
||||||
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
||||||
|
|
||||||
|
// ── Count cache ─────────────────────────────────────────────────────────────
|
||||||
|
// The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan).
|
||||||
|
// Cache it per unique filter combination for 90 seconds so that navigating
|
||||||
|
// between pages 1→192 with the same filters skips the COUNT entirely.
|
||||||
|
const COUNT_CACHE_TTL_MS = 90_000;
|
||||||
|
const countCache = new Map(); // key → { total, expiresAt }
|
||||||
|
|
||||||
|
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }) {
|
||||||
|
return JSON.stringify([
|
||||||
|
modequery,
|
||||||
|
tag ?? '',
|
||||||
|
user ?? '',
|
||||||
|
hall ?? '',
|
||||||
|
mime ?? '',
|
||||||
|
fav ? 1 : 0,
|
||||||
|
session ? 1 : 0, // guests get globalfilter applied; members don't
|
||||||
|
excludedTags.slice().sort().join(','),
|
||||||
|
newerThan ?? '',
|
||||||
|
minXd,
|
||||||
|
userHallObj?.id ?? ''
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedCount(key) {
|
||||||
|
const entry = countCache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) { countCache.delete(key); return null; }
|
||||||
|
return entry.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedCount(key, total) {
|
||||||
|
countCache.set(key, { total, expiresAt: Date.now() + COUNT_CACHE_TTL_MS });
|
||||||
|
// Prevent unbounded growth — evict all expired entries when cache grows large
|
||||||
|
if (countCache.size > 500) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of countCache) { if (now > v.expiresAt) countCache.delete(k); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const processMentions = async (comments) => {
|
const processMentions = async (comments) => {
|
||||||
if (!comments || comments.length === 0) return comments;
|
if (!comments || comments.length === 0) return comments;
|
||||||
|
|
||||||
@@ -79,25 +120,31 @@ const computeXdScore = (comments) => {
|
|||||||
for (const c of comments) {
|
for (const c of comments) {
|
||||||
if (!c.content || c.is_deleted) continue;
|
if (!c.content || c.is_deleted) continue;
|
||||||
for (const m of c.content.matchAll(xdRegex)) {
|
for (const m of c.content.matchAll(xdRegex)) {
|
||||||
score += m[1].length; // 1pt per D: xD=1, xDD=2, xDDD=3, ...
|
score += m[1].length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return score;
|
return score;
|
||||||
};
|
};
|
||||||
|
|
||||||
const xdScoreMeta = (score) => {
|
const xdScoreMeta = (score) => {
|
||||||
if (score <= 0) return { tier: 0, label: '' };
|
if (score < 1) return { tier: 0, label: '' };
|
||||||
if (score < 5) return { tier: 1, label: 'xD' };
|
if (score < 200) return { tier: 1, label: 'xD' };
|
||||||
if (score < 15) return { tier: 2, label: 'xDD' };
|
if (score < 1000) return { tier: 2, label: 'xDD' };
|
||||||
if (score < 30) return { tier: 3, label: 'xDDD' };
|
if (score < 100000) return { tier: 3, label: 'xDDD' };
|
||||||
if (score < 60) return { tier: 4, label: 'xDDDD' };
|
if (score < 20000000) return { tier: 4, label: 'xDDDD' };
|
||||||
return { tier: 5, label: 'xDDDDD+' };
|
return { tier: 5, label: 'xDDDDD+' };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => {
|
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => {
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
const tag = lib.parseTag(rawTag ?? null);
|
|
||||||
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
|
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
|
||||||
|
const isTitleSearch = _decodedTag.startsWith('title:');
|
||||||
|
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
|
||||||
|
|
||||||
|
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
|
||||||
let hall = rawHall ?? null;
|
let hall = rawHall ?? null;
|
||||||
let hallObj = null;
|
let hallObj = null;
|
||||||
if (hall) {
|
if (hall) {
|
||||||
@@ -142,12 +189,18 @@ export default {
|
|||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||||
const isStrict = strictParams.length > 0;
|
const isStrict = strictParams.length > 0;
|
||||||
|
|
||||||
const tmp = { user, tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||||
const baseMode = lib.getMode(mode ?? 0);
|
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||||
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
|
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||||
const modequery = baseMode;
|
const modequery = baseMode;
|
||||||
|
|
||||||
let tagFilter = db``;
|
let tagFilter = db``;
|
||||||
if (tag) {
|
let titleFilter = db``;
|
||||||
|
if (isTitleSearch && titleQuery) {
|
||||||
|
// Title search: match items.title ILIKE '%query%'
|
||||||
|
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
|
||||||
|
} else if (tag) {
|
||||||
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
if (terms.length > 0) {
|
if (terms.length > 0) {
|
||||||
if (isStrict) {
|
if (isStrict) {
|
||||||
@@ -180,6 +233,10 @@ export default {
|
|||||||
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj });
|
||||||
|
let total = getCachedCount(cacheKey);
|
||||||
|
|
||||||
|
if (total === null) {
|
||||||
const totalRows = await db`
|
const totalRows = await db`
|
||||||
select count(distinct items.id) as total
|
select count(distinct items.id) as total
|
||||||
from items
|
from items
|
||||||
@@ -188,17 +245,21 @@ export default {
|
|||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
|
${titleFilter}
|
||||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||||
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
${userHallFilter}
|
${userHallFilter}
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||||
${xdFilter}
|
${xdFilter}
|
||||||
`;
|
`;
|
||||||
const total = Number(totalRows[0].total);
|
total = Number(totalRows[0].total);
|
||||||
|
if (total > 0) setCachedCount(cacheKey, total);
|
||||||
|
}
|
||||||
|
|
||||||
if (!total || total === 0) {
|
if (!total || total === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -211,7 +272,50 @@ export default {
|
|||||||
const act_page = Math.min(page || 1, pages);
|
const act_page = Math.min(page || 1, pages);
|
||||||
const offset = Math.max(0, (act_page - 1) * eps);
|
const offset = Math.max(0, (act_page - 1) * eps);
|
||||||
|
|
||||||
const rows = await db`
|
// ── Deferred-join pagination ──────────────────────────────────────────────
|
||||||
|
// Step 1: Get only item IDs with all filters + OFFSET applied on the bare
|
||||||
|
// items table. No expensive JOINs here, so Postgres can use the
|
||||||
|
// (is_pinned DESC, id DESC) index efficiently even at page 192.
|
||||||
|
// The fav case still needs the favorites join in step 1 for the WHERE clause.
|
||||||
|
const pageIdRows = await db`
|
||||||
|
select items.id, items.is_pinned
|
||||||
|
from items
|
||||||
|
${fav ? db`
|
||||||
|
inner join favorites on favorites.item_id = items.id
|
||||||
|
inner join "user" fav_u on fav_u.id = favorites.user_id
|
||||||
|
` : db``}
|
||||||
|
where
|
||||||
|
${db.unsafe(modequery)}
|
||||||
|
and items.active = true
|
||||||
|
${tagFilter}
|
||||||
|
${titleFilter}
|
||||||
|
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||||
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
|
${mimeSQL}
|
||||||
|
${hallFilter}
|
||||||
|
${userHallFilter}
|
||||||
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
|
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||||
|
${xdFilter}
|
||||||
|
${fav ? db`group by items.id, items.is_pinned` : db``}
|
||||||
|
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
|
||||||
|
offset ${newerThan ? 0 : offset}
|
||||||
|
limit ${eps}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (pageIdRows.length === 0) {
|
||||||
|
// Off the end of the dataset (e.g. stale cached total sent user to a page that no longer exists)
|
||||||
|
return { success: false, message: "404 - no uploads found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIds = pageIdRows.map(r => r.id);
|
||||||
|
// Preserve the page order returned by step 1 after the join scrambles it
|
||||||
|
const pageOrder = Object.fromEntries(pageIds.map((id, i) => [id, i]));
|
||||||
|
|
||||||
|
// Step 2: Enrich only those IDs — expensive JOINs on at most `eps` rows.
|
||||||
|
const rows = (await db`
|
||||||
select
|
select
|
||||||
items.id,
|
items.id,
|
||||||
items.mime,
|
items.mime,
|
||||||
@@ -234,36 +338,23 @@ export default {
|
|||||||
from items
|
from items
|
||||||
left join "user" author_u on author_u."user" = items.username or author_u.login = items.username
|
left join "user" author_u on author_u."user" = items.username or author_u.login = items.username
|
||||||
left join user_options uo on uo.user_id = author_u.id
|
left join user_options uo on uo.user_id = author_u.id
|
||||||
left join tags_assign on tags_assign.item_id = items.id
|
|
||||||
left join tags on tags.id = tags_assign.tag_id
|
|
||||||
left join favorites on favorites.item_id = items.id
|
|
||||||
left join "user" fav_u on fav_u.id = favorites.user_id
|
|
||||||
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2 ${cfg.enable_nsfl ? db`or ta.tag_id = ${cfg.nsfl_tag_id || 3}` : db``})
|
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2 ${cfg.enable_nsfl ? db`or ta.tag_id = ${cfg.nsfl_tag_id || 3}` : db``})
|
||||||
left join tags badge_t on badge_t.id = ta.tag_id
|
|
||||||
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
||||||
where
|
where items.id = any(${pageIds})
|
||||||
${db.unsafe(modequery)}
|
|
||||||
and items.active = true
|
|
||||||
${tagFilter}
|
|
||||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
|
||||||
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
|
||||||
${mimeSQL}
|
|
||||||
${hallFilter}
|
|
||||||
${userHallFilter}
|
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
|
||||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
|
||||||
${xdFilter}
|
|
||||||
group by items.id
|
group by items.id
|
||||||
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
|
`).sort((a, b) => pageOrder[a.id] - pageOrder[b.id]);
|
||||||
offset ${newerThan ? 0 : offset}
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
limit ${eps}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Dynamic thumb sizing: only on the unfiltered main feed.
|
for (const row of rows) {
|
||||||
// Profile pages, tag searches, halls, favorites, mime filters all use tier 1 (1×1).
|
const meta = xdScoreMeta(row.xd_score);
|
||||||
|
row.xd_tier = meta.tier;
|
||||||
|
row.xd_label = meta.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic thumb sizing: applies to the main feed including mime/rating filters.
|
||||||
|
// Only per-user profiles, tag searches, halls, and favorites disable it.
|
||||||
const isMainFeed = cfg.websrv.enable_dynamic_thumbs
|
const isMainFeed = cfg.websrv.enable_dynamic_thumbs
|
||||||
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !rawMime && !fav;
|
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !fav;
|
||||||
|
|
||||||
if (isMainFeed) {
|
if (isMainFeed) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -285,10 +376,19 @@ export default {
|
|||||||
|
|
||||||
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict });
|
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict });
|
||||||
|
|
||||||
|
// Override link for title searches — pagination must use the /tag/title:... prefix
|
||||||
|
if (isTitleSearch && titleQuery) {
|
||||||
|
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
|
||||||
|
link.mainDisplay = `/tag/title:${titleQuery}/`;
|
||||||
|
link.path = 'p/';
|
||||||
|
link.suffix = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Override link for user hall context
|
// Override link for user hall context
|
||||||
if (userHallObj && userHallOwner) {
|
if (userHallObj && userHallOwner) {
|
||||||
const ownerName = userHallObj.owner_name || userHallOwner;
|
const ownerName = userHallObj.owner_name || userHallOwner;
|
||||||
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
|
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
|
||||||
|
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
|
||||||
link.path = 'p/';
|
link.path = 'p/';
|
||||||
link.suffix = '';
|
link.suffix = '';
|
||||||
}
|
}
|
||||||
@@ -313,9 +413,14 @@ export default {
|
|||||||
view_mode: fav ? 'favs' : 'uploads'
|
view_mode: fav ? 'favs' : 'uploads'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
const tag = lib.parseTag(rawTag ?? null);
|
|
||||||
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
|
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
|
||||||
|
const isTitleSearch = _decodedTag.startsWith('title:');
|
||||||
|
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
|
||||||
|
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
|
||||||
let hall = rawHall ?? null;
|
let hall = rawHall ?? null;
|
||||||
if (hall) {
|
if (hall) {
|
||||||
const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`;
|
const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`;
|
||||||
@@ -352,10 +457,11 @@ export default {
|
|||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||||
const isStrict = strictParams.length > 0;
|
const isStrict = strictParams.length > 0;
|
||||||
|
|
||||||
const tmp = { user, tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||||
|
|
||||||
const effMode = Number(mode ?? 0);
|
const effMode = Number(mode ?? 0);
|
||||||
const modequery = lib.getMode(effMode);
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
|
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||||
|
|
||||||
if (itemid === null) {
|
if (itemid === null) {
|
||||||
return {
|
return {
|
||||||
@@ -365,7 +471,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tagFilter = db``;
|
let tagFilter = db``;
|
||||||
if (tag) {
|
let titleFilter = db``;
|
||||||
|
if (isTitleSearch && titleQuery) {
|
||||||
|
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
|
||||||
|
} else if (tag) {
|
||||||
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
if (terms.length > 0) {
|
if (terms.length > 0) {
|
||||||
if (isStrict) {
|
if (isStrict) {
|
||||||
@@ -402,12 +511,14 @@ export default {
|
|||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
|
${titleFilter}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
${userHallFilter}
|
${userHallFilter}
|
||||||
${fav ? db`and "user"."user" ilike ${user}` : db``}
|
${fav ? db`and "user"."user" ilike ${user}` : db``}
|
||||||
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
@@ -426,6 +537,9 @@ export default {
|
|||||||
uo.display_name as author_display_name,
|
uo.display_name as author_display_name,
|
||||||
uo.avatar as author_avatar,
|
uo.avatar as author_avatar,
|
||||||
uo.avatar_file as author_avatar_file,
|
uo.avatar_file as author_avatar_file,
|
||||||
|
uo.banner_file as author_banner_file,
|
||||||
|
uo.banner_position as author_banner_position,
|
||||||
|
uo.banner_size as author_banner_size,
|
||||||
uo.description as author_description,
|
uo.description as author_description,
|
||||||
author_u.id as author_id,
|
author_u.id as author_id,
|
||||||
items.is_pinned,
|
items.is_pinned,
|
||||||
@@ -440,7 +554,8 @@ export default {
|
|||||||
where
|
where
|
||||||
items.id = ${itemid} and
|
items.id = ${itemid} and
|
||||||
items.active = true
|
items.active = true
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -458,18 +573,23 @@ export default {
|
|||||||
|
|
||||||
if (!actitem) {
|
if (!actitem) {
|
||||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
||||||
if (!session && globalfilter) {
|
if (!session && (getGlobalfilter() || cfg.websrv.public_untagged === false)) {
|
||||||
const unfilteredItem = await db`
|
const unfilteredItem = await db`
|
||||||
select id from items where id = ${itemid} and active = true limit 1
|
select id from items where id = ${itemid} and active = true limit 1
|
||||||
`;
|
`;
|
||||||
if (unfilteredItem[0]) {
|
if (unfilteredItem[0]) {
|
||||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||||
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Sorry, this post is currently not visible.",
|
message: "Sorry, this post is currently not visible.",
|
||||||
item: {
|
item: {
|
||||||
id: itemid,
|
id: itemid,
|
||||||
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`
|
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`,
|
||||||
|
og_url: hallSlug
|
||||||
|
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
|
||||||
|
: `https://${cfg.main.url.domain}/${itemid}`,
|
||||||
|
og_description: `Content not visible in current mode`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -556,10 +676,18 @@ export default {
|
|||||||
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
|
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
|
||||||
: [];
|
: [];
|
||||||
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
|
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
|
||||||
|
// Override link for title searches — pagination must use the /tag/title:... prefix
|
||||||
|
if (isTitleSearch && titleQuery) {
|
||||||
|
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
|
||||||
|
link.mainDisplay = `/tag/title:${titleQuery}/`;
|
||||||
|
link.path = '';
|
||||||
|
link.suffix = '';
|
||||||
|
}
|
||||||
// Override link for user hall context
|
// Override link for user hall context
|
||||||
if (userHallObj && userHallOwner) {
|
if (userHallObj && userHallOwner) {
|
||||||
const ownerName = userHallObj.owner_name || userHallOwner;
|
const ownerName = userHallObj.owner_name || userHallOwner;
|
||||||
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
|
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
|
||||||
|
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
|
||||||
link.path = '';
|
link.path = '';
|
||||||
link.suffix = '';
|
link.suffix = '';
|
||||||
}
|
}
|
||||||
@@ -571,6 +699,50 @@ export default {
|
|||||||
where "favorites".item_id = ${itemid}
|
where "favorites".item_id = ${itemid}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Detect reposts: items uploaded with bypass_duplicate_check have checksum = `{hash}_bypass_{ts}`
|
||||||
|
// Find all items (including this one) that share the same base checksum.
|
||||||
|
let repostItems = [];
|
||||||
|
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||||
|
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||||
|
const repostRows = await db`
|
||||||
|
SELECT id, username, stamp FROM items
|
||||||
|
WHERE active = true
|
||||||
|
AND id != ${itemid}
|
||||||
|
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||||
|
} else if (actitem.checksum) {
|
||||||
|
// Even without bypass, check if other bypass-entries exist with this same hash
|
||||||
|
const baseChecksum = actitem.checksum;
|
||||||
|
const repostRows = await db`
|
||||||
|
SELECT id, username, stamp FROM items
|
||||||
|
WHERE active = true
|
||||||
|
AND id != ${itemid}
|
||||||
|
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also find visually-similar items via phash, merging with checksum results
|
||||||
|
if (actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING') {
|
||||||
|
try {
|
||||||
|
const phashMatches = await queue.findallrepostphash(actitem.phash, itemid);
|
||||||
|
const existingIds = new Set(repostItems.map(r => r.id));
|
||||||
|
for (const pm of phashMatches) {
|
||||||
|
if (!existingIds.has(pm.id)) {
|
||||||
|
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||||
|
existingIds.add(pm.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repostItems.sort((a, b) => a.id - b.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GETF0CK] phash repost lookup failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Efficient coverart fallback
|
// Efficient coverart fallback
|
||||||
const coverartUrl = actitem.has_coverart
|
const coverartUrl = actitem.has_coverart
|
||||||
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
|
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
|
||||||
@@ -596,12 +768,17 @@ export default {
|
|||||||
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
||||||
|
|
||||||
if (modeBlocked) {
|
if (modeBlocked) {
|
||||||
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Sorry, this post is currently not visible.",
|
message: "Sorry, this post is currently not visible.",
|
||||||
item: {
|
item: {
|
||||||
id: itemid,
|
id: itemid,
|
||||||
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`
|
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`,
|
||||||
|
og_url: hallSlug
|
||||||
|
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
|
||||||
|
: `https://${cfg.main.url.domain}/${itemid}`,
|
||||||
|
og_description: `Content not visible in current mode`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -624,7 +801,11 @@ export default {
|
|||||||
author_display_name: actitem.author_display_name || null,
|
author_display_name: actitem.author_display_name || null,
|
||||||
author_avatar: actitem.author_avatar,
|
author_avatar: actitem.author_avatar,
|
||||||
author_avatar_file: actitem.author_avatar_file,
|
author_avatar_file: actitem.author_avatar_file,
|
||||||
|
author_banner_file: actitem.author_banner_file,
|
||||||
|
author_banner_position: actitem.author_banner_position,
|
||||||
|
author_banner_size: actitem.author_banner_size,
|
||||||
author_description: actitem.author_description,
|
author_description: actitem.author_description,
|
||||||
|
title: actitem.title || null,
|
||||||
|
|
||||||
src: {
|
src: {
|
||||||
long: actitem.src,
|
long: actitem.src,
|
||||||
@@ -632,10 +813,30 @@ export default {
|
|||||||
},
|
},
|
||||||
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`,
|
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`,
|
||||||
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}${(isNsfw || isNsfl) ? '_blur' : ''}.webp`,
|
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}${(isNsfw || isNsfl) ? '_blur' : ''}.webp`,
|
||||||
|
// og_url: canonical URL for OG/bots — hall context preserved, plain /<id> as fallback
|
||||||
|
og_url: (() => {
|
||||||
|
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||||
|
if (hallSlug) return `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${actitem.id}`;
|
||||||
|
return `https://${cfg.main.url.domain}/${actitem.id}`;
|
||||||
|
})(),
|
||||||
|
// og_description: include rating + uploader for bots (Matrix, Discord, etc.)
|
||||||
|
og_description: (() => {
|
||||||
|
const ratingLabel = isNsfl ? 'NSFL' : (isNsfw ? 'NSFW' : (isSfw ? 'SFW' : 'Untagged'));
|
||||||
|
const titlePart = actitem.title ? ` · "${actitem.title}"` : '';
|
||||||
|
return `${ratingLabel}${titlePart} · uploaded by ${actitem.username}`;
|
||||||
|
})(),
|
||||||
coverart: coverartUrl,
|
coverart: coverartUrl,
|
||||||
dest: actitem.mime === 'video/youtube' ? actitem.dest : `${cfg.websrv.paths.images}/${actitem.dest}`,
|
dest: (() => {
|
||||||
|
if (actitem.mime !== 'video/youtube') return `${cfg.websrv.paths.images}/${actitem.dest}`;
|
||||||
|
if (actitem.dest && actitem.dest.startsWith('yt:')) return actitem.dest;
|
||||||
|
// dest was corrupted by UUID backfill — recover from src
|
||||||
|
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||||
|
const m = actitem.src && actitem.src.match(ytSrcRegex);
|
||||||
|
return m ? `yt:${m[1]}` : actitem.dest;
|
||||||
|
})(),
|
||||||
mime: actitem.mime,
|
mime: actitem.mime,
|
||||||
size: lib.formatSize(actitem.size),
|
size: lib.formatSize(actitem.size),
|
||||||
|
checksum: actitem.checksum,
|
||||||
timestamp: {
|
timestamp: {
|
||||||
timeago: lib.timeAgo(new Date(actitem.stamp * 1e3).toISOString(), lang),
|
timeago: lib.timeAgo(new Date(actitem.stamp * 1e3).toISOString(), lang),
|
||||||
timefull: new Date(actitem.stamp * 1e3).toISOString()
|
timefull: new Date(actitem.stamp * 1e3).toISOString()
|
||||||
@@ -649,7 +850,12 @@ export default {
|
|||||||
is_sfw: isSfw,
|
is_sfw: isSfw,
|
||||||
is_pinned: actitem.is_pinned || false,
|
is_pinned: actitem.is_pinned || false,
|
||||||
is_comments_locked: actitem.is_comments_locked || false,
|
is_comments_locked: actitem.is_comments_locked || false,
|
||||||
is_oc: actitem.is_oc || false
|
is_oc: actitem.is_oc || false,
|
||||||
|
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
||||||
|
reposts: repostItems,
|
||||||
|
width: actitem.width || null,
|
||||||
|
height: actitem.height || null,
|
||||||
|
original_filename: actitem.original_filename || null
|
||||||
},
|
},
|
||||||
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -665,10 +871,16 @@ export default {
|
|||||||
tmp
|
tmp
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
|
||||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
const hall = rawHall || null;
|
const hall = rawHall || null;
|
||||||
const tag = lib.parseTag(rawTag ?? null);
|
|
||||||
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
|
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
|
||||||
|
const isTitleSearch = _decodedTag.startsWith('title:');
|
||||||
|
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
|
||||||
|
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
|
||||||
|
|
||||||
const mime = (rawMime ?? "");
|
const mime = (rawMime ?? "");
|
||||||
const userHallSlug = rawUserHall || null;
|
const userHallSlug = rawUserHall || null;
|
||||||
const userHallOwner = rawUserHallOwner || null;
|
const userHallOwner = rawUserHallOwner || null;
|
||||||
@@ -699,12 +911,30 @@ export default {
|
|||||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||||
const isStrict = strictParams.length > 0;
|
const isStrict = strictParams.length > 0;
|
||||||
|
|
||||||
const baseMode = lib.getMode(mode ?? 0);
|
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||||
|
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||||
const modequery = baseMode;
|
const modequery = baseMode;
|
||||||
|
|
||||||
let item;
|
let item;
|
||||||
|
|
||||||
if (fav && user) {
|
if (isTitleSearch && titleQuery) {
|
||||||
|
// Title search random: filter by items.title, no tag join needed
|
||||||
|
item = await db`
|
||||||
|
SELECT items.id
|
||||||
|
FROM items
|
||||||
|
WHERE
|
||||||
|
${db.unsafe(modequery)}
|
||||||
|
AND items.active = true
|
||||||
|
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||||
|
AND items.title IS NOT NULL
|
||||||
|
${mimeSQL}
|
||||||
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
} else if (fav && user) {
|
||||||
// Special case: random from user's favorites
|
// Special case: random from user's favorites
|
||||||
item = await db`
|
item = await db`
|
||||||
select
|
select
|
||||||
@@ -719,7 +949,8 @@ export default {
|
|||||||
and "user".user ilike ${user}
|
and "user".user ilike ${user}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
group by items.id
|
group by items.id
|
||||||
order by random()
|
order by random()
|
||||||
limit 1
|
limit 1
|
||||||
@@ -761,7 +992,8 @@ export default {
|
|||||||
${user ? db`and items.username ilike ${user}` : db``}
|
${user ? db`and items.username ilike ${user}` : db``}
|
||||||
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
group by items.id, tags.tag
|
group by items.id, tags.tag
|
||||||
order by random()
|
order by random()
|
||||||
@@ -780,7 +1012,8 @@ export default {
|
|||||||
and h.slug = ${hall}
|
and h.slug = ${hall}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
order by random()
|
order by random()
|
||||||
limit 1
|
limit 1
|
||||||
@@ -796,15 +1029,20 @@ export default {
|
|||||||
and uha.hall_id = ${userHallId}
|
and uha.hall_id = ${userHallId}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
|
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||||
order by random()
|
order by random()
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Uniform random logic for global requests (no user/tag)
|
// Uniform random logic for global requests (no user/tag/hall)
|
||||||
const baseMode = lib.getMode(mode ?? 0);
|
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
|
||||||
const modequery = baseMode;
|
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||||
const tagId = (mode === 0 || mode === 1 || mode === 4) ? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1)) : null;
|
// tagId optimisation only applies for single native modes (not multi-rating)
|
||||||
|
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||||
|
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
||||||
|
: null;
|
||||||
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
||||||
const useTagIdOpt = tagId && !mimeParts.includes('audio');
|
const useTagIdOpt = tagId && !mimeParts.includes('audio');
|
||||||
const nsfpIds = cfg.nsfp || [];
|
const nsfpIds = cfg.nsfp || [];
|
||||||
@@ -820,8 +1058,9 @@ export default {
|
|||||||
WHERE items.active = true
|
WHERE items.active = true
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||||
|
${!session && cfg.websrv.public_untagged === false ? db`AND EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id)` : db``}
|
||||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||||
${!useTagIdOpt ? db`AND ${db.unsafe(modequery)}` : db``}
|
${!useTagIdOpt ? db`AND ${db.unsafe(globalModeQuery)}` : db``}
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
@@ -884,6 +1123,73 @@ export default {
|
|||||||
// Table might not exist yet, gracefully degrade
|
// Table might not exist yet, gracefully degrade
|
||||||
for (const c of comments) c.files = [];
|
for (const c of comments) c.files = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch poll data for comments that have one
|
||||||
|
try {
|
||||||
|
const pollRows = await db`
|
||||||
|
SELECT
|
||||||
|
cp.id as poll_id,
|
||||||
|
cp.comment_id,
|
||||||
|
cp.question,
|
||||||
|
cp.expires_at,
|
||||||
|
COALESCE(cp.is_anonymous, true) as is_anonymous,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cpo.id,
|
||||||
|
'text', cpo.text,
|
||||||
|
'sort_order', cpo.sort_order,
|
||||||
|
'vote_count', COALESCE(vote_counts.cnt, 0)
|
||||||
|
) ORDER BY cpo.sort_order ASC, cpo.id ASC
|
||||||
|
) AS options,
|
||||||
|
COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes
|
||||||
|
FROM comment_polls cp
|
||||||
|
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT option_id, COUNT(*) AS cnt
|
||||||
|
FROM comment_poll_votes
|
||||||
|
GROUP BY option_id
|
||||||
|
) vote_counts ON vote_counts.option_id = cpo.id
|
||||||
|
WHERE cp.comment_id = ANY(${commentIds}::int[])
|
||||||
|
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
|
||||||
|
`;
|
||||||
|
// For non-anonymous polls, fetch voter names
|
||||||
|
const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id);
|
||||||
|
let votersByOption = new Map();
|
||||||
|
if (nonAnonIds.length > 0) {
|
||||||
|
const voterRows = await db`
|
||||||
|
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
|
||||||
|
FROM comment_poll_votes cpv
|
||||||
|
JOIN public."user" u ON u.id = cpv.user_id
|
||||||
|
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
|
||||||
|
WHERE cpv.poll_id = ANY(${nonAnonIds}::int[])
|
||||||
|
`;
|
||||||
|
for (const v of voterRows) {
|
||||||
|
if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []);
|
||||||
|
votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pollMap = new Map();
|
||||||
|
for (const p of pollRows) {
|
||||||
|
const options = p.is_anonymous
|
||||||
|
? p.options
|
||||||
|
: p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] }));
|
||||||
|
pollMap.set(p.comment_id, {
|
||||||
|
id: p.poll_id,
|
||||||
|
question: p.question,
|
||||||
|
expires_at: p.expires_at,
|
||||||
|
is_anonymous: p.is_anonymous,
|
||||||
|
options,
|
||||||
|
total_votes: parseInt(p.total_votes) || 0,
|
||||||
|
user_vote_option_id: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const c of comments) {
|
||||||
|
c.poll = pollMap.get(c.id) || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[POLLS] getComments poll fetch error:', e.message, e.code);
|
||||||
|
for (const c of comments) c.poll = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);
|
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);
|
||||||
@@ -972,14 +1278,12 @@ export default {
|
|||||||
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = i.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
|
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = i.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
|
||||||
: db``;
|
: db``;
|
||||||
|
|
||||||
// Build mode condition using alias 'i' (getMode uses raw 'items' table name, incompatible with subquery alias)
|
|
||||||
const modeNum = Number(mode) || 0;
|
const modeNum = Number(mode) || 0;
|
||||||
const modeFilter = modeNum === 1 ? db`AND i.id IN (SELECT item_id FROM tags_assign WHERE tag_id = 2)`
|
|
||||||
: modeNum === 2 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = i.id)`
|
|
||||||
: modeNum === 3 ? db``
|
|
||||||
: db`AND i.id IN (SELECT item_id FROM tags_assign WHERE tag_id = 1)`; // default: sfw
|
|
||||||
|
|
||||||
// Filter halls by their rating column to match the current mode
|
// Filter halls by their rating column to match the current mode.
|
||||||
|
// The hall's own rating is the source of truth for mode gating — the old
|
||||||
|
// item-level modeFilter (tag_id check) caused NSFW halls to show 0 posts
|
||||||
|
// when items didn't carry the exact NSFW tag_id.
|
||||||
// mode 0=sfw -> rating='sfw', mode 1=nsfw -> rating='nsfw', mode 4=nsfl -> rating='nsfl'
|
// mode 0=sfw -> rating='sfw', mode 1=nsfw -> rating='nsfw', mode 4=nsfl -> rating='nsfl'
|
||||||
// mode 3=all and mode 2=untagged show all halls
|
// mode 3=all and mode 2=untagged show all halls
|
||||||
const hallRating = modeNum === 0 ? 'sfw' : modeNum === 1 ? 'nsfw' : modeNum === 4 ? 'nsfl' : null;
|
const hallRating = modeNum === 0 ? 'sfw' : modeNum === 1 ? 'nsfw' : modeNum === 4 ? 'nsfl' : null;
|
||||||
@@ -1005,7 +1309,6 @@ export default {
|
|||||||
FROM halls_assign ha
|
FROM halls_assign ha
|
||||||
JOIN items i ON i.id = ha.item_id
|
JOIN items i ON i.id = ha.item_id
|
||||||
WHERE i.active = true
|
WHERE i.active = true
|
||||||
${modeFilter}
|
|
||||||
${userExcludeFilter}
|
${userExcludeFilter}
|
||||||
GROUP BY ha.hall_id
|
GROUP BY ha.hall_id
|
||||||
) counts ON counts.hall_id = h.id
|
) counts ON counts.hall_id = h.id
|
||||||
@@ -1267,5 +1570,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
computeXdScore,
|
computeXdScore,
|
||||||
xdScoreMeta
|
xdScoreMeta,
|
||||||
|
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
||||||
|
clearCountCache: () => countCache.clear()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default (router, tpl) => {
|
|||||||
return res.reply({ code: 429, body: msg });
|
return res.reply({ code: 429, body: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !password || password.length < 20) {
|
if (!username || !password) {
|
||||||
return fail("Invalid username or password.");
|
return fail("Invalid username or password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +507,8 @@ export default (router, tpl) => {
|
|||||||
update "user"
|
update "user"
|
||||||
set banned = false,
|
set banned = false,
|
||||||
ban_reason = null,
|
ban_reason = null,
|
||||||
ban_expires = null
|
ban_expires = null,
|
||||||
|
last_seen = ${~~(Date.now() / 1e3)}
|
||||||
where id = ${+user_id}
|
where id = ${+user_id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -803,8 +804,11 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// User Management Routes
|
// User Management Routes
|
||||||
router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => {
|
router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => {
|
||||||
const q = req.url.qs?.q || '';
|
const rawQ = req.url.qs?.q || '';
|
||||||
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
// Exact match mode: strip surrounding double quotes and match exactly
|
||||||
|
const exactMatch = rawQ.startsWith('"') && rawQ.endsWith('"') && rawQ.length > 2;
|
||||||
|
const q = exactMatch ? rawQ.slice(1, -1) : rawQ;
|
||||||
|
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@@ -816,7 +820,10 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||||
FROM "user" u
|
FROM "user" u
|
||||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``}
|
${q ? (exactMatch
|
||||||
|
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
||||||
|
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
||||||
|
) : db``}
|
||||||
),
|
),
|
||||||
ghost_users AS (
|
ghost_users AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -825,7 +832,10 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||||
FROM items i
|
FROM items i
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||||
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
|
${q ? (exactMatch
|
||||||
|
? db`AND lower(i.username) = lower(${q})`
|
||||||
|
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||||
|
) : db``}
|
||||||
GROUP BY i.username
|
GROUP BY i.username
|
||||||
),
|
),
|
||||||
all_users AS (
|
all_users AS (
|
||||||
@@ -867,13 +877,19 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
|
|
||||||
const totalCountActual = await db`
|
const totalCountActual = await db`
|
||||||
SELECT COUNT(*) as c FROM "user" u
|
SELECT COUNT(*) as c FROM "user" u
|
||||||
${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``}
|
${q ? (exactMatch
|
||||||
|
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
||||||
|
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
||||||
|
) : db``}
|
||||||
`;
|
`;
|
||||||
const totalCountGhost = await db`
|
const totalCountGhost = await db`
|
||||||
SELECT COUNT(DISTINCT i.username) as c
|
SELECT COUNT(DISTINCT i.username) as c
|
||||||
FROM items i
|
FROM items i
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||||
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
|
${q ? (exactMatch
|
||||||
|
? db`AND lower(i.username) = lower(${q})`
|
||||||
|
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||||
|
) : db``}
|
||||||
`;
|
`;
|
||||||
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
|
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
|
||||||
|
|
||||||
@@ -952,11 +968,57 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/users\/set-role\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, role } = req.post;
|
||||||
|
if (!user_id) throw new Error('Missing user_id');
|
||||||
|
if (!['user', 'mod', 'admin'].includes(role)) throw new Error('Invalid role. Must be user, mod, or admin.');
|
||||||
|
|
||||||
|
// Fetch target
|
||||||
|
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||||
|
if (!target.length) throw new Error('User not found.');
|
||||||
|
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected.');
|
||||||
|
|
||||||
|
// Prevent self-demotion
|
||||||
|
if (+user_id === req.session.id && role !== 'admin') {
|
||||||
|
throw new Error('You cannot change your own role away from admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = role === 'admin';
|
||||||
|
const isMod = role === 'mod';
|
||||||
|
|
||||||
|
await db`
|
||||||
|
UPDATE "user"
|
||||||
|
SET admin = ${isAdmin}, is_moderator = ${isMod}
|
||||||
|
WHERE id = ${+user_id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Invalidate sessions so permissions refresh on next login
|
||||||
|
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
|
||||||
|
|
||||||
|
await audit.log(req.session.id, 'admin_set_role', 'user', +user_id, {
|
||||||
|
target_login: target[0].login,
|
||||||
|
role
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
role,
|
||||||
|
msg: `Role for "${target[0].login}" set to ${role}.`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN] Set role failed:', err);
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { user_id, mode, lock } = req.post;
|
const { user_id, mode, lock } = req.post;
|
||||||
if (!user_id) throw new Error('Missing user_id');
|
if (!user_id) throw new Error('Missing user_id');
|
||||||
|
|
||||||
|
|
||||||
const isLocked = lock === true || lock === 'true' || lock === 1;
|
const isLocked = lock === true || lock === 'true' || lock === 1;
|
||||||
const targetMode = parseInt(mode, 10);
|
const targetMode = parseInt(mode, 10);
|
||||||
|
|
||||||
@@ -1257,6 +1319,136 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/users\/rename\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, new_username } = req.post;
|
||||||
|
if (!user_id) throw new Error('Missing user_id');
|
||||||
|
if (!new_username || !new_username.trim()) throw new Error('Missing new_username');
|
||||||
|
|
||||||
|
const newName = new_username.trim();
|
||||||
|
|
||||||
|
// Validate format (same rules as registration)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(newName)) {
|
||||||
|
throw new Error('Invalid username. Only A-Z, 0-9, _, -, and . are allowed.');
|
||||||
|
}
|
||||||
|
if (newName.length < 2 || newName.length > 32) {
|
||||||
|
throw new Error('Username must be between 2 and 32 characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user info
|
||||||
|
const target = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||||
|
if (!target.length) throw new Error('User not found');
|
||||||
|
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be renamed.');
|
||||||
|
|
||||||
|
const oldLogin = target[0].login;
|
||||||
|
const oldUser = target[0].user;
|
||||||
|
const newLogin = newName.toLowerCase();
|
||||||
|
|
||||||
|
if (newLogin === oldLogin && newName === oldUser) throw new Error('New username is the same as the current one.');
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
const conflict = await db`SELECT id FROM "user" WHERE (lower(login) = ${newLogin} OR lower("user") = lower(${newName})) AND id != ${+user_id} LIMIT 1`;
|
||||||
|
if (conflict.length) throw new Error(`Username "${newName}" is already taken.`);
|
||||||
|
|
||||||
|
await db.begin(async sql => {
|
||||||
|
// 1. Update the user record
|
||||||
|
await sql`UPDATE "user" SET login = ${newLogin}, "user" = ${newName} WHERE id = ${+user_id}`;
|
||||||
|
|
||||||
|
// 2. Update items.username (matches both old login and old display name)
|
||||||
|
await sql`UPDATE items SET username = ${newLogin} WHERE username ILIKE ${oldLogin} OR username ILIKE ${oldUser}`;
|
||||||
|
|
||||||
|
// 3. Clear old login_attempts so the new name starts clean
|
||||||
|
await sql`DELETE FROM login_attempts WHERE username = ${oldLogin}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate all sessions so the user must re-log with the new name
|
||||||
|
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
|
||||||
|
|
||||||
|
// Log it in audit
|
||||||
|
await audit.log(req.session.id, 'admin_rename_user', 'user', +user_id, {
|
||||||
|
old_login: oldLogin,
|
||||||
|
new_login: newLogin,
|
||||||
|
old_user: oldUser,
|
||||||
|
new_user: newName
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
new_login: newLogin,
|
||||||
|
new_user: newName,
|
||||||
|
msg: `User renamed from "${oldLogin}" to "${newLogin}". All uploads updated. Sessions invalidated.`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN] Rename failed:', err);
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/users\/create\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { username, email, password, role } = req.post;
|
||||||
|
if (!username || !username.trim()) throw new Error('Username is required.');
|
||||||
|
username = username.trim();
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||||
|
throw new Error('Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.');
|
||||||
|
}
|
||||||
|
if (username.length < 2 || username.length > 32) {
|
||||||
|
throw new Error('Username must be between 2 and 32 characters.');
|
||||||
|
}
|
||||||
|
if (!password || password.length < 20) {
|
||||||
|
throw new Error('Password must be at least 20 characters long.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing user
|
||||||
|
const existing = await db`
|
||||||
|
SELECT id FROM "user"
|
||||||
|
WHERE lower(login) = lower(${username}) OR lower("user") = lower(${username})
|
||||||
|
${email ? db`OR (email IS NOT NULL AND lower(email) = lower(${email}))` : db``}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (existing.length) throw new Error('Username or email is already taken.');
|
||||||
|
|
||||||
|
const isAdmin = role === 'admin';
|
||||||
|
const isMod = role === 'mod';
|
||||||
|
const hash = await lib.hash(password);
|
||||||
|
const ts = ~~(Date.now() / 1e3);
|
||||||
|
|
||||||
|
const { getDefaultLayout } = await import('../settings.mjs');
|
||||||
|
|
||||||
|
const newUser = await db`
|
||||||
|
INSERT INTO "user" (login, password, "user", created_at, admin, is_moderator, email, activated, activation_token)
|
||||||
|
VALUES (
|
||||||
|
${username.toLowerCase()}, ${hash}, ${username},
|
||||||
|
to_timestamp(${ts}), ${isAdmin}, ${isMod},
|
||||||
|
${email || null}, true, NULL
|
||||||
|
)
|
||||||
|
RETURNING id, login
|
||||||
|
`;
|
||||||
|
const userId = newUser[0].id;
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||||
|
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
||||||
|
`;
|
||||||
|
|
||||||
|
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
|
||||||
|
new_login: username.toLowerCase(),
|
||||||
|
role: role || 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
user_id: userId,
|
||||||
|
username: username.toLowerCase(),
|
||||||
|
msg: `User "${username.toLowerCase()}" created successfully.`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN] Create user failed:', err);
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// About page text editor
|
// About page text editor
|
||||||
router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => {
|
router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => {
|
||||||
const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
|
const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
|
||||||
@@ -1422,6 +1614,9 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
|
|
||||||
// Chat Manager
|
// Chat Manager
|
||||||
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
|
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
|
||||||
|
if (!cfg.websrv.enable_global_chat) {
|
||||||
|
return res.redirect("/admin");
|
||||||
|
}
|
||||||
res.reply({
|
res.reply({
|
||||||
body: tpl.render('admin/chat', {
|
body: tpl.render('admin/chat', {
|
||||||
session: req.session,
|
session: req.session,
|
||||||
@@ -1431,5 +1626,45 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Koepfe Manager
|
||||||
|
router.get(/^\/admin\/koepfe\/?$/, lib.auth, async (req, res) => {
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render('admin/koepfe', { session: req.session, tmp: null }, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(/^\/api\/v2\/admin\/koepfe\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fs = (await import('fs')).promises;
|
||||||
|
const path = await import('path');
|
||||||
|
const kDir = cfg.paths.koepfe;
|
||||||
|
const files = await fs.readdir(kDir).catch(() => []);
|
||||||
|
const koepfeFiles = files.filter(f => /\.(png|jpe?g|gif|webp|avif)$/i.test(f)).map(f => ({
|
||||||
|
filename: f,
|
||||||
|
url: '/s/koepfe/' + f
|
||||||
|
}));
|
||||||
|
if (res.json) return res.json({ koepfe: koepfeFiles });
|
||||||
|
return res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({ koepfe: koepfeFiles }));
|
||||||
|
} catch (e) {
|
||||||
|
if (res.json) return res.json({ success: false, msg: e.message });
|
||||||
|
return res.writeHead(500, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: false, msg: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/koepfe\/delete\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.post || req.body || {};
|
||||||
|
if (!filename || filename.includes('..') || filename.includes('/')) throw new Error('Invalid filename');
|
||||||
|
const fs = (await import('fs')).promises;
|
||||||
|
const path = await import('path');
|
||||||
|
await fs.unlink(path.join(cfg.paths.koepfe, filename));
|
||||||
|
if (res.json) return res.json({ success: true });
|
||||||
|
return res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: true }));
|
||||||
|
} catch (e) {
|
||||||
|
if (res.json) return res.json({ success: false, msg: e.message });
|
||||||
|
return res.writeHead(500, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: false, msg: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
|
import { createI18n } from "../i18n.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||||
@@ -31,11 +32,14 @@ export default (router, tpl) => {
|
|||||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||||
|
|
||||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
|
||||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
||||||
const data = await f0cklib.getf0ck({
|
const data = await f0cklib.getf0ck({
|
||||||
itemid: itemid,
|
itemid: itemid,
|
||||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
url: contextUrl,
|
url: contextUrl,
|
||||||
user: query.user,
|
user: query.user,
|
||||||
@@ -54,9 +58,17 @@ export default (router, tpl) => {
|
|||||||
const tAjaxFetch = Date.now();
|
const tAjaxFetch = Date.now();
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
const reqMode = (query.mode !== undefined ? +query.mode : req.mode) ?? 0;
|
||||||
|
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
|
||||||
|
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
|
||||||
|
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||||
const errorHtml = tpl.render('error-partial', {
|
const errorHtml = tpl.render('error-partial', {
|
||||||
message: data.message || '404 - Not f0cked',
|
message: tErr('error.post_not_visible'),
|
||||||
tmp: null
|
tmp: null,
|
||||||
|
session: req.session ? { ...req.session } : false,
|
||||||
|
error_mode_label: errorModeLabel,
|
||||||
|
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
|
||||||
|
error_filter_hint_link: tErr('error.filter_hint_link')
|
||||||
}, req);
|
}, req);
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -126,7 +138,7 @@ export default (router, tpl) => {
|
|||||||
const item = data.item;
|
const item = data.item;
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||||
@@ -135,9 +147,11 @@ export default (router, tpl) => {
|
|||||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||||
data.item_username_lower = (item.username || '').toLowerCase();
|
data.item_username_lower = (item.username || '').toLowerCase();
|
||||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||||
|
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
||||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||||
|
data.item_has_dimensions = !!(item.width && item.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render both the item content and the pagination
|
// Render both the item content and the pagination
|
||||||
@@ -191,14 +205,19 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const page = parseInt(query.page) || 1;
|
const page = parseInt(query.page) || 1;
|
||||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
|
||||||
const data = await f0cklib.getf0cks({
|
const data = await f0cklib.getf0cks({
|
||||||
page: page,
|
page: page,
|
||||||
tag: query.tag || null,
|
tag: query.tag || null,
|
||||||
hall: query.hall || null,
|
hall: query.hall || null,
|
||||||
user: query.user || null,
|
user: query.user || null,
|
||||||
|
userHall: query.userHall || null,
|
||||||
|
userHallOwner: query.userHallOwner || null,
|
||||||
mime: query.mime || (req.cookies.mime || null),
|
mime: query.mime || (req.cookies.mime || null),
|
||||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
@@ -252,7 +271,7 @@ export default (router, tpl) => {
|
|||||||
return res.reply({
|
return res.reply({
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
'Cache-Control': 'no-cache, must-revalidate, proxy-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
'Expires': '0'
|
'Expires': '0'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import audit from '../../audit.mjs';
|
|||||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||||
|
|
||||||
const allowedMimes = ["audio", "image", "video", "%"];
|
const allowedMimes = ["audio", "image", "video", "%"];
|
||||||
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||||
const metaCache = new Map();
|
const metaCache = new Map();
|
||||||
const MAX_META_CACHE = 2000;
|
const MAX_META_CACHE = 2000;
|
||||||
|
|
||||||
@@ -299,9 +299,49 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow authenticated clients to push metadata they fetched client-side (e.g. YouTube oEmbed
|
||||||
|
// fetched directly from the browser, bypassing Tor/proxy consent walls).
|
||||||
|
group.post(/\/meta\/cache$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (!cfg.websrv.web_meta_extraction) {
|
||||||
|
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await collectBody(req);
|
||||||
|
const payload = JSON.parse(body.toString());
|
||||||
|
const { url, meta } = payload || {};
|
||||||
|
if (!url || !meta || !meta.title) {
|
||||||
|
return res.json({ success: false, msg: 'url and meta.title required' }, 400);
|
||||||
|
}
|
||||||
|
// Only accept YouTube URLs to prevent arbitrary cache poisoning
|
||||||
|
if (!/(youtube\.com|youtu\.be)/i.test(url)) {
|
||||||
|
return res.json({ success: false, msg: 'Only YouTube URLs accepted' }, 400);
|
||||||
|
}
|
||||||
|
// Sanitise — only store known-safe fields
|
||||||
|
const safeMeta = {
|
||||||
|
title: String(meta.title).substring(0, 500),
|
||||||
|
site_name: 'youtube.com',
|
||||||
|
author: meta.author ? String(meta.author).substring(0, 200) : 'Unknown'
|
||||||
|
};
|
||||||
|
metaCache.set(url, safeMeta);
|
||||||
|
if (metaCache.size > MAX_META_CACHE) {
|
||||||
|
const first = metaCache.keys().next().value;
|
||||||
|
metaCache.delete(first);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db`INSERT INTO meta_cache (url, data) VALUES (${url}, ${safeMeta})
|
||||||
|
ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[META-CACHE] DB save failed:', err);
|
||||||
|
}
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: 'Invalid request body' }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
|
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
|
||||||
// Guests use cached entries from DB (populated by authenticated user requests).
|
// Guests may read from the cache (in-memory or DB); only authenticated users trigger real outbound fetches.
|
||||||
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
|
group.get(/\/meta\/fetch$/, async (req, res) => {
|
||||||
if (!cfg.websrv.web_meta_extraction) {
|
if (!cfg.websrv.web_meta_extraction) {
|
||||||
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
||||||
}
|
}
|
||||||
@@ -325,6 +365,11 @@ export default router => {
|
|||||||
console.error('[META-CACHE] DB lookup failed:', err);
|
console.error('[META-CACHE] DB lookup failed:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache miss — require auth to perform real outbound fetch (SSRF prevention)
|
||||||
|
if (!req.session) {
|
||||||
|
return res.json({ success: false, msg: 'Not cached' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const setCache = async (u, m) => {
|
const setCache = async (u, m) => {
|
||||||
if (!m || !m.title) return;
|
if (!m || !m.title) return;
|
||||||
metaCache.set(u, m);
|
metaCache.set(u, m);
|
||||||
@@ -352,18 +397,26 @@ export default router => {
|
|||||||
? cfg.main.socks.replace(/^socks5:\/\//, 'socks5h://')
|
? cfg.main.socks.replace(/^socks5:\/\//, 'socks5h://')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 1. YouTube: go straight to oEmbed — faster and more reliable than yt-dlp for these
|
// 1. YouTube: try oEmbed first (fast), then fall through to yt-dlp on failure
|
||||||
if (isYouTube) {
|
if (isYouTube) {
|
||||||
try {
|
try {
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||||
|
// Do NOT use --fail: non-2xx responses (e.g. age-restricted/private videos) still
|
||||||
|
// return valid JSON we can attempt to parse. Use ignoreExitCode so we always get output.
|
||||||
const { stdout: oembedOut } = await queue.spawn('curl', [
|
const { stdout: oembedOut } = await queue.spawn('curl', [
|
||||||
'-s', '--max-time', '10', '--fail',
|
'-s', '--max-time', '15',
|
||||||
'--user-agent', 'Mozilla/5.0 (compatible; oEmbed)',
|
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||||
...(socksH ? ['--proxy', socksH] : []),
|
...(socksH ? ['--proxy', socksH] : []),
|
||||||
oembedUrl
|
oembedUrl
|
||||||
]);
|
], { ignoreExitCode: true });
|
||||||
if (oembedOut && oembedOut.trim()) {
|
if (oembedOut && oembedOut.trim()) {
|
||||||
const data = JSON.parse(oembedOut);
|
const trimmed = oembedOut.trim();
|
||||||
|
// YouTube often returns HTML (consent/cookie walls, CAPTCHAs, geo-blocks)
|
||||||
|
// instead of JSON when accessed through a proxy — skip those gracefully.
|
||||||
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||||
|
console.warn(`[META-FETCH] YouTube oEmbed returned non-JSON (likely HTML wall), falling back to yt-dlp`);
|
||||||
|
} else {
|
||||||
|
const data = JSON.parse(trimmed);
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
const meta = {
|
const meta = {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -371,15 +424,44 @@ export default router => {
|
|||||||
author: data.author_name || 'Unknown'
|
author: data.author_name || 'Unknown'
|
||||||
};
|
};
|
||||||
await setCache(url, meta);
|
await setCache(url, meta);
|
||||||
return res.json({
|
return res.json({ success: true, meta });
|
||||||
success: true,
|
}
|
||||||
meta
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (oembedErr) {
|
} catch (oembedErr) {
|
||||||
console.error(`[META-FETCH] YouTube oEmbed failed:`, oembedErr.message || oembedErr);
|
console.warn(`[META-FETCH] YouTube oEmbed failed, will try yt-dlp:`, oembedErr.message || oembedErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oEmbed failed — fall back to yt-dlp for YouTube (handles age-restricted, private, etc.)
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const { stdout: ytOut } = await queue.spawn('yt-dlp', [
|
||||||
|
...proxyArgs,
|
||||||
|
'--quiet',
|
||||||
|
'--no-warnings',
|
||||||
|
'--js-runtimes', 'node',
|
||||||
|
'--print', '%(title)s',
|
||||||
|
'--print', '%(uploader)s',
|
||||||
|
'--skip-download',
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
const ytLines = ytOut.trim().split('\n');
|
||||||
|
const ytTitle = ytLines[0] ? ytLines[0].trim() : '';
|
||||||
|
if (ytTitle) {
|
||||||
|
const meta = {
|
||||||
|
title: ytTitle,
|
||||||
|
site_name: 'youtube.com',
|
||||||
|
author: ytLines[1] ? ytLines[1].trim() : 'Unknown'
|
||||||
|
};
|
||||||
|
await setCache(url, meta);
|
||||||
|
return res.json({ success: true, meta });
|
||||||
|
}
|
||||||
|
} catch (ytErr) {
|
||||||
|
if (attempt < 2) { await sleep(1000); continue; }
|
||||||
|
console.error(`[META-FETCH] YouTube yt-dlp fallback failed:`, ytErr.message || ytErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({ success: false, msg: 'Failed to extract YouTube metadata' }, 500);
|
return res.json({ success: false, msg: 'Failed to extract YouTube metadata' }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +578,9 @@ export default router => {
|
|||||||
const userHallOwner = req.url.qs.userHallOwner || null;
|
const userHallOwner = req.url.qs.userHallOwner || null;
|
||||||
const isFav = req.url.qs.fav === 'true';
|
const isFav = req.url.qs.fav === 'true';
|
||||||
const isStrict = req.url.qs.strict === '1';
|
const isStrict = req.url.qs.strict === '1';
|
||||||
const mode = req.session?.mode ?? 0;
|
const mode = req.mode ?? 0; // Use req.mode (set by middleware) for consistency with all other routes
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
|
||||||
const data = await f0cklib.getRandom({
|
const data = await f0cklib.getRandom({
|
||||||
user,
|
user,
|
||||||
@@ -507,6 +591,7 @@ export default router => {
|
|||||||
mime,
|
mime,
|
||||||
fav: isFav,
|
fav: isFav,
|
||||||
mode,
|
mode,
|
||||||
|
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||||
strict: isStrict,
|
strict: isStrict,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
exclude: req.session?.excluded_tags || []
|
exclude: req.session?.excluded_tags || []
|
||||||
@@ -519,41 +604,61 @@ export default router => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// API expects { success: true, items: { id: ... } } (based on f0ck.js usage)
|
const rows = await db`
|
||||||
// The old query returned full item row. f0cklib.getRandom returns { itemid: ... } or { itemid: ... } (actually it returns { itemid: ... } on success)
|
SELECT *
|
||||||
|
FROM "items"
|
||||||
|
WHERE id = ${data.itemid} AND active = true
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const item = rows[0];
|
||||||
|
|
||||||
// We need to fetch the item details if the frontend expects them?
|
if (!item) {
|
||||||
// Looking at f0ck.js:
|
return res.json({
|
||||||
// if (data.success && data.items && data.items.id) { loadItemAjax(`/${data.items.id}`, true); }
|
success: false,
|
||||||
// So it only really needs the ID.
|
items: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isYouTube = item.mime === 'video/youtube';
|
||||||
|
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||||
|
let ytDest = item.dest;
|
||||||
|
if (isYouTube && (!ytDest || !ytDest.startsWith('yt:'))) {
|
||||||
|
const m = item.src && item.src.match(ytSrcRegex);
|
||||||
|
if (m) ytDest = `yt:${m[1]}`;
|
||||||
|
}
|
||||||
|
const relativeDest = isYouTube ? ytDest : `${cfg.websrv.paths.images}/${item.dest}`;
|
||||||
|
const directUrl = isYouTube ? ytDest : `${cfg.main.url.full}${cfg.websrv.paths.images}/${item.dest}`;
|
||||||
|
|
||||||
|
const { username, src, xd_score, ...safeItem } = item;
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
items: { id: data.itemid }
|
items: {
|
||||||
|
...safeItem,
|
||||||
|
id: item.id,
|
||||||
|
dest: relativeDest,
|
||||||
|
url: directUrl,
|
||||||
|
direct_url: directUrl
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group.get(/\/orakel\/user$/, async (req, res) => {
|
group.get(/\/orakel\/user$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const now = ~~(Date.now() / 1000);
|
const now = ~~(Date.now() / 1000);
|
||||||
const thirtyDaysAgo = now - 2592000; // 30 days in seconds
|
const sevenDaysAgo = now - 604800; // 7 days in seconds
|
||||||
|
|
||||||
// Tiered selection from user.last_seen (updated fire-and-forget on every authenticated request):
|
// Flat random pick from all users seen in the last 7 days.
|
||||||
// Tier 0 — active in last 15 minutes
|
// No tiered bias — gives a proper pool of recently-active users
|
||||||
// Tier 1 — active in last 24 hours
|
// rather than always favouring whoever is online right now.
|
||||||
// Tier 2 — active in last 30 days (includes lurkers — anyone who visited the site)
|
|
||||||
// Banned users are always excluded.
|
// Banned users are always excluded.
|
||||||
let activeUsers = await db`
|
let activeUsers = await db`
|
||||||
SELECT "user"."user", "user".id, uo.display_name
|
SELECT "user"."user", "user".id, uo.display_name
|
||||||
FROM "user"
|
FROM "user"
|
||||||
LEFT JOIN user_options uo ON uo.user_id = "user".id
|
LEFT JOIN user_options uo ON uo.user_id = "user".id
|
||||||
WHERE "user".last_seen > ${thirtyDaysAgo}
|
WHERE "user".last_seen > ${sevenDaysAgo}
|
||||||
AND "user".banned = false
|
AND "user".banned = false
|
||||||
ORDER BY (CASE
|
ORDER BY RANDOM()
|
||||||
WHEN "user".last_seen > ${now - 900} THEN 0
|
|
||||||
WHEN "user".last_seen > ${now - 86400} THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END), RANDOM()
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -748,6 +853,28 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.get(/\/items\/suggest$/, async (req, res) => {
|
||||||
|
const searchString = req.url.qs.q;
|
||||||
|
if (!searchString || searchString.length < 1) {
|
||||||
|
return res.json({ success: false, suggestions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await db`
|
||||||
|
SELECT id, title
|
||||||
|
FROM items
|
||||||
|
WHERE title IS NOT NULL
|
||||||
|
AND active = true
|
||||||
|
AND title ILIKE ${'%' + searchString + '%'}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 8
|
||||||
|
`;
|
||||||
|
return res.json({ success: true, suggestions: items });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, error: 'Item title suggestion error', suggestions: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// tags lol
|
// tags lol
|
||||||
|
|
||||||
group.put(/\/tags\/rename\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
|
group.put(/\/tags\/rename\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
|
||||||
@@ -798,6 +925,33 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// PATCH /api/v2/items/:id/title — set or clear the title for an item
|
||||||
|
// Allowed by: item owner, moderators, admins
|
||||||
|
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.loggedin, async (req, res) => {
|
||||||
|
const id = +req.params.id;
|
||||||
|
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
|
||||||
|
|
||||||
|
// Fetch item to check ownership
|
||||||
|
const rows = await db`SELECT id, username FROM items WHERE id = ${id} AND active = true LIMIT 1`;
|
||||||
|
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
|
|
||||||
|
const item = rows[0];
|
||||||
|
const isOwner = req.session.user === item.username;
|
||||||
|
const isMod = !!(req.session.is_moderator || req.session.admin);
|
||||||
|
if (!isOwner && !isMod) return res.json({ success: false, msg: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
// Accept title from JSON or URL-encoded body
|
||||||
|
let rawTitle = req.post?.title ?? req.body?.title ?? null;
|
||||||
|
if (rawTitle !== null) rawTitle = String(rawTitle).trim();
|
||||||
|
// Empty string → null (clears the title)
|
||||||
|
const title = (rawTitle === '' || rawTitle === null) ? null : rawTitle.substring(0, 500);
|
||||||
|
|
||||||
|
await db`UPDATE items SET title = ${title} WHERE id = ${id}`;
|
||||||
|
|
||||||
|
return res.json({ success: true, title });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
group.post(/\/admin\/deletepost$/, lib.modAuth, async (req, res) => {
|
group.post(/\/admin\/deletepost$/, lib.modAuth, async (req, res) => {
|
||||||
if (req.post.postid === undefined || req.post.postid === null) {
|
if (req.post.postid === undefined || req.post.postid === null) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -1045,6 +1199,15 @@ export default router => {
|
|||||||
|
|
||||||
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
|
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
|
||||||
let newRatingId;
|
let newRatingId;
|
||||||
|
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
||||||
|
if (reqRating === 'sfw') {
|
||||||
|
newRatingId = 1;
|
||||||
|
} else if (reqRating === 'nsfw') {
|
||||||
|
newRatingId = 2;
|
||||||
|
} else if (reqRating === 'nsfl') {
|
||||||
|
newRatingId = nsfl_id;
|
||||||
|
} else {
|
||||||
|
// fallback to cycling
|
||||||
if (currentRatingId === 1) {
|
if (currentRatingId === 1) {
|
||||||
newRatingId = 2; // SFW -> NSFW
|
newRatingId = 2; // SFW -> NSFW
|
||||||
} else if (currentRatingId === 2) {
|
} else if (currentRatingId === 2) {
|
||||||
@@ -1052,6 +1215,7 @@ export default router => {
|
|||||||
} else {
|
} else {
|
||||||
newRatingId = 1; // NSFL or none -> SFW
|
newRatingId = 1; // NSFL or none -> SFW
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.begin(async sql => {
|
await db.begin(async sql => {
|
||||||
// Remove old rating tags
|
// Remove old rating tags
|
||||||
@@ -1063,12 +1227,10 @@ export default router => {
|
|||||||
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
|
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// If switching to NSFW/NSFL, ensure blurred thumbnail exists
|
// Ensure blurred thumbnail exists
|
||||||
if (newRatingId === 2 || newRatingId === nsfl_id) {
|
|
||||||
await queue.genBlurredThumbnail(itemid).catch(err => {
|
await queue.genBlurredThumbnail(itemid).catch(err => {
|
||||||
console.error(`[RATING_TOGGLE] Blurred thumbnail generation failed for ${itemid}:`, err);
|
console.error(`[RATING_TOGGLE] Blurred thumbnail generation failed for ${itemid}:`, err);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const newRating = newRatingId === 1 ? 'sfw' : (newRatingId === 2 ? 'nsfw' : 'nsfl');
|
const newRating = newRatingId === 1 ? 'sfw' : (newRatingId === 2 ? 'nsfw' : 'nsfl');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import lib from '../../lib.mjs';
|
|||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||||
// These routes remain for other settings API endpoints
|
// These routes remain for other settings API endpoints
|
||||||
@@ -330,6 +331,14 @@ export default router => {
|
|||||||
`;
|
`;
|
||||||
// Sync session immediately
|
// Sync session immediately
|
||||||
if (req.session) req.session.username_color = color;
|
if (req.session) req.session.username_color = color;
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
username_color: color
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
return res.json({ success: true, color }, 200);
|
return res.json({ success: true, color }, 200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Update Username Color error:', e);
|
console.error('Update Username Color error:', e);
|
||||||
@@ -419,6 +428,14 @@ export default router => {
|
|||||||
`;
|
`;
|
||||||
// Sync session immediately
|
// Sync session immediately
|
||||||
if (req.session) req.session.display_name = cleanDisplayName;
|
if (req.session) req.session.display_name = cleanDisplayName;
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
display_name: cleanDisplayName
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
|
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Update Display Name error:', e);
|
console.error('Update Display Name error:', e);
|
||||||
@@ -437,7 +454,7 @@ export default router => {
|
|||||||
return res.json({ success: false, msg: 'Invalid description format' }, 400);
|
return res.json({ success: false, msg: 'Invalid description format' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanDescription = description ? description.trim().substring(0, 255) : null;
|
const cleanDescription = description ? description.trim().substring(0, 2000) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db`
|
await db`
|
||||||
@@ -447,6 +464,14 @@ export default router => {
|
|||||||
`;
|
`;
|
||||||
// Sync session immediately
|
// Sync session immediately
|
||||||
if (req.session) req.session.description = cleanDescription;
|
if (req.session) req.session.description = cleanDescription;
|
||||||
|
|
||||||
|
const payloadStr = JSON.stringify({
|
||||||
|
user_id: req.session.id,
|
||||||
|
user: req.session.user,
|
||||||
|
description: cleanDescription
|
||||||
|
});
|
||||||
|
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||||
|
|
||||||
return res.json({ success: true, description: cleanDescription }, 200);
|
return res.json({ success: true, description: cleanDescription }, 200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Update Description error:', e);
|
console.error('Update Description error:', e);
|
||||||
@@ -644,6 +669,24 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update alternative steuerung preference (per-user toggle for icon-only nav)
|
||||||
|
group.put(/\/alternative_steuerung/, lib.loggedin, async (req, res) => {
|
||||||
|
const use_alternative_steuerung = req.post.use_alternative_steuerung === true || req.post.use_alternative_steuerung === 'true';
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set use_alternative_steuerung = ${use_alternative_steuerung}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
if (req.session) req.session.use_alternative_steuerung = use_alternative_steuerung;
|
||||||
|
return res.json({ success: true, use_alternative_steuerung }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Update alternative_steuerung error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Update per-user language preference
|
// Update per-user language preference
|
||||||
group.put(/\/language/, lib.loggedin, async (req, res) => {
|
group.put(/\/language/, lib.loggedin, async (req, res) => {
|
||||||
if (cfg.websrv.allow_language_change === false) {
|
if (cfg.websrv.allow_language_change === false) {
|
||||||
@@ -726,6 +769,342 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Upload API Key Management ---
|
||||||
|
|
||||||
|
// GET /api/v2/settings/api-key
|
||||||
|
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
|
||||||
|
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
|
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = (await db`
|
||||||
|
SELECT api_key, created_at
|
||||||
|
FROM user_api_keys
|
||||||
|
WHERE user_id = ${+req.session.id}
|
||||||
|
LIMIT 1
|
||||||
|
`)[0];
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.json({ success: true, has_key: false }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
has_key: true,
|
||||||
|
preview: `****${row.api_key.slice(-8)}`,
|
||||||
|
created_at: row.created_at
|
||||||
|
}, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API KEY] GET error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error fetching API key' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/settings/api-key/regenerate
|
||||||
|
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
|
||||||
|
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
|
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const newKey = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO user_api_keys (user_id, api_key, created_at)
|
||||||
|
VALUES (${+req.session.id}, ${newKey}, now())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET api_key = EXCLUDED.api_key,
|
||||||
|
created_at = now()
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
api_key: newKey,
|
||||||
|
msg: 'API key generated. Copy it now — it will not be shown again in full.'
|
||||||
|
}, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API KEY] Regenerate error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error generating API key' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v2/settings/api-key
|
||||||
|
// Revokes (deletes) the user's API key.
|
||||||
|
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
|
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await db`
|
||||||
|
DELETE FROM user_api_keys
|
||||||
|
WHERE user_id = ${+req.session.id}
|
||||||
|
RETURNING user_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.json({ success: false, msg: 'No API key to revoke' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, msg: 'API key revoked' }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API KEY] Delete error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error revoking API key' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v2/settings/api-key/sharex-config
|
||||||
|
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
|
||||||
|
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
|
return res.status(403).reply({ body: 'API keys are disabled' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = (await db`
|
||||||
|
SELECT api_key FROM user_api_keys
|
||||||
|
WHERE user_id = ${+req.session.id}
|
||||||
|
LIMIT 1
|
||||||
|
`)[0];
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).reply({ body: 'No API key — generate one first in Settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine a safe default rating for the ShareX config.
|
||||||
|
// In shitpost mode the server accepts uploads without a rating, but
|
||||||
|
// we still send 'sfw' so the item gets a rating tag by default.
|
||||||
|
const defaultRating = 'sfw';
|
||||||
|
|
||||||
|
const sxcu = {
|
||||||
|
Version: '15.0.0',
|
||||||
|
Name: cfg.main.url.domain,
|
||||||
|
DestinationType: 'ImageUploader, FileUploader',
|
||||||
|
RequestMethod: 'POST',
|
||||||
|
RequestURL: `${cfg.main.url.full}/api/v2/upload`,
|
||||||
|
Headers: {
|
||||||
|
'X-Api-Key': row.api_key
|
||||||
|
},
|
||||||
|
Body: 'MultipartFormData',
|
||||||
|
FileFormName: 'file',
|
||||||
|
// The server requires a rating field. Without it every upload is rejected.
|
||||||
|
// Users can change this value in ShareX's custom uploader settings.
|
||||||
|
Parameters: {
|
||||||
|
rating: defaultRating
|
||||||
|
},
|
||||||
|
// {json:file_url} maps to the direct file URL in the success response JSON
|
||||||
|
URL: '{json:file_url}',
|
||||||
|
ThumbnailURL: '{json:file_url}',
|
||||||
|
ErrorMessage: '{response}'
|
||||||
|
};
|
||||||
|
|
||||||
|
const filename = `${cfg.main.url.domain}.sxcu`;
|
||||||
|
const body = JSON.stringify(sxcu, null, 2);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
'Content-Length': Buffer.byteLength(body)
|
||||||
|
}).end(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API KEY] ShareX config error:', e);
|
||||||
|
return res.status(500).reply({ body: 'Error generating config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- User Invite System ---
|
||||||
|
// Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags
|
||||||
|
// Slots: configurable (default 2), refresh 30 days after a token is used.
|
||||||
|
|
||||||
|
const getInviteCriteria = () => {
|
||||||
|
const ic = cfg.websrv.invite_criteria || {};
|
||||||
|
return {
|
||||||
|
uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150,
|
||||||
|
age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30,
|
||||||
|
comments: Number.isFinite(+ic.comments) ? +ic.comments : 66,
|
||||||
|
tags: Number.isFinite(+ic.tags) ? +ic.tags : 200,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInviteSlots = () => {
|
||||||
|
const n = parseInt(cfg.websrv.user_invite_slots);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/v2/settings/invites
|
||||||
|
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
|
||||||
|
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const userId = +req.session.id;
|
||||||
|
const username = req.session.user;
|
||||||
|
const totalSlots = getInviteSlots();
|
||||||
|
const refreshDays = 30;
|
||||||
|
const isAdmin = !!req.session.admin;
|
||||||
|
|
||||||
|
// Always fetch this user's token history
|
||||||
|
const tokens = await db`
|
||||||
|
SELECT
|
||||||
|
it.id,
|
||||||
|
it.token,
|
||||||
|
it.is_used,
|
||||||
|
it.used_at,
|
||||||
|
it.created_at,
|
||||||
|
u.user AS used_by_name
|
||||||
|
FROM invite_tokens it
|
||||||
|
LEFT JOIN "user" u ON u.id = it.used_by
|
||||||
|
WHERE it.created_by = ${userId}
|
||||||
|
ORDER BY it.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
let eligible, criteria, slotsConsumed, slotsAvailable;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admins bypass all criteria and slot limits
|
||||||
|
eligible = true;
|
||||||
|
criteria = null;
|
||||||
|
slotsConsumed = 0;
|
||||||
|
slotsAvailable = Infinity;
|
||||||
|
} else {
|
||||||
|
// Gather eligibility stats in one query
|
||||||
|
const [stats] = await db`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||||
|
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||||
|
FROM "user" u
|
||||||
|
WHERE u.id = ${userId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const INVITE_CRITERIA = getInviteCriteria();
|
||||||
|
criteria = {
|
||||||
|
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
|
||||||
|
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
|
||||||
|
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
|
||||||
|
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
|
||||||
|
};
|
||||||
|
eligible = Object.values(criteria).every(c => c.met);
|
||||||
|
|
||||||
|
// Slots consumed = tokens used within the last 30 days
|
||||||
|
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||||
|
slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
|
||||||
|
slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
is_admin: isAdmin,
|
||||||
|
eligible,
|
||||||
|
criteria,
|
||||||
|
tokens,
|
||||||
|
slots_total: isAdmin ? null : totalSlots,
|
||||||
|
slots_consumed: isAdmin ? null : slotsConsumed,
|
||||||
|
slots_available: isAdmin ? null : slotsAvailable,
|
||||||
|
refresh_days: refreshDays,
|
||||||
|
}, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVITES] GET error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error fetching invite data' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/settings/invites/create
|
||||||
|
// Generates a new invite token if eligible and slots remain.
|
||||||
|
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const userId = +req.session.id;
|
||||||
|
const username = req.session.user;
|
||||||
|
const totalSlots = getInviteSlots();
|
||||||
|
const refreshDays = 30;
|
||||||
|
|
||||||
|
const isAdmin = !!req.session.admin;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
// Eligibility check
|
||||||
|
const [stats] = await db`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||||
|
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||||
|
FROM "user" u WHERE u.id = ${userId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const INVITE_CRITERIA = getInviteCriteria();
|
||||||
|
const eligible =
|
||||||
|
stats.upload_count >= INVITE_CRITERIA.uploads &&
|
||||||
|
stats.age_days >= INVITE_CRITERIA.age_days &&
|
||||||
|
stats.comment_count >= INVITE_CRITERIA.comments &&
|
||||||
|
stats.tag_count >= INVITE_CRITERIA.tags;
|
||||||
|
|
||||||
|
if (!eligible) {
|
||||||
|
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check available slots (used within last 30 days)
|
||||||
|
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||||
|
const [{ slots_consumed }] = await db`
|
||||||
|
SELECT COUNT(*)::int AS slots_consumed
|
||||||
|
FROM invite_tokens
|
||||||
|
WHERE created_by = ${userId}
|
||||||
|
AND is_used = true
|
||||||
|
AND used_at > ${cutoff}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (slots_consumed >= totalSlots) {
|
||||||
|
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
|
||||||
|
await db`
|
||||||
|
INSERT INTO invite_tokens (token, created_at, created_by)
|
||||||
|
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId})
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`);
|
||||||
|
return res.json({ success: true, token }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVITES] Create error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error creating invite token' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/settings/invites/delete
|
||||||
|
// Deletes an unused invite token owned by the calling user.
|
||||||
|
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
|
||||||
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { id } = req.post;
|
||||||
|
if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400);
|
||||||
|
|
||||||
|
const result = await db`
|
||||||
|
DELETE FROM invite_tokens
|
||||||
|
WHERE id = ${+id}
|
||||||
|
AND created_by = ${+req.session.id}
|
||||||
|
AND is_used = false
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.json({ success: false, msg: 'Token not found or already used' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true }, 200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVITES] Delete error:', e);
|
||||||
|
return res.json({ success: false, msg: 'Error deleting invite token' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagname.length > 70) {
|
if (tagname.length > 85) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
msg: 'tag is too long!'
|
msg: 'tag is too long!'
|
||||||
@@ -105,8 +105,19 @@ export default router => {
|
|||||||
const cycle = [1, 2, nsflId];
|
const cycle = [1, 2, nsflId];
|
||||||
const currentTags = await lib.getTags(postid);
|
const currentTags = await lib.getTags(postid);
|
||||||
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
|
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
|
||||||
|
|
||||||
|
let nextTagId;
|
||||||
|
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
||||||
|
if (reqRating === 'sfw') {
|
||||||
|
nextTagId = 1;
|
||||||
|
} else if (reqRating === 'nsfw') {
|
||||||
|
nextTagId = 2;
|
||||||
|
} else if (reqRating === 'nsfl') {
|
||||||
|
nextTagId = nsflId;
|
||||||
|
} else {
|
||||||
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
||||||
const nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove any existing rating tag
|
// Remove any existing rating tag
|
||||||
@@ -115,6 +126,14 @@ export default router => {
|
|||||||
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
|
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically generate/verify blurred thumbnail on cycle
|
||||||
|
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
||||||
|
try {
|
||||||
|
await fs.promises.access(blurPath);
|
||||||
|
} catch {
|
||||||
|
await queue.genBlurredThumbnail(postid, false);
|
||||||
|
}
|
||||||
|
|
||||||
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
|
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
|
||||||
const { label, cls } = labels[nextTagId];
|
const { label, cls } = labels[nextTagId];
|
||||||
|
|
||||||
@@ -170,9 +189,7 @@ export default router => {
|
|||||||
|
|
||||||
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
|
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
|
||||||
|
|
||||||
// Generate blurred thumbnail if toggling TO NSFW
|
// Ensure blurred thumbnail exists on toggle
|
||||||
if (hasSFW && !hasNSFW) {
|
|
||||||
// Was SFW, now NSFW - check if blur exists and generate if not
|
|
||||||
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(blurPath);
|
await fs.promises.access(blurPath);
|
||||||
@@ -180,7 +197,6 @@ export default router => {
|
|||||||
// Doesn't exist - generate it
|
// Doesn't exist - generate it
|
||||||
await queue.genBlurredThumbnail(postid, false);
|
await queue.genBlurredThumbnail(postid, false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const freshTags = await lib.getTags(postid);
|
const freshTags = await lib.getTags(postid);
|
||||||
console.log(`[API] Notifying 'tags' (toggle) for item ${postid}`);
|
console.log(`[API] Notifying 'tags' (toggle) for item ${postid}`);
|
||||||
|
|||||||
@@ -1,10 +1,96 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
import { spawn as _spawnRaw } from 'child_process';
|
||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
|
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// In-memory job progress map (keyed by jobId string)
|
||||||
|
// Entries: { stage, percent, speed, eta, done, error }
|
||||||
|
// Auto-cleaned 10 min after completion.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
const progressMap = new Map();
|
||||||
|
|
||||||
|
const setProgress = (jobId, patch) => {
|
||||||
|
const existing = progressMap.get(jobId) || { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null };
|
||||||
|
progressMap.set(jobId, { ...existing, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupJob = (jobId) => {
|
||||||
|
setTimeout(() => progressMap.delete(jobId), 10 * 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like queue.spawn() but streams stderr lines to a callback for live progress,
|
||||||
|
* while still resolving with { stdout, stderr } when the process exits.
|
||||||
|
* Splits on both \n and \r so yt-dlp's carriage-return progress works.
|
||||||
|
*/
|
||||||
|
const spawnWithProgress = (cmd, args, onLine) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = _spawnRaw(cmd, args);
|
||||||
|
const stdoutChunks = [];
|
||||||
|
const stderrChunks = [];
|
||||||
|
let stderrBuf = '';
|
||||||
|
|
||||||
|
if (child.stdout) child.stdout.on('data', d => stdoutChunks.push(d));
|
||||||
|
if (child.stderr) {
|
||||||
|
child.stderr.on('data', chunk => {
|
||||||
|
stderrChunks.push(chunk);
|
||||||
|
stderrBuf += chunk.toString();
|
||||||
|
// Split on \r or \n (yt-dlp uses \r to overwrite progress in-place)
|
||||||
|
const parts = stderrBuf.split(/[\r\n]/);
|
||||||
|
// Keep the last (potentially incomplete) segment in the buffer
|
||||||
|
stderrBuf = parts.pop() ?? '';
|
||||||
|
for (const line of parts) {
|
||||||
|
// Strip ANSI escape codes
|
||||||
|
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
||||||
|
if (clean && onLine) onLine(clean);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('close', code => {
|
||||||
|
// Process any remaining buffered content
|
||||||
|
if (stderrBuf.trim() && onLine) {
|
||||||
|
const clean = stderrBuf.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
||||||
|
if (clean) onLine(clean);
|
||||||
|
}
|
||||||
|
const stdout = Buffer.concat(stdoutChunks).toString();
|
||||||
|
const stderr = Buffer.concat(stderrChunks).toString();
|
||||||
|
if (code !== 0) {
|
||||||
|
const err = new Error(`Command '${cmd} ${args.join(' ')}' failed with code ${code}`);
|
||||||
|
err.stderr = stderr;
|
||||||
|
err.stdout = stdout;
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
});
|
||||||
|
child.on('error', err => {
|
||||||
|
err.stderr = Buffer.concat(stderrChunks).toString();
|
||||||
|
err.stdout = Buffer.concat(stdoutChunks).toString();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse a yt-dlp stderr line and return a progress patch object or null. */
|
||||||
|
const parseYtdlpLine = (line) => {
|
||||||
|
// [download] 47.3% of ~ 58.23MiB at 3.22MiB/s ETA 00:13
|
||||||
|
const dlMatch = line.match(/\[download\]\s+([\d.]+)%.*?at\s+([\d.]+\s*\S+\/s)(?:.*?ETA\s+(\S+))?/);
|
||||||
|
if (dlMatch) {
|
||||||
|
return { stage: 'downloading', percent: parseFloat(dlMatch[1]), speed: dlMatch[2] || null, eta: dlMatch[3] || null };
|
||||||
|
}
|
||||||
|
// [download] Destination: ...
|
||||||
|
if (line.includes('[download] Destination:')) return { stage: 'downloading' };
|
||||||
|
if (line.includes('[Merger]') || line.includes('[ffmpeg]')) return { stage: 'processing', percent: 99 };
|
||||||
|
if (line.includes('[ExtractAudio]')) return { stage: 'extracting', percent: 99 };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Native multipart form data parser
|
// Native multipart form data parser
|
||||||
const parseMultipart = (buffer, boundary) => {
|
const parseMultipart = (buffer, boundary) => {
|
||||||
const parts = {};
|
const parts = {};
|
||||||
@@ -79,15 +165,25 @@ const collectBody = (req) => {
|
|||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2/, group => {
|
router.group(/^\/api\/v2/, group => {
|
||||||
|
|
||||||
|
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
|
||||||
|
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
|
||||||
|
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
|
||||||
|
const state = progressMap.get(jobId);
|
||||||
|
res.setHeader?.('Cache-Control', 'no-store');
|
||||||
|
if (!state) return res.json({ success: false, msg: 'Job not found' }, 404);
|
||||||
|
return res.json({ success: true, ...state });
|
||||||
|
});
|
||||||
|
|
||||||
const saveComment = async (itemid, userid, content) => {
|
const saveComment = async (itemid, userid, content) => {
|
||||||
if (!content || !content.trim()) return;
|
if (!content || !content.trim()) return;
|
||||||
try {
|
try {
|
||||||
|
const filteredContent = await applyWordFilter(content);
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO comments ${db({
|
INSERT INTO comments ${db({
|
||||||
item_id: itemid,
|
item_id: itemid,
|
||||||
user_id: userid,
|
user_id: userid,
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
content: content.trim()
|
content: filteredContent.trim()
|
||||||
}, 'item_id', 'user_id', 'parent_id', 'content')}
|
}, 'item_id', 'user_id', 'parent_id', 'content')}
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -133,8 +229,6 @@ export default router => {
|
|||||||
domain = parts.slice(-2).join('.');
|
domain = parts.slice(-2).join('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domain) tags.push(domain);
|
|
||||||
|
|
||||||
// YouTube-specific tag
|
// YouTube-specific tag
|
||||||
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
|
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
|
||||||
tags.push('youtube');
|
tags.push('youtube');
|
||||||
@@ -202,7 +296,13 @@ export default router => {
|
|||||||
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost } = req.post || {};
|
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost, title: rawTitle } = req.post || {};
|
||||||
|
const title = (rawTitle && typeof rawTitle === 'string' && rawTitle.trim()) ? rawTitle.trim().substring(0, 500) : null;
|
||||||
|
|
||||||
|
const maxLen = cfg.main.comment_max_length;
|
||||||
|
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
||||||
|
return res.json({ success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!inputUrl || !inputUrl.trim()) {
|
if (!inputUrl || !inputUrl.trim()) {
|
||||||
return res.json({ success: false, msg: 'URL is required' }, 400);
|
return res.json({ success: false, msg: 'URL is required' }, 400);
|
||||||
@@ -219,8 +319,8 @@ export default router => {
|
|||||||
}
|
}
|
||||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||||
const minTags = getMinTags();
|
const minTags = getMinTags();
|
||||||
// In shitpost mode tags are optional
|
// In shitpost mode tags are optional; skip entirely when minTags is 0
|
||||||
if (!is_shitpost && tags.length < minTags) {
|
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
|
||||||
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,8 +373,9 @@ export default router => {
|
|||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc
|
is_oc: !!is_oc,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
title: title
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -296,10 +397,8 @@ export default router => {
|
|||||||
if (effectiveRating) {
|
if (effectiveRating) {
|
||||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
|
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
|
||||||
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
|
|
||||||
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
|
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Assign user tags + auto-tags
|
// Assign user tags + auto-tags
|
||||||
const autoTags = autoTagsFromUrl(ytUrl); // always includes 'youtube' + 'youtube.com'
|
const autoTags = autoTagsFromUrl(ytUrl); // always includes 'youtube' + 'youtube.com'
|
||||||
@@ -338,9 +437,14 @@ export default router => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Return immediately to avoid proxy timeouts
|
// Return immediately to avoid proxy timeouts
|
||||||
|
// Generate a client-side trackable job ID
|
||||||
|
const jobId = await queue.genuuid();
|
||||||
|
setProgress(jobId, { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
pending: true,
|
pending: true,
|
||||||
|
jobId,
|
||||||
msg: 'URL processing started in background. You will receive a notification when it is finished.'
|
msg: 'URL processing started in background. You will receive a notification when it is finished.'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,7 +489,7 @@ export default router => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
|
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
|
||||||
let maxfilesize = cfg.main.maxfilesize;
|
let maxfilesize = cfg.main.maxfilesize;
|
||||||
if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
||||||
|
|
||||||
@@ -396,9 +500,10 @@ export default router => {
|
|||||||
|
|
||||||
let source;
|
let source;
|
||||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
|
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
|
||||||
|
setProgress(jobId, { stage: 'downloading', percent: 0 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
source = (await queue.spawn('yt-dlp', [
|
source = (await spawnWithProgress('yt-dlp', [
|
||||||
...proxyArgs, ...ytdlpArgs,
|
...proxyArgs, ...ytdlpArgs,
|
||||||
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
|
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
|
||||||
url,
|
url,
|
||||||
@@ -407,22 +512,30 @@ export default router => {
|
|||||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||||
'--print', 'after_move:filepath',
|
'--print', 'after_move:filepath',
|
||||||
'--merge-output-format', 'mp4'
|
'--merge-output-format', 'mp4'
|
||||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
], (line) => {
|
||||||
|
const patch = parseYtdlpLine(line);
|
||||||
|
if (patch) setProgress(jobId, patch);
|
||||||
|
})).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||||
if (isInstagram) throw new Error(sanitizeError(err));
|
if (isInstagram) throw new Error(sanitizeError(err));
|
||||||
|
|
||||||
|
setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null });
|
||||||
try {
|
try {
|
||||||
source = (await queue.spawn('yt-dlp', [
|
source = (await spawnWithProgress('yt-dlp', [
|
||||||
...proxyArgs, ...ytdlpArgs,
|
...proxyArgs, ...ytdlpArgs,
|
||||||
url,
|
url,
|
||||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||||
'--print', 'after_move:filepath'
|
'--print', 'after_move:filepath'
|
||||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
], (line) => {
|
||||||
|
const patch = parseYtdlpLine(line);
|
||||||
|
if (patch) setProgress(jobId, patch);
|
||||||
|
})).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
||||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
|
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
|
||||||
|
setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null });
|
||||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||||
let referer = url;
|
let referer = url;
|
||||||
try {
|
try {
|
||||||
@@ -467,6 +580,8 @@ export default router => {
|
|||||||
|
|
||||||
if (!source || source.match(/larger than/)) throw new Error('File too large or download failed');
|
if (!source || source.match(/larger than/)) throw new Error('File too large or download failed');
|
||||||
|
|
||||||
|
setProgress(jobId, { stage: 'analyzing', percent: 100, speed: null, eta: null });
|
||||||
|
|
||||||
const { stat } = await import('fs/promises');
|
const { stat } = await import('fs/promises');
|
||||||
const size = (await stat(source)).size;
|
const size = (await stat(source)).size;
|
||||||
if (size > maxfilesize) {
|
if (size > maxfilesize) {
|
||||||
@@ -508,7 +623,9 @@ export default router => {
|
|||||||
const repostSum = await queue.checkrepostsum(checksum);
|
const repostSum = await queue.checkrepostsum(checksum);
|
||||||
if (repostSum) {
|
if (repostSum) {
|
||||||
await fs.unlink(source).catch(() => {});
|
await fs.unlink(source).catch(() => {});
|
||||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ url, msg: 'Duplicate detected (Checksum)' })})`;
|
setProgress(jobId, { stage: 'error', done: true, error: 'This file already exists', itemId: repostSum });
|
||||||
|
cleanupJob(jobId);
|
||||||
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ jobId, url, msg: 'This file already exists' })})`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +637,9 @@ export default router => {
|
|||||||
const phashMatch = await queue.checkrepostphash(phash);
|
const phashMatch = await queue.checkrepostphash(phash);
|
||||||
if (phashMatch) {
|
if (phashMatch) {
|
||||||
await fs.unlink(source).catch(() => {});
|
await fs.unlink(source).catch(() => {});
|
||||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ url, msg: 'Visual duplicate detected (PHash)' })})`;
|
setProgress(jobId, { stage: 'error', done: true, error: 'This file is a visual duplicate', itemId: phashMatch });
|
||||||
|
cleanupJob(jobId);
|
||||||
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ jobId, url, msg: 'This file is a visual duplicate' })})`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,6 +660,7 @@ export default router => {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setProgress(jobId, { stage: 'saving', percent: 100 });
|
||||||
if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename));
|
if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename));
|
||||||
await fs.unlink(source).catch(() => { });
|
await fs.unlink(source).catch(() => { });
|
||||||
|
|
||||||
@@ -559,8 +679,9 @@ export default router => {
|
|||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc
|
is_oc: !!is_oc,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
title: title
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -570,7 +691,7 @@ export default router => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||||
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||||
@@ -632,12 +753,16 @@ export default router => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Completion notification
|
// Completion notification
|
||||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
setProgress(jobId, { stage: 'done', percent: 100, done: true, error: null, itemId: itemid });
|
||||||
|
cleanupJob(jobId);
|
||||||
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_success', 0, ${itemid}, ${db.json({ jobId })})`;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
|
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
|
||||||
// Error notification
|
// Error notification
|
||||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`;
|
setProgress(jobId, { stage: 'error', done: true, error: sanitizeError(err) });
|
||||||
|
cleanupJob(jobId);
|
||||||
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ jobId, url, msg: sanitizeError(err) })})`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cfg from "../config.mjs";
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import audit from "../audit.mjs";
|
import audit from "../audit.mjs";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
import { applyWordFilter } from "../wordfilter.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
@@ -42,6 +43,24 @@ export default (router, tpl) => {
|
|||||||
if (sub.length > 0) is_subscribed = true;
|
if (sub.length > 0) is_subscribed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill in per-user poll votes
|
||||||
|
if (req.session && cfg.websrv.enable_comment_polls) {
|
||||||
|
const pollComments = comments.filter(c => c.poll);
|
||||||
|
if (pollComments.length > 0) {
|
||||||
|
const pollIds = pollComments.map(c => c.poll.id);
|
||||||
|
try {
|
||||||
|
const votes = await db`
|
||||||
|
SELECT poll_id, option_id FROM comment_poll_votes
|
||||||
|
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
|
||||||
|
`;
|
||||||
|
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
|
||||||
|
for (const c of pollComments) {
|
||||||
|
if (c.poll) c.poll.user_vote_option_id = voteMap.get(c.poll.id) || null;
|
||||||
|
}
|
||||||
|
} catch (e) { /* graceful */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Transform for frontend if needed, or send as is
|
// Transform for frontend if needed, or send as is
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
@@ -145,7 +164,14 @@ export default (router, tpl) => {
|
|||||||
mode = parseInt(req.url.qs.mode);
|
mode = parseInt(req.url.qs.mode);
|
||||||
}
|
}
|
||||||
/* </mode-override> */
|
/* </mode-override> */
|
||||||
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
|
|
||||||
|
// Multi-rating cookie support (same logic as other routes)
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||||
|
|
||||||
|
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||||
|
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||||
|
|
||||||
const comments = await db`
|
const comments = await db`
|
||||||
SELECT c.*, i.mime, i.id as item_id
|
SELECT c.*, i.mime, i.id as item_id
|
||||||
@@ -179,13 +205,122 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Let's modify comments content in-place (or new array) before mapping
|
// Let's modify comments content in-place (or new array) before mapping
|
||||||
const mentionsProcessed = await f0cklib.processMentions(comments);
|
const mentionsProcessed = await f0cklib.processMentions(comments);
|
||||||
const processedComments = mentionsProcessed.map(c => {
|
let processedComments = mentionsProcessed.map(c => {
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
content: c.content
|
content: c.content
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch file attachments for all fetched comments
|
||||||
|
if (processedComments.length > 0) {
|
||||||
|
const commentIds = processedComments.map(c => c.id);
|
||||||
|
try {
|
||||||
|
const files = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ANY(${commentIds}::int[])
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
const filesMap = new Map();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
|
||||||
|
filesMap.get(f.comment_id).push(f);
|
||||||
|
}
|
||||||
|
for (const c of processedComments) {
|
||||||
|
c.files = filesMap.get(c.id) || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
for (const c of processedComments) c.files = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch poll data for comments
|
||||||
|
if (cfg.websrv.enable_comment_polls) {
|
||||||
|
try {
|
||||||
|
const commentIds = processedComments.map(c => c.id);
|
||||||
|
const pollRows = await db`
|
||||||
|
SELECT
|
||||||
|
cp.id as poll_id,
|
||||||
|
cp.comment_id,
|
||||||
|
cp.question,
|
||||||
|
cp.expires_at,
|
||||||
|
COALESCE(cp.is_anonymous, true) as is_anonymous,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cpo.id,
|
||||||
|
'text', cpo.text,
|
||||||
|
'sort_order', cpo.sort_order,
|
||||||
|
'vote_count', COALESCE(vote_counts.cnt, 0)
|
||||||
|
) ORDER BY cpo.sort_order ASC, cpo.id ASC
|
||||||
|
) AS options,
|
||||||
|
COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes
|
||||||
|
FROM comment_polls cp
|
||||||
|
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT option_id, COUNT(*) AS cnt
|
||||||
|
FROM comment_poll_votes
|
||||||
|
GROUP BY option_id
|
||||||
|
) vote_counts ON vote_counts.option_id = cpo.id
|
||||||
|
WHERE cp.comment_id = ANY(${commentIds}::int[])
|
||||||
|
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
|
||||||
|
`;
|
||||||
|
// For non-anonymous polls, fetch voter names
|
||||||
|
const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id);
|
||||||
|
let votersByOption = new Map();
|
||||||
|
if (nonAnonIds.length > 0) {
|
||||||
|
const voterRows = await db`
|
||||||
|
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
|
||||||
|
FROM comment_poll_votes cpv
|
||||||
|
JOIN public."user" u ON u.id = cpv.user_id
|
||||||
|
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
|
||||||
|
WHERE cpv.poll_id = ANY(${nonAnonIds}::int[])
|
||||||
|
`;
|
||||||
|
for (const v of voterRows) {
|
||||||
|
if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []);
|
||||||
|
votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pollMap = new Map();
|
||||||
|
for (const p of pollRows) {
|
||||||
|
const options = p.is_anonymous
|
||||||
|
? p.options
|
||||||
|
: p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] }));
|
||||||
|
pollMap.set(p.comment_id, {
|
||||||
|
id: p.poll_id,
|
||||||
|
question: p.question,
|
||||||
|
expires_at: p.expires_at,
|
||||||
|
is_anonymous: p.is_anonymous,
|
||||||
|
options,
|
||||||
|
total_votes: parseInt(p.total_votes) || 0,
|
||||||
|
user_vote_option_id: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fill in per-user poll votes if logged in
|
||||||
|
if (req.session && pollRows.length > 0) {
|
||||||
|
const pollIds = pollRows.map(p => p.poll_id);
|
||||||
|
try {
|
||||||
|
const votes = await db`
|
||||||
|
SELECT poll_id, option_id FROM comment_poll_votes
|
||||||
|
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
|
||||||
|
`;
|
||||||
|
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
|
||||||
|
for (const [comment_id, poll] of pollMap.entries()) {
|
||||||
|
poll.user_vote_option_id = voteMap.get(poll.id) || null;
|
||||||
|
}
|
||||||
|
} catch (e) { /* graceful */ }
|
||||||
|
}
|
||||||
|
for (const c of processedComments) {
|
||||||
|
c.poll = pollMap.get(c.id) || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[USER_COMMENTS] Poll fetch error:', e.message);
|
||||||
|
for (const c of processedComments) c.poll = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const c of processedComments) c.poll = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
@@ -252,14 +387,20 @@ export default (router, tpl) => {
|
|||||||
const body = req.post || {};
|
const body = req.post || {};
|
||||||
const item_id = parseInt(body.item_id, 10);
|
const item_id = parseInt(body.item_id, 10);
|
||||||
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
|
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
|
||||||
const content = body.content;
|
let content = body.content || '';
|
||||||
|
content = await applyWordFilter(content);
|
||||||
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
|
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
|
||||||
? parseFloat(body.video_time)
|
? parseFloat(body.video_time)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||||
|
|
||||||
if (!content || !content.trim()) {
|
const fileIdsRaw = body.file_ids || '';
|
||||||
|
const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : [];
|
||||||
|
|
||||||
|
const hasPoll = body.has_poll === '1' || body.has_poll === 'true';
|
||||||
|
|
||||||
|
if ((!content || !content.trim()) && fileIds.length === 0 && !hasPoll) {
|
||||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +422,7 @@ export default (router, tpl) => {
|
|||||||
item_id,
|
item_id,
|
||||||
user_id: req.session.id,
|
user_id: req.session.id,
|
||||||
parent_id: parent_id || null,
|
parent_id: parent_id || null,
|
||||||
content: content
|
content: content || ''
|
||||||
};
|
};
|
||||||
if (video_time !== null) insertData.video_time = video_time;
|
if (video_time !== null) insertData.video_time = video_time;
|
||||||
|
|
||||||
@@ -293,6 +434,7 @@ export default (router, tpl) => {
|
|||||||
const commentId = parseInt(newComment[0].id, 10);
|
const commentId = parseInt(newComment[0].id, 10);
|
||||||
|
|
||||||
// Link uploaded files to this comment (if any)
|
// Link uploaded files to this comment (if any)
|
||||||
|
let activityFiles = [];
|
||||||
const fileIdsRaw = body.file_ids || '';
|
const fileIdsRaw = body.file_ids || '';
|
||||||
if (fileIdsRaw) {
|
if (fileIdsRaw) {
|
||||||
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
||||||
@@ -306,6 +448,13 @@ export default (router, tpl) => {
|
|||||||
AND user_id = ${req.session.id}
|
AND user_id = ${req.session.id}
|
||||||
AND comment_id IS NULL
|
AND comment_id IS NULL
|
||||||
`;
|
`;
|
||||||
|
// Fetch the linked files to send with live notification and post response
|
||||||
|
activityFiles = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ${commentId}
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[COMMENTS] Failed to link files to comment:', err);
|
console.error('[COMMENTS] Failed to link files to comment:', err);
|
||||||
}
|
}
|
||||||
@@ -418,8 +567,23 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify for live updates
|
// Notify for live updates
|
||||||
// Fetch the trigger-updated xd_score from the DB (trigger runs synchronously before we get here)
|
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
|
||||||
const [xdRow] = await db`SELECT xd_score FROM items WHERE id = ${item_id}`;
|
const itemQuery = await db`
|
||||||
|
SELECT
|
||||||
|
i.xd_score,
|
||||||
|
(SELECT ta.tag_id FROM tags_assign ta
|
||||||
|
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||||
|
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||||
|
FROM items i WHERE i.id = ${item_id}
|
||||||
|
`;
|
||||||
|
const xdRow = itemQuery[0];
|
||||||
|
const ratingTagId = itemQuery[0]?.rating_tag_id;
|
||||||
|
let ratingLabel = '?';
|
||||||
|
let ratingClass = 'untagged';
|
||||||
|
if (ratingTagId == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
|
||||||
|
else if (ratingTagId == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
|
||||||
|
else if (ratingTagId == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
|
||||||
|
|
||||||
// Truncate body to 500 chars: PostgreSQL NOTIFY has an 8000-byte hard limit.
|
// Truncate body to 500 chars: PostgreSQL NOTIFY has an 8000-byte hard limit.
|
||||||
// Large comments would silently drop the notification. The client fetches
|
// Large comments would silently drop the notification. The client fetches
|
||||||
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
|
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
|
||||||
@@ -438,19 +602,35 @@ export default (router, tpl) => {
|
|||||||
username_color: req.session.username_color,
|
username_color: req.session.username_color,
|
||||||
display_name: req.session.display_name || null,
|
display_name: req.session.display_name || null,
|
||||||
xd_score: xdRow?.xd_score ?? null,
|
xd_score: xdRow?.xd_score ?? null,
|
||||||
video_time: newComment[0]?.video_time ?? null
|
video_time: newComment[0]?.video_time ?? null,
|
||||||
|
files: activityFiles
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Thread live update
|
// 1. Thread live update
|
||||||
db.notify('comments', JSON.stringify(livePayload));
|
db.notify('comments', JSON.stringify(livePayload));
|
||||||
|
|
||||||
// 2. Sidebar activity update
|
// 2. Sidebar activity update
|
||||||
|
// Compute is_long using the full content (not the truncated notifyBody) so the
|
||||||
|
// sidebar renders the correct clamped state immediately on first paint.
|
||||||
|
const activityIsLong = content.length > 120
|
||||||
|
|| content.split('\n').length > 2
|
||||||
|
|| activityFiles.length > 0
|
||||||
|
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
|
||||||
db.notify('activity', JSON.stringify({
|
db.notify('activity', JSON.stringify({
|
||||||
user_id: req.session.id,
|
user_id: req.session.id,
|
||||||
item_id: item_id,
|
item_id: item_id,
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
body: notifyBody,
|
body: notifyBody,
|
||||||
id: commentId
|
id: commentId,
|
||||||
|
item_rating_class: ratingClass,
|
||||||
|
item_rating_label: ratingLabel,
|
||||||
|
avatar: req.session.avatar,
|
||||||
|
avatar_file: req.session.avatar_file,
|
||||||
|
username: req.session.user,
|
||||||
|
username_color: req.session.username_color,
|
||||||
|
display_name: req.session.display_name || null,
|
||||||
|
files: activityFiles,
|
||||||
|
is_long: activityIsLong
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Automatically subscribe user to the thread
|
// Automatically subscribe user to the thread
|
||||||
@@ -466,7 +646,11 @@ export default (router, tpl) => {
|
|||||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
comment: newComment[0],
|
comment: {
|
||||||
|
...newComment[0],
|
||||||
|
content,
|
||||||
|
files: activityFiles
|
||||||
|
},
|
||||||
xd_score: xdRow?.xd_score ?? null,
|
xd_score: xdRow?.xd_score ?? null,
|
||||||
is_new_subscription
|
is_new_subscription
|
||||||
})
|
})
|
||||||
@@ -519,9 +703,15 @@ export default (router, tpl) => {
|
|||||||
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
|
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
|
||||||
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||||
|
|
||||||
if (!req.session.admin && !req.session.is_moderator && comment[0].user_id !== req.session.id) {
|
const { getAllowCommentDeletion } = await import("../settings.mjs");
|
||||||
|
const canDeleteOwn = getAllowCommentDeletion();
|
||||||
|
const isOwner = comment[0].user_id === req.session.id;
|
||||||
|
|
||||||
|
if (!req.session.admin && !req.session.is_moderator) {
|
||||||
|
if (!canDeleteOwn || !isOwner) {
|
||||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log all deletions in audit log
|
// Log all deletions in audit log
|
||||||
const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided');
|
const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided');
|
||||||
@@ -691,7 +881,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const commentId = req.params.id;
|
const commentId = req.params.id;
|
||||||
const body = req.post || {};
|
const body = req.post || {};
|
||||||
const content = body.content;
|
let content = body.content;
|
||||||
|
content = await applyWordFilter(content);
|
||||||
|
|
||||||
if (!content || !content.trim()) {
|
if (!content || !content.trim()) {
|
||||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
|
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
|
||||||
@@ -781,6 +972,15 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Recent Activity Page
|
// Recent Activity Page
|
||||||
router.get(/\/activity\/?/, async (req, res) => {
|
router.get(/\/activity\/?/, async (req, res) => {
|
||||||
|
if (!req.session && cfg.main.hide_comments_from_public) {
|
||||||
|
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, comments: [], page: 1, hasMore: false, require_login: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.reply({ code: 403, body: "Forbidden" });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const page = +(req.url.qs?.page || 1);
|
const page = +(req.url.qs?.page || 1);
|
||||||
const limit = Math.min(+(req.url.qs?.limit || 50), 50);
|
const limit = Math.min(+(req.url.qs?.limit || 50), 50);
|
||||||
@@ -796,7 +996,14 @@ export default (router, tpl) => {
|
|||||||
mode = parseInt(req.url.qs.mode);
|
mode = parseInt(req.url.qs.mode);
|
||||||
}
|
}
|
||||||
/* </mode-override> */
|
/* </mode-override> */
|
||||||
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
|
|
||||||
|
// Multi-rating cookie support (same logic as other routes)
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||||
|
|
||||||
|
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||||
|
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||||
|
|
||||||
@@ -806,6 +1013,9 @@ export default (router, tpl) => {
|
|||||||
i.mime,
|
i.mime,
|
||||||
i.id as item_id,
|
i.id as item_id,
|
||||||
i.dest as item_dest,
|
i.dest as item_dest,
|
||||||
|
(SELECT ta.tag_id FROM tags_assign ta
|
||||||
|
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||||
|
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
|
||||||
u.user as username,
|
u.user as username,
|
||||||
uo.avatar,
|
uo.avatar,
|
||||||
uo.avatar_file,
|
uo.avatar_file,
|
||||||
@@ -826,11 +1036,95 @@ export default (router, tpl) => {
|
|||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch comment file attachments
|
||||||
|
const filesMap = new Map();
|
||||||
|
if (comments.length > 0) {
|
||||||
|
const commentIds = comments.map(c => c.id);
|
||||||
|
try {
|
||||||
|
const files = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ANY(${commentIds}::int[])
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
for (const f of files) {
|
||||||
|
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
|
||||||
|
filesMap.get(f.comment_id).push(f);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ACTIVITY] Failed to fetch comment files:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch poll data for these comments
|
||||||
|
const pollMap = new Map();
|
||||||
|
if (comments.length > 0 && cfg.websrv.enable_comment_polls) {
|
||||||
|
try {
|
||||||
|
const commentIds = comments.map(c => c.id);
|
||||||
|
const pollRows = await db`
|
||||||
|
SELECT
|
||||||
|
cp.id as poll_id,
|
||||||
|
cp.comment_id,
|
||||||
|
cp.question,
|
||||||
|
cp.expires_at,
|
||||||
|
COALESCE(cp.is_anonymous, true) as is_anonymous,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cpo.id,
|
||||||
|
'text', cpo.text,
|
||||||
|
'sort_order', cpo.sort_order,
|
||||||
|
'vote_count', COALESCE(vc.cnt, 0)
|
||||||
|
) ORDER BY cpo.sort_order ASC, cpo.id ASC
|
||||||
|
) AS options,
|
||||||
|
COALESCE(SUM(vc.cnt), 0)::int AS total_votes
|
||||||
|
FROM comment_polls cp
|
||||||
|
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
|
||||||
|
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
|
||||||
|
WHERE cp.comment_id = ANY(${commentIds}::int[])
|
||||||
|
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
|
||||||
|
`;
|
||||||
|
for (const p of pollRows) {
|
||||||
|
pollMap.set(p.comment_id, {
|
||||||
|
id: p.poll_id,
|
||||||
|
question: p.question,
|
||||||
|
expires_at: p.expires_at,
|
||||||
|
is_anonymous: p.is_anonymous,
|
||||||
|
options: p.options,
|
||||||
|
total_votes: parseInt(p.total_votes) || 0,
|
||||||
|
user_vote_option_id: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ACTIVITY] Failed to fetch polls:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const processedComments = comments.map(c => {
|
const processedComments = comments.map(c => {
|
||||||
|
let ratingLabel = '?';
|
||||||
|
let ratingClass = 'untagged';
|
||||||
|
if (c.rating_tag_id == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
|
||||||
|
else if (c.rating_tag_id == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
|
||||||
|
else if (c.rating_tag_id == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
|
||||||
|
|
||||||
|
const commentContent = (c.content || '').trim();
|
||||||
|
const commentFiles = filesMap.get(c.id) || [];
|
||||||
|
// Compute overflow hint: true if content is likely taller than the 80px clamp.
|
||||||
|
// ~120 chars ≈ 2-3 wrapped lines in the sidebar; any newlines > 2 or file
|
||||||
|
// attachments (images/video) will also push height above the limit.
|
||||||
|
const isLong = commentContent.length > 120
|
||||||
|
|| commentContent.split('\n').length > 2
|
||||||
|
|| commentFiles.length > 0
|
||||||
|
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(commentContent);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
content: (c.content || '').trim(),
|
content: commentContent,
|
||||||
username_color: c.username_color
|
username_color: c.username_color,
|
||||||
|
item_rating_class: ratingClass,
|
||||||
|
item_rating_label: ratingLabel,
|
||||||
|
files: commentFiles,
|
||||||
|
poll: pollMap.get(c.id) || null,
|
||||||
|
is_long: isLong
|
||||||
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -890,5 +1184,259 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Poll creation — called after a comment is inserted (internal helper)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
const createPollForComment = async (commentId, pollData) => {
|
||||||
|
if (!cfg.websrv.enable_comment_polls) return null;
|
||||||
|
const { question, options, is_anonymous } = pollData || {};
|
||||||
|
if (!question || !question.trim()) return null;
|
||||||
|
if (!Array.isArray(options) || options.length < 2) return null;
|
||||||
|
const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean);
|
||||||
|
if (cleanOptions.length < 2 || cleanOptions.length > 10) return null;
|
||||||
|
const anonymous = is_anonymous !== false; // default true
|
||||||
|
|
||||||
|
const [poll] = await db`
|
||||||
|
INSERT INTO comment_polls (comment_id, question, is_anonymous)
|
||||||
|
VALUES (${commentId}, ${question.trim()}, ${anonymous})
|
||||||
|
RETURNING id, is_anonymous
|
||||||
|
`;
|
||||||
|
const pollId = poll.id;
|
||||||
|
for (let i = 0; i < cleanOptions.length; i++) {
|
||||||
|
await db`
|
||||||
|
INSERT INTO comment_poll_options (poll_id, text, sort_order)
|
||||||
|
VALUES (${pollId}, ${cleanOptions[i]}, ${i})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const optRows = await db`SELECT id, text, sort_order FROM comment_poll_options WHERE poll_id = ${pollId} ORDER BY sort_order ASC`;
|
||||||
|
return {
|
||||||
|
id: pollId,
|
||||||
|
question: question.trim(),
|
||||||
|
is_anonymous: poll.is_anonymous,
|
||||||
|
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0, voters: [] })),
|
||||||
|
total_votes: 0,
|
||||||
|
user_vote_option_id: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patch POST /api/comments to support optional poll payload
|
||||||
|
// We cannot re-define the same route, so we intercept via a pre-middleware trick.
|
||||||
|
// Instead we add a dedicated endpoint that the frontend always uses for polls.
|
||||||
|
|
||||||
|
// POST /api/polls/:commentId — attach a poll to an existing just-created comment
|
||||||
|
// (frontend calls this immediately after posting the comment)
|
||||||
|
router.post(/\/api\/polls\/attach\/(?<commentId>\d+)/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Polls disabled' }) });
|
||||||
|
|
||||||
|
const commentId = req.params.commentId;
|
||||||
|
const body = req.post || {};
|
||||||
|
|
||||||
|
// Verify this comment belongs to the logged-in user and has no poll yet
|
||||||
|
const comment = await db`SELECT id, user_id FROM comments WHERE id = ${commentId} AND is_deleted = false LIMIT 1`;
|
||||||
|
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: 'Comment not found' }) });
|
||||||
|
if (comment[0].user_id !== req.session.id && !req.session.admin && !req.session.is_moderator) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
|
||||||
|
}
|
||||||
|
const existing = await db`SELECT id FROM comment_polls WHERE comment_id = ${commentId} LIMIT 1`;
|
||||||
|
if (existing.length) return res.reply({ code: 409, body: JSON.stringify({ success: false, message: 'Poll already exists' }) });
|
||||||
|
|
||||||
|
let pollData;
|
||||||
|
try {
|
||||||
|
pollData = typeof body.poll === 'string' ? JSON.parse(body.poll) : body.poll;
|
||||||
|
} catch (e) {
|
||||||
|
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll JSON' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const poll = await createPollForComment(parseInt(commentId, 10), pollData);
|
||||||
|
if (!poll) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll data (need question + 2-10 options)' }) });
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll }) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[POLLS] createPollForComment error:', err);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: 'Database error' }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/polls/:pollId — fetch poll with current user's vote
|
||||||
|
router.get(/\/api\/polls\/(?<pollId>\d+)/, async (req, res) => {
|
||||||
|
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
|
||||||
|
const pollId = req.params.pollId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pollRows = await db`
|
||||||
|
SELECT
|
||||||
|
cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order,
|
||||||
|
'vote_count', COALESCE(vc.cnt, 0)
|
||||||
|
) ORDER BY cpo.sort_order ASC, cpo.id ASC
|
||||||
|
) AS options,
|
||||||
|
COALESCE(SUM(vc.cnt), 0)::int AS total_votes
|
||||||
|
FROM comment_polls cp
|
||||||
|
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
|
||||||
|
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
|
||||||
|
WHERE cp.id = ${pollId}
|
||||||
|
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
|
||||||
|
`;
|
||||||
|
if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||||
|
const p = pollRows[0];
|
||||||
|
|
||||||
|
let userVoteOptionId = null;
|
||||||
|
if (req.session) {
|
||||||
|
const vote = await db`SELECT option_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1`;
|
||||||
|
if (vote.length) userVoteOptionId = vote[0].option_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not anonymous, attach voter usernames to each option
|
||||||
|
let options = p.options;
|
||||||
|
if (!p.is_anonymous) {
|
||||||
|
const voterRows = await db`
|
||||||
|
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
|
||||||
|
FROM comment_poll_votes cpv
|
||||||
|
JOIN public."user" u ON u.id = cpv.user_id
|
||||||
|
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
|
||||||
|
WHERE cpv.poll_id = ${pollId}
|
||||||
|
`;
|
||||||
|
const voterMap = new Map();
|
||||||
|
for (const v of voterRows) {
|
||||||
|
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
|
||||||
|
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
|
||||||
|
}
|
||||||
|
options = options.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
poll: {
|
||||||
|
id: p.poll_id,
|
||||||
|
comment_id: p.comment_id,
|
||||||
|
question: p.question,
|
||||||
|
expires_at: p.expires_at,
|
||||||
|
is_anonymous: p.is_anonymous,
|
||||||
|
options,
|
||||||
|
total_votes: parseInt(p.total_votes) || 0,
|
||||||
|
user_vote_option_id: userVoteOptionId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[POLLS] GET error:', err);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/polls/:pollId/vote — cast or change vote
|
||||||
|
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
const pollId = req.params.pollId;
|
||||||
|
const body = req.post || {};
|
||||||
|
const optionId = parseInt(body.option_id, 10);
|
||||||
|
if (!optionId) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Missing option_id' }) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify option belongs to poll
|
||||||
|
const opt = await db`SELECT id FROM comment_poll_options WHERE id = ${optionId} AND poll_id = ${pollId} LIMIT 1`;
|
||||||
|
if (!opt.length) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid option' }) });
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
const poll = await db`SELECT expires_at FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
|
||||||
|
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||||
|
if (poll[0].expires_at && new Date(poll[0].expires_at) < new Date()) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert vote (change allowed) — manual check avoids needing a unique constraint
|
||||||
|
const existing = await db`
|
||||||
|
SELECT poll_id FROM comment_poll_votes
|
||||||
|
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (existing.length) {
|
||||||
|
await db`
|
||||||
|
UPDATE comment_poll_votes
|
||||||
|
SET option_id = ${optionId}, created_at = now()
|
||||||
|
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
await db`
|
||||||
|
INSERT INTO comment_poll_votes (poll_id, option_id, user_id)
|
||||||
|
VALUES (${pollId}, ${optionId}, ${req.session.id})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated tally
|
||||||
|
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
|
||||||
|
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count
|
||||||
|
FROM comment_poll_options cpo
|
||||||
|
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes WHERE poll_id = ${pollId} GROUP BY option_id) vc ON vc.option_id = cpo.id
|
||||||
|
WHERE cpo.poll_id = ${pollId}
|
||||||
|
ORDER BY cpo.sort_order ASC
|
||||||
|
`;
|
||||||
|
const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0);
|
||||||
|
|
||||||
|
let options = rows;
|
||||||
|
if (!isAnon) {
|
||||||
|
const voterRows = await db`
|
||||||
|
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
|
||||||
|
FROM comment_poll_votes cpv
|
||||||
|
JOIN public."user" u ON u.id = cpv.user_id
|
||||||
|
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
|
||||||
|
WHERE cpv.poll_id = ${pollId}
|
||||||
|
`;
|
||||||
|
const voterMap = new Map();
|
||||||
|
for (const v of voterRows) {
|
||||||
|
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
|
||||||
|
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
|
||||||
|
}
|
||||||
|
options = rows.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, is_anonymous: isAnon, options, total_votes: totalVotes, user_vote_option_id: optionId })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[POLLS] vote error:', err);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/polls/:pollId — admin/mod or creator can delete
|
||||||
|
router.post(/\/api\/polls\/(?<pollId>\d+)\/delete/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
const pollId = req.params.pollId;
|
||||||
|
try {
|
||||||
|
const poll = await db`
|
||||||
|
SELECT cp.id, cp.comment_id, c.user_id
|
||||||
|
FROM comment_polls cp
|
||||||
|
JOIN comments c ON c.id = cp.comment_id
|
||||||
|
WHERE cp.id = ${pollId} LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||||
|
const isCreator = poll[0].user_id === req.session.id;
|
||||||
|
if (!isCreator && !req.session.admin && !req.session.is_moderator) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
|
||||||
|
}
|
||||||
|
await db`DELETE FROM comment_polls WHERE id = ${pollId}`;
|
||||||
|
// Notify live update
|
||||||
|
db.notify('comments', JSON.stringify({ type: 'poll_deleted', poll_id: pollId, comment_id: poll[0].comment_id }));
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[POLLS] delete error:', err);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { execFile as _execFile } from "child_process";
|
import { execFile as _execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
const execFile = promisify(_execFile);
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
@@ -23,14 +24,311 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all emojis (Public)
|
// List all emojis — returns both a flat list and a grouped-by-pack structure
|
||||||
router.get('/api/v2/emojis', async (req, res) => {
|
router.get('/api/v2/emojis', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
|
// Try the full pack-aware query first; fall back if pack_id column doesn't exist yet
|
||||||
|
let emojis;
|
||||||
|
let hasPacks = true;
|
||||||
|
try {
|
||||||
|
emojis = await db`
|
||||||
|
SELECT e.id, e.name, e.url, e.pack_id,
|
||||||
|
p.name as pack_name, p.tg_name, p.tg_title, p.thumb_url as pack_thumb_url
|
||||||
|
FROM custom_emojis e
|
||||||
|
LEFT JOIN sticker_packs p ON p.id = e.pack_id
|
||||||
|
ORDER BY e.pack_id NULLS FIRST, e.id ASC
|
||||||
|
`;
|
||||||
|
} catch (colErr) {
|
||||||
|
// column pack_id or thumb_url may not exist — try without thumb_url, then flat
|
||||||
|
try {
|
||||||
|
emojis = await db`
|
||||||
|
SELECT e.id, e.name, e.url, e.pack_id,
|
||||||
|
p.name as pack_name, p.tg_name, p.tg_title, NULL as pack_thumb_url
|
||||||
|
FROM custom_emojis e
|
||||||
|
LEFT JOIN sticker_packs p ON p.id = e.pack_id
|
||||||
|
ORDER BY e.pack_id NULLS FIRST, e.id ASC
|
||||||
|
`;
|
||||||
|
} catch (_) {
|
||||||
|
// pack_id column missing entirely — serve flat list
|
||||||
|
console.warn('[EMOJIS] pack_id column missing, serving flat list');
|
||||||
|
hasPacks = false;
|
||||||
|
emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id ASC`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat list (backwards compat)
|
||||||
|
const flat = emojis.map(e => ({ id: e.id, name: e.name, url: e.url, pack_id: e.pack_id ?? null }));
|
||||||
|
|
||||||
|
if (!hasPacks) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ success: true, emojis })
|
body: JSON.stringify({ success: true, emojis: flat, packs: [] })
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouped by pack
|
||||||
|
const packsMap = new Map();
|
||||||
|
// "No pack" group for standalone emojis
|
||||||
|
packsMap.set(null, { id: null, name: 'Custom Emojis', tg_name: null, emojis: [] });
|
||||||
|
|
||||||
|
for (const e of emojis) {
|
||||||
|
if (e.pack_id && !packsMap.has(e.pack_id)) {
|
||||||
|
packsMap.set(e.pack_id, {
|
||||||
|
id: e.pack_id,
|
||||||
|
name: e.pack_name || e.tg_title || `Pack #${e.pack_id}`,
|
||||||
|
tg_name: e.tg_name,
|
||||||
|
thumb_url: e.pack_thumb_url || null,
|
||||||
|
emojis: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const packKey = e.pack_id ?? null;
|
||||||
|
packsMap.get(packKey).emojis.push({ id: e.id, name: e.name, url: e.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty groups
|
||||||
|
const packs = [...packsMap.values()].filter(p => p.emojis.length > 0);
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, emojis: flat, packs })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List sticker packs (Admin)
|
||||||
|
router.get('/api/v2/admin/sticker-packs', async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let packs;
|
||||||
|
try {
|
||||||
|
packs = await db`
|
||||||
|
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, sp.thumb_url, sp.sticker_count, sp.created_at,
|
||||||
|
count(e.id)::int as actual_count
|
||||||
|
FROM sticker_packs sp
|
||||||
|
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
|
||||||
|
GROUP BY sp.id
|
||||||
|
ORDER BY sp.created_at DESC
|
||||||
|
`;
|
||||||
|
} catch (_) {
|
||||||
|
// thumb_url column may not exist yet — fall back without it
|
||||||
|
packs = await db`
|
||||||
|
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, NULL as thumb_url, sp.sticker_count, sp.created_at,
|
||||||
|
count(e.id)::int as actual_count
|
||||||
|
FROM sticker_packs sp
|
||||||
|
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
|
||||||
|
GROUP BY sp.id
|
||||||
|
ORDER BY sp.created_at DESC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, packs })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Delete a sticker pack and all its emojis (Admin)
|
||||||
|
router.delete(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)/, async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||||
|
}
|
||||||
|
const id = req.params.id;
|
||||||
|
try {
|
||||||
|
// Fetch emoji file URLs before deletion for filesystem cleanup
|
||||||
|
const emojiFiles = await db`SELECT url FROM custom_emojis WHERE pack_id = ${id}`;
|
||||||
|
await db`DELETE FROM custom_emojis WHERE pack_id = ${id}`;
|
||||||
|
await db`DELETE FROM sticker_packs WHERE id = ${id}`;
|
||||||
|
|
||||||
|
// Clean up local files
|
||||||
|
for (const e of emojiFiles) {
|
||||||
|
if (e.url && e.url.startsWith('/s/emojis/')) {
|
||||||
|
const filename = path.basename(e.url);
|
||||||
|
await fs.unlink(path.join(cfg.paths.emojis, filename)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rename a sticker pack (Admin) — optionally backfills emoji names and comment references
|
||||||
|
router.post(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)\/rename/, async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||||
|
}
|
||||||
|
const id = req.params.id;
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = req.body || req.post || {};
|
||||||
|
body = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
} catch (_) {}
|
||||||
|
const newName = (body.name || '').trim();
|
||||||
|
const backfill = body.backfill === true;
|
||||||
|
if (!newName) {
|
||||||
|
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name is required' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!backfill) {
|
||||||
|
// Simple rename only
|
||||||
|
await db`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backfill: rename emojis + update comment references ──────────
|
||||||
|
// Build new slug from new display name (same logic as import)
|
||||||
|
const newSlug = newName
|
||||||
|
.replace(/[^a-z0-9]/gi, '_')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_|_$/, '');
|
||||||
|
|
||||||
|
// Load all emojis in this pack
|
||||||
|
const emojis = await db`SELECT id, name FROM custom_emojis WHERE pack_id = ${id} ORDER BY id`;
|
||||||
|
|
||||||
|
// Build rename map: old_name → new_name
|
||||||
|
const renames = [];
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
const suffixMatch = emoji.name.match(/_(\d+)$/);
|
||||||
|
if (!suffixMatch) continue; // skip non-standard names
|
||||||
|
const newEmojiName = newSlug + '_' + suffixMatch[1].padStart(3, '0');
|
||||||
|
if (newEmojiName !== emoji.name) {
|
||||||
|
renames.push({ id: emoji.id, oldName: emoji.name, newName: newEmojiName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply everything in a transaction
|
||||||
|
let emojiRenamed = 0;
|
||||||
|
let commentRowsUpdated = 0;
|
||||||
|
|
||||||
|
await db.begin(async sql => {
|
||||||
|
// Rename the pack
|
||||||
|
await sql`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
|
||||||
|
|
||||||
|
for (const r of renames) {
|
||||||
|
// Check name conflict (another emoji already has this name)
|
||||||
|
const [conflict] = await sql`SELECT id FROM custom_emojis WHERE name = ${r.newName} AND id != ${r.id} LIMIT 1`;
|
||||||
|
if (conflict) {
|
||||||
|
console.warn(`[RENAME] Skipping ${r.oldName} → ${r.newName}: name conflict`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Rename the emoji
|
||||||
|
await sql`UPDATE custom_emojis SET name = ${r.newName} WHERE id = ${r.id}`;
|
||||||
|
emojiRenamed++;
|
||||||
|
|
||||||
|
// Replace :old_name: with :new_name: in all comments
|
||||||
|
const result = await sql`
|
||||||
|
UPDATE comments
|
||||||
|
SET content = REPLACE(content, ${':' + r.oldName + ':'}, ${':' + r.newName + ':'})
|
||||||
|
WHERE content LIKE ${'%:' + r.oldName + ':%'}
|
||||||
|
`;
|
||||||
|
commentRowsUpdated += result.count ?? 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, emojiRenamed, commentRowsUpdated })
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RENAME PACK]', e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error: " + e.message }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set tab thumbnail for a sticker pack (Admin)
|
||||||
|
router.post(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)\/thumb/, async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||||
|
}
|
||||||
|
const id = req.params.id;
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = req.body || req.post || {};
|
||||||
|
body = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
} catch (_) {}
|
||||||
|
const thumbUrl = (body.thumb_url || '').trim();
|
||||||
|
// Only allow local emoji paths to prevent SSRF
|
||||||
|
if (!thumbUrl || !thumbUrl.startsWith('/s/emojis/')) {
|
||||||
|
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'thumb_url must be a local /s/emojis/ path' }) });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let finalThumbUrl = thumbUrl;
|
||||||
|
|
||||||
|
// For .webm stickers: extract first frame -> save as WebP so tabs always show a static image
|
||||||
|
if (thumbUrl.endsWith('.webm')) {
|
||||||
|
try {
|
||||||
|
const webmPath = path.join(cfg.paths.emojis, path.basename(thumbUrl));
|
||||||
|
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||||
|
const tmpPng = path.join(cfg.paths.emojis, randSuffix + '_tmp.png');
|
||||||
|
const webpPath = path.join(cfg.paths.emojis, randSuffix + '_thumb.webp');
|
||||||
|
|
||||||
|
// Step 1: ffmpeg extracts first frame to a temp PNG file
|
||||||
|
await execFile('ffmpeg', [
|
||||||
|
'-y', '-i', webmPath,
|
||||||
|
'-vframes', '1',
|
||||||
|
tmpPng
|
||||||
|
], { maxBuffer: 20 * 1024 * 1024 });
|
||||||
|
|
||||||
|
// Step 2: ImageMagick converts PNG -> WebP
|
||||||
|
try {
|
||||||
|
await execFile('magick', [tmpPng, '-quality', '85', webpPath], { env: magickEnv });
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tmpPng).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(webpPath).catch(() => null);
|
||||||
|
if (stat && stat.size > 0) {
|
||||||
|
finalThumbUrl = '/s/emojis/' + randSuffix + '_thumb.webp';
|
||||||
|
console.log('[SET THUMB] Generated WebP thumbnail from .webm: ' + finalThumbUrl);
|
||||||
|
}
|
||||||
|
} catch (thumbErr) {
|
||||||
|
console.warn('[SET THUMB] Could not generate WebP from .webm:', thumbErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`UPDATE sticker_packs SET thumb_url = ${finalThumbUrl} WHERE id = ${id}`;
|
||||||
|
} catch (colErr) {
|
||||||
|
// Column might not exist yet — add it and retry once
|
||||||
|
if (colErr.message && colErr.message.includes('thumb_url')) {
|
||||||
|
await db`ALTER TABLE public.sticker_packs ADD COLUMN IF NOT EXISTS thumb_url text`;
|
||||||
|
await db`UPDATE sticker_packs SET thumb_url = ${finalThumbUrl} WHERE id = ${id}`;
|
||||||
|
} else {
|
||||||
|
throw colErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, thumb_url: finalThumbUrl }) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
@@ -132,5 +430,61 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── User Emoji Favorites ─────────────────────────────────────────
|
||||||
|
// Create table lazily on first use (idempotent)
|
||||||
|
const ensureFavTable = async () => {
|
||||||
|
await db`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_emoji_favorites (
|
||||||
|
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
emoji_name text NOT NULL,
|
||||||
|
emoji_url text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, emoji_name)
|
||||||
|
)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/v2/user/emoji-favorites — returns logged-in user's favorites
|
||||||
|
router.get('/api/v2/user/emoji-favorites', lib.loggedin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureFavTable();
|
||||||
|
const rows = await db`
|
||||||
|
SELECT emoji_name AS name, emoji_url AS url
|
||||||
|
FROM user_emoji_favorites
|
||||||
|
WHERE user_id = ${req.session.id}
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, favorites: rows }) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EMOJI FAVS GET]', e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/user/emoji-favorites/toggle — add or remove a single favorite
|
||||||
|
router.post('/api/v2/user/emoji-favorites/toggle', lib.loggedin, async (req, res) => {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Invalid CSRF token' }) });
|
||||||
|
}
|
||||||
|
let body = {};
|
||||||
|
try { const raw = req.body || req.post || {}; body = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) {}
|
||||||
|
const { name, url, action } = body;
|
||||||
|
if (!name || !url) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name and url required' }) });
|
||||||
|
try {
|
||||||
|
await ensureFavTable();
|
||||||
|
if (action === 'remove') {
|
||||||
|
await db`DELETE FROM user_emoji_favorites WHERE user_id = ${req.session.id} AND emoji_name = ${name}`;
|
||||||
|
} else {
|
||||||
|
await db`INSERT INTO user_emoji_favorites (user_id, emoji_name, emoji_url) VALUES (${req.session.id}, ${name}, ${url}) ON CONFLICT (user_id, emoji_name) DO NOTHING`;
|
||||||
|
}
|
||||||
|
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EMOJI FAVS TOGGLE]', e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit emoji (Admin only) — handled by bypass middleware in index.mjs
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export default (router) => {
|
|||||||
// POST /api/v2/scroller/rehost
|
// POST /api/v2/scroller/rehost
|
||||||
// Downloads an external item and adds it to the platform
|
// Downloads an external item and adds it to the platform
|
||||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
||||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc } = req.post || {};
|
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
|
||||||
|
|
||||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||||
|
|
||||||
@@ -436,8 +436,9 @@ export default (router) => {
|
|||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
active: !isApprovalRequired,
|
active: !isApprovalRequired,
|
||||||
is_oc: !!is_oc
|
is_oc: !!is_oc,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
original_filename: original_filename || null
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -449,7 +450,7 @@ export default (router) => {
|
|||||||
// Process thumbnail
|
// Process thumbnail
|
||||||
try {
|
try {
|
||||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||||
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[REHOST] Thumbnail error:', err);
|
console.error('[REHOST] Thumbnail error:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ export default (router, tpl) => {
|
|||||||
const util = await import('util');
|
const util = await import('util');
|
||||||
const execFilePromise = util.promisify(execFile);
|
const execFilePromise = util.promisify(execFile);
|
||||||
|
|
||||||
// If only 1 or 2 items, just use what we have
|
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||||
return res.end(await fs.readFile(cachePath));
|
return res.end(await fs.readFile(cachePath));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import cfg from "../config.mjs";
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
|
import { createI18n } from "../i18n.mjs";
|
||||||
|
|
||||||
const auth = async (req, res, next) => {
|
const auth = async (req, res, next) => {
|
||||||
if (!req.session)
|
if (!req.session)
|
||||||
@@ -62,9 +63,14 @@ export default (router, tpl) => {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const isRandom = req.cookies.random_mode === '1';
|
const isRandom = req.cookies.random_mode === '1';
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
// In All mode (mode=3), ignore the ratings cookie — it would otherwise
|
||||||
|
// filter out e.g. NSFW uploads even though the user is in "All" mode.
|
||||||
|
const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||||
f0cks = await f0cklib.getf0cks({
|
f0cks = await f0cklib.getf0cks({
|
||||||
user: user,
|
user: user,
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
fav: false,
|
fav: false,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
@@ -83,9 +89,14 @@ export default (router, tpl) => {
|
|||||||
if (!userData.is_ghost) {
|
if (!userData.is_ghost) {
|
||||||
try {
|
try {
|
||||||
const isRandom = req.cookies.random_mode === '1';
|
const isRandom = req.cookies.random_mode === '1';
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
// In All mode (mode=3), ignore the ratings cookie — a stale ratings cookie
|
||||||
|
// (e.g. only 'sfw') would cause NSFW favorites to show 0 on the profile.
|
||||||
|
const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||||
favs = await f0cklib.getf0cks({
|
favs = await f0cklib.getf0cks({
|
||||||
user: user,
|
user: user,
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
fav: true,
|
fav: true,
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
@@ -137,7 +148,7 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
userData.timestamp = {
|
userData.timestamp = {
|
||||||
timeago: lib.timeAgo(userData.created_at, req.lang),
|
timeago: lib.timeAgo(userData.created_at, req.lang),
|
||||||
timefull: userData.created_at
|
timefull: new Date(userData.created_at).toISOString()
|
||||||
};
|
};
|
||||||
userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000);
|
userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000);
|
||||||
|
|
||||||
@@ -212,15 +223,19 @@ export default (router, tpl) => {
|
|||||||
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
|
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
|
||||||
|
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
|
||||||
|
|
||||||
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
|
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
|
||||||
user: req.params.user,
|
user: req.params.user,
|
||||||
tag: req.params.tag,
|
tag: reqTag,
|
||||||
mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null),
|
mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null),
|
||||||
page: req.params.page,
|
page: req.params.page,
|
||||||
itemid: req.params.itemid,
|
itemid: req.params.itemid,
|
||||||
hall: req.params.hall,
|
hall: req.params.hall,
|
||||||
fav: req.params.mode == 'favs',
|
fav: req.params.mode == 'favs',
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
|
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
user_id: req.session?.id,
|
user_id: req.session?.id,
|
||||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||||
@@ -243,7 +258,7 @@ export default (router, tpl) => {
|
|||||||
data.success = true;
|
data.success = true;
|
||||||
if (!data.link) {
|
if (!data.link) {
|
||||||
if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
|
if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
|
||||||
else if (req.params.tag) data.link = { main: '/tag/' + encodeURIComponent(req.params.tag) + '/', path: 'p/', suffix: '' };
|
else if (reqTag) data.link = { main: '/tag/' + encodeURIComponent(reqTag) + '/', path: 'p/', suffix: '' };
|
||||||
else data.link = { main: '/', path: 'p/', suffix: '' };
|
else data.link = { main: '/', path: 'p/', suffix: '' };
|
||||||
}
|
}
|
||||||
data.tmp = data.tmp || {};
|
data.tmp = data.tmp || {};
|
||||||
@@ -251,18 +266,26 @@ export default (router, tpl) => {
|
|||||||
const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`;
|
const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`;
|
||||||
data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall;
|
data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall;
|
||||||
}
|
}
|
||||||
if (req.params.tag && !data.tmp.tag) data.tmp.tag = req.params.tag;
|
if (reqTag && !data.tmp.tag) data.tmp.tag = reqTag;
|
||||||
} else {
|
} else {
|
||||||
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
|
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
|
||||||
// Return 404 only for truly missing items
|
// Return 404 only for truly missing items
|
||||||
const statusCode = data.item ? 200 : 404;
|
const statusCode = data.item ? 200 : 404;
|
||||||
|
const reqMode = req.mode ?? 0;
|
||||||
|
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
|
||||||
|
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
|
||||||
|
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||||
return res.reply({
|
return res.reply({
|
||||||
code: statusCode,
|
code: statusCode,
|
||||||
body: tpl.render('error', {
|
body: tpl.render('error', {
|
||||||
message: data.message,
|
message: tErr('error.post_not_visible'),
|
||||||
item: data.item, // For OG meta tags on filtered NSFW items
|
item: data.item, // For OG meta tags on filtered NSFW items
|
||||||
domain: cfg.main.url.domain,
|
domain: cfg.main.url.domain,
|
||||||
tmp: null
|
tmp: null,
|
||||||
|
session: req.session ? { ...req.session } : false,
|
||||||
|
error_mode_label: errorModeLabel,
|
||||||
|
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
|
||||||
|
error_filter_hint_link: tErr('error.filter_hint_link')
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -333,7 +356,8 @@ export default (router, tpl) => {
|
|||||||
// Can the current user manage this item (owner, admin, or mod)?
|
// Can the current user manage this item (owner, admin, or mod)?
|
||||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||||
// Is the item's MIME type suitable for metadata extraction?
|
// Is the item's MIME type suitable for metadata extraction?
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
|
||||||
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
// Has the current user favorited this item?
|
// Has the current user favorited this item?
|
||||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||||
// Hall columns for display
|
// Hall columns for display
|
||||||
@@ -344,9 +368,11 @@ export default (router, tpl) => {
|
|||||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||||
data.item_username_lower = (item.username || '').toLowerCase();
|
data.item_username_lower = (item.username || '').toLowerCase();
|
||||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||||
|
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
||||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||||
|
data.item_has_dimensions = !!(item.width && item.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
|||||||
@@ -29,6 +29,30 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom meme template page
|
||||||
|
router.get(/^\/meme\/custom$/, lib.userauth, async (req, res) => {
|
||||||
|
if (!cfg.websrv.meme_creator) {
|
||||||
|
res.writeHead(404).end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render('meme-creator', {
|
||||||
|
template: {
|
||||||
|
id: 'custom',
|
||||||
|
name: 'Custom Template',
|
||||||
|
url: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600"><rect width="800" height="600" fill="%231a1a1b"/><text x="50%25" y="50%25" fill="%23888888" font-family="sans-serif" font-size="24" dominant-baseline="middle" text-anchor="middle">Click %22Choose Image%22 or Drag and Drop here</text></svg>',
|
||||||
|
category: 'Custom',
|
||||||
|
sub_category: ''
|
||||||
|
},
|
||||||
|
page_meta: {
|
||||||
|
title: 'Create Meme - Custom Template',
|
||||||
|
description: 'Create a meme using your own custom template',
|
||||||
|
url: `https://${cfg.main.url.domain}/meme/custom`
|
||||||
|
}
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Meme creator page
|
// Meme creator page
|
||||||
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
|
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
|
||||||
if (!cfg.websrv.meme_creator) {
|
if (!cfg.websrv.meme_creator) {
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ export default (router, tpl) => {
|
|||||||
pm.ciphertext,
|
pm.ciphertext,
|
||||||
pm.iv,
|
pm.iv,
|
||||||
pm.is_read,
|
pm.is_read,
|
||||||
pm.created_at
|
pm.created_at,
|
||||||
|
pm.edited_at
|
||||||
FROM private_messages pm
|
FROM private_messages pm
|
||||||
WHERE (
|
WHERE (
|
||||||
(pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId})
|
(pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId})
|
||||||
@@ -312,6 +313,81 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit own message (re-encrypt in browser, send new ciphertext + iv)
|
||||||
|
router.patch(/\/api\/dm\/message\/(?<msgId>\d+)/, async (req, res) => {
|
||||||
|
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||||
|
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const csrf = req.headers['x-csrf-token'];
|
||||||
|
if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||||
|
|
||||||
|
const msgId = parseInt(req.params.msgId, 10);
|
||||||
|
const body = req.post || {};
|
||||||
|
const { ciphertext, iv } = body;
|
||||||
|
|
||||||
|
if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') {
|
||||||
|
return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400);
|
||||||
|
}
|
||||||
|
if (ciphertext.length > 65536 || iv.length > 32) {
|
||||||
|
return json(res, { success: false, msg: 'Payload too large' }, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db`
|
||||||
|
UPDATE private_messages
|
||||||
|
SET ciphertext = ${ciphertext}, iv = ${iv}, edited_at = NOW()
|
||||||
|
WHERE id = ${msgId} AND sender_id = ${req.session.id}
|
||||||
|
RETURNING id, edited_at
|
||||||
|
`;
|
||||||
|
if (!result.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404);
|
||||||
|
return json(res, { success: true, id: result[0].id, edited_at: result[0].edited_at });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM] edit message failed:', err);
|
||||||
|
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete own message (and optionally its attachment blobs)
|
||||||
|
router.delete(/\/api\/dm\/message\/(?<msgId>\d+)/, async (req, res) => {
|
||||||
|
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||||
|
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const csrf = req.headers['x-csrf-token'];
|
||||||
|
if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||||
|
|
||||||
|
const msgId = parseInt(req.params.msgId, 10);
|
||||||
|
const body = req.post || {};
|
||||||
|
const rawIds = body['attachment_ids[]'] ?? body.attachment_ids;
|
||||||
|
const attachmentIds = (Array.isArray(rawIds) ? rawIds : rawIds ? [rawIds] : [])
|
||||||
|
.map(Number).filter(n => Number.isFinite(n) && n > 0);
|
||||||
|
|
||||||
|
// Verify message belongs to sender
|
||||||
|
const rows = await db`SELECT id FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id} LIMIT 1`;
|
||||||
|
if (!rows.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up attachments the client identified (verify sender ownership server-side)
|
||||||
|
if (attachmentIds.length) {
|
||||||
|
const { promises: fsP } = await import('fs');
|
||||||
|
const atts = await db`
|
||||||
|
SELECT id, file_path FROM dm_attachments
|
||||||
|
WHERE id = ANY(${attachmentIds}) AND sender_id = ${req.session.id}
|
||||||
|
`;
|
||||||
|
for (const att of atts) await fsP.unlink(att.file_path).catch(() => {});
|
||||||
|
if (atts.length) {
|
||||||
|
const ids = atts.map(a => a.id);
|
||||||
|
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`DELETE FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id}`;
|
||||||
|
return json(res, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM] delete message failed:', err);
|
||||||
|
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Hide a whole conversation (Close DM)
|
// Hide a whole conversation (Close DM)
|
||||||
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
|
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
|
||||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||||
@@ -330,6 +406,24 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Presence check — last_seen timestamp for a given user (online = seen < 5 min ago)
|
||||||
|
router.get(/\/api\/dm\/presence\/(?<userId>\d+)/, async (req, res) => {
|
||||||
|
if (!getPrivateMessages()) return json(res, { success: false }, 404);
|
||||||
|
if (!req.session) return json(res, { success: false }, 401);
|
||||||
|
const userId = parseInt(req.params.userId, 10);
|
||||||
|
try {
|
||||||
|
const rows = await db`SELECT last_seen FROM "user" WHERE id = ${userId} AND banned = false LIMIT 1`;
|
||||||
|
if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404);
|
||||||
|
const lastSeen = rows[0].last_seen || 0; // unix seconds
|
||||||
|
const now = ~~(Date.now() / 1000);
|
||||||
|
const online = (now - lastSeen) < 300; // 5-minute window
|
||||||
|
return json(res, { success: true, online, last_seen: lastSeen });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DM] presence failed:', err);
|
||||||
|
return json(res, { success: false }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Total unread DM count (for navbar badge polling)
|
// Total unread DM count (for navbar badge polling)
|
||||||
router.get('/api/dm/unread', async (req, res) => {
|
router.get('/api/dm/unread', async (req, res) => {
|
||||||
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });
|
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function broadcastChatPresence() {
|
|||||||
if (!seen.has(client.userId)) {
|
if (!seen.has(client.userId)) {
|
||||||
seen.add(client.userId);
|
seen.add(client.userId);
|
||||||
users.push({
|
users.push({
|
||||||
|
id: client.userId,
|
||||||
username: client.username,
|
username: client.username,
|
||||||
display_name: client.display_name,
|
display_name: client.display_name,
|
||||||
avatar_file: client.avatar_file,
|
avatar_file: client.avatar_file,
|
||||||
@@ -56,7 +57,8 @@ db.listen('notifications', (payload) => {
|
|||||||
if (client.do_not_disturb === true) continue;
|
if (client.do_not_disturb === true) continue;
|
||||||
|
|
||||||
if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
|
if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
|
||||||
if (USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
|
// warnings bypass user settings
|
||||||
|
if (data.type !== 'warning' && USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
|
||||||
client.send({ type: 'notify', data });
|
client.send({ type: 'notify', data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,20 @@ db.listen('profile_update', (payload) => {
|
|||||||
if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
|
if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
|
||||||
|
|
||||||
client.send({ type: 'profile_update', data });
|
client.send({ type: 'profile_update', data });
|
||||||
|
} else {
|
||||||
|
// For other users, only send public profile fields (so their DOM can live-update)
|
||||||
|
const publicData = {
|
||||||
|
user_id: data.user_id,
|
||||||
|
user: data.user,
|
||||||
|
display_name: data.display_name,
|
||||||
|
banner_file: data.banner_file,
|
||||||
|
banner_position: data.banner_position,
|
||||||
|
banner_size: data.banner_size,
|
||||||
|
avatar_file: data.avatar_file,
|
||||||
|
username_color: data.username_color,
|
||||||
|
description: data.description
|
||||||
|
};
|
||||||
|
client.send({ type: 'profile_update', data: publicData });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -126,8 +142,9 @@ db.listen('activity', async (payload) => {
|
|||||||
data.username = 'System';
|
data.username = 'System';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to ALL connected clients
|
// Broadcast to ALL connected clients (except guests if comments are hidden)
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (!client.userId && cfg.main.hide_comments_from_public) continue;
|
||||||
client.send({ type: 'activity', data });
|
client.send({ type: 'activity', data });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -342,7 +359,9 @@ db.listen('global_chat_topic', (payload) => {
|
|||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
|
||||||
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||||
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
|
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
||||||
|
|
||||||
|
const nsflTagId = cfg.nsfl_tag_id || 3;
|
||||||
|
|
||||||
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
|
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -354,7 +373,13 @@ export default (router, tpl) => {
|
|||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
uo.username_color,
|
uo.username_color,
|
||||||
i.dest, i.mime
|
i.dest, i.mime,
|
||||||
|
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
|
||||||
|
WHEN 1 THEN 'sfw'
|
||||||
|
WHEN 2 THEN 'nsfw'
|
||||||
|
WHEN ${nsflTagId} THEN 'nsfl'
|
||||||
|
ELSE NULL
|
||||||
|
END as item_mode
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN comments c ON n.reference_id = c.id
|
LEFT JOIN comments c ON n.reference_id = c.id
|
||||||
LEFT JOIN "user" u ON c.user_id = u.id
|
LEFT JOIN "user" u ON c.user_id = u.id
|
||||||
@@ -362,7 +387,7 @@ export default (router, tpl) => {
|
|||||||
LEFT JOIN items i ON n.item_id = i.id
|
LEFT JOIN items i ON n.item_id = i.id
|
||||||
WHERE n.user_id = ${userId}
|
WHERE n.user_id = ${userId}
|
||||||
AND n.type = ANY(${typeFilter})
|
AND n.type = ANY(${typeFilter})
|
||||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||||
ORDER BY n.created_at DESC
|
ORDER BY n.created_at DESC
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
OFFSET ${offset}
|
OFFSET ${offset}
|
||||||
@@ -373,14 +398,20 @@ export default (router, tpl) => {
|
|||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
uo.username_color,
|
uo.username_color,
|
||||||
i.dest, i.mime
|
i.dest, i.mime,
|
||||||
|
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
|
||||||
|
WHEN 1 THEN 'sfw'
|
||||||
|
WHEN 2 THEN 'nsfw'
|
||||||
|
WHEN ${nsflTagId} THEN 'nsfl'
|
||||||
|
ELSE NULL
|
||||||
|
END as item_mode
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN comments c ON n.reference_id = c.id
|
LEFT JOIN comments c ON n.reference_id = c.id
|
||||||
LEFT JOIN "user" u ON c.user_id = u.id
|
LEFT JOIN "user" u ON c.user_id = u.id
|
||||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||||
LEFT JOIN items i ON n.item_id = i.id
|
LEFT JOIN items i ON n.item_id = i.id
|
||||||
WHERE n.user_id = ${userId}
|
WHERE n.user_id = ${userId}
|
||||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||||
ORDER BY n.created_at DESC
|
ORDER BY n.created_at DESC
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
OFFSET ${offset}
|
OFFSET ${offset}
|
||||||
@@ -418,14 +449,20 @@ export default (router, tpl) => {
|
|||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
COALESCE(u.id, 0) as from_user_id,
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
uo.username_color,
|
uo.username_color,
|
||||||
i.dest, i.mime
|
i.dest, i.mime,
|
||||||
|
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
|
||||||
|
WHEN 1 THEN 'sfw'
|
||||||
|
WHEN 2 THEN 'nsfw'
|
||||||
|
WHEN ${nsflTagId} THEN 'nsfl'
|
||||||
|
ELSE NULL
|
||||||
|
END as item_mode
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN comments c ON n.reference_id = c.id
|
LEFT JOIN comments c ON n.reference_id = c.id
|
||||||
LEFT JOIN "user" u ON c.user_id = u.id
|
LEFT JOIN "user" u ON c.user_id = u.id
|
||||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||||
LEFT JOIN items i ON n.item_id = i.id
|
LEFT JOIN items i ON n.item_id = i.id
|
||||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve')
|
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning')
|
||||||
OR (
|
OR (
|
||||||
${req.session.do_not_disturb !== true} AND (
|
${req.session.do_not_disturb !== true} AND (
|
||||||
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
|
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
|
||||||
@@ -433,7 +470,7 @@ export default (router, tpl) => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report'))
|
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning'))
|
||||||
ORDER BY n.created_at DESC
|
ORDER BY n.created_at DESC
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
`;
|
`;
|
||||||
@@ -495,7 +532,7 @@ export default (router, tpl) => {
|
|||||||
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
|
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
|
||||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
const itemId = req.params.itemId;
|
const itemId = req.params.itemId;
|
||||||
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report'];
|
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
|
||||||
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
|
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
|
||||||
try {
|
try {
|
||||||
await db`
|
await db`
|
||||||
@@ -645,7 +682,9 @@ export default (router, tpl) => {
|
|||||||
next: data.hasMore ? 2 : null
|
next: data.hasMore ? 2 : null
|
||||||
};
|
};
|
||||||
data.link = { main: '/notifications', path: '/' };
|
data.link = { main: '/notifications', path: '/' };
|
||||||
|
data.activeTab = tab;
|
||||||
data.domain = cfg.main.url.domain; // For header
|
data.domain = cfg.main.url.domain; // For header
|
||||||
|
data.active_mode = req.session?.mode ?? 0;
|
||||||
return res.html(tpl.render('notifications', data, req));
|
return res.html(tpl.render('notifications', data, req));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +697,7 @@ export default (router, tpl) => {
|
|||||||
const tab = req.url.qs.tab || null;
|
const tab = req.url.qs.tab || null;
|
||||||
const data = await getNotificationHistory(req.session.id, page, 50, tab);
|
const data = await getNotificationHistory(req.session.id, page, 50, tab);
|
||||||
|
|
||||||
const html = tpl.render('snippets/notifications-list', data, req);
|
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
136
src/inc/routes/nsfp.mjs
Normal file
136
src/inc/routes/nsfp.mjs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
import audit from "../audit.mjs";
|
||||||
|
import { getNsfpIds, setNsfpIds } from "../settings.mjs";
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
|
||||||
|
// Admin page
|
||||||
|
router.get(/^\/admin\/nsfp\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render("admin/nsfp", {
|
||||||
|
session: req.session,
|
||||||
|
totals: await lib.countf0cks(),
|
||||||
|
csrf_token: req.session?.csrf_token || ''
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NSFP] Page render failed:', err);
|
||||||
|
res.reply({ code: 500, body: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: list current NSFP tag IDs with names
|
||||||
|
router.get('/api/v2/admin/nsfp', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = getNsfpIds();
|
||||||
|
const tagRows = ids.length > 0
|
||||||
|
? await db`SELECT id, tag, normalized FROM tags WHERE id IN ${db(ids)} ORDER BY id`
|
||||||
|
: [];
|
||||||
|
const tagMap = Object.fromEntries(tagRows.map(r => [r.id, r]));
|
||||||
|
const enriched = ids.map(id => tagMap[id] || { id, tag: '(unknown)', normalized: null });
|
||||||
|
|
||||||
|
const blockedCount = ids.length > 0
|
||||||
|
? Number((await db`
|
||||||
|
SELECT COUNT(DISTINCT item_id) AS cnt
|
||||||
|
FROM tags_assign
|
||||||
|
WHERE tag_id IN ${db(ids)}
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE active = true)
|
||||||
|
`)[0].cnt)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return res.json({ success: true, nsfp: enriched, raw_ids: ids, blocked_count: blockedCount });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: search tags for the add-tag autocomplete
|
||||||
|
router.get('/api/v2/admin/nsfp/search', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = (req.url.qs?.q || '').trim();
|
||||||
|
if (!q) return res.json({ success: true, tags: [] });
|
||||||
|
const pattern = '%' + q + '%';
|
||||||
|
const tags = await db`
|
||||||
|
SELECT id, tag, normalized
|
||||||
|
FROM tags
|
||||||
|
WHERE tag ILIKE ${pattern} OR normalized ILIKE ${pattern}
|
||||||
|
ORDER BY tag
|
||||||
|
LIMIT 20
|
||||||
|
`;
|
||||||
|
return res.json({ success: true, tags });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: add a tag ID to the NSFP list
|
||||||
|
router.post('/api/v2/admin/nsfp/add', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tagId = parseInt(req.post?.tag_id);
|
||||||
|
if (!tagId || isNaN(tagId) || tagId <= 0) {
|
||||||
|
return res.json({ success: false, msg: 'A valid tag_id is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = await db`SELECT id, tag FROM tags WHERE id = ${tagId} LIMIT 1`;
|
||||||
|
if (tag.length === 0) {
|
||||||
|
return res.json({ success: false, msg: 'Tag with id ' + tagId + ' does not exist' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getNsfpIds();
|
||||||
|
if (current.includes(tagId)) {
|
||||||
|
return res.json({ success: false, msg: 'Tag #' + tagId + ' (' + tag[0].tag + ') is already in the NSFP list' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...current, tagId];
|
||||||
|
setNsfpIds(updated);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO site_settings (key, value)
|
||||||
|
VALUES ('nsfp', ${JSON.stringify(updated)})
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
`;
|
||||||
|
|
||||||
|
await audit.log(req.session.id, 'nsfp_add', 'tag', tagId, { tag: tag[0].tag, nsfp: updated });
|
||||||
|
|
||||||
|
return res.json({ success: true, nsfp_ids: getNsfpIds(), added: tag[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NSFP] Add failed:', err);
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: remove a tag ID from the NSFP list
|
||||||
|
router.post('/api/v2/admin/nsfp/remove', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tagId = parseInt(req.post?.tag_id);
|
||||||
|
if (!tagId || isNaN(tagId)) {
|
||||||
|
return res.json({ success: false, msg: 'tag_id is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getNsfpIds();
|
||||||
|
if (!current.includes(tagId)) {
|
||||||
|
return res.json({ success: false, msg: 'Tag #' + tagId + ' is not in the NSFP list' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.filter(id => id !== tagId);
|
||||||
|
setNsfpIds(updated);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO site_settings (key, value)
|
||||||
|
VALUES ('nsfp', ${JSON.stringify(updated)})
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
`;
|
||||||
|
|
||||||
|
await audit.log(req.session.id, 'nsfp_remove', 'tag', tagId, { nsfp: updated });
|
||||||
|
|
||||||
|
return res.json({ success: true, nsfp_ids: getNsfpIds() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NSFP] Remove failed:', err);
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -39,6 +39,10 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ratingsRaw = req.cookies.ratings;
|
||||||
|
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||||
|
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
|
||||||
|
|
||||||
const data = await f0cklib.getRandom({
|
const data = await f0cklib.getRandom({
|
||||||
user: opts.user,
|
user: opts.user,
|
||||||
tag: opts.tag,
|
tag: opts.tag,
|
||||||
@@ -47,6 +51,7 @@ export default (router, tpl) => {
|
|||||||
page: opts.page,
|
page: opts.page,
|
||||||
fav: opts.mode === 'favs',
|
fav: opts.mode === 'favs',
|
||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
|
ratings: ratingsArr,
|
||||||
strict: opts.strict,
|
strict: opts.strict,
|
||||||
session: !!req.session
|
session: !!req.session
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export default (router, tpl) => {
|
|||||||
where items.active = true
|
where items.active = true
|
||||||
`)[0].total;
|
`)[0].total;
|
||||||
|
|
||||||
|
const totalUsers = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "user"
|
||||||
|
`)[0].total;
|
||||||
|
|
||||||
const hoster = await db`
|
const hoster = await db`
|
||||||
with t as (
|
with t as (
|
||||||
select
|
select
|
||||||
@@ -135,6 +140,7 @@ export default (router, tpl) => {
|
|||||||
xdtop,
|
xdtop,
|
||||||
totalComments,
|
totalComments,
|
||||||
totalFavs,
|
totalFavs,
|
||||||
|
totalUsers,
|
||||||
enable_nsfl: config.enable_nsfl,
|
enable_nsfl: config.enable_nsfl,
|
||||||
diskSize: cachedDiskSize,
|
diskSize: cachedDiskSize,
|
||||||
tmp: null,
|
tmp: null,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import security from "../security.mjs";
|
import security from "../security.mjs";
|
||||||
import { getRegistrationOpen, getDefaultLayout } from "../settings.mjs";
|
import { getRegistrationOpen, getRegistrationRequireMailAndorToken, getDefaultLayout } from "../settings.mjs";
|
||||||
import { sendMail } from "../../lib/smtp.mjs";
|
import { sendMail } from "../../lib/smtp.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -88,26 +88,48 @@ export default (router, tpl) => {
|
|||||||
return renderError("Passwords do not match.");
|
return renderError("Passwords do not match.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registration Logic
|
// reCAPTCHA verification
|
||||||
|
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
||||||
|
const rcToken = req.post['g-recaptcha-response'];
|
||||||
|
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
|
||||||
|
});
|
||||||
|
const { success } = await verifyRes.json();
|
||||||
|
if (!success) return renderError("reCAPTCHA verification failed. Please try again.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[REGISTER] reCAPTCHA error:', e.message);
|
||||||
|
return renderError("reCAPTCHA check failed. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
let activated = true;
|
let activated = true;
|
||||||
let activationToken = null;
|
let activationToken = null;
|
||||||
|
|
||||||
if (!token && !getRegistrationOpen()) {
|
const registrationOpen = getRegistrationOpen();
|
||||||
|
const requireMailOrToken = getRegistrationRequireMailAndorToken();
|
||||||
|
|
||||||
|
if (!registrationOpen && !token) {
|
||||||
|
// Closed registration — invite token is always required
|
||||||
return renderError("Invite token is required for registration.");
|
return renderError("Invite token is required for registration.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
// Invite token path — validate and activate immediately
|
||||||
const tokenRow = await db`
|
const tokenRow = await db`
|
||||||
select * from invite_tokens where token = ${token} and is_used = false
|
select * from invite_tokens where token = ${token} and is_used = false
|
||||||
`;
|
`;
|
||||||
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
|
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
|
||||||
// Token used, so it will be activated by default
|
// Token is valid; account activated immediately
|
||||||
} else {
|
} else if (requireMailOrToken) {
|
||||||
// No token, Open Registration
|
// Open registration but email/token required — email path
|
||||||
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
|
if (!email || !email.includes('@')) return renderError("A valid email is required for registration.");
|
||||||
activated = false;
|
activated = false;
|
||||||
activationToken = crypto.randomBytes(32).toString('hex');
|
activationToken = crypto.randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
// else: open registration, no mail/token required — just username+password, activated immediately
|
||||||
|
|
||||||
// Check user existence
|
// Check user existence
|
||||||
const existing = await db`
|
const existing = await db`
|
||||||
@@ -186,7 +208,7 @@ export default (router, tpl) => {
|
|||||||
if (tokenRow.length > 0) {
|
if (tokenRow.length > 0) {
|
||||||
await db`
|
await db`
|
||||||
update invite_tokens
|
update invite_tokens
|
||||||
set is_used = true, used_by = ${userId}
|
set is_used = true, used_by = ${userId}, used_at = now()
|
||||||
where id = ${tokenRow[0].id}
|
where id = ${tokenRow[0].id}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,57 @@ import lib from "../lib.mjs";
|
|||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
// Serve the scroller page
|
// Serve the scroller page
|
||||||
router.get(/^\/abyss\/?$/, async (req, res) => {
|
router.get(/^\/abyss(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, async (req, res) => {
|
||||||
if (cfg.websrv.abyss_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
if (cfg.websrv.abyss_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
||||||
if (cfg.websrv.private_society && !req.session) {
|
if (cfg.websrv.private_society && !req.session) {
|
||||||
return res.reply({ code: 502, body: '<html><body>502 Bad Gateway</body></html>' });
|
return res.reply({ code: 502, body: '<html><body>502 Bad Gateway</body></html>' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = req.params?.id || req.params?.[0];
|
||||||
|
console.log('[SCROLLER] URL:', req.url.pathname, 'Params:', req.params, 'ID:', id);
|
||||||
|
let page_meta = {
|
||||||
|
title: 'doomscroll',
|
||||||
|
description: 'Scroll through content endlessly',
|
||||||
|
url: `https://${cfg.main.url.domain}/abyss`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id && /^\d+$/.test(id.trim())) {
|
||||||
|
try {
|
||||||
|
const items = await db`
|
||||||
|
select i.*, uo.display_name
|
||||||
|
from "items" i
|
||||||
|
left join "user" u on u."user" = i.username or u.login = i.username
|
||||||
|
left join user_options uo on uo.user_id = u.id
|
||||||
|
where i.id = ${+id} and i.active = true
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
if (items.length > 0) {
|
||||||
|
const item = items[0];
|
||||||
|
|
||||||
|
// Fetch tags to check for NSFW/NSFL
|
||||||
|
const tags = await db`
|
||||||
|
select tag_id from tags_assign where item_id = ${+id}
|
||||||
|
`;
|
||||||
|
const tagIds = tags.map(t => t.tag_id);
|
||||||
|
const isBlurred = tagIds.includes(2) || tagIds.includes(cfg.nsfl_tag_id || 3);
|
||||||
|
|
||||||
|
page_meta.title = `${id}`;
|
||||||
|
page_meta.description = cfg.websrv.description || "The webs dumpster";
|
||||||
|
page_meta.url = `https://${cfg.main.url.domain}/abyss/${id}`;
|
||||||
|
page_meta.image = `https://${cfg.main.url.domain}/t/${id}${isBlurred ? '_blur' : ''}.webp`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SCROLLER] Failed to fetch meta for ID:', id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render('scroller', {
|
body: tpl.render('scroller', {
|
||||||
tmp: null,
|
tmp: null,
|
||||||
session: req.session ? { ...req.session } : false,
|
session: req.session ? { ...req.session } : false,
|
||||||
enable_nsfl: !!cfg.enable_nsfl,
|
enable_nsfl: !!cfg.enable_nsfl,
|
||||||
enable_swf: !!cfg.websrv.enable_swf,
|
enable_swf: !!cfg.websrv.enable_swf,
|
||||||
page_meta: {
|
page_meta
|
||||||
title: 'doomscroll',
|
|
||||||
description: 'Scroll through content endlessly',
|
|
||||||
url: `https://${cfg.main.url.domain}/abyss`
|
|
||||||
}
|
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -131,8 +166,12 @@ export default (router, tpl) => {
|
|||||||
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
|
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
|
||||||
|
|
||||||
const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie'];
|
const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie'];
|
||||||
|
const archiveMimes = Object.entries(cfg.mimes)
|
||||||
|
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||||
|
.map(([mime]) => mime);
|
||||||
const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``;
|
const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``;
|
||||||
const excludePdfSQL = db`AND items.mime != 'application/pdf'`;
|
const excludePdfSQL = db`AND items.mime != 'application/pdf'`;
|
||||||
|
const excludeArchiveSQL = db`AND items.mime != ALL(${archiveMimes})`;
|
||||||
const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m));
|
const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m));
|
||||||
const mimeSQL = mimeParts.length > 0
|
const mimeSQL = mimeParts.length > 0
|
||||||
? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})`
|
? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})`
|
||||||
@@ -211,7 +250,9 @@ export default (router, tpl) => {
|
|||||||
WHERE items.id = ${anchorId}
|
WHERE items.id = ${anchorId}
|
||||||
AND items.active = true
|
AND items.active = true
|
||||||
AND ${db.unsafe(modeQuery)}
|
AND ${db.unsafe(modeQuery)}
|
||||||
|
${excludeSwfSQL}
|
||||||
${excludePdfSQL}
|
${excludePdfSQL}
|
||||||
|
${excludeArchiveSQL}
|
||||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||||
`;
|
`;
|
||||||
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
|
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
|
||||||
@@ -229,6 +270,7 @@ export default (router, tpl) => {
|
|||||||
AND items.active = true
|
AND items.active = true
|
||||||
${excludeSwfSQL}
|
${excludeSwfSQL}
|
||||||
${excludePdfSQL}
|
${excludePdfSQL}
|
||||||
|
${excludeArchiveSQL}
|
||||||
AND items.id != ${anchorId}
|
AND items.id != ${anchorId}
|
||||||
${excludeSQL}
|
${excludeSQL}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
@@ -252,6 +294,7 @@ export default (router, tpl) => {
|
|||||||
AND items.active = true
|
AND items.active = true
|
||||||
${excludeSwfSQL}
|
${excludeSwfSQL}
|
||||||
${excludePdfSQL}
|
${excludePdfSQL}
|
||||||
|
${excludeArchiveSQL}
|
||||||
${cursorSQL}
|
${cursorSQL}
|
||||||
${excludeSQL}
|
${excludeSQL}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
@@ -263,6 +306,8 @@ export default (router, tpl) => {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||||
|
|
||||||
const items = rows.map(row => {
|
const items = rows.map(row => {
|
||||||
const isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
|
const isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
|
||||||
const isYouTube = row.mime === 'video/youtube';
|
const isYouTube = row.mime === 'video/youtube';
|
||||||
@@ -270,7 +315,16 @@ export default (router, tpl) => {
|
|||||||
const isImage = row.mime && row.mime.startsWith('image');
|
const isImage = row.mime && row.mime.startsWith('image');
|
||||||
|
|
||||||
let dest = row.dest;
|
let dest = row.dest;
|
||||||
if (!isYouTube && dest) dest = `${cfg.websrv.paths.images}/${row.dest}`;
|
if (isYouTube) {
|
||||||
|
// Guard against dest values corrupted by the UUID backfill script:
|
||||||
|
// dest should be "yt:VIDEO_ID" — if it isn't, recover the ID from src.
|
||||||
|
if (!dest || !dest.startsWith('yt:')) {
|
||||||
|
const m = row.src && row.src.match(ytSrcRegex);
|
||||||
|
if (m) dest = `yt:${m[1]}`;
|
||||||
|
}
|
||||||
|
} else if (dest) {
|
||||||
|
dest = `${cfg.websrv.paths.images}/${row.dest}`;
|
||||||
|
}
|
||||||
const thumbnail = `${cfg.websrv.paths.thumbnails}/${row.id}.webp`;
|
const thumbnail = `${cfg.websrv.paths.thumbnails}/${row.id}.webp`;
|
||||||
|
|
||||||
let ratingLabel = '?'; let ratingClass = 'untagged';
|
let ratingLabel = '?'; let ratingClass = 'untagged';
|
||||||
@@ -295,6 +349,7 @@ export default (router, tpl) => {
|
|||||||
fav_count: +row.fav_count || 0,
|
fav_count: +row.fav_count || 0,
|
||||||
comment_count: +row.comment_count || 0,
|
comment_count: +row.comment_count || 0,
|
||||||
is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'),
|
is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'),
|
||||||
|
is_archive: !!(row.mime && archiveMimes.includes(row.mime)),
|
||||||
is_video: isVideo,
|
is_video: isVideo,
|
||||||
is_youtube: isYouTube,
|
is_youtube: isYouTube,
|
||||||
is_audio: isAudio,
|
is_audio: isAudio,
|
||||||
|
|||||||
@@ -56,6 +56,48 @@ export default (router, tpl) => {
|
|||||||
path: '&page='
|
path: '&page='
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (tag.startsWith('title:')) {
|
||||||
|
const titleQuery = tag.substring(6).trim();
|
||||||
|
const q = '%' + titleQuery + '%';
|
||||||
|
|
||||||
|
total = (await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where title ilike ${q} and active = true
|
||||||
|
`)[0]?.total ?? 0;
|
||||||
|
total = +total;
|
||||||
|
|
||||||
|
const pages = +Math.ceil(total / _eps);
|
||||||
|
const act_page = Math.min(Math.max(pages, 1), page || 1);
|
||||||
|
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||||
|
|
||||||
|
ret = await db`
|
||||||
|
select *
|
||||||
|
from "items"
|
||||||
|
where title ilike ${q} and active = true
|
||||||
|
order by id desc
|
||||||
|
offset ${offset}
|
||||||
|
limit ${_eps}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cheat = [];
|
||||||
|
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
||||||
|
cheat.push(i);
|
||||||
|
|
||||||
|
pagination = {
|
||||||
|
start: 1,
|
||||||
|
end: pages,
|
||||||
|
prev: (act_page > 1) ? act_page - 1 : null,
|
||||||
|
next: (act_page < pages) ? act_page + 1 : null,
|
||||||
|
page: act_page,
|
||||||
|
cheat: cheat,
|
||||||
|
uff: false
|
||||||
|
};
|
||||||
|
link = {
|
||||||
|
main: `/search/?tag=${encodeURIComponent(tag)}`,
|
||||||
|
path: '&page='
|
||||||
|
};
|
||||||
|
}
|
||||||
else if (mode === 'strict') {
|
else if (mode === 'strict') {
|
||||||
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export default (router, tpl) => {
|
|||||||
join tags t on t.id = et.id
|
join tags t on t.id = et.id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Get custom avatar file if exists
|
// Get custom avatar file and banner file if exists
|
||||||
const userOptions = (await db`
|
const userOptions = (await db`
|
||||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
select avatar_file, banner_file from user_options where user_id = ${+req.session.id}
|
||||||
`)[0];
|
`)[0];
|
||||||
|
|
||||||
// Get full user info
|
// Get full user info
|
||||||
@@ -46,10 +46,16 @@ export default (router, tpl) => {
|
|||||||
sessions,
|
sessions,
|
||||||
excluded_tags: excluded_tags || [],
|
excluded_tags: excluded_tags || [],
|
||||||
avatar_file: userOptions?.avatar_file || null,
|
avatar_file: userOptions?.avatar_file || null,
|
||||||
|
banner_file: userOptions?.banner_file || null,
|
||||||
|
banner_position: userOptions?.banner_position || 'center',
|
||||||
|
banner_size: userOptions?.banner_size || 'cover',
|
||||||
email: user?.email || '',
|
email: user?.email || '',
|
||||||
joined: user?.created_at || null,
|
joined: user?.created_at || null,
|
||||||
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
enable_swf: cfg.enable_swf,
|
enable_swf: cfg.enable_swf,
|
||||||
enable_data_export: cfg.websrv.enable_data_export,
|
enable_data_export: cfg.websrv.enable_data_export,
|
||||||
|
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
||||||
|
enable_user_invites: cfg.websrv.enable_user_invites !== false,
|
||||||
site_domain: cfg.main.url.domain,
|
site_domain: cfg.main.url.domain,
|
||||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
page_meta: {
|
page_meta: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function regenerateTagImage(tag, mode) {
|
|||||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
||||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
|
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||||
return cachePath;
|
return cachePath;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -123,17 +123,17 @@ function generateFallbackSvg(tag) {
|
|||||||
const n2 = parseInt(hash.substring(20, 22), 16);
|
const n2 = parseInt(hash.substring(20, 22), 16);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
|
<svg width="600" height="300" viewBox="0 0 600 300" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
|
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
|
||||||
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
|
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="300" height="150" fill="url(#grad)" />
|
<rect width="600" height="300" fill="url(#grad)" />
|
||||||
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
|
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 2}" fill="${c3}" fill-opacity="0.25" />
|
||||||
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
|
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 1.5}" fill="${c3}" fill-opacity="0.15" />
|
||||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="monospace, sans-serif" font-size="36" fill="#fff" fill-opacity="0.95" font-weight="bold">${displayTag}</text>
|
||||||
</svg>
|
</svg>
|
||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export default (router, tpl) => {
|
|||||||
const item = data.item;
|
const item = data.item;
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||||
@@ -171,9 +171,11 @@ export default (router, tpl) => {
|
|||||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||||
data.item_username_lower = (item.username || '').toLowerCase();
|
data.item_username_lower = (item.username || '').toLowerCase();
|
||||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||||
|
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
||||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||||
|
data.item_has_dimensions = !!(item.width && item.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precompute hall display
|
// Precompute hall display
|
||||||
@@ -268,7 +270,7 @@ export default (router, tpl) => {
|
|||||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
await execFile('magick', [
|
await execFile('magick', [
|
||||||
...inputs, '+append', '-background', 'none',
|
...inputs, '+append', '-background', 'none',
|
||||||
'-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath
|
'-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath
|
||||||
]);
|
]);
|
||||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||||
return res.end(await fs.readFile(cachePath));
|
return res.end(await fs.readFile(cachePath));
|
||||||
|
|||||||
197
src/inc/routes/user_tags.mjs
Normal file
197
src/inc/routes/user_tags.mjs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import db from "../../inc/sql.mjs";
|
||||||
|
import lib from "../../inc/lib.mjs";
|
||||||
|
import cfg from "../../inc/config.mjs";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
|
||||||
|
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||||
|
const isGuest = !sessionObj;
|
||||||
|
const modequery = lib.getMode(mode);
|
||||||
|
|
||||||
|
let restrictedFilter = db``;
|
||||||
|
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||||
|
restrictedFilter = db`
|
||||||
|
AND t.id NOT IN ${db(cfg.nsfp)}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tags_assign ta_res
|
||||||
|
WHERE ta_res.item_id = items.id
|
||||||
|
AND ta_res.tag_id IN ${db(cfg.nsfp)}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userExcludeFilter = excludedTags.length > 0
|
||||||
|
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = items.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
|
||||||
|
: db``;
|
||||||
|
|
||||||
|
// Step 1: Get tags sorted by exact count (fast, indexed)
|
||||||
|
// Group by normalized to merge duplicates (e.g. "Music" and "music")
|
||||||
|
const baseTags = await db`
|
||||||
|
SELECT MIN(t.id) as id, MIN(t.tag) as tag, t.normalized, COUNT(DISTINCT items.id) AS total_items
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
JOIN items ON items.id = ta.item_id
|
||||||
|
WHERE items.active = true
|
||||||
|
AND t.id NOT IN (1, 2)
|
||||||
|
AND ta.user_id = ${userId}
|
||||||
|
AND ${db.unsafe(modequery)}
|
||||||
|
${restrictedFilter}
|
||||||
|
${userExcludeFilter}
|
||||||
|
GROUP BY t.normalized
|
||||||
|
HAVING COUNT(DISTINCT items.id) >= 1
|
||||||
|
ORDER BY total_items DESC, MIN(t.id) DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Step 2: In normal (non-strict) mode, replace counts with fuzzy counts
|
||||||
|
// Only runs for the ~50 tags being displayed, not all tags
|
||||||
|
if (!strict && baseTags.length > 0) {
|
||||||
|
await Promise.all(baseTags.map(async (tag) => {
|
||||||
|
if (!tag.normalized) return;
|
||||||
|
const [row] = await db`
|
||||||
|
SELECT COUNT(DISTINCT items.id) as total
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
JOIN items ON items.id = ta.item_id
|
||||||
|
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||||
|
AND items.active = true
|
||||||
|
AND ta.user_id = ${userId}
|
||||||
|
AND ${db.unsafe(modequery)}
|
||||||
|
${restrictedFilter}
|
||||||
|
${userExcludeFilter}
|
||||||
|
`;
|
||||||
|
tag.total_items = +row.total;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processTags = (tags) => tags.map(t => ({
|
||||||
|
...t,
|
||||||
|
safe_tag: t.normalized || encodeURIComponent(t.tag),
|
||||||
|
encoded_tag: encodeURIComponent(t.tag)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// API endpoint for lazy loading tags for a user
|
||||||
|
router.get(/^\/api\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
|
||||||
|
const userParam = decodeURIComponent(req.params.user);
|
||||||
|
|
||||||
|
const u = await db`
|
||||||
|
SELECT "user".id, "user".user
|
||||||
|
FROM "user"
|
||||||
|
WHERE "user".user ILIKE ${userParam}
|
||||||
|
`;
|
||||||
|
if (!u.length) {
|
||||||
|
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: "User not found" }) });
|
||||||
|
}
|
||||||
|
const userId = u[0].id;
|
||||||
|
const userName = u[0].user;
|
||||||
|
|
||||||
|
let query = {};
|
||||||
|
if (typeof req.url === 'string') {
|
||||||
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
query = parsedUrl.query;
|
||||||
|
} else {
|
||||||
|
query = req.url.qs || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Math.max(1, +(query.page ?? 1));
|
||||||
|
const offset = (page - 1) * TAGS_PER_PAGE;
|
||||||
|
const mode = req.mode ?? 0;
|
||||||
|
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
|
||||||
|
|
||||||
|
const tags = processTags(await getTagsQuery(userId, mode, offset, TAGS_PER_PAGE, req.session, isStrict));
|
||||||
|
|
||||||
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||||
|
const paginationHtml = tpl.render('snippets/pagination', {
|
||||||
|
pagination: {
|
||||||
|
page: page,
|
||||||
|
prev: page > 1 ? page - 1 : null,
|
||||||
|
next: tags.length === TAGS_PER_PAGE ? page + 1 : null,
|
||||||
|
cheat: [1]
|
||||||
|
},
|
||||||
|
link: `/user/${encodeURIComponent(userName)}/tags`
|
||||||
|
}, req);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||||
|
currentPage: page,
|
||||||
|
pagination: paginationHtml,
|
||||||
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
tags,
|
||||||
|
currentPage: page,
|
||||||
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main tags page
|
||||||
|
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
|
||||||
|
const userParam = decodeURIComponent(req.params.user);
|
||||||
|
|
||||||
|
const u = await db`
|
||||||
|
SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color
|
||||||
|
FROM "user"
|
||||||
|
LEFT JOIN user_options ON "user".id = user_options.user_id
|
||||||
|
WHERE "user".user ILIKE ${userParam}
|
||||||
|
`;
|
||||||
|
if (!u.length) {
|
||||||
|
return res.reply({ code: 404, body: "User not found" });
|
||||||
|
}
|
||||||
|
const userRow = u[0];
|
||||||
|
const userId = userRow.id;
|
||||||
|
|
||||||
|
const mode = req.mode ?? 0;
|
||||||
|
const query = req.url.qs || {};
|
||||||
|
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
|
||||||
|
|
||||||
|
const toptags = processTags(await getTagsQuery(userId, mode, 0, TAGS_PER_PAGE, req.session, isStrict));
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
res.setHeader('Surrogate-Control', 'no-store');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
user: userRow,
|
||||||
|
toptags: toptags,
|
||||||
|
tmp: null,
|
||||||
|
hidePagination: false,
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
prev: null,
|
||||||
|
next: toptags.length === TAGS_PER_PAGE ? 2 : null,
|
||||||
|
cheat: [1]
|
||||||
|
},
|
||||||
|
link: `/user/${encodeURIComponent(userRow.user)}/tags`,
|
||||||
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
|
page_meta: {
|
||||||
|
title: `${userRow.user}'s Tags`,
|
||||||
|
description: `Browse ${toptags.length}+ tags made by ${userRow.user}`,
|
||||||
|
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userRow.user)}/tags`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||||
|
return res.reply({
|
||||||
|
body: tpl.render('tags_user-partial', data, req)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render('tags_user', data, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -21,6 +21,11 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Broadcast to SSE clients instantly
|
// Broadcast to SSE clients instantly
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
await db`
|
||||||
|
INSERT INTO notifications (user_id, type, reference_id, data, is_read)
|
||||||
|
VALUES (${+user_id}, 'warning', 0, ${JSON.stringify({ reason: reason.trim(), warning_id: result[0].id })}, false)
|
||||||
|
`;
|
||||||
|
|
||||||
await db`SELECT pg_notify('warnings', ${JSON.stringify({
|
await db`SELECT pg_notify('warnings', ${JSON.stringify({
|
||||||
user_id: +user_id,
|
user_id: +user_id,
|
||||||
warning_id: result[0].id,
|
warning_id: result[0].id,
|
||||||
|
|||||||
83
src/inc/routes/wordfilter.mjs
Normal file
83
src/inc/routes/wordfilter.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
import audit from "../audit.mjs";
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
// Auto-migration on startup
|
||||||
|
db`CREATE TABLE IF NOT EXISTS public.wordfilter (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
word VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
replacement VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`.catch(err => console.error('[WORDFILTER] DB Table Setup Failed:', err));
|
||||||
|
|
||||||
|
// Admin View Render
|
||||||
|
router.get(/^\/admin\/wordfilter\/?$/, lib.auth, async (req, res) => {
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render("admin/wordfilter", {
|
||||||
|
session: req.session,
|
||||||
|
totals: await lib.countf0cks(),
|
||||||
|
csrf_token: req.session?.csrf_token || ''
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API list rules
|
||||||
|
router.get('/api/v2/admin/wordfilter', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filters = await db`SELECT * FROM wordfilter ORDER BY created_at DESC`;
|
||||||
|
return res.json({ success: true, filters });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API create rule
|
||||||
|
router.post('/api/v2/admin/wordfilter', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { word, replacement } = req.post || {};
|
||||||
|
if (!word || !word.trim() || !replacement || !replacement.trim()) {
|
||||||
|
return res.json({ success: false, msg: 'Word and replacement are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanWord = word.trim();
|
||||||
|
const cleanReplacement = replacement.trim();
|
||||||
|
|
||||||
|
// Ensure no infinite loops or matches
|
||||||
|
if (cleanWord.toLowerCase() === cleanReplacement.toLowerCase()) {
|
||||||
|
return res.json({ success: false, msg: 'Word and replacement cannot be identical' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO wordfilter (word, replacement)
|
||||||
|
VALUES (${cleanWord}, ${cleanReplacement})
|
||||||
|
ON CONFLICT (word) DO UPDATE SET replacement = EXCLUDED.replacement
|
||||||
|
`;
|
||||||
|
|
||||||
|
await audit.log(req.session.id, 'add_wordfilter', 'wordfilter', 0, { word: cleanWord, replacement: cleanReplacement });
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API delete rule
|
||||||
|
router.post('/api/v2/admin/wordfilter/delete', lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.post || {};
|
||||||
|
if (!id) return res.json({ success: false, msg: 'ID required' }, 400);
|
||||||
|
|
||||||
|
const rows = await db`DELETE FROM wordfilter WHERE id = ${id} RETURNING word`;
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await audit.log(req.session.id, 'delete_wordfilter', 'wordfilter', id, { word: rows[0].word });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res.json({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -7,6 +7,8 @@ let trusted_uploads = 0;
|
|||||||
let bypass_duplicate_check = false;
|
let bypass_duplicate_check = false;
|
||||||
let protect_files = false;
|
let protect_files = false;
|
||||||
let private_messages = true;
|
let private_messages = true;
|
||||||
|
let dm_attachments = true;
|
||||||
|
let dm_unencrypted = false;
|
||||||
let default_layout = 'modern';
|
let default_layout = 'modern';
|
||||||
let enable_pdf = false;
|
let enable_pdf = false;
|
||||||
let enable_cleanup = false;
|
let enable_cleanup = false;
|
||||||
@@ -47,6 +49,11 @@ export const getRegistrationOpen = () => {
|
|||||||
};
|
};
|
||||||
export const setRegistrationOpen = (val) => registration_open = !!val;
|
export const setRegistrationOpen = (val) => registration_open = !!val;
|
||||||
|
|
||||||
|
// When false (default): open_registration=true means anyone can register with just username+password, activated immediately.
|
||||||
|
// When true: even in open registration, a valid email OR invite token is required.
|
||||||
|
export const getRegistrationRequireMailAndorToken = () => !!cfg.websrv.open_registration_require_mail_andor_token;
|
||||||
|
export const setRegistrationRequireMailAndorToken = (val) => {}; // No-op, strictly config-based
|
||||||
|
|
||||||
export const getTrustedUploads = () => trusted_uploads;
|
export const getTrustedUploads = () => trusted_uploads;
|
||||||
export const setTrustedUploads = (val) => trusted_uploads = Math.max(0, parseInt(val) ?? 3);
|
export const setTrustedUploads = (val) => trusted_uploads = Math.max(0, parseInt(val) ?? 3);
|
||||||
|
|
||||||
@@ -59,6 +66,17 @@ export const setProtectFiles = (val) => protect_files = !!val;
|
|||||||
export const getPrivateMessages = () => private_messages;
|
export const getPrivateMessages = () => private_messages;
|
||||||
export const setPrivateMessages = (val) => private_messages = !!val;
|
export const setPrivateMessages = (val) => private_messages = !!val;
|
||||||
|
|
||||||
|
export const getDmAttachments = () => dm_attachments;
|
||||||
|
export const setDmAttachments = (val) => dm_attachments = !!val;
|
||||||
|
|
||||||
|
export const getDmUnencrypted = () => dm_unencrypted;
|
||||||
|
export const setDmUnencrypted = (val) => dm_unencrypted = !!val;
|
||||||
|
|
||||||
|
export const getDmAttachmentExpiryDays = () => {
|
||||||
|
const v = parseInt(cfg.websrv.dm_attachment_expiry_days);
|
||||||
|
return (Number.isFinite(v) && v > 0) ? v : 90;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultLayout = () => default_layout;
|
export const getDefaultLayout = () => default_layout;
|
||||||
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
|
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
|
||||||
|
|
||||||
@@ -67,3 +85,17 @@ export const setLogUserIps = (val) => {}; // No-op, strictly config-based
|
|||||||
|
|
||||||
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
|
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
|
||||||
export const setHashUserIps = (val) => {}; // No-op, strictly config-based
|
export const setHashUserIps = (val) => {}; // No-op, strictly config-based
|
||||||
|
|
||||||
|
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
|
||||||
|
export const setAllowCommentDeletion = (val) => {}; // No-op, strictly config-based
|
||||||
|
|
||||||
|
// Live-editable NSFP tag ID list — seeded from config.json, can be overridden by DB setting
|
||||||
|
let nsfp_ids = Array.isArray(cfg.nsfp) ? [...cfg.nsfp.map(Number).filter(n => !isNaN(n))] : [];
|
||||||
|
|
||||||
|
export const getNsfpIds = () => nsfp_ids;
|
||||||
|
export const setNsfpIds = (ids) => {
|
||||||
|
nsfp_ids = Array.isArray(ids) ? ids.map(Number).filter(n => !isNaN(n) && n > 0) : [];
|
||||||
|
// Also sync to cfg.nsfp so all code reading cfg.nsfp directly still works
|
||||||
|
cfg.nsfp = [...nsfp_ids];
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import cfg from "../config.mjs";
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
|
|
||||||
const regex = new RegExp(`(https?:\\/\\/${cfg.main.url.regex})(\\/(?:video|image|audio|tag\\/[^/\\s]+|user\\/[^/\\s]+(?:\\/favs)?))?\\/(\\d+|(?:b\\/)\\w{8}\\.(?:jpg|webm|gif|mp4|png|mov|mp3|ogg|flac))`, 'gi');
|
const regex = new RegExp(`(https?:\\/\\/${cfg.main.url.regex})(\\/(?:video|image|audio|tag\\/[^/\\s]+|user\\/[^/\\s]+(?:\\/favs)?|h\\/[^/\\s]+))?\\/(\\d+|(?:b\\/)\\w{8}\\.(?:jpg|webm|gif|mp4|png|mov|mp3|ogg|flac))`, 'gi');
|
||||||
|
|
||||||
export default async bot => {
|
export default async bot => {
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ export default async bot => {
|
|||||||
active: true,
|
active: true,
|
||||||
f: async e => {
|
f: async e => {
|
||||||
const dat = e.message.match(regex)[0].split(/\//).pop();
|
const dat = e.message.match(regex)[0].split(/\//).pop();
|
||||||
|
const nsflId = cfg.nsfl_tag_id || 3;
|
||||||
const rows = await db`
|
const rows = await db`
|
||||||
select i.id, i.mime, i.size, i.username, i.stamp,
|
select i.id, i.mime, i.size, i.username, i.stamp,
|
||||||
(select t.tag from tags_assign ta join tags t on t.id = ta.tag_id where ta.item_id = i.id and t.id in (1,2) limit 1) as rating
|
(select t.tag from tags_assign ta join tags t on t.id = ta.tag_id where ta.item_id = i.id and ta.tag_id in (1, 2, ${nsflId}) order by ta.tag_id asc limit 1) as rating
|
||||||
from "items" i
|
from "items" i
|
||||||
${dat.includes('.')
|
${dat.includes('.')
|
||||||
? db`where i.dest = ${dat}`
|
? db`where i.dest = ${dat}`
|
||||||
@@ -32,15 +33,6 @@ export default async bot => {
|
|||||||
if (e.type === 'irc') {
|
if (e.type === 'irc') {
|
||||||
const color = rating === 'sfw' ? 'green' : (rating === 'nsfw' ? 'red' : 'brown');
|
const color = rating === 'sfw' ? 'green' : (rating === 'nsfw' ? 'red' : 'brown');
|
||||||
ratingStr = `[color=${color}]${rating}[/color]`;
|
ratingStr = `[color=${color}]${rating}[/color]`;
|
||||||
} else if (e.type === 'matrix') {
|
|
||||||
const color = rating === 'sfw' ? '#00ff00' : (rating === 'nsfw' ? '#ff0000' : '#888888');
|
|
||||||
ratingStr = `[b][color=${color}]${rating}[/color][/b]`;
|
|
||||||
// matrix.mjs format() handles [b], but not [color].
|
|
||||||
// However, matrix.mjs send() handles objects with formatted_body.
|
|
||||||
// Let's use a simpler approach that works with the existing formatter if possible,
|
|
||||||
// or just construct the object.
|
|
||||||
} else if (e.type === 'tg') {
|
|
||||||
ratingStr = `[b]${rating}[/b]`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = `${cfg.main.url.full}/${row.id}`.replace('http://', 'https://');
|
const link = `${cfg.main.url.full}/${row.id}`.replace('http://', 'https://');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getLevel } from "../admin.mjs";
|
import { getLevel } from "../admin.mjs";
|
||||||
|
import cfg from "../config.mjs";
|
||||||
|
|
||||||
export default async self => {
|
export default async self => {
|
||||||
return [{
|
return [{
|
||||||
@@ -45,10 +46,11 @@ export default async self => {
|
|||||||
// Deduplicate and sort
|
// Deduplicate and sort
|
||||||
const uniqueCommands = [...new Set(availableCommands)].sort();
|
const uniqueCommands = [...new Set(availableCommands)].sort();
|
||||||
|
|
||||||
|
const trigger = cfg.main.trigger || "!w";
|
||||||
if (uniqueCommands.length === 0) {
|
if (uniqueCommands.length === 0) {
|
||||||
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
|
availableCommands.push(e.type === 'matrix' ? `${trigger} -sfw/nsfw -t tag1,tag2,tag3` : `${trigger} <url> or attachment`);
|
||||||
} else {
|
} else {
|
||||||
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
|
availableCommands.push(e.type === 'matrix' ? `${trigger} -sfw/nsfw -t tag1,tag2,tag3` : `${trigger} <url> or attachment`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-sort to include manually added command
|
// Re-sort to include manually added command
|
||||||
|
|||||||
@@ -37,11 +37,56 @@ const extractJSON = (stdout) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeLogUrl = url => typeof url === 'string' ? url.replace(/\/bot[0-9a-zA-Z_-]+/i, '/bot<token>') : url;
|
||||||
|
|
||||||
|
const getMimeViaCurl = async (link) => {
|
||||||
|
try {
|
||||||
|
let referer = link;
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(link);
|
||||||
|
let host = parsedUrl.hostname;
|
||||||
|
const parts = host.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
host = parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
referer = `${parsedUrl.protocol}//${host}/`;
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const curlArgs = ['-I', '-s', '-f', '-L', '--user-agent', pcUA, '--referer', referer, link];
|
||||||
|
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||||
|
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||||
|
curlArgs.push('--socks5-hostname', proxyHost);
|
||||||
|
}
|
||||||
|
const headOut = await queue.spawn('curl', curlArgs);
|
||||||
|
const contentTypeMatch = headOut.stdout.match(/content-type:\s*([^\r\n]+)/i);
|
||||||
|
if (contentTypeMatch) {
|
||||||
|
let tmphead = contentTypeMatch[1].trim();
|
||||||
|
if (tmphead.includes(';')) {
|
||||||
|
tmphead = tmphead.split(';')[0].trim();
|
||||||
|
}
|
||||||
|
tmphead = tmphead.toLowerCase();
|
||||||
|
const ext = cfg.mimes[tmphead];
|
||||||
|
if (ext) {
|
||||||
|
console.log(`[PARSER DEBUG] Generic Metadata Fallback (curl) success: ${tmphead} -> ${ext}`);
|
||||||
|
return ext;
|
||||||
|
} else {
|
||||||
|
console.warn(`[PARSER DEBUG] Generic Metadata Fallback (curl) unsupported MIME: "${tmphead}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(fErr) {
|
||||||
|
console.error(`[METADATA ERROR] Generic Fallback curl failed for ${sanitizeLogUrl(link)}:`, fErr.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = cfg.main.trigger || "!w";
|
||||||
|
const escapedTrigger = trigger.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
|
||||||
|
|
||||||
export default async bot => {
|
export default async bot => {
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
name: "parser",
|
name: "parser",
|
||||||
call: new RegExp(`${regex.all.source}|^!w(0bm)?\\b`, 'i'),
|
call: new RegExp(`${regex.all.source}|^${escapedTrigger}(0bm)?\\b`, 'i'),
|
||||||
active: true,
|
active: true,
|
||||||
clients: ["irc", "tg", "slack", "discord", "matrix"],
|
clients: ["irc", "tg", "slack", "discord", "matrix"],
|
||||||
f: async e => {
|
f: async e => {
|
||||||
@@ -98,6 +143,47 @@ export default async bot => {
|
|||||||
if (replyLinks) links.push(...replyLinks);
|
if (replyLinks) links.push(...replyLinks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram: reply to a media message with !w
|
||||||
|
if (e.type === 'tg' && e.raw?.reply_to_message) {
|
||||||
|
const replied = e.raw.reply_to_message;
|
||||||
|
console.log(`[PARSER DEBUG] TG reply_to_message keys: ${Object.keys(replied).join(', ')}`);
|
||||||
|
|
||||||
|
// Try to resolve a media file from the replied-to message
|
||||||
|
const allowedTgKeys = ['video', 'audio', 'photo', 'document', 'animation', 'voice', 'video_note'];
|
||||||
|
const mediaKey = Object.keys(replied).find(k => allowedTgKeys.includes(k));
|
||||||
|
|
||||||
|
if (mediaKey) {
|
||||||
|
try {
|
||||||
|
// Get the TG client instance to call getFile()
|
||||||
|
const tgWrapper = bot.bot?.clients?.find(c => c.type === 'tg');
|
||||||
|
const tgClient = tgWrapper?.client;
|
||||||
|
if (!tgClient) throw new Error('TG client not available');
|
||||||
|
|
||||||
|
let mediaObj = replied[mediaKey];
|
||||||
|
// photo is an array — pick the largest (last)
|
||||||
|
if (mediaKey === 'photo') mediaObj = mediaObj[mediaObj.length - 1];
|
||||||
|
|
||||||
|
const fileUrl = await tgClient.getFile(mediaObj.file_id);
|
||||||
|
if (fileUrl) {
|
||||||
|
console.log(`[PARSER] TG reply media resolved: ${fileUrl.replace(/\/bot[0-9a-zA-Z_-]+/i, '/bot<token>')}`);
|
||||||
|
links.push(fileUrl);
|
||||||
|
} else {
|
||||||
|
await e.reply('Could not resolve the replied-to file. It may be too large (>20MB) for the Telegram Bot API.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PARSER] TG reply getFile error:', err);
|
||||||
|
await e.reply('Failed to fetch the replied-to media file.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (replied.text || replied.caption) {
|
||||||
|
// No media — extract any URLs from replied-to text
|
||||||
|
const replyText = replied.text || replied.caption || '';
|
||||||
|
const replyLinks = replyText.match(regex.all)?.filter(l => !l.includes(cfg.main.url.domain));
|
||||||
|
if (replyLinks?.length) links.push(...replyLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[PARSER DEBUG] Final links count: ${links.length}`, links);
|
console.log(`[PARSER DEBUG] Final links count: ${links.length}`, links);
|
||||||
@@ -114,24 +200,24 @@ export default async bot => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for !w command
|
// Check for !w command
|
||||||
const isWCommand = e.message.match(/\!w(0bm)?\b/i);
|
const isWCommand = e.message.match(new RegExp(`${escapedTrigger}(0bm)?\\b`, 'i'));
|
||||||
|
|
||||||
// Matrix-specific: enforce strict !w format
|
// Matrix / Telegram: enforce strict !w format with rating + tags
|
||||||
let matrixRating = null; // 'sfw' or 'nsfw'
|
let botRating = null; // 'sfw' or 'nsfw'
|
||||||
let matrixTags = []; // user-provided tags
|
let botTags = []; // user-provided tags
|
||||||
let isNSFW = false;
|
let isNSFW = false;
|
||||||
if (isWCommand && e.type === 'matrix') {
|
if (isWCommand && (e.type === 'matrix' || e.type === 'tg')) {
|
||||||
const msgLower = e.message.toLowerCase();
|
const msgLower = e.message.toLowerCase();
|
||||||
const hasSfw = /(?:^|\s)-sfw\b/i.test(msgLower);
|
const hasSfw = /(?:^|\s)-sfw\b/i.test(msgLower);
|
||||||
const hasNsfw = /(?:^|\s)-nsfw\b/i.test(msgLower);
|
const hasNsfw = /(?:^|\s)-nsfw\b/i.test(msgLower);
|
||||||
console.log(`[PARSER DEBUG] Matrix Flags: hasSfw=${hasSfw}, hasNsfw=${hasNsfw}, msg='${msgLower}'`);
|
console.log(`[PARSER DEBUG] ${e.type} Flags: hasSfw=${hasSfw}, hasNsfw=${hasNsfw}, msg='${msgLower}'`);
|
||||||
const hasRating = hasSfw || hasNsfw;
|
const hasRating = hasSfw || hasNsfw;
|
||||||
const tagMatch = e.message.match(/-t\s+([^\s].+)/i);
|
const tagMatch = e.message.match(/-t\s+([^\s].+)/i);
|
||||||
const hasTags = !!tagMatch;
|
const hasTags = !!tagMatch;
|
||||||
|
|
||||||
// Bare !w with no arguments → show help
|
// Bare !w with no arguments → show help
|
||||||
if (!hasRating && !hasTags) {
|
if (!hasRating && !hasTags) {
|
||||||
await e.reply(`!w -sfw/nsfw -t tag1,tag2,tag3 (at least ${getMinTags()} tags required)`);
|
await e.reply(`${trigger} -sfw/-nsfw -t tag1,tag2,tag3 (at least ${getMinTags()} tags required)`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,12 +229,12 @@ export default async bot => {
|
|||||||
|
|
||||||
// Missing rating
|
// Missing rating
|
||||||
if (!hasRating) {
|
if (!hasRating) {
|
||||||
await e.reply('missing rating: !w -sfw/nsfw -t tag1,tag2,tag3');
|
await e.reply(`missing rating: ${trigger} -sfw/-nsfw -t tag1,tag2,tag3`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse rating
|
// Parse rating
|
||||||
matrixRating = hasNsfw ? 'nsfw' : 'sfw';
|
botRating = hasNsfw ? 'nsfw' : 'sfw';
|
||||||
if (hasNsfw) isNSFW = true;
|
if (hasNsfw) isNSFW = true;
|
||||||
|
|
||||||
const minTags = getMinTags();
|
const minTags = getMinTags();
|
||||||
@@ -160,17 +246,17 @@ export default async bot => {
|
|||||||
} else {
|
} else {
|
||||||
// Remove links, !w and -sfw/nsfw to get tags
|
// Remove links, !w and -sfw/nsfw to get tags
|
||||||
tagsInput = e.message.replace(regex.all, "")
|
tagsInput = e.message.replace(regex.all, "")
|
||||||
.replace(/\!w(0bm)?\b/i, "")
|
.replace(new RegExp(`${escapedTrigger}(0bm)?\\b`, 'i'), "")
|
||||||
.replace(/\s*-(nsfw|sfw)\b/gi, "")
|
.replace(/\s*-(nsfw|sfw)\b/gi, "")
|
||||||
.replace(/\s*-t\b/gi, "")
|
.replace(/\s*-t\b/gi, "")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by commas only to support multi-word tags
|
// Split by commas only to support multi-word tags
|
||||||
matrixTags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
botTags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||||
|
|
||||||
if (matrixTags.length < minTags) {
|
if (botTags.length < minTags) {
|
||||||
await e.reply(`at least ${minTags} tags required (got ${matrixTags.length}): !w -sfw/nsfw tag1, tag2, tag3`);
|
await e.reply(`at least ${minTags} tags required (got ${botTags.length}): ${trigger} -sfw/-nsfw -t tag1, tag2, tag3`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +265,8 @@ export default async bot => {
|
|||||||
const isForwarded = e.raw?.forward_from !== undefined;
|
const isForwarded = e.raw?.forward_from !== undefined;
|
||||||
const mediaGroupId = e.raw?.media_group_id;
|
const mediaGroupId = e.raw?.media_group_id;
|
||||||
|
|
||||||
if ((e.type === 'matrix' || !e.channel.includes("w0bm")) && (!isWCommand && !isForwarded)) {
|
const channelKeyword = (cfg.main.upload_channel_keyword || "w0bm").toLowerCase();
|
||||||
|
if ((e.type === 'matrix' || !e.channel.toLowerCase().includes(channelKeyword)) && (!isWCommand && !isForwarded)) {
|
||||||
console.log(`[PARSER DEBUG] Channel/Client check failed. Channel: ${e.channel}, Type: ${e.type}, IsW: ${!!isWCommand}`);
|
console.log(`[PARSER DEBUG] Channel/Client check failed. Channel: ${e.channel}, Type: ${e.type}, IsW: ${!!isWCommand}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -187,9 +274,10 @@ export default async bot => {
|
|||||||
// --- LINKED ACCOUNT CHECK ---
|
// --- LINKED ACCOUNT CHECK ---
|
||||||
let websiteUser = null;
|
let websiteUser = null;
|
||||||
|
|
||||||
if (e.type === 'matrix' || e.type === 'discord') {
|
if (e.type === 'matrix' || e.type === 'discord' || e.type === 'tg') {
|
||||||
// Identify lookup key
|
// Identify lookup key and type
|
||||||
// Matrix: Use unique ID (MXID)
|
// Matrix: Use unique ID (MXID)
|
||||||
|
// Telegram: Use numeric user ID (most stable, never changes)
|
||||||
// Discord: Use Nick or Username (Legacy) - TODO: Migration to ID recommended later
|
// Discord: Use Nick or Username (Legacy) - TODO: Migration to ID recommended later
|
||||||
let lookupAlias = e.user.nick || e.user.username;
|
let lookupAlias = e.user.nick || e.user.username;
|
||||||
let lookupType = 'discord'; // default check
|
let lookupType = 'discord'; // default check
|
||||||
@@ -197,6 +285,9 @@ export default async bot => {
|
|||||||
if (e.type === 'matrix') {
|
if (e.type === 'matrix') {
|
||||||
lookupAlias = e.user.account; // MXID
|
lookupAlias = e.user.account; // MXID
|
||||||
lookupType = 'matrix';
|
lookupType = 'matrix';
|
||||||
|
} else if (e.type === 'tg') {
|
||||||
|
lookupAlias = e.user.account; // numeric Telegram user ID as string
|
||||||
|
lookupType = 'telegram';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check DB
|
// Check DB
|
||||||
@@ -220,7 +311,8 @@ export default async bot => {
|
|||||||
|
|
||||||
// Enforce Link for !w command
|
// Enforce Link for !w command
|
||||||
if (isWCommand && !websiteUser) {
|
if (isWCommand && !websiteUser) {
|
||||||
await e.reply(`You must link your account to use this command. Go to ${cfg.main.url.full}/settings to link your ${e.type} account.`);
|
const linkType = e.type === 'tg' ? 'Telegram' : e.type;
|
||||||
|
await e.reply(`You must link your account to use this command. Go to ${cfg.main.url.full}/settings to link your ${linkType} account. Then send !link <TOKEN> to this bot.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,10 +342,11 @@ export default async bot => {
|
|||||||
|
|
||||||
// Use for..of to handle async await properly and serialize debugging
|
// Use for..of to handle async await properly and serialize debugging
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
console.log(`[PARSER LOOP] Processing link: ${link}`);
|
const sanitizedLink = link.replace(/\/bot[0-9a-zA-Z_-]+/i, '/bot<token>');
|
||||||
|
console.log(`[PARSER LOOP] Processing link: ${sanitizedLink}`);
|
||||||
// check repost (link)
|
// check repost (link)
|
||||||
try {
|
try {
|
||||||
repost = await queue.checkrepostlink(link);
|
repost = await queue.checkrepostlink(sanitizedLink);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[PARSER LOOP] Checkrepost failed:`, e);
|
console.error(`[PARSER LOOP] Checkrepost failed:`, e);
|
||||||
}
|
}
|
||||||
@@ -302,18 +395,7 @@ export default async bot => {
|
|||||||
console.error(`[METADATA ERROR] Pornhub yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] Pornhub yt-dlp failed for ${link}:`, err.message);
|
||||||
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
try {
|
ext = await getMimeViaCurl(link);
|
||||||
const headFetch = await fetch(link, {
|
|
||||||
method: "HEAD",
|
|
||||||
headers: { 'User-Agent': pcUA }
|
|
||||||
});
|
|
||||||
const tmphead = headFetch.headers["content-type"];
|
|
||||||
const status = headFetch.status || headFetch.statusCode || (headFetch.res ? headFetch.res.statusCode : 'unknown');
|
|
||||||
console.log(`[PARSER DEBUG] Fallback fetch for ${link} (Status: ${status}): ${tmphead}`);
|
|
||||||
ext = cfg.mimes[tmphead];
|
|
||||||
} catch(fErr) {
|
|
||||||
console.error(`[METADATA ERROR] Fallback fetch failed for ${link}:`, fErr.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (link.match(regex.instagram)) {
|
else if (link.match(regex.instagram)) {
|
||||||
@@ -325,8 +407,7 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[METADATA ERROR] Instagram yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] Instagram yt-dlp failed for ${link}:`, err.message);
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
ext = await getMimeViaCurl(link);
|
||||||
ext = cfg.mimes[tmphead];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (link.match(regex.imgur)) {
|
else if (link.match(regex.imgur)) {
|
||||||
@@ -338,36 +419,7 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[METADATA ERROR] Imgur yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] Imgur yt-dlp failed for ${link}:`, err.message);
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
|
ext = await getMimeViaCurl(link);
|
||||||
// Fallback: Check MIME via curl (more robust than fetch for blocked direct links)
|
|
||||||
try {
|
|
||||||
let referer = link;
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(link);
|
|
||||||
let host = parsedUrl.hostname;
|
|
||||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
|
||||||
referer = `${parsedUrl.protocol}//${host}/`;
|
|
||||||
} catch(e) {}
|
|
||||||
|
|
||||||
const curlArgs = ['-I', '-s', '-f', '-L', '--user-agent', pcUA, '--referer', referer, link];
|
|
||||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
|
||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
|
||||||
}
|
|
||||||
const headOut = await queue.spawn('curl', curlArgs);
|
|
||||||
const contentTypeMatch = headOut.stdout.match(/content-type:\s*([^\r\n]+)/i);
|
|
||||||
if (contentTypeMatch) {
|
|
||||||
const tmphead = contentTypeMatch[1].trim();
|
|
||||||
ext = cfg.mimes[tmphead];
|
|
||||||
if (ext) {
|
|
||||||
console.log(`[PARSER DEBUG] Imgur Metadata Fallback (curl) success: ${tmphead} -> ${ext}`);
|
|
||||||
} else {
|
|
||||||
console.warn(`[PARSER DEBUG] Imgur Metadata Fallback (curl) unsupported MIME: "${tmphead}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(fErr) {
|
|
||||||
console.error(`[METADATA ERROR] Imgur Fallback curl failed for ${link}:`, fErr.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (link.match(regex.yt)) {
|
else if (link.match(regex.yt)) {
|
||||||
@@ -378,8 +430,7 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[METADATA ERROR] YouTube yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] YouTube yt-dlp failed for ${link}:`, err.message);
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
ext = await getMimeViaCurl(link);
|
||||||
ext = cfg.mimes[tmphead];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (link.match(regex.fourchan)) {
|
else if (link.match(regex.fourchan)) {
|
||||||
@@ -390,8 +441,7 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[METADATA ERROR] 4chan yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] 4chan yt-dlp failed for ${link}:`, err.message);
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
ext = await getMimeViaCurl(link);
|
||||||
ext = cfg.mimes[tmphead];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -402,10 +452,7 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[METADATA ERROR] General yt-dlp failed for ${link}:`, err.message);
|
console.error(`[METADATA ERROR] General yt-dlp failed for ${link}:`, err.message);
|
||||||
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
ext = await getMimeViaCurl(link);
|
||||||
if (e.type == 'tg')
|
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
|
||||||
return await e.reply(errorMsg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +467,13 @@ export default async bot => {
|
|||||||
disable_notification: true
|
disable_notification: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Safe TG edit helper — falls back to e.reply() if the charging message was never sent
|
||||||
|
const tgEdit = (text) => {
|
||||||
|
if (msg?.result)
|
||||||
|
return e.editMessageText(msg.result.chat.id, msg.result.message_id, text);
|
||||||
|
return e.reply(text);
|
||||||
|
};
|
||||||
|
|
||||||
// <download data>
|
// <download data>
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
console.log(`[PARSER] Downloading ${link}...`);
|
console.log(`[PARSER] Downloading ${link}...`);
|
||||||
@@ -450,7 +504,7 @@ export default async bot => {
|
|||||||
console.error('Pornhub dl error:', err);
|
console.error('Pornhub dl error:', err);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,7 +516,7 @@ export default async bot => {
|
|||||||
console.error('Instagram dl error:', err);
|
console.error('Instagram dl error:', err);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,7 +577,7 @@ export default async bot => {
|
|||||||
console.error(`[PARSER] All Imgur download stages failed for ${link}:`, fallbackErr.message);
|
console.error(`[PARSER] All Imgur download stages failed for ${link}:`, fallbackErr.message);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,7 +589,7 @@ export default async bot => {
|
|||||||
console.error('YouTube dl error:', err);
|
console.error('YouTube dl error:', err);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,7 +600,7 @@ export default async bot => {
|
|||||||
console.error('4chan dl error:', err);
|
console.error('4chan dl error:', err);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,20 +611,20 @@ export default async bot => {
|
|||||||
console.error('General dl error:', err);
|
console.error('General dl error:', err);
|
||||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
return await tgEdit(errorMsg);
|
||||||
return await e.reply(errorMsg);
|
return await e.reply(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "something went wrong lol");
|
return await tgEdit("something went wrong lol");
|
||||||
return await e.reply("something went wrong lol");
|
return await e.reply("something went wrong lol");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.match(/larger than/)) {
|
if (source.match(/larger than/)) {
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "too large lol");
|
return await tgEdit("too large lol");
|
||||||
return await e.reply("too large lol");
|
return await e.reply("too large lol");
|
||||||
}
|
}
|
||||||
const end = ~~((new Date() - start) / 1e3);
|
const end = ~~((new Date() - start) / 1e3);
|
||||||
@@ -580,7 +634,7 @@ export default async bot => {
|
|||||||
if (size > maxfilesize) {
|
if (size > maxfilesize) {
|
||||||
await fs.promises.unlink(source).catch(_ => { });
|
await fs.promises.unlink(source).catch(_ => { });
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
return await tgEdit(`too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
||||||
return await e.reply(`too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
return await e.reply(`too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,17 +657,29 @@ export default async bot => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fs.promises.unlink(source).catch(_ => { });
|
await fs.promises.unlink(source).catch(_ => { });
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "something went wrong lol");
|
return await tgEdit("something went wrong lol");
|
||||||
return await e.reply("something went wrong lol");
|
return await e.reply("something went wrong lol");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(cfg.mimes).includes(mime)) {
|
if (!Object.keys(cfg.mimes).includes(mime)) {
|
||||||
await fs.promises.unlink(source).catch(_ => { });
|
await fs.promises.unlink(source).catch(_ => { });
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg')
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `lol, go f0ck yourself (${mime})`);
|
return await tgEdit(`lol, go f0ck yourself (${mime})`);
|
||||||
return await e.reply(`lol, go f0ck yourself (${mime})`);
|
return await e.reply(`lol, go f0ck yourself (${mime})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce allowedMimes whitelist from config (e.g. block archives/pdf if not configured)
|
||||||
|
const allowedMimes = cfg.allowedMimes || [];
|
||||||
|
const mimeCategory = mime.split('/')[0]; // e.g. 'video', 'image', 'audio', 'application'
|
||||||
|
const mimeAllowed = allowedMimes.some(allowed => allowed === mime || allowed === mimeCategory);
|
||||||
|
if (!mimeAllowed) {
|
||||||
|
await fs.promises.unlink(source).catch(_ => { });
|
||||||
|
const blockedMsg = `lol, go f0ck yourself (blocked type: ${mime})`;
|
||||||
|
if (e.type == 'tg')
|
||||||
|
return await tgEdit(blockedMsg);
|
||||||
|
return await e.reply(blockedMsg);
|
||||||
|
}
|
||||||
|
|
||||||
// generate checksum
|
// generate checksum
|
||||||
const checksum = (await queue.spawn('sha256sum', [source])).stdout.trim().split(" ")[0];
|
const checksum = (await queue.spawn('sha256sum', [source])).stdout.trim().split(" ")[0];
|
||||||
|
|
||||||
@@ -645,8 +711,11 @@ export default async bot => {
|
|||||||
|
|
||||||
if (repost) {
|
if (repost) {
|
||||||
await fs.promises.unlink(source).catch(_ => { });
|
await fs.promises.unlink(source).catch(_ => { });
|
||||||
if (e.type == 'tg')
|
if (e.type == 'tg') {
|
||||||
|
if (msg?.result)
|
||||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
||||||
|
return await e.reply(`repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (e.type === 'discord') {
|
if (e.type === 'discord') {
|
||||||
// For Discord, we EDIT the original "charging" message
|
// For Discord, we EDIT the original "charging" message
|
||||||
@@ -673,7 +742,7 @@ export default async bot => {
|
|||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into items ${db({
|
insert into items ${db({
|
||||||
src: e.media ? "" : link,
|
src: (e.media || link.includes('api.telegram.org') || link.startsWith('mxc://')) ? "" : link,
|
||||||
dest: filename,
|
dest: filename,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
size: size,
|
size: size,
|
||||||
@@ -702,10 +771,48 @@ export default async bot => {
|
|||||||
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram: assign rating + user tags (same as Matrix)
|
||||||
|
if (botRating && botTags.length >= getMinTags()) {
|
||||||
|
try {
|
||||||
|
const userId = websiteUser ? websiteUser.id : 1;
|
||||||
|
|
||||||
|
// Assign rating tag (sfw=1, nsfw=2)
|
||||||
|
const ratingTagId = botRating === 'sfw' ? 1 : 2;
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${db({
|
||||||
|
tag_id: ratingTagId,
|
||||||
|
item_id: itemid,
|
||||||
|
user_id: userId
|
||||||
|
})}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Assign user-provided tags
|
||||||
|
for (const tagName of botTags) {
|
||||||
|
let tagid;
|
||||||
|
const tag_exists = await db`select id from "tags" where tag = ${tagName}`;
|
||||||
|
if (tag_exists.length === 0) {
|
||||||
|
tagid = (await db`insert into "tags" ${db({ tag: tagName })} returning id`)[0].id;
|
||||||
|
} else {
|
||||||
|
tagid = tag_exists[0].id;
|
||||||
|
}
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${db({
|
||||||
|
tag_id: tagid,
|
||||||
|
item_id: itemid,
|
||||||
|
user_id: userId
|
||||||
|
})}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
console.log(`[PARSER] TG tags assigned: ${botRating}, [${botTags.join(', ')}] to item ${itemid}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PARSER] Failed to assign TG tags:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate Thumbnail
|
// Generate Thumbnail
|
||||||
try {
|
try {
|
||||||
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
||||||
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
|
await queue.genBlurredThumbnail(itemid, manualApproval);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
||||||
@@ -729,6 +836,7 @@ export default async bot => {
|
|||||||
console.error('[PARSER] Matrix notification error:', err);
|
console.error('[PARSER] Matrix notification error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg?.result)
|
||||||
await e.deleteMessage(msg.result.chat.id, msg.result.message_id);
|
await e.deleteMessage(msg.result.chat.id, msg.result.message_id);
|
||||||
await e.reply(`${outputmsgirc} | link: ${cfg.main.url.full}/${itemid}`);
|
await e.reply(`${outputmsgirc} | link: ${cfg.main.url.full}/${itemid}`);
|
||||||
}
|
}
|
||||||
@@ -740,7 +848,7 @@ export default async bot => {
|
|||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into items ${db({
|
insert into items ${db({
|
||||||
src: e.media ? "" : link,
|
src: (e.media || link.includes('api.telegram.org') || link.startsWith('mxc://')) ? "" : link,
|
||||||
dest: filename,
|
dest: filename,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
size: size,
|
size: size,
|
||||||
@@ -769,13 +877,13 @@ export default async bot => {
|
|||||||
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix: assign rating + user tags
|
// Matrix / Telegram: assign rating + user tags
|
||||||
if (e.type === 'matrix' && matrixRating && matrixTags.length >= getMinTags()) {
|
if (botRating && botTags.length >= getMinTags()) {
|
||||||
try {
|
try {
|
||||||
const userId = websiteUser ? websiteUser.id : 1;
|
const userId = websiteUser ? websiteUser.id : 1;
|
||||||
|
|
||||||
// Assign rating tag (sfw=1, nsfw=2)
|
// Assign rating tag (sfw=1, nsfw=2)
|
||||||
const ratingTagId = matrixRating === 'sfw' ? 1 : 2;
|
const ratingTagId = botRating === 'sfw' ? 1 : 2;
|
||||||
await db`
|
await db`
|
||||||
insert into "tags_assign" ${db({
|
insert into "tags_assign" ${db({
|
||||||
tag_id: ratingTagId,
|
tag_id: ratingTagId,
|
||||||
@@ -785,7 +893,7 @@ export default async bot => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Assign user-provided tags
|
// Assign user-provided tags
|
||||||
for (const tagName of matrixTags) {
|
for (const tagName of botTags) {
|
||||||
let tagid;
|
let tagid;
|
||||||
const tag_exists = await db`
|
const tag_exists = await db`
|
||||||
select id from "tags" where tag = ${tagName}
|
select id from "tags" where tag = ${tagName}
|
||||||
@@ -806,16 +914,16 @@ export default async bot => {
|
|||||||
})}
|
})}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
console.log(`[PARSER] Matrix tags assigned: ${matrixRating}, [${matrixTags.join(', ')}] to item ${itemid}`);
|
console.log(`[PARSER] Tags assigned: ${botRating}, [${botTags.join(', ')}] to item ${itemid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[PARSER] Failed to assign Matrix tags:', err);
|
console.error('[PARSER] Failed to assign tags:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Thumbnail
|
// Generate Thumbnail
|
||||||
try {
|
try {
|
||||||
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
||||||
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
|
await queue.genBlurredThumbnail(itemid, manualApproval);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
||||||
|
|||||||
78
src/inc/trigger/telegram_link.mjs
Normal file
78
src/inc/trigger/telegram_link.mjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
import cfg from "../config.mjs";
|
||||||
|
|
||||||
|
export default async self => {
|
||||||
|
return [{
|
||||||
|
name: "telegram_link",
|
||||||
|
call: /^!link(?:\s+(.+))?$/i,
|
||||||
|
active: true,
|
||||||
|
clients: ["tg"],
|
||||||
|
f: async e => {
|
||||||
|
// Only for Telegram
|
||||||
|
if (e.type !== 'tg') return false;
|
||||||
|
|
||||||
|
let token = (e.args[0] || '').trim().toUpperCase();
|
||||||
|
|
||||||
|
// Scenario 1: User typed "!link" only (no token)
|
||||||
|
if (!token) {
|
||||||
|
await e.reply(`Please generate a link token on the website at ${cfg.main.url.full}/settings and then send: !link <TOKEN>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 2: Processing a token
|
||||||
|
try {
|
||||||
|
// Find token in DB
|
||||||
|
const linkData = (await db`
|
||||||
|
SELECT user_id FROM link_token WHERE token = ${token}
|
||||||
|
`)[0];
|
||||||
|
|
||||||
|
if (!linkData) {
|
||||||
|
return await e.reply("Invalid or expired token. Please generate a new one at the website settings page.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the Telegram numeric user ID as the alias (most stable identifier)
|
||||||
|
const alias = e.user.account; // e.g. "123456789"
|
||||||
|
|
||||||
|
// Check if already linked to THIS user
|
||||||
|
const existing = await db`
|
||||||
|
SELECT 1 FROM user_alias
|
||||||
|
WHERE userid = ${linkData.user_id}
|
||||||
|
AND alias = ${alias}
|
||||||
|
AND type = 'telegram'
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return await e.reply("This Telegram account is already linked to your website profile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this Telegram account is linked to ANOTHER user (prevent hijacking)
|
||||||
|
const taken = await db`
|
||||||
|
SELECT 1 FROM user_alias
|
||||||
|
WHERE alias = ${alias}
|
||||||
|
AND type = 'telegram'
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (taken.length > 0) {
|
||||||
|
return await e.reply("This Telegram account is already linked to a different website profile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform Link
|
||||||
|
await db`
|
||||||
|
INSERT INTO user_alias (userid, alias, type)
|
||||||
|
VALUES (${linkData.user_id}, ${alias}, 'telegram')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Delete used token
|
||||||
|
await db`DELETE FROM link_token WHERE token = ${token}`;
|
||||||
|
|
||||||
|
const tgUsername = e.user.username ? `@${e.user.username}` : e.user.nick;
|
||||||
|
return await e.reply(`Successfully linked Telegram account ${tgUsername} (ID: ${alias}) to your website profile!`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Telegram Link] Error:", err);
|
||||||
|
return await e.reply("An internal error occurred while linking your account.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
};
|
||||||
44
src/inc/wordfilter.mjs
Normal file
44
src/inc/wordfilter.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import db from "./sql.mjs";
|
||||||
|
|
||||||
|
function escapeRegExp(string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces words matching database rules inside comments.
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {Promise<string>} The filtered comment text.
|
||||||
|
*/
|
||||||
|
export async function applyWordFilter(content) {
|
||||||
|
if (!content || typeof content !== 'string') return content;
|
||||||
|
try {
|
||||||
|
const filters = await db`SELECT word, replacement FROM wordfilter`;
|
||||||
|
if (filters.length === 0) return content;
|
||||||
|
|
||||||
|
let filtered = content;
|
||||||
|
for (const f of filters) {
|
||||||
|
const escaped = escapeRegExp(f.word);
|
||||||
|
const regex = new RegExp(escaped, 'gi');
|
||||||
|
filtered = filtered.replace(regex, (match) => {
|
||||||
|
// 1. Check if the matched substring is entirely UPPERCASE
|
||||||
|
if (match === match.toUpperCase() && match !== match.toLowerCase()) {
|
||||||
|
return f.replacement.toUpperCase();
|
||||||
|
}
|
||||||
|
// 2. Check if the matched substring is Capitalized / Titlecase
|
||||||
|
if (match[0] === match[0].toUpperCase() && match[0] !== match[0].toLowerCase()) {
|
||||||
|
return f.replacement.charAt(0).toUpperCase() + f.replacement.slice(1);
|
||||||
|
}
|
||||||
|
// 3. Check if the matched substring is lowercase
|
||||||
|
if (match === match.toLowerCase()) {
|
||||||
|
return f.replacement.toLowerCase();
|
||||||
|
}
|
||||||
|
// Fallback to exact replacement string
|
||||||
|
return f.replacement;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WORDFILTER] Error applying filter:', err);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/index.mjs
324
src/index.mjs
@@ -10,14 +10,17 @@ import { getAboutText, setAboutText, getRulesText, setRulesText, getTermsText, s
|
|||||||
import flummpress from "flummpress";
|
import flummpress from "flummpress";
|
||||||
import { handleUpload } from "./upload_handler.mjs";
|
import { handleUpload } from "./upload_handler.mjs";
|
||||||
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
||||||
|
import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs";
|
||||||
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
||||||
import { handleMemeUpload } from "./meme_upload_handler.mjs";
|
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
|
||||||
import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
|
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
|
||||||
|
import { handleImportTelegramPack } from "./sticker_pack_handler.mjs";
|
||||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
import { handleCommentUpload } from "./comment_upload_handler.mjs";
|
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
|
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
||||||
|
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs";
|
||||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
import security from "./inc/security.mjs";
|
import security from "./inc/security.mjs";
|
||||||
@@ -76,6 +79,10 @@ const nginx502Fallback = `<html>
|
|||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
// Login + Register modal injected before </body>
|
// Login + Register modal injected before </body>
|
||||||
|
// This string is built at startup so it can reference cfg.recaptcha values.
|
||||||
|
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
|
||||||
|
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
|
||||||
|
|
||||||
const gateLoginInjection = `
|
const gateLoginInjection = `
|
||||||
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
|
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@ const gateLoginInjection = `
|
|||||||
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
|
||||||
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
|
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
|
||||||
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
|
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
|
||||||
|
${_rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
|
||||||
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
|
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
|
||||||
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
|
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
|
||||||
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
|
||||||
@@ -136,6 +144,7 @@ const gateLoginInjection = `
|
|||||||
function gateShowView(view) {
|
function gateShowView(view) {
|
||||||
document.getElementById('gate-login-view').style.display = view === 'login' ? '' : 'none';
|
document.getElementById('gate-login-view').style.display = view === 'login' ? '' : 'none';
|
||||||
document.getElementById('gate-register-view').style.display = view === 'register' ? '' : 'none';
|
document.getElementById('gate-register-view').style.display = view === 'register' ? '' : 'none';
|
||||||
|
if (view === 'register') gateRenderRecaptcha();
|
||||||
}
|
}
|
||||||
function gateSetError(id, msg) {
|
function gateSetError(id, msg) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
@@ -151,6 +160,24 @@ const gateLoginInjection = `
|
|||||||
btn.style.cursor = loading ? 'default' : 'pointer';
|
btn.style.cursor = loading ? 'default' : 'pointer';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reCAPTCHA
|
||||||
|
var _gateRcWidgetId = null;
|
||||||
|
var _gateRcLoaded = false;
|
||||||
|
function gateRenderRecaptcha() {
|
||||||
|
var el = document.getElementById('gate-recaptcha');
|
||||||
|
if (!el || !window.grecaptcha) return;
|
||||||
|
if (_gateRcWidgetId !== null) {
|
||||||
|
try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {}
|
||||||
|
} else {
|
||||||
|
_gateRcWidgetId = grecaptcha.render(el, { sitekey: '${_rcSiteKey}', theme: 'light' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.onRecaptchaGateReady = function() {
|
||||||
|
_gateRcLoaded = true;
|
||||||
|
var rv = document.getElementById('gate-register-view');
|
||||||
|
if (rv && rv.style.display !== 'none') gateRenderRecaptcha();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var modal = document.getElementById('gate-modal');
|
var modal = document.getElementById('gate-modal');
|
||||||
var close = document.getElementById('gate-modal-close');
|
var close = document.getElementById('gate-modal-close');
|
||||||
@@ -163,7 +190,7 @@ const gateLoginInjection = `
|
|||||||
var hc = document.getElementById('hot-corner');
|
var hc = document.getElementById('hot-corner');
|
||||||
if (hc) hc.onclick = function() { openLoginGate('login'); };
|
if (hc) hc.onclick = function() { openLoginGate('login'); };
|
||||||
|
|
||||||
// ── Login form ──────────────────────────────────────────────────────────
|
// Login form
|
||||||
document.getElementById('gate-login-form').onsubmit = function(e) {
|
document.getElementById('gate-login-form').onsubmit = function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
gateSetError('gate-login-error', '');
|
gateSetError('gate-login-error', '');
|
||||||
@@ -181,23 +208,28 @@ const gateLoginInjection = `
|
|||||||
gateSetError('gate-login-error', d.msg || 'Login failed.');
|
gateSetError('gate-login-error', d.msg || 'Login failed.');
|
||||||
gateSetBtn('gate-login-btn', false);
|
gateSetBtn('gate-login-btn', false);
|
||||||
} else {
|
} else {
|
||||||
// success — server set the cookie, reload to enter the site
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() { window.location.reload(); });
|
||||||
// On redirect (301) fetch follows it — if the redirect lands on '/' we reload
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Register form ───────────────────────────────────────────────────────
|
// Register form
|
||||||
document.getElementById('gate-register-form').onsubmit = function(e) {
|
document.getElementById('gate-register-form').onsubmit = function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
gateSetError('gate-register-error', '');
|
gateSetError('gate-register-error', '');
|
||||||
gateSetError('gate-register-ok', '');
|
gateSetError('gate-register-ok', '');
|
||||||
gateSetBtn('gate-register-btn', true);
|
gateSetBtn('gate-register-btn', true);
|
||||||
var fd = new FormData(this);
|
var fd = new FormData(this);
|
||||||
|
if (_gateRcWidgetId !== null && window.grecaptcha) {
|
||||||
|
var token = grecaptcha.getResponse(_gateRcWidgetId);
|
||||||
|
if (!token) {
|
||||||
|
gateSetError('gate-register-error', 'Please complete the CAPTCHA.');
|
||||||
|
gateSetBtn('gate-register-btn', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fd.append('g-recaptcha-response', token);
|
||||||
|
}
|
||||||
var body = new URLSearchParams(fd).toString();
|
var body = new URLSearchParams(fd).toString();
|
||||||
fetch('/register', {
|
fetch('/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -209,6 +241,7 @@ const gateLoginInjection = `
|
|||||||
gateSetBtn('gate-register-btn', false);
|
gateSetBtn('gate-register-btn', false);
|
||||||
if (d.success === false) {
|
if (d.success === false) {
|
||||||
gateSetError('gate-register-error', d.msg || 'Registration failed.');
|
gateSetError('gate-register-error', d.msg || 'Registration failed.');
|
||||||
|
if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} }
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('gate-register-ok').textContent = d.msg || 'Account created! You can now sign in.';
|
document.getElementById('gate-register-ok').textContent = d.msg || 'Account created! You can now sign in.';
|
||||||
document.getElementById('gate-register-ok').style.display = '';
|
document.getElementById('gate-register-ok').style.display = '';
|
||||||
@@ -219,6 +252,7 @@ const gateLoginInjection = `
|
|||||||
.catch(function() {
|
.catch(function() {
|
||||||
gateSetBtn('gate-register-btn', false);
|
gateSetBtn('gate-register-btn', false);
|
||||||
gateSetError('gate-register-error', 'An error occurred. Please try again.');
|
gateSetError('gate-register-error', 'An error occurred. Please try again.');
|
||||||
|
if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -230,6 +264,7 @@ const gateLoginInjection = `
|
|||||||
if (_sb.length > 12) _sb = _sb.slice(-12);
|
if (_sb.length > 12) _sb = _sb.slice(-12);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
${_rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
@@ -351,9 +386,10 @@ process.on('uncaughtException', err => {
|
|||||||
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
||||||
];
|
];
|
||||||
for (const dir of initDirs) {
|
for (const dir of initDirs) {
|
||||||
if (!fs.existsSync(dir)) {
|
try {
|
||||||
console.log(`[BOOT] Creating directory: ${dir}`);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== 'EEXIST') console.warn(`[BOOT] Could not create directory ${dir}: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,11 +454,39 @@ process.on('uncaughtException', err => {
|
|||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cache-Control headers for static assets.
|
||||||
|
// flummpress router.static() sends no caching headers, which forces Chrome to
|
||||||
|
// re-fetch all thumbnails on every grid visit even when they haven't changed.
|
||||||
|
// Guard: only set long-lived caching when protect_files is OFF so we never attach
|
||||||
|
// immutable headers to a 401 response (which would be incorrectly cached by browsers).
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
const p = req.url?.pathname;
|
||||||
|
if (!p) return;
|
||||||
|
if (getProtectFiles()) return; // Protect-files gates these with auth — don't cache
|
||||||
|
if (p.startsWith('/t/') || p.startsWith('/ca/') || p.startsWith('/b/')) {
|
||||||
|
// Thumbnails, covers, and source blobs: 1-year cache.
|
||||||
|
// These never change for a given ID (content-addressed by item ID).
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
} else if (p.startsWith('/s/') && !p.startsWith('/s/emojis/') && !p.startsWith('/s/koepfe/')) {
|
||||||
|
// CSS/JS/images in /s/ are versioned by query param (?v=...) — safe to cache long-term.
|
||||||
|
// Emojis and koepfe are excluded (user-uploadable, mutable).
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
} else if (p.startsWith('/a/')) {
|
||||||
|
// Avatars can be re-uploaded — use short cache with revalidation.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
|
||||||
|
} else if (p.startsWith('/memes/') || p.startsWith('/s/emojis/') || p.startsWith('/s/koepfe/')) {
|
||||||
|
// Mutable user content: short cache.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Block source map requests early — flummpress crashes on unknown MIME types (e.g. .map)
|
// Block source map requests early — flummpress crashes on unknown MIME types (e.g. .map)
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (req.url?.pathname?.endsWith('.map')) {
|
if (req.url?.pathname?.endsWith('.map')) {
|
||||||
@@ -485,14 +549,18 @@ process.on('uncaughtException', err => {
|
|||||||
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
|
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
|
||||||
return;
|
return;
|
||||||
if (req.url.pathname.match(/^\/(b|c|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
|
if (req.url.pathname.match(/^\/(b|c|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
|
||||||
if (cfg.websrv.private_society && !req.cookies?.session) {
|
// protect_files gates raw file URLs behind a session (401 if not logged in).
|
||||||
|
// private_society also gates file URLs — but only when protect_files is ALSO enabled.
|
||||||
|
// If private_society is on but protect_files is off, direct file URLs are intentionally
|
||||||
|
// left public so they can be shared without requiring a login.
|
||||||
|
if (getProtectFiles() && !req.cookies?.session) {
|
||||||
|
if (cfg.websrv.private_society) {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
||||||
req.url.pathname = '/private_society_media_bypass';
|
req.url.pathname = '/private_society_media_bypass';
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
if (getProtectFiles() && !req.cookies?.session) {
|
|
||||||
res.writeHead(401).end('Unauthorized');
|
res.writeHead(401).end('Unauthorized');
|
||||||
req.url.pathname = '/protect_files_bypass';
|
req.url.pathname = '/protect_files_bypass';
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -504,7 +572,7 @@ process.on('uncaughtException', err => {
|
|||||||
|
|
||||||
if (req.cookies.session) {
|
if (req.cookies.session) {
|
||||||
const user = await db`
|
const user = await db`
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||||
from "user_sessions"
|
from "user_sessions"
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
@@ -631,9 +699,10 @@ process.on('uncaughtException', err => {
|
|||||||
hide_koepfe: user[0].hide_koepfe ?? false,
|
hide_koepfe: user[0].hide_koepfe ?? false,
|
||||||
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null,
|
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null,
|
||||||
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false),
|
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false),
|
||||||
|
use_alternative_steuerung: user[0].use_alternative_steuerung ?? (cfg.websrv.user_alternative_steuerung !== false),
|
||||||
comment_display_mode: user[0].comment_display_mode ?? (cfg.websrv.default_comment_display_mode || 0),
|
comment_display_mode: user[0].comment_display_mode ?? (cfg.websrv.default_comment_display_mode || 0),
|
||||||
force_comment_display_mode: user[0].force_comment_display_mode ?? 0
|
force_comment_display_mode: user[0].force_comment_display_mode ?? 0
|
||||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox', 'comment_display_mode', 'force_comment_display_mode')
|
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox', 'use_alternative_steuerung', 'comment_display_mode', 'force_comment_display_mode')
|
||||||
}
|
}
|
||||||
on conflict ("user_id") do update set
|
on conflict ("user_id") do update set
|
||||||
theme = excluded.theme,
|
theme = excluded.theme,
|
||||||
@@ -650,6 +719,7 @@ process.on('uncaughtException', err => {
|
|||||||
hide_koepfe = excluded.hide_koepfe,
|
hide_koepfe = excluded.hide_koepfe,
|
||||||
language = excluded.language,
|
language = excluded.language,
|
||||||
use_alternative_infobox = excluded.use_alternative_infobox,
|
use_alternative_infobox = excluded.use_alternative_infobox,
|
||||||
|
use_alternative_steuerung = excluded.use_alternative_steuerung,
|
||||||
comment_display_mode = excluded.comment_display_mode,
|
comment_display_mode = excluded.comment_display_mode,
|
||||||
force_comment_display_mode = excluded.force_comment_display_mode,
|
force_comment_display_mode = excluded.force_comment_display_mode,
|
||||||
user_id = excluded.user_id
|
user_id = excluded.user_id
|
||||||
@@ -659,14 +729,19 @@ process.on('uncaughtException', err => {
|
|||||||
const queryMode = req.url.qs?.mode !== undefined ? +req.url.qs.mode : undefined;
|
const queryMode = req.url.qs?.mode !== undefined ? +req.url.qs.mode : undefined;
|
||||||
req.mode = queryMode !== undefined ? queryMode : (req.session ? +(req.session.mode ?? 0) : +(req.cookies?.mode ?? 0));
|
req.mode = queryMode !== undefined ? queryMode : (req.session ? +(req.session.mode ?? 0) : +(req.cookies?.mode ?? 0));
|
||||||
|
|
||||||
// Guest protection: Strictly enforce SFW mode (0) for non-logged-in users
|
// public_nsfw: when true, the *default* for guests is mode 3 (all content).
|
||||||
|
// Only applies when the guest has no explicit mode preference (no ?mode= param, no cookie).
|
||||||
|
// If the guest has chosen a filter (e.g. SFW), that choice is always respected.
|
||||||
if (!req.session) {
|
if (!req.session) {
|
||||||
req.mode = 0;
|
const hasExplicitMode = queryMode !== undefined || req.cookies?.mode !== undefined;
|
||||||
|
if (!hasExplicitMode) {
|
||||||
|
req.mode = cfg.websrv.public_nsfw ? 3 : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private Society gate — require login for all content when enabled
|
// Private Society gate — require login for all content when enabled
|
||||||
if (cfg.websrv.private_society && !req.session) {
|
if (cfg.websrv.private_society && !req.session) {
|
||||||
const publicPaths = /^\/(s|login|logout|register|activate|forgot-password|reset-password|banned|api\/v2\/auth|manifest\.json|sw\.js|robots\.txt|favicon\.(ico|png|gif)|s\/img\/duck-icon-(192|512)\.png)(\/.*)?$/;
|
const publicPaths = /^\/(s|login|logout|register|activate|forgot-password|reset-password|banned|api\/v2\/auth|api\/v2\/upload|manifest\.json|sw\.js|robots\.txt|favicon\.(ico|png|gif)|s\/img\/duck-icon-(192|512)\.png)(\/.*)?$/;
|
||||||
if (!publicPaths.test(req.url.pathname)) {
|
if (!publicPaths.test(req.url.pathname)) {
|
||||||
// For AJAX requests, return 502 so it looks like the backend is down
|
// For AJAX requests, return 502 so it looks like the backend is down
|
||||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||||
@@ -711,7 +786,9 @@ process.on('uncaughtException', err => {
|
|||||||
// because the session middleware will have completed by the time router callbacks execute.
|
// because the session middleware will have completed by the time router callbacks execute.
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return;
|
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
|
||||||
|
// DM attachment upload validates CSRF internally
|
||||||
|
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
||||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||||
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||||
// User hall image upload is handled by bypass middleware below
|
// User hall image upload is handled by bypass middleware below
|
||||||
@@ -754,6 +831,23 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for banner upload (needs raw body before router consumes it)
|
||||||
|
// CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.url.pathname === '/api/v2/settings/uploadBanner') {
|
||||||
|
if (cfg.websrv.user_banner_enabled === false) {
|
||||||
|
return res.reply({ success: false, msg: 'Banner feature is currently disabled' }, 403);
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
await handleBannerUpload(req, res);
|
||||||
|
req.url.pathname = '/handled_banner_upload_bypass';
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
await handleBannerDelete(req, res);
|
||||||
|
req.url.pathname = '/handled_banner_delete_bypass';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bypass middleware for custom thumbnail uploads
|
// Bypass middleware for custom thumbnail uploads
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/);
|
const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/);
|
||||||
@@ -771,6 +865,16 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for meme template edits
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
const editMatch = req.url.pathname.match(/^\/api\/v2\/admin\/memes\/(\d+)\/edit$/);
|
||||||
|
if (req.method === 'POST' && editMatch) {
|
||||||
|
req.params = { id: editMatch[1] };
|
||||||
|
await handleMemeEdit(req, res);
|
||||||
|
req.url.pathname = '/handled_meme_edit_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bypass middleware for emoji uploads
|
// Bypass middleware for emoji uploads
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/emojis') {
|
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/emojis') {
|
||||||
@@ -779,6 +883,33 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for emoji edits
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
const editMatch = req.url.pathname.match(/^\/api\/v2\/admin\/emojis\/(\d+)\/edit$/);
|
||||||
|
if (req.method === 'POST' && editMatch) {
|
||||||
|
req.params = { id: editMatch[1] };
|
||||||
|
await handleEmojiEdit(req, res);
|
||||||
|
req.url.pathname = '/handled_emoji_edit_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for Telegram sticker pack import
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/sticker-packs/import') {
|
||||||
|
await handleImportTelegramPack(req, res);
|
||||||
|
req.url.pathname = '/handled_sticker_pack_import_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for koepfe uploads
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/koepfe/upload') {
|
||||||
|
const { handleKoepfeUpload } = await import('./koepfe_handler.mjs');
|
||||||
|
await handleKoepfeUpload(req, res);
|
||||||
|
req.url.pathname = '/handled_koepfe_upload_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bypass middleware for hall image uploads (multipart — needs raw body)
|
// Bypass middleware for hall image uploads (multipart — needs raw body)
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (cfg.websrv.halls_enabled === false) return;
|
if (cfg.websrv.halls_enabled === false) return;
|
||||||
@@ -832,6 +963,28 @@ process.on('uncaughtException', err => {
|
|||||||
await handleCommentUpload(req, res);
|
await handleCommentUpload(req, res);
|
||||||
req.url.pathname = '/handled_comment_upload_bypass';
|
req.url.pathname = '/handled_comment_upload_bypass';
|
||||||
}
|
}
|
||||||
|
// DELETE /api/v2/comments/upload/:id — user cancels a staged attachment
|
||||||
|
const cancelMatch = req.url.pathname.match(/^\/api\/v2\/comments\/upload\/(\d+)$/);
|
||||||
|
if (req.method === 'DELETE' && cancelMatch) {
|
||||||
|
await handleCommentUploadCancel(req, res, cancelMatch[1]);
|
||||||
|
req.url.pathname = '/handled_comment_upload_cancel_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for DM encrypted attachment upload/download/delete
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
const uploadMatch = req.url.pathname.match(/^\/api\/dm\/attachment\/upload\/(\d+)$/);
|
||||||
|
const downloadMatch = req.url.pathname.match(/^\/api\/dm\/attachment\/(\d+)$/);
|
||||||
|
if (req.method === 'POST' && uploadMatch) {
|
||||||
|
await handleDmAttachmentUpload(req, res, uploadMatch[1]);
|
||||||
|
req.url.pathname = '/handled_dm_attachment_upload_bypass';
|
||||||
|
} else if (req.method === 'GET' && downloadMatch) {
|
||||||
|
await handleDmAttachmentDownload(req, res, downloadMatch[1]);
|
||||||
|
req.url.pathname = '/handled_dm_attachment_download_bypass';
|
||||||
|
} else if (req.method === 'DELETE' && downloadMatch) {
|
||||||
|
await handleDmAttachmentDelete(req, res, downloadMatch[1]);
|
||||||
|
req.url.pathname = '/handled_dm_attachment_delete_bypass';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tpl.views = "views";
|
tpl.views = "views";
|
||||||
@@ -983,6 +1136,14 @@ process.on('uncaughtException', err => {
|
|||||||
setPrivateMessages(cfg.websrv.private_messages !== false);
|
setPrivateMessages(cfg.websrv.private_messages !== false);
|
||||||
console.log(`[BOOT] Private messaging: ${cfg.websrv.private_messages !== false ? 'ENABLED' : 'DISABLED'}`);
|
console.log(`[BOOT] Private messaging: ${cfg.websrv.private_messages !== false ? 'ENABLED' : 'DISABLED'}`);
|
||||||
|
|
||||||
|
// Load dm_attachments from config.json (static — not a DB setting)
|
||||||
|
// Default is true; requires private_messages to also be enabled
|
||||||
|
setDmAttachments(cfg.websrv.dm_attachments !== false);
|
||||||
|
console.log(`[BOOT] DM attachments: ${cfg.websrv.dm_attachments !== false ? 'ENABLED' : 'DISABLED'}`);
|
||||||
|
|
||||||
|
// Load dm_unencrypted from config.json (static — not a DB setting)
|
||||||
|
setDmUnencrypted(!!cfg.websrv.dm_unencrypted);
|
||||||
|
console.log(`[BOOT] DM unencrypted: ${cfg.websrv.dm_unencrypted ? 'ENABLED' : 'DISABLED'}`);
|
||||||
// Load default_layout from config.json (static)
|
// Load default_layout from config.json (static)
|
||||||
if (cfg.websrv.default_layout) {
|
if (cfg.websrv.default_layout) {
|
||||||
setDefaultLayout(cfg.websrv.default_layout);
|
setDefaultLayout(cfg.websrv.default_layout);
|
||||||
@@ -1022,6 +1183,22 @@ process.on('uncaughtException', err => {
|
|||||||
console.warn(`[BOOT] Terms text fetch failed:`, e.message);
|
console.warn(`[BOOT] Terms text fetch failed:`, e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch nsfp (NSFP tag IDs) setting — overrides config.json if set in DB
|
||||||
|
try {
|
||||||
|
const nsfpSetting = await db`SELECT value FROM site_settings WHERE key = 'nsfp' LIMIT 1`;
|
||||||
|
if (nsfpSetting.length > 0) {
|
||||||
|
const parsed = JSON.parse(nsfpSetting[0].value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setNsfpIds(parsed);
|
||||||
|
console.log(`[BOOT] NSFP tag IDs loaded from DB: [${getNsfpIds().join(', ')}]`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[BOOT] No NSFP setting in DB, using config.json: [${getNsfpIds().join(', ')}]`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
const globals = {
|
const globals = {
|
||||||
lul: cfg.websrv.lul,
|
lul: cfg.websrv.lul,
|
||||||
themes: cfg.websrv.themes,
|
themes: cfg.websrv.themes,
|
||||||
@@ -1037,17 +1214,25 @@ process.on('uncaughtException', err => {
|
|||||||
get min_tags() { return getMinTags(); },
|
get min_tags() { return getMinTags(); },
|
||||||
get registration_open() { return getRegistrationOpen(); },
|
get registration_open() { return getRegistrationOpen(); },
|
||||||
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
||||||
|
registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token,
|
||||||
get trusted_uploads() { return getTrustedUploads(); },
|
get trusted_uploads() { return getTrustedUploads(); },
|
||||||
get shitpost_mode() { return getShitpostMode(); },
|
get shitpost_mode() { return getShitpostMode(); },
|
||||||
|
shitpost_require_rating: !!cfg.websrv.shitpost_require_rating,
|
||||||
|
shitpost_min_tags: parseInt(cfg.websrv.shitpost_min_tags) || 0,
|
||||||
get about_text() { return getAboutText(); },
|
get about_text() { return getAboutText(); },
|
||||||
get rules_text() { return getRulesText(); },
|
get rules_text() { return getRulesText(); },
|
||||||
get terms_text() { return getTermsText(); },
|
get terms_text() { return getTermsText(); },
|
||||||
|
get about_text_b64() { return Buffer.from(getAboutText() || '').toString('base64'); },
|
||||||
|
get rules_text_b64() { return Buffer.from(getRulesText() || '').toString('base64'); },
|
||||||
|
get terms_text_b64() { return Buffer.from(getTermsText() || '').toString('base64'); },
|
||||||
get halls() { return getHalls(); },
|
get halls() { return getHalls(); },
|
||||||
halls_enabled: cfg.websrv.halls_enabled !== false,
|
halls_enabled: cfg.websrv.halls_enabled !== false,
|
||||||
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
|
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
|
||||||
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
|
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
|
||||||
abyss_enabled: cfg.websrv.abyss_enabled !== false,
|
abyss_enabled: cfg.websrv.abyss_enabled !== false,
|
||||||
smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password),
|
smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password),
|
||||||
|
recaptcha_enabled: !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key),
|
||||||
|
recaptcha_site_key: (cfg.recaptcha && cfg.recaptcha.site_key) || '',
|
||||||
show_background_cfg: cfg.websrv.background !== false,
|
show_background_cfg: cfg.websrv.background !== false,
|
||||||
allowed_mimes: Object.keys(cfg.mimes).concat([...new Set(Object.values(cfg.mimes))].map(ext => `.${ext}`)).join(','),
|
allowed_mimes: Object.keys(cfg.mimes).concat([...new Set(Object.values(cfg.mimes))].map(ext => `.${ext}`)).join(','),
|
||||||
mimes_json: JSON.stringify(cfg.mimes),
|
mimes_json: JSON.stringify(cfg.mimes),
|
||||||
@@ -1062,6 +1247,7 @@ process.on('uncaughtException', err => {
|
|||||||
meme_creator: !!cfg.websrv.meme_creator,
|
meme_creator: !!cfg.websrv.meme_creator,
|
||||||
custom_favicon: cfg.websrv.custom_favicon || "",
|
custom_favicon: cfg.websrv.custom_favicon || "",
|
||||||
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
|
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
|
||||||
|
custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
|
||||||
site_description: cfg.websrv.description || "The webs dumpster",
|
site_description: cfg.websrv.description || "The webs dumpster",
|
||||||
enable_nsfl: !!cfg.enable_nsfl,
|
enable_nsfl: !!cfg.enable_nsfl,
|
||||||
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
||||||
@@ -1069,11 +1255,15 @@ process.on('uncaughtException', err => {
|
|||||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||||
get private_messages() { return getPrivateMessages(); },
|
get private_messages() { return getPrivateMessages(); },
|
||||||
|
get dm_attachments() { return getDmAttachments(); },
|
||||||
|
get dm_unencrypted() { return getDmUnencrypted(); },
|
||||||
|
get allow_comment_deletion() { return getAllowCommentDeletion(); },
|
||||||
get enable_pdf() { return getEnablePdf(); },
|
get enable_pdf() { return getEnablePdf(); },
|
||||||
get enable_cleanup() { return getEnableCleanup(); },
|
get enable_cleanup() { return getEnableCleanup(); },
|
||||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||||
get cleanup_end_date() { return getCleanupEndDate(); },
|
get cleanup_end_date() { return getCleanupEndDate(); },
|
||||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||||
|
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
get default_layout() { return getDefaultLayout(); },
|
get default_layout() { return getDefaultLayout(); },
|
||||||
show_koepfe: !!cfg.websrv.show_koepfe,
|
show_koepfe: !!cfg.websrv.show_koepfe,
|
||||||
@@ -1082,10 +1272,23 @@ process.on('uncaughtException', err => {
|
|||||||
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
|
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
|
||||||
comment_max_length: cfg.main.comment_max_length ?? null,
|
comment_max_length: cfg.main.comment_max_length ?? null,
|
||||||
enable_swf: !!cfg.websrv.enable_swf,
|
enable_swf: !!cfg.websrv.enable_swf,
|
||||||
|
enable_archive: !!cfg.websrv.enable_archive,
|
||||||
enable_danmaku: cfg.websrv.enable_danmaku !== false,
|
enable_danmaku: cfg.websrv.enable_danmaku !== false,
|
||||||
|
enable_item_title: cfg.websrv.enable_item_title !== false,
|
||||||
enable_global_chat: !!cfg.websrv.enable_global_chat,
|
enable_global_chat: !!cfg.websrv.enable_global_chat,
|
||||||
|
enable_sidebar: cfg.websrv.enable_sidebar !== false,
|
||||||
|
sidebar_closed_default: cfg.websrv.sidebar_closed_default === true,
|
||||||
embed_youtube_in_comments: cfg.websrv.embed_youtube_in_comments !== false,
|
embed_youtube_in_comments: cfg.websrv.embed_youtube_in_comments !== false,
|
||||||
koepfe_json: JSON.stringify(cfg.websrv.koepfe || []),
|
get koepfe_json() {
|
||||||
|
try {
|
||||||
|
const kDir = cfg.paths.koepfe;
|
||||||
|
if (!fs.existsSync(kDir)) return JSON.stringify(cfg.websrv.koepfe || []);
|
||||||
|
const files = fs.readdirSync(kDir).filter(f => /\.(png|jpe?g|gif|webp|avif)$/i.test(f)).map(f => '/s/koepfe/' + f);
|
||||||
|
return JSON.stringify(files.length ? files : (cfg.websrv.koepfe || []));
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify(cfg.websrv.koepfe || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
|
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
|
||||||
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
||||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||||
@@ -1096,6 +1299,8 @@ process.on('uncaughtException', err => {
|
|||||||
fileupload_comments_size: cfg.websrv.fileupload_comments_size || (10 * 1024 * 1024),
|
fileupload_comments_size: cfg.websrv.fileupload_comments_size || (10 * 1024 * 1024),
|
||||||
fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5,
|
fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5,
|
||||||
fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment',
|
fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment',
|
||||||
|
fileupload_comments_mimes: Array.isArray(cfg.websrv.fileupload_comments_mimes) ? cfg.websrv.fileupload_comments_mimes : ['image', 'video', 'audio'],
|
||||||
|
enable_comment_polls: cfg.websrv.enable_comment_polls || false,
|
||||||
|
|
||||||
get fonts() {
|
get fonts() {
|
||||||
try {
|
try {
|
||||||
@@ -1154,16 +1359,29 @@ process.on('uncaughtException', err => {
|
|||||||
globals.lang = perRequestLang;
|
globals.lang = perRequestLang;
|
||||||
|
|
||||||
// Resolve per-request infobox preference
|
// Resolve per-request infobox preference
|
||||||
|
// Guests check the new config flag, whereas users use their session pref or default to the user flag
|
||||||
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
||||||
? req.session.use_alternative_infobox
|
? req.session.use_alternative_infobox
|
||||||
|
: (req && !req.session
|
||||||
|
? (cfg.websrv.guest_alternative_infobox === true)
|
||||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||||
? data.user_alternative_infobox
|
? data.user_alternative_infobox
|
||||||
: (cfg.websrv.user_alternative_infobox !== false));
|
: (cfg.websrv.user_alternative_infobox !== false)));
|
||||||
|
|
||||||
|
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
|
||||||
|
? req.session.use_alternative_steuerung
|
||||||
|
: (req && !req.session
|
||||||
|
? false
|
||||||
|
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||||
|
? data.user_alternative_steuerung
|
||||||
|
: (cfg.websrv.user_alternative_steuerung !== false)));
|
||||||
|
|
||||||
data = Object.assign({}, globals, data || {}, {
|
data = Object.assign({}, globals, data || {}, {
|
||||||
t: perRequestT,
|
t: perRequestT,
|
||||||
lang: perRequestLang,
|
lang: perRequestLang,
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||||
? req.session.comment_display_mode
|
? req.session.comment_display_mode
|
||||||
: (data && typeof data.comment_display_mode === 'number'
|
: (data && typeof data.comment_display_mode === 'number'
|
||||||
@@ -1223,4 +1441,60 @@ process.on('uncaughtException', err => {
|
|||||||
setTimeout(cleanupStaleSessions, 30_000);
|
setTimeout(cleanupStaleSessions, 30_000);
|
||||||
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days
|
||||||
|
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
||||||
|
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
|
||||||
|
|
||||||
|
if (INACTIVITY_BAN_DAYS <= 0) {
|
||||||
|
console.log(`[BOOT] Inactivity ban: DISABLED (set websrv.inactivity_ban_days > 0 to enable)`);
|
||||||
|
} else {
|
||||||
|
const INACTIVITY_BAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6 hours
|
||||||
|
|
||||||
|
const banInactiveUsers = async () => {
|
||||||
|
try {
|
||||||
|
const cutoffSecs = ~~(Date.now() / 1e3) - INACTIVITY_BAN_DAYS * 24 * 60 * 60;
|
||||||
|
|
||||||
|
// Find activated, non-banned, non-admin, non-moderator accounts whose
|
||||||
|
// last_seen timestamp has passed the inactivity threshold.
|
||||||
|
// Accounts with last_seen = 0 (never logged in) are also included.
|
||||||
|
const targets = await db`
|
||||||
|
SELECT id, login
|
||||||
|
FROM "user"
|
||||||
|
WHERE banned = false
|
||||||
|
AND activated = true
|
||||||
|
AND admin = false
|
||||||
|
AND is_moderator = false
|
||||||
|
AND login != 'deleted_user'
|
||||||
|
AND last_seen <= ${cutoffSecs}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
const ids = targets.map(u => u.id);
|
||||||
|
const reason = `Inactivity (no login for ${INACTIVITY_BAN_DAYS}+ days)`;
|
||||||
|
|
||||||
|
await db`
|
||||||
|
UPDATE "user"
|
||||||
|
SET banned = true,
|
||||||
|
ban_reason = ${reason},
|
||||||
|
ban_expires = NULL
|
||||||
|
WHERE id = ANY(${ids})
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Terminate all open sessions for banned accounts
|
||||||
|
await db`DELETE FROM user_sessions WHERE user_id = ANY(${ids})`;
|
||||||
|
|
||||||
|
console.log(`[INACTIVITY BAN] Banned ${targets.length} inactive account(s) (threshold: ${INACTIVITY_BAN_DAYS} days): ${targets.map(u => u.login).join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[INACTIVITY BAN] Failed:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[BOOT] Inactivity ban: enabled — threshold ${INACTIVITY_BAN_DAYS} days (config: websrv.inactivity_ban_days)`);
|
||||||
|
|
||||||
|
// Run once after startup (60s delay to let DB settle), then every 6 hours
|
||||||
|
setTimeout(banInactiveUsers, 60_000);
|
||||||
|
setInterval(banInactiveUsers, INACTIVITY_BAN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
92
src/koepfe_handler.mjs
Normal file
92
src/koepfe_handler.mjs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import lib from './inc/lib.mjs';
|
||||||
|
import cfg from './inc/config.mjs';
|
||||||
|
|
||||||
|
function parseMultipart(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = [];
|
||||||
|
req.on('data', chunk => body.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(body);
|
||||||
|
const boundaryMatch = req.headers['content-type']?.match(/boundary=(.+)$/i);
|
||||||
|
if (!boundaryMatch) return reject(new Error('No boundary'));
|
||||||
|
const boundary = '--' + boundaryMatch[1];
|
||||||
|
let start = buffer.indexOf(boundary) + boundary.length + 2;
|
||||||
|
let fileData = null;
|
||||||
|
let filename = null;
|
||||||
|
let csrfToken = null;
|
||||||
|
|
||||||
|
while (start < buffer.length) {
|
||||||
|
const headerEnd = buffer.indexOf('\r\n\r\n', start);
|
||||||
|
if (headerEnd === -1) break;
|
||||||
|
const headerStr = buffer.slice(start, headerEnd).toString();
|
||||||
|
const nextBoundary = buffer.indexOf(boundary, headerEnd);
|
||||||
|
if (nextBoundary === -1) break;
|
||||||
|
const chunkData = buffer.slice(headerEnd + 4, nextBoundary - 2);
|
||||||
|
|
||||||
|
if (headerStr.includes('name="file"')) {
|
||||||
|
const fnMatch = headerStr.match(/filename="(.+?)"/);
|
||||||
|
if (fnMatch) filename = fnMatch[1];
|
||||||
|
fileData = chunkData;
|
||||||
|
} else if (headerStr.includes('name="csrf_token"')) {
|
||||||
|
csrfToken = chunkData.toString();
|
||||||
|
}
|
||||||
|
start = nextBoundary + boundary.length + 2;
|
||||||
|
}
|
||||||
|
resolve({ fileData, filename, csrfToken });
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
import db from './inc/sql.mjs';
|
||||||
|
|
||||||
|
export async function handleKoepfeUpload(req, res) {
|
||||||
|
// Manual Session Lookup since this is a bypass middleware
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0 || !user[0].admin) {
|
||||||
|
return res.reply ? res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) }) : res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Forbidden' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { fileData, filename, csrfToken } = await parseMultipart(req);
|
||||||
|
|
||||||
|
if (!req.session.csrf_token || csrfToken !== req.session.csrf_token) {
|
||||||
|
return res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileData || fileData.length === 0) {
|
||||||
|
throw new Error('No file data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filename || '.png').toLowerCase();
|
||||||
|
if (!['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].includes(ext)) {
|
||||||
|
throw new Error('Unsupported format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
||||||
|
const targetPath = path.join(cfg.paths.koepfe, newName);
|
||||||
|
|
||||||
|
await fs.writeFile(targetPath, fileData);
|
||||||
|
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, file: '/s/koepfe/' + newName }));
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,3 +107,117 @@ export const handleMemeUpload = async (req, res) => {
|
|||||||
return sendJson(res, { success: false, message: err.message }, 500);
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleMemeEdit = async (req, res) => {
|
||||||
|
console.log('[BOOT] [MEME HANDLER] Edit Started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0 || !user[0].admin) {
|
||||||
|
console.log('[MEME HANDLER] Unauthorized');
|
||||||
|
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked meme edit for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch existing template first
|
||||||
|
const currentMeme = await db`SELECT * FROM meme_templates WHERE id = ${id}`;
|
||||||
|
if (currentMeme.length === 0) {
|
||||||
|
return sendJson(res, { success: false, message: 'Meme template not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = boundaryMatch[1].trim();
|
||||||
|
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||||
|
boundary = boundary.substring(1, boundary.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyBuffer = await collectBody(req);
|
||||||
|
const parts = parseMultipart(bodyBuffer, boundary);
|
||||||
|
|
||||||
|
const template_id = (parts.template_id || '').trim().toLowerCase();
|
||||||
|
const name = (parts.name || '').trim();
|
||||||
|
const category = (parts.category || '').trim() || 'General';
|
||||||
|
let url = (parts.url || '').trim();
|
||||||
|
|
||||||
|
if (!template_id || !name) {
|
||||||
|
return sendJson(res, { success: false, message: 'Template ID and Name are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(template_id)) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid ID. Use lowercase a-z, 0-9, - only.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure template_id is unique
|
||||||
|
const existing = await db`SELECT id FROM meme_templates WHERE template_id = ${template_id} AND id != ${id}`;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return sendJson(res, { success: false, message: 'Template ID is already in use by another template' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parts.file;
|
||||||
|
if (file && file.data && file.data.length > 0) {
|
||||||
|
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||||
|
const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg';
|
||||||
|
const filename = `${template_id}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
|
|
||||||
|
const filePath = path.join(cfg.paths.memes, filename);
|
||||||
|
console.error(`[BOOT] [MEME HANDLER] Writing file to: ${filePath} (Size: ${file.data.length})`);
|
||||||
|
await fs.writeFile(filePath, file.data);
|
||||||
|
|
||||||
|
const exists = (await fs.stat(filePath)).size > 0;
|
||||||
|
console.error(`[BOOT] [MEME HANDLER] Write verify: ${exists ? 'SUCCESS' : 'FAILURE'}`);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
url = `/memes/${filename}`;
|
||||||
|
} else {
|
||||||
|
throw new Error("File was written but verify failed (size 0 or not found)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no file uploaded and no URL input provided, keep the existing one
|
||||||
|
if (!url) {
|
||||||
|
url = currentMeme[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
UPDATE meme_templates
|
||||||
|
SET template_id = ${template_id}, name = ${name}, category = ${category}, url = ${url}
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendJson(res, { success: true });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MEME HANDLER ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export const handleRethumbUpload = async (req, res, itemId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to tmp for verification
|
// Save to tmp for verification
|
||||||
const uuid = (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||||
|
const uuid = raw.substring(0, 48);
|
||||||
const tmpPath = path.join(cfg.paths.tmp, `rethumb_${uuid}_${itemId}`);
|
const tmpPath = path.join(cfg.paths.tmp, `rethumb_${uuid}_${itemId}`);
|
||||||
|
|
||||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||||
@@ -130,16 +131,8 @@ export const handleRethumbUpload = async (req, res, itemId) => {
|
|||||||
try {
|
try {
|
||||||
await execFile('magick', [tmpPath, '-coalesce', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', finalPath]);
|
await execFile('magick', [tmpPath, '-coalesce', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', finalPath]);
|
||||||
|
|
||||||
// Check if item contains NSFW or NSFL tag
|
|
||||||
const tags = await db`
|
|
||||||
select tag_id from tags_assign
|
|
||||||
where item_id = ${+item.id}
|
|
||||||
and tag_id in (2, ${cfg.nsfl_tag_id || 3})
|
|
||||||
`;
|
|
||||||
if (tags.length > 0) {
|
|
||||||
// Generate blurred thumbnail
|
// Generate blurred thumbnail
|
||||||
await queue.genBlurredThumbnail(item.id, !item.active);
|
await queue.genBlurredThumbnail(item.id, !item.active);
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[RETHUMB HANDLER] Magick error:', err);
|
console.error('[RETHUMB HANDLER] Magick error:', err);
|
||||||
|
|||||||
286
src/sticker_pack_handler.mjs
Normal file
286
src/sticker_pack_handler.mjs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import { collectBody } from "./inc/multipart.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { execFile as _execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import fetch from "flumm-fetch"; // used for JSON API calls only
|
||||||
|
import https from "https";
|
||||||
|
import http from "http";
|
||||||
|
|
||||||
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MAGICK_POLICY_PATH = path.resolve(__dirname, '../config/magick-policy');
|
||||||
|
const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH };
|
||||||
|
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = async (req, res) => {
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (user.length === 0 || !user[0].admin) {
|
||||||
|
sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (user[0].csrf_token && (!csrfToken || csrfToken !== user[0].csrf_token)) {
|
||||||
|
sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from a URL and return it as a raw Buffer.
|
||||||
|
* Uses Node's built-in https/http directly — flumm-fetch's .buffer() method
|
||||||
|
* calls setEncoding('utf8') which corrupts binary image data.
|
||||||
|
*/
|
||||||
|
const downloadBuffer = (url) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = url.startsWith('https://') ? https : http;
|
||||||
|
mod.get(url, (res) => {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
res.resume(); // drain the socket
|
||||||
|
return reject(new Error('HTTP ' + res.statusCode + ' fetching ' + url));
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a buffer to WebP and write to the emojis dir.
|
||||||
|
* Returns the relative URL path.
|
||||||
|
*/
|
||||||
|
const saveAsWebP = async (buffer, originalExt) => {
|
||||||
|
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||||
|
const webpFilename = randSuffix + '.webp';
|
||||||
|
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||||
|
|
||||||
|
if (originalExt === 'webp') {
|
||||||
|
await fs.writeFile(webpPath, buffer);
|
||||||
|
} else {
|
||||||
|
const tmpFilename = randSuffix + '_tmp.' + originalExt;
|
||||||
|
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
|
||||||
|
await fs.writeFile(tmpPath, buffer);
|
||||||
|
try {
|
||||||
|
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(webpPath);
|
||||||
|
if (!stat || stat.size === 0) throw new Error('WebP conversion produced empty file');
|
||||||
|
|
||||||
|
return '/s/emojis/' + webpFilename;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a raw buffer directly to the emojis dir with the given extension (no re-encoding).
|
||||||
|
* Used for video stickers (.webm) which browsers can play natively.
|
||||||
|
*/
|
||||||
|
const saveRaw = async (buffer, ext) => {
|
||||||
|
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||||
|
const filename = randSuffix + '.' + ext;
|
||||||
|
const filePath = path.join(cfg.paths.emojis, filename);
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (!stat || stat.size === 0) throw new Error('Saved file is empty');
|
||||||
|
return '/s/emojis/' + filename;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a Telegram sticker pack given its name (short name from t.me/addstickers/<name>).
|
||||||
|
* Creates a sticker_pack record and downloads all stickers as custom_emojis.
|
||||||
|
*/
|
||||||
|
export const handleImportTelegramPack = async (req, res) => {
|
||||||
|
const user = await requireAdmin(req, res);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = await collectBody(req);
|
||||||
|
body = JSON.parse(raw.toString());
|
||||||
|
} catch (e) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid JSON body' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pack_name, display_name } = body;
|
||||||
|
if (!pack_name || typeof pack_name !== 'string') {
|
||||||
|
return sendJson(res, { success: false, message: 'pack_name is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Telegram bot token
|
||||||
|
const tgClient = cfg.clients.find(c => c.type === 'tg');
|
||||||
|
if (!tgClient || !tgClient.token) {
|
||||||
|
return sendJson(res, { success: false, message: 'Telegram bot not configured' }, 500);
|
||||||
|
}
|
||||||
|
const botToken = tgClient.token;
|
||||||
|
|
||||||
|
const tgName = pack_name.trim().replace(/^https?:\/\/t\.me\/addstickers\//i, '');
|
||||||
|
if (!tgName) {
|
||||||
|
return sendJson(res, { success: false, message: 'Could not extract pack name from URL' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[STICKER PACK] Importing Telegram pack: ${tgName}`);
|
||||||
|
|
||||||
|
// Check if pack already imported
|
||||||
|
const existing = await db`SELECT id FROM sticker_packs WHERE tg_name = ${tgName} LIMIT 1`;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return sendJson(res, { success: false, message: `Pack "${tgName}" was already imported (ID ${existing[0].id})` }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Telegram API
|
||||||
|
let stickerSet;
|
||||||
|
try {
|
||||||
|
const apiRes = await fetch(`https://api.telegram.org/bot${botToken}/getStickerSet?name=${encodeURIComponent(tgName)}`);
|
||||||
|
const apiData = await apiRes.json();
|
||||||
|
if (!apiData.ok) {
|
||||||
|
return sendJson(res, { success: false, message: `Telegram API error: ${apiData.description || 'unknown'}` }, 400);
|
||||||
|
}
|
||||||
|
stickerSet = apiData.result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[STICKER PACK] Telegram API fetch failed:', e);
|
||||||
|
return sendJson(res, { success: false, message: `Failed to contact Telegram API: ${e.message}` }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packTitle = display_name?.trim() || stickerSet.title || tgName;
|
||||||
|
const stickers = stickerSet.stickers || [];
|
||||||
|
|
||||||
|
console.log(`[STICKER PACK] Found ${stickers.length} stickers in "${stickerSet.title}"`);
|
||||||
|
|
||||||
|
// Create pack record
|
||||||
|
const [newPack] = await db`
|
||||||
|
INSERT INTO sticker_packs (name, tg_name, tg_title, sticker_count)
|
||||||
|
VALUES (${packTitle}, ${tgName}, ${stickerSet.title}, ${stickers.length})
|
||||||
|
RETURNING id, name, tg_name, tg_title
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Generate a static WebP thumbnail for the pack from the first sticker's Telegram thumbnail.
|
||||||
|
// Telegram always provides a static webp thumbnail for every sticker regardless of type.
|
||||||
|
let packThumbUrl = null;
|
||||||
|
const firstThumb = stickers[0]?.thumbnail;
|
||||||
|
if (firstThumb && firstThumb.file_id) {
|
||||||
|
try {
|
||||||
|
const tRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(firstThumb.file_id));
|
||||||
|
const tData = await tRes.json();
|
||||||
|
if (tData.ok && tData.result && tData.result.file_path) {
|
||||||
|
const tBuf = await downloadBuffer('https://api.telegram.org/file/bot' + botToken + '/' + tData.result.file_path);
|
||||||
|
const tExt = (tData.result.file_path.match(/\.([a-z0-9]+)$/i) || ['', 'webp'])[1].toLowerCase();
|
||||||
|
packThumbUrl = await saveAsWebP(tBuf, tExt);
|
||||||
|
await db`UPDATE sticker_packs SET thumb_url = ${packThumbUrl} WHERE id = ${newPack.id}`;
|
||||||
|
console.log('[STICKER PACK] Pack thumbnail saved: ' + packThumbUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[STICKER PACK] Could not save pack thumbnail:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let imported = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Derive a slug from the pack's display name (admin-supplied > Telegram title > URL slug)
|
||||||
|
// e.g. "My Cool Pack" → "my_cool_pack"
|
||||||
|
const safeName = packTitle
|
||||||
|
.replace(/[^a-z0-9]/gi, '_')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_|_$/g, '') || tgName.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
|
||||||
|
|
||||||
|
for (let i = 0; i < stickers.length; i++) {
|
||||||
|
const sticker = stickers[i];
|
||||||
|
try {
|
||||||
|
// Skip only animated TGS stickers (Lottie format — cannot be displayed as-is)
|
||||||
|
const isAnimated = sticker.is_animated || false;
|
||||||
|
const isVideo = sticker.is_video || false;
|
||||||
|
if (isAnimated) {
|
||||||
|
console.log('[STICKER PACK] Skipping animated (TGS) sticker ' + (i + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = sticker.file_id;
|
||||||
|
|
||||||
|
// Resolve the download URL via getFile
|
||||||
|
const fileRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(fileId));
|
||||||
|
const fileData = await fileRes.json();
|
||||||
|
if (!fileData.ok || !fileData.result || !fileData.result.file_path) {
|
||||||
|
throw new Error('getFile failed: ' + (fileData.description || 'no file_path'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = fileData.result.file_path;
|
||||||
|
const extMatch = filePath.match(/\.([a-z0-9]+)$/i);
|
||||||
|
const ext = extMatch ? extMatch[1].toLowerCase() : 'webp';
|
||||||
|
|
||||||
|
const downloadUrl = 'https://api.telegram.org/file/bot' + botToken + '/' + filePath;
|
||||||
|
const buffer = await downloadBuffer(downloadUrl);
|
||||||
|
|
||||||
|
// Generate emoji name: <pack_display_slug>_<padded_index>
|
||||||
|
const emojiName = safeName + '_' + String(i + 1).padStart(3, '0');
|
||||||
|
|
||||||
|
// Check for name conflict
|
||||||
|
const nameConflict = await db`SELECT id FROM custom_emojis WHERE name = ${emojiName} LIMIT 1`;
|
||||||
|
const finalName = nameConflict.length > 0 ? emojiName + '_' + crypto.randomBytes(3).toString('hex') : emojiName;
|
||||||
|
|
||||||
|
// Save: video stickers (.webm) stored as-is; static stickers converted to WebP
|
||||||
|
let savedUrl;
|
||||||
|
if (isVideo) {
|
||||||
|
savedUrl = await saveRaw(buffer, ext); // keeps original webm, no re-encoding
|
||||||
|
console.log('[STICKER PACK] Saved video sticker ' + (i + 1) + ' as ' + ext);
|
||||||
|
} else {
|
||||||
|
savedUrl = await saveAsWebP(buffer, ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO custom_emojis (name, url, pack_id)
|
||||||
|
VALUES (${finalName}, ${savedUrl}, ${newPack.id})
|
||||||
|
`;
|
||||||
|
|
||||||
|
imported++;
|
||||||
|
console.log('[STICKER PACK] Imported sticker ' + (i + 1) + '/' + stickers.length + ': ' + finalName + ' (' + (isVideo ? 'video' : 'static') + ')');
|
||||||
|
|
||||||
|
// Small delay to avoid Telegram rate limits
|
||||||
|
if (i < stickers.length - 1) await new Promise(function(resolve) { setTimeout(resolve, 50); });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[STICKER PACK] Failed sticker ' + (i + 1) + ':', e.message);
|
||||||
|
errors.push('Sticker ' + (i + 1) + ': ' + e.message);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pack with actual imported count
|
||||||
|
await db`UPDATE sticker_packs SET sticker_count = ${imported} WHERE id = ${newPack.id}`;
|
||||||
|
|
||||||
|
// Notify clients that emojis changed
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
|
||||||
|
console.log(`[STICKER PACK] Done. Imported: ${imported}, Failed: ${failed}`);
|
||||||
|
|
||||||
|
return sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
pack: { ...newPack, sticker_count: imported },
|
||||||
|
imported,
|
||||||
|
failed,
|
||||||
|
errors
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,11 +2,22 @@ import { promises as fs } from "fs";
|
|||||||
import db from "./inc/sql.mjs";
|
import db from "./inc/sql.mjs";
|
||||||
import lib from "./inc/lib.mjs";
|
import lib from "./inc/lib.mjs";
|
||||||
import cfg from "./inc/config.mjs";
|
import cfg from "./inc/config.mjs";
|
||||||
|
import { applyWordFilter } from "./inc/wordfilter.mjs";
|
||||||
import queue from "./inc/queue.mjs";
|
import queue from "./inc/queue.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
||||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
||||||
|
|
||||||
|
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
|
||||||
|
// Adding a new archive type to config.json is sufficient; no code change needed.
|
||||||
|
const ARCHIVE_MIMES = new Set(
|
||||||
|
Object.entries(cfg.mimes)
|
||||||
|
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||||
|
.map(([mime]) => mime)
|
||||||
|
);
|
||||||
|
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||||
|
|
||||||
// Helper for JSON response
|
// Helper for JSON response
|
||||||
const sendJson = (res, data, code = 200) => {
|
const sendJson = (res, data, code = 200) => {
|
||||||
@@ -17,6 +28,21 @@ const sendJson = (res, data, code = 200) => {
|
|||||||
// One-time migration: add original_filename column if it doesn't exist
|
// One-time migration: add original_filename column if it doesn't exist
|
||||||
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text`.catch(() => {});
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text`.catch(() => {});
|
||||||
|
|
||||||
|
// One-time migration: restore title column for backwards compatibility with old databases
|
||||||
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {});
|
||||||
|
|
||||||
|
// One-time migration: add width/height columns for image and video dimension storage
|
||||||
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
|
||||||
|
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
|
||||||
|
|
||||||
|
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
|
||||||
|
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
|
||||||
|
db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(() => {});
|
||||||
|
|
||||||
|
// One-time migration: widen dest column to varchar(60) — UUID (32) + dot + extension can exceed 40 chars
|
||||||
|
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||||
|
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||||
|
|
||||||
export const handleUpload = async (req, res, self) => {
|
export const handleUpload = async (req, res, self) => {
|
||||||
// Manual session lookup is required here because this handler is called from a
|
// Manual session lookup is required here because this handler is called from a
|
||||||
// bypass middleware that runs in parallel with the main session middleware.
|
// bypass middleware that runs in parallel with the main session middleware.
|
||||||
@@ -37,15 +63,41 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: authenticate via X-Api-Key header (upload-only; no CSRF required)
|
||||||
|
if (!req.session && req.headers['x-api-key'] && cfg.websrv.enable_user_api_keys !== false) {
|
||||||
|
const key = req.headers['x-api-key'];
|
||||||
|
try {
|
||||||
|
const rows = await db`
|
||||||
|
SELECT u.id, u.user, u.login, u.admin, u.is_moderator, u.banned,
|
||||||
|
uo.*
|
||||||
|
FROM user_api_keys k
|
||||||
|
JOIN "user" u ON u.id = k.user_id
|
||||||
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
|
WHERE k.api_key = ${key}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (rows.length > 0) {
|
||||||
|
if (rows[0].banned) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Account banned' }, 403);
|
||||||
|
}
|
||||||
|
req.session = { ...rows[0], api_key_auth: true };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UPLOAD] API key lookup error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.session) {
|
if (!req.session) {
|
||||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel.
|
// CSRF validation — required for browser sessions, skipped for API key auth.
|
||||||
|
if (!req.session.api_key_auth) {
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contentType = req.headers['content-type'] || '';
|
const contentType = req.headers['content-type'] || '';
|
||||||
@@ -79,37 +131,72 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
const rating = parts.rating;
|
const rating = parts.rating;
|
||||||
const tagsRaw = parts.tags;
|
const tagsRaw = parts.tags;
|
||||||
const comment = parts.comment ? parts.comment.trim() : '';
|
const comment = parts.comment ? parts.comment.trim() : '';
|
||||||
|
const rawTitle = parts.title ? parts.title.trim() : '';
|
||||||
|
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
|
||||||
|
|
||||||
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1');
|
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1');
|
||||||
|
|
||||||
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1');
|
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
||||||
|
|
||||||
|
const maxLen = cfg.main.comment_max_length;
|
||||||
|
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
||||||
|
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!file || !file.data) {
|
if (!file || !file.data) {
|
||||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged)
|
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged).
|
||||||
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : (is_shitpost ? null : null);
|
// If shitpost_require_rating is configured to true, a rating is strictly required.
|
||||||
|
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
|
||||||
|
|
||||||
if (!is_shitpost && !effectiveRating) {
|
if (!is_shitpost && !effectiveRating) {
|
||||||
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
|
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_shitpost && cfg.websrv.shitpost_require_rating === true && !effectiveRating) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required for each item' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (effectiveRating === 'nsfl' && !cfg.enable_nsfl) {
|
if (effectiveRating === 'nsfl' && !cfg.enable_nsfl) {
|
||||||
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||||
const minTags = getMinTags();
|
const minTags = getMinTags();
|
||||||
// In shitpost mode, tags are optional — items without tags enter as untagged
|
// In shitpost mode, tags are optional by default — unless shitpost_min_tags is configured.
|
||||||
if (!is_shitpost && tags.length < minTags) {
|
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
|
||||||
return sendJson(res, { success: false, msg: `At least ${minTags} tags are required` }, 400);
|
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
|
||||||
|
return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
||||||
|
}
|
||||||
|
if (is_shitpost && shitpostMinTags > 0 && tags.length < shitpostMinTags) {
|
||||||
|
return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate MIME type
|
// Validate MIME type
|
||||||
const allowedMimes = Object.keys(cfg.mimes);
|
// cfg.allowedMimes entries can be category prefixes ("image", "video", "audio")
|
||||||
|
// OR exact MIME types ("application/pdf"). Entries with "/" are matched exactly.
|
||||||
|
const allowedCats = Array.isArray(cfg.allowedMimes)
|
||||||
|
? cfg.allowedMimes.map(c => c.toLowerCase())
|
||||||
|
: null;
|
||||||
|
const allowedMimes = allowedCats
|
||||||
|
? Object.keys(cfg.mimes).filter(m =>
|
||||||
|
allowedCats.some(cat =>
|
||||||
|
cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Object.keys(cfg.mimes);
|
||||||
let mime = file.contentType;
|
let mime = file.contentType;
|
||||||
|
|
||||||
|
// Browsers often don't know the SWF MIME type and send application/octet-stream or nothing.
|
||||||
|
// Normalize it here based on extension so the allowedMimes check doesn't spuriously reject.
|
||||||
|
// The server-side `file --mime-type` check on line ~248 is the authoritative validation.
|
||||||
|
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
|
||||||
|
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
|
||||||
|
mime = 'application/x-shockwave-flash';
|
||||||
|
}
|
||||||
|
|
||||||
if (!allowedMimes.includes(mime)) {
|
if (!allowedMimes.includes(mime)) {
|
||||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||||
}
|
}
|
||||||
@@ -152,10 +239,11 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
AND stamp > ${twelveHoursAgo}
|
AND stamp > ${twelveHoursAgo}
|
||||||
AND is_deleted = false
|
AND is_deleted = false
|
||||||
`;
|
`;
|
||||||
if (parseInt(uploadCount[0].count) >= 69) {
|
const uploadLimit = cfg.main.upload_limit ?? 69;
|
||||||
|
if (parseInt(uploadCount[0].count) >= uploadLimit) {
|
||||||
return sendJson(res, {
|
return sendJson(res, {
|
||||||
success: false,
|
success: false,
|
||||||
msg: 'Rate limit exceeded. You can only upload 69 files every 12 hours.'
|
msg: `Rate limit exceeded. You can only upload ${uploadLimit} files every 12 hours.`
|
||||||
}, 429);
|
}, 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +261,7 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
// Save temporarily to detect actual MIME
|
// Save temporarily to detect actual MIME
|
||||||
await fs.writeFile(tmpPath, file.data);
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
// Verify MIME
|
// Verify actual MIME (second check after file-command detection)
|
||||||
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||||
if (!allowedMimes.includes(actualMime)) {
|
if (!allowedMimes.includes(actualMime)) {
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
@@ -185,68 +273,120 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
return sendJson(res, { success: false, msg: 'PDF uploads are currently disabled.' }, 403);
|
return sendJson(res, { success: false, msg: 'PDF uploads are currently disabled.' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reclassify audio-only MP4 containers (e.g. .m4a files detected as video/mp4)
|
if (isArchiveMime(actualMime) && cfg.websrv.enable_archive === false) {
|
||||||
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, { success: false, msg: 'Archive uploads are currently disabled.' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase A: parallel analysis of tmpPath ─────────────────────────────
|
||||||
|
// sha256sum, PHash, and dimension probing all just read the file —
|
||||||
|
// none depends on the other, so run them concurrently.
|
||||||
|
const needsPHash = !isArchiveMime(actualMime);
|
||||||
|
const needsDims = !isArchiveMime(actualMime);
|
||||||
|
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
|
||||||
|
|
||||||
|
const [
|
||||||
|
checksumResult,
|
||||||
|
phashResult,
|
||||||
|
dimResult,
|
||||||
|
audioMp4Result
|
||||||
|
] = await Promise.all([
|
||||||
|
// 1. SHA-256 checksum
|
||||||
|
queue.spawn('sha256sum', [tmpPath]).then(r => r.stdout.trim().split(' ')[0]),
|
||||||
|
|
||||||
|
// 2. Perceptual hash (skip for archives)
|
||||||
|
needsPHash
|
||||||
|
? queue.generatePHash(tmpPath).catch(e => { console.error('[UPLOAD] PHash error:', e); return null; })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
|
||||||
|
// 3. Pixel dimensions (skip for archives)
|
||||||
|
needsDims
|
||||||
|
? (async () => {
|
||||||
|
try {
|
||||||
|
if (actualMime.startsWith('image/')) {
|
||||||
|
const { stdout: magickOut } = await queue.spawn('magick', [
|
||||||
|
'identify', '-format', '%wx%h\n', tmpPath + '[0]'
|
||||||
|
], { quiet: true, ignoreExitCode: true });
|
||||||
|
const line = magickOut.trim().split('\n')[0];
|
||||||
|
const match = line.match(/^(\d+)x(\d+)$/);
|
||||||
|
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||||
|
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
|
||||||
|
const { stdout: probeOut } = await queue.spawn('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height',
|
||||||
|
'-of', 'csv=p=0',
|
||||||
|
tmpPath
|
||||||
|
], { quiet: true, ignoreExitCode: true });
|
||||||
|
const dimParts = probeOut.trim().split(',');
|
||||||
|
if (dimParts.length >= 2) {
|
||||||
|
const w = parseInt(dimParts[0], 10);
|
||||||
|
const h = parseInt(dimParts[1], 10);
|
||||||
|
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (dimErr) {
|
||||||
|
console.warn(`[UPLOAD] Dimension probe failed for ${actualMime} (non-fatal):`, dimErr.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
: Promise.resolve(null),
|
||||||
|
|
||||||
|
// 4. Audio-only MP4 reclassification probe (only when needed)
|
||||||
|
needsAudioMP4
|
||||||
|
? (async () => {
|
||||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||||
if (['m4a', 'aac'].includes(origExt)) {
|
if (['m4a', 'aac'].includes(origExt)) {
|
||||||
actualMime = 'audio/mp4';
|
return 'audio/mp4'; // Extension-based reclassification
|
||||||
console.log(`[UPLOAD] Reclassified ${origExt} from video/mp4 to audio/mp4`);
|
}
|
||||||
} else {
|
|
||||||
// Check with ffprobe if it has video streams
|
|
||||||
try {
|
try {
|
||||||
const probeResult = await queue.spawn('ffprobe', [
|
const probeResult = await queue.spawn('ffprobe', [
|
||||||
'-v', 'error', '-select_streams', 'v:0',
|
'-v', 'error', '-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=codec_type',
|
'-show_entries', 'stream=codec_type',
|
||||||
'-of', 'csv=p=0', tmpPath
|
'-of', 'csv=p=0', tmpPath
|
||||||
]);
|
]);
|
||||||
if (!probeResult.stdout.trim()) {
|
if (!probeResult.stdout.trim()) return 'audio/mp4';
|
||||||
actualMime = 'audio/mp4';
|
} catch (err) { /* ffprobe unavailable, keep original */ }
|
||||||
console.log(`[UPLOAD] Reclassified audio-only MP4 to audio/mp4`);
|
return null; // No reclassification needed
|
||||||
}
|
})()
|
||||||
} catch (err) {
|
: Promise.resolve(null),
|
||||||
// ffprobe not available or failed, keep original MIME
|
]);
|
||||||
}
|
|
||||||
}
|
const checksum = checksumResult;
|
||||||
|
let phash = phashResult;
|
||||||
|
const itemWidth = dimResult?.width ?? null;
|
||||||
|
const itemHeight = dimResult?.height ?? null;
|
||||||
|
|
||||||
|
// Apply audio/MP4 reclassification if detected
|
||||||
|
if (audioMp4Result) {
|
||||||
|
actualMime = audioMp4Result;
|
||||||
|
console.log(`[UPLOAD] Reclassified to ${audioMp4Result}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve final filename now that actualMime is settled
|
||||||
const ext = cfg.mimes[actualMime] || 'bin';
|
const ext = cfg.mimes[actualMime] || 'bin';
|
||||||
const filename = `${uuid}.${ext}`;
|
const filename = `${uuid}.${ext}`;
|
||||||
const destPath = path.join(cfg.paths.pending, 'b', filename);
|
const destPath = path.join(cfg.paths.pending, 'b', filename);
|
||||||
|
|
||||||
// Constants
|
// ── Phase B: parallel duplicate checks ────────────────────────────────
|
||||||
const checksum = (await queue.spawn('sha256sum', [tmpPath])).stdout.trim().split(" ")[0];
|
// Both are read-only DB queries — run them together.
|
||||||
|
|
||||||
// Check repost
|
|
||||||
if (!getBypassDuplicateCheck()) {
|
if (!getBypassDuplicateCheck()) {
|
||||||
const repost = await queue.checkrepostsum(checksum);
|
const [repostBySum, repostByPhash] = await Promise.all([
|
||||||
if (repost) {
|
queue.checkrepostsum(checksum),
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
(phash ? queue.checkrepostphash(phash) : Promise.resolve(null)),
|
||||||
return sendJson(res, {
|
]);
|
||||||
success: false,
|
|
||||||
msg: `This file already exists`,
|
if (repostBySum) {
|
||||||
repost: repost
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
}, 409);
|
return sendJson(res, { success: false, msg: 'This file already exists', repost: repostBySum }, 409);
|
||||||
|
}
|
||||||
|
if (repostByPhash) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
return sendJson(res, { success: false, msg: 'This file is a visual duplicate', repost: repostByPhash }, 409);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PHash check
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
let phash = null;
|
|
||||||
try {
|
|
||||||
phash = await queue.generatePHash(tmpPath);
|
|
||||||
if (phash && !getBypassDuplicateCheck()) {
|
|
||||||
const phashMatch = await queue.checkrepostphash(phash);
|
|
||||||
if (phashMatch) {
|
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
|
||||||
return sendJson(res, {
|
|
||||||
success: false,
|
|
||||||
msg: `This file is a visual duplicate`,
|
|
||||||
repost: phashMatch
|
|
||||||
}, 409);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[UPLOAD] PHash error:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When bypass is active, symlink to the existing file if one already exists on disk.
|
// When bypass is active, symlink to the existing file if one already exists on disk.
|
||||||
// We write the symlink straight into cfg.paths.b so we can skip the pending flow entirely.
|
// We write the symlink straight into cfg.paths.b so we can skip the pending flow entirely.
|
||||||
@@ -296,6 +436,7 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
// Suffix it so the INSERT can proceed — the file is genuinely a new item entry.
|
// Suffix it so the INSERT can proceed — the file is genuinely a new item entry.
|
||||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||||
|
|
||||||
|
|
||||||
// Insert
|
// Insert
|
||||||
const originalFilename = file.filename || null;
|
const originalFilename = file.filename || null;
|
||||||
await db`
|
await db`
|
||||||
@@ -312,9 +453,11 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
active: !manualApproval,
|
active: !manualApproval,
|
||||||
is_oc: is_oc,
|
is_oc: is_oc,
|
||||||
original_filename: originalFilename
|
original_filename: originalFilename,
|
||||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')
|
title: title,
|
||||||
}
|
width: itemWidth,
|
||||||
|
height: itemHeight
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const itemid = await queue.getItemID(filename);
|
const itemid = await queue.getItemID(filename);
|
||||||
@@ -374,19 +517,18 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate blurred thumbnail for NSFW/NSFL
|
// Generate blurred thumbnail for all posts (SFW, NSFW, NSFL, Untagged)
|
||||||
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
|
|
||||||
await queue.genBlurredThumbnail(itemid, isPending);
|
await queue.genBlurredThumbnail(itemid, isPending);
|
||||||
}
|
|
||||||
|
|
||||||
// Insert optional first comment
|
// Insert optional first comment
|
||||||
if (comment && comment.length > 0 && comment.length <= 2000) {
|
if (comment && comment.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
const filteredComment = await applyWordFilter(comment);
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO comments ${db({
|
INSERT INTO comments ${db({
|
||||||
item_id: itemid,
|
item_id: itemid,
|
||||||
user_id: req.session.id,
|
user_id: req.session.id,
|
||||||
content: comment
|
content: filteredComment
|
||||||
})}
|
})}
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -457,8 +599,11 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Action if auto-approved
|
// Action if auto-approved
|
||||||
if (!manualApproval) {
|
if (!manualApproval) {
|
||||||
|
// Bust the count cache so page totals update immediately
|
||||||
|
f0cklib.clearCountCache();
|
||||||
if (!linkedToExisting) {
|
if (!linkedToExisting) {
|
||||||
// Move logic: Handles both real files and symlinks (reposts) correctly
|
// Move logic: Handles both real files and symlinks (reposts) correctly
|
||||||
const moveSafe = async (src, dst) => {
|
const moveSafe = async (src, dst) => {
|
||||||
@@ -521,15 +666,13 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
console.error(`[BACKGROUND ERROR] genThumbnail failed for item ${itemid}:`, err);
|
console.error(`[BACKGROUND ERROR] genThumbnail failed for item ${itemid}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure blurred thumbnail exists if needed
|
// Ensure blurred thumbnail exists
|
||||||
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
|
|
||||||
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||||
const blurPath = path.join(tDir, `${itemid}_blur.webp`);
|
const blurPath = path.join(tDir, `${itemid}_blur.webp`);
|
||||||
const blurExists = await fs.access(blurPath).then(() => true).catch(() => false);
|
const blurExists = await fs.access(blurPath).then(() => true).catch(() => false);
|
||||||
if (!blurExists) {
|
if (!blurExists) {
|
||||||
await queue.genBlurredThumbnail(itemid, isPending).catch(err => console.error(`[BACKGROUND ERROR] genBlurredThumbnail failed:`, err));
|
await queue.genBlurredThumbnail(itemid, isPending).catch(err => console.error(`[BACKGROUND ERROR] genBlurredThumbnail failed:`, err));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Note: video title metadata is surfaced to the user as a suggestion in the upload form.
|
// Note: video title metadata is surfaced to the user as a suggestion in the upload form.
|
||||||
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
||||||
@@ -624,12 +767,22 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
? 'Upload successful! Your upload is pending admin approval.'
|
? 'Upload successful! Your upload is pending admin approval.'
|
||||||
: 'Upload successful! Your upload is now live.';
|
: 'Upload successful! Your upload is now live.';
|
||||||
|
|
||||||
|
const imagesPath = cfg.websrv.paths?.images || '/b';
|
||||||
return sendJson(res, {
|
return sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
msg: successMsg,
|
msg: successMsg,
|
||||||
itemid: itemid,
|
itemid: itemid,
|
||||||
manual_approval: manualApproval,
|
manual_approval: manualApproval,
|
||||||
redirect: !manualApproval ? `/${itemid}` : null
|
redirect: !manualApproval ? `/${itemid}` : null,
|
||||||
|
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`,
|
||||||
|
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
|
||||||
|
// Fields for immediate client-side grid injection (avoids SSE race condition)
|
||||||
|
dest: filename,
|
||||||
|
mime: actualMime,
|
||||||
|
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||||
|
is_oc: !!is_oc,
|
||||||
|
display_name: req.session.display_name || null,
|
||||||
|
username: req.session.user
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,15 +4,42 @@
|
|||||||
<div class="rules">
|
<div class="rules">
|
||||||
@if(about_text)
|
@if(about_text)
|
||||||
<div class="dynamic-page-content" id="about-dynamic-content"></div>
|
<div class="dynamic-page-content" id="about-dynamic-content"></div>
|
||||||
<textarea id="about-raw-data" hidden>{!! about_text !!}</textarea>
|
<script id="about-raw-data" type="application/json">{{ about_text_b64 }}</script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var raw = document.getElementById('about-raw-data');
|
var raw = document.getElementById('about-raw-data');
|
||||||
var el = document.getElementById('about-dynamic-content');
|
var el = document.getElementById('about-dynamic-content');
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
function sanitizeHtml(html) {
|
||||||
|
var tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
tmp.querySelectorAll('script,iframe,object,embed,form,input,button,select,meta,link,base,style').forEach(function(el) { el.remove(); });
|
||||||
|
tmp.querySelectorAll('*').forEach(function(node) {
|
||||||
|
Array.from(node.attributes).forEach(function(attr) {
|
||||||
|
if (/^on/i.test(attr.name) || (attr.name === 'href' && /^javascript:/i.test(attr.value.trim()))) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tmp.innerHTML;
|
||||||
|
}
|
||||||
function render() {
|
function render() {
|
||||||
if (raw && el && typeof marked !== 'undefined') {
|
if (raw && el && typeof marked !== 'undefined') {
|
||||||
el.innerHTML = marked.parse(raw.value || '', { gfm: true, breaks: true });
|
var bytes = Uint8Array.from(atob(raw.textContent.trim()), function(c) { return c.charCodeAt(0); });
|
||||||
raw.remove();
|
var text = new TextDecoder('utf-8').decode(bytes);
|
||||||
|
var renderer = new marked.Renderer();
|
||||||
|
renderer.code = function(code, lang) {
|
||||||
|
var escaped = escapeHtml(typeof code === 'object' ? (code.text || '') : code);
|
||||||
|
var langAttr = (typeof code === 'object' ? code.lang : lang) || '';
|
||||||
|
return '<pre><code' + (langAttr ? ' class="language-' + escapeHtml(langAttr) + '"' : '') + '>' + escaped + '</code></pre>';
|
||||||
|
};
|
||||||
|
renderer.codespan = function(code) {
|
||||||
|
var escaped = escapeHtml(typeof code === 'object' ? (code.text || '') : code);
|
||||||
|
return '<code>' + escaped + '</code>';
|
||||||
|
};
|
||||||
|
el.innerHTML = sanitizeHtml(marked.parse(text, { gfm: true, breaks: true, renderer: renderer }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof marked !== 'undefined') {
|
if (typeof marked !== 'undefined') {
|
||||||
|
|||||||
@@ -18,15 +18,20 @@
|
|||||||
<li><a href="/admin/users">User Manager</a></li>
|
<li><a href="/admin/users">User Manager</a></li>
|
||||||
<li><a href="/admin/emojis">Emoji Manager</a></li>
|
<li><a href="/admin/emojis">Emoji Manager</a></li>
|
||||||
<li><a href="/admin/memes">Meme Manager</a></li>
|
<li><a href="/admin/memes">Meme Manager</a></li>
|
||||||
|
<li><a href="/admin/koepfe">Köpfe Manager</a></li>
|
||||||
<li><a href="/admin/halls">Hall Manager</a></li>
|
<li><a href="/admin/halls">Hall Manager</a></li>
|
||||||
<li><a href="/admin/motd">MOTD Manager</a></li>
|
<li><a href="/admin/motd">MOTD Manager</a></li>
|
||||||
|
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li>
|
||||||
|
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li>
|
||||||
@if(enable_cleanup)
|
@if(enable_cleanup)
|
||||||
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
||||||
@endif
|
@endif
|
||||||
<li><a href="/admin/about">About Page</a></li>
|
<li><a href="/admin/about">About Page</a></li>
|
||||||
<li><a href="/admin/rules">Rules Page</a></li>
|
<li><a href="/admin/rules">Rules Page</a></li>
|
||||||
<li><a href="/admin/terms">ToS Page</a></li>
|
<li><a href="/admin/terms">ToS Page</a></li>
|
||||||
|
@if(enable_global_chat)
|
||||||
<li><a href="/admin/chat">Global Chat Manager</a></li>
|
<li><a href="/admin/chat">Global Chat Manager</a></li>
|
||||||
|
@endif
|
||||||
</ul>
|
</ul>
|
||||||
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<label for="about-text" style="display: block; margin-bottom: 8px; color: var(--accent);">About Page Content (Markdown supported)</label>
|
<label for="about-text" style="display: block; margin-bottom: 8px; color: var(--accent);">About Page Content (Markdown supported)</label>
|
||||||
<textarea id="about-text" name="about_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! about_text !!}</textarea>
|
<textarea id="about-text" name="about_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;"></textarea>
|
||||||
|
<script>document.getElementById('about-text').value = new TextDecoder('utf-8').decode(Uint8Array.from(atob('{{ about_text_b64 }}'), function(c) { return c.charCodeAt(0); }));</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center;">
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
@each(pending as post)
|
@each(pending as post)
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
|
<video controls loop muted preload="none" style="max-height: 200px; max-width: 300px;">
|
||||||
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
|
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
|
||||||
</video>
|
</video>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -2,171 +2,534 @@
|
|||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Custom Emojis</h2>
|
<h2>Custom Emojis & Sticker Packs</h2>
|
||||||
|
|
||||||
|
<!-- Telegram Pack Import -->
|
||||||
|
<div class="admin-form-container sp-import-card"
|
||||||
|
style="margin-bottom: 28px; text-align: left; background: var(--dropdown-bg); padding: 20px; border: 1px solid var(--nav-border-color); border-radius: 10px;">
|
||||||
|
<h4 style="margin: 0 0 6px; font-size: 1rem; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fa-brands fa-telegram" style="color: #26a5e4;"></i>
|
||||||
|
Import Telegram Sticker Pack
|
||||||
|
</h4>
|
||||||
|
<p style="font-size: 0.82em; color: rgba(255,255,255,0.5); margin: 0 0 14px;">
|
||||||
|
Enter a Telegram sticker pack URL (e.g. <code>https://t.me/addstickers/PackName</code>) or just the short name.
|
||||||
|
All stickers will be downloaded and added as a new sticker pack.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Pack URL or Name</label>
|
||||||
|
<input type="text" id="sp-pack-url" placeholder="https://t.me/addstickers/PackName"
|
||||||
|
style="width: 100%; background: var(--bg); border: 1px solid var(--black); padding: 7px 10px; color: var(--white); border-radius: 5px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Display Name (optional)</label>
|
||||||
|
<input type="text" id="sp-display-name" placeholder="My Pack Name"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--black); padding: 7px 10px; color: var(--white); border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
<button id="sp-import-btn" class="btn-upload"
|
||||||
|
style="width: auto; padding: 8px 20px; border: 1px solid var(--nav-border-color); background: #26a5e4; color: #fff; cursor: pointer; border-radius: 5px; font-weight: 700; white-space: nowrap;">
|
||||||
|
<i class="fa-solid fa-download"></i> Import Pack
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sp-import-status" style="margin-top: 12px; font-size: 0.85em; display: none; padding: 10px 14px; border-radius: 7px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Sticker Packs -->
|
||||||
|
<div id="sp-packs-section" style="margin-bottom: 28px;">
|
||||||
|
<h3 style="margin: 0 0 12px; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6;">Imported Sticker Packs</h3>
|
||||||
|
<div id="sp-packs-list" style="display: flex; flex-direction: column; gap: 10px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Standalone Emojis -->
|
||||||
<div class="admin-form-container"
|
<div class="admin-form-container"
|
||||||
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
|
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color); border-radius: 8px;">
|
||||||
<h4>Add New Emoji</h4>
|
<h4 style="margin: 0 0 12px;">Add Standalone Emoji</h4>
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
<div>
|
<div>
|
||||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Name</label>
|
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Name</label>
|
||||||
<input type="text" id="emoji-name" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
<input type="text" id="emoji-name" placeholder=""
|
||||||
|
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Image File</label>
|
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Image File</label>
|
||||||
<input type="file" id="emoji-file" style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
<input type="file" id="emoji-file"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex-grow: 1;">
|
<button id="add-emoji" class="btn-upload"
|
||||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">OR Image URL</label>
|
style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
||||||
<input type="text" id="emoji-url" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white); width: 100%;">
|
|
||||||
</div>
|
|
||||||
<button id="add-emoji" class="btn-upload" style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
<div id="emoji-list" class="emoji-grid"></div>
|
||||||
<button id="reconvert-webp" class="btn-upload" style="width: auto; padding: 7px 18px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">
|
|
||||||
Reconvert All to WebP
|
|
||||||
</button>
|
|
||||||
<span id="reconvert-status" style="font-size: 0.85em; opacity: 0.8;"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="emoji-list" class="emoji-grid">
|
<!-- Edit Emoji Modal -->
|
||||||
<!-- Populated by JS -->
|
<div id="edit-emoji-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 460px; background: var(--dropdown-bg, #222); border: 1px solid var(--nav-border-color, #444); border-radius: 8px; padding: 20px;">
|
||||||
|
<h3 style="margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); padding-bottom: 10px; color: var(--white);">Edit Emoji</h3>
|
||||||
|
<input type="hidden" id="edit-emoji-id">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; text-align: left;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img id="edit-emoji-preview" src="" alt="" style="height: 64px; width: 64px; object-fit: contain; border-radius: 4px; background: rgba(0,0,0,0.3);">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Name (lowercase a-z, 0-9, _, - only)</label>
|
||||||
|
<input type="text" id="edit-emoji-name" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Replace Image — Upload New File</label>
|
||||||
|
<input type="file" id="edit-emoji-file" accept="image/*" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
|
<button onclick="window.emojiAdmin.closeEditModal()" class="btn-cancel" style="padding: 8px 16px; background: rgba(255,255,255,0.1); color: var(--white); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; cursor: pointer;">Cancel</button>
|
||||||
|
<button onclick="window.emojiAdmin.saveEmoji()" class="btn-save" style="padding: 8px 16px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Pack Modal -->
|
||||||
|
<div id="rename-pack-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:440px;background:var(--dropdown-bg,#222);border:1px solid var(--nav-border-color,#444);border-radius:10px;padding:24px;">
|
||||||
|
<h3 style="margin:0 0 18px;border-bottom:1px solid var(--nav-border-color,rgba(255,255,255,0.1));padding-bottom:12px;color:var(--white);font-size:1rem;">Rename Sticker Pack</h3>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:0.82em;margin-bottom:5px;color:rgba(255,255,255,0.6);">New pack name</label>
|
||||||
|
<input type="text" id="rename-pack-input" style="width:100%;background:var(--bg,#111);border:1px solid var(--nav-border-color,#555);padding:9px 11px;color:var(--white);border-radius:5px;box-sizing:border-box;font-size:0.95em;">
|
||||||
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:9px;cursor:pointer;font-size:0.88em;color:rgba(255,255,255,0.75);">
|
||||||
|
<input type="checkbox" id="rename-pack-backfill" style="width:15px;height:15px;accent-color:#26a5e4;cursor:pointer;">
|
||||||
|
Also rename all stickers and update comment references
|
||||||
|
</label>
|
||||||
|
<div id="rename-pack-result" style="display:none;font-size:0.82em;padding:8px 12px;border-radius:5px;background:rgba(40,167,69,0.15);border:1px solid rgba(40,167,69,0.3);color:#4caf50;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px;display:flex;justify-content:flex-end;gap:10px;">
|
||||||
|
<button id="rename-pack-cancel" style="padding:8px 16px;background:rgba(255,255,255,0.08);color:var(--white);border:1px solid rgba(255,255,255,0.2);border-radius:5px;cursor:pointer;font-size:0.9em;">Cancel</button>
|
||||||
|
<button id="rename-pack-submit" style="padding:8px 20px;background:#26a5e4;color:#fff;border:none;border-radius:5px;cursor:pointer;font-weight:700;font-size:0.9em;">Rename</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
var i18n = window.f0ckI18n || {};
|
var i18n = window.f0ckI18n || {};
|
||||||
const esc = (s) => (s || '').toString().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
var csrf = '{{ csrf_token }}';
|
||||||
const loadEmojis = async () => {
|
|
||||||
try {
|
function esc(s) {
|
||||||
const res = await fetch('/api/v2/emojis');
|
return (s || '').toString()
|
||||||
const data = await res.json();
|
.replace(/&/g, '&')
|
||||||
if (data.success) {
|
.replace(/</g, '<')
|
||||||
const grid = document.getElementById('emoji-list');
|
.replace(/>/g, '>')
|
||||||
if (!grid) return;
|
.replace(/"/g, '"')
|
||||||
grid.innerHTML = data.emojis.reverse().map(e =>
|
.replace(/'/g, ''');
|
||||||
'<div class="emoji-card">' +
|
|
||||||
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">✕</button>' +
|
|
||||||
'<img class="emoji-preview" src="' + e.url + '" alt=":' + esc(e.name) + ':">' +
|
|
||||||
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
|
||||||
'<span class="emoji-url">' + esc(e.url) + '</span>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
}
|
||||||
} catch (err) { console.error('[EMOJI_ADMIN] Load Error:', err); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEmoji = async (e) => {
|
/* Returns an HTML string for a sticker preview — <video> for .webm, <img> otherwise */
|
||||||
|
function stickerEl(url, name, extraAttrs) {
|
||||||
|
var a = extraAttrs || '';
|
||||||
|
if (url && url.slice(-5) === '.webm') {
|
||||||
|
return '<video class="emoji-preview" src="' + url + '" autoplay loop muted playsinline title=":' + esc(name) + ':"' + a + '></video>';
|
||||||
|
}
|
||||||
|
return '<img class="emoji-preview" src="' + url + '" alt=":' + esc(name) + ':"' + a + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DATA LOAD ── */
|
||||||
|
function loadAll() {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/v2/emojis'),
|
||||||
|
fetch('/api/v2/admin/sticker-packs', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
]).then(function(responses) {
|
||||||
|
return Promise.all([responses[0].json(), responses[1].json()]);
|
||||||
|
}).then(function(results) {
|
||||||
|
var emojisData = results[0];
|
||||||
|
var packsData = results[1];
|
||||||
|
window.emojiAdmin.emojis = emojisData.emojis || [];
|
||||||
|
window.emojiAdmin.packs = packsData.packs || [];
|
||||||
|
renderStandaloneEmojis(window.emojiAdmin.emojis);
|
||||||
|
renderPacksList(window.emojiAdmin.packs);
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('[EMOJI_ADMIN] Load Error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STANDALONE EMOJIS ── */
|
||||||
|
function renderStandaloneEmojis(emojis) {
|
||||||
|
var grid = document.getElementById('emoji-list');
|
||||||
|
if (!grid) return;
|
||||||
|
var standalone = emojis.filter(function(e) { return !e.pack_id; });
|
||||||
|
if (standalone.length === 0) {
|
||||||
|
grid.innerHTML = '<p style="opacity:0.5;font-size:0.85em;">No standalone emojis yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = standalone.map(function(e) {
|
||||||
|
return '<div class="emoji-card">' +
|
||||||
|
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">x</button>' +
|
||||||
|
stickerEl(e.url, e.name) +
|
||||||
|
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||||
|
'<button onclick="window.emojiAdmin.openEditModal(' + e.id + ')" style="margin-top:6px;width:100%;padding:4px 0;font-size:0.75em;background:#28a745;color:white;border:none;border-radius:3px;cursor:pointer;">Edit</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STICKER PACKS LIST ── */
|
||||||
|
function renderPacksList(packs) {
|
||||||
|
var container = document.getElementById('sp-packs-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (packs.length === 0) {
|
||||||
|
container.innerHTML = '<p style="opacity:0.5;font-size:0.85em;">No sticker packs imported yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = packs.map(function(p) {
|
||||||
|
var tgLink = p.tg_name
|
||||||
|
? '<a href="https://t.me/addstickers/' + esc(p.tg_name) + '" target="_blank" rel="noopener" style="font-size:0.72em;color:#26a5e4;text-decoration:none;opacity:0.8;">@' + esc(p.tg_name) + ' ↗</a>'
|
||||||
|
: '';
|
||||||
|
var count = (p.actual_count !== undefined && p.actual_count !== null) ? p.actual_count : (p.sticker_count || 0);
|
||||||
|
return '<div class="sp-pack-row" data-pack-id="' + p.id + '" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:10px;padding:14px 16px;">' +
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
|
||||||
|
'<div style="flex:1;min-width:0;">' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
||||||
|
'<span class="sp-pack-name" style="font-size:0.95rem;font-weight:700;">' + esc(p.name) + '</span>' +
|
||||||
|
tgLink +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="font-size:0.76em;opacity:0.5;margin-top:2px;">' + count + ' sticker(s)</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="display:flex;gap:8px;flex-shrink:0;">' +
|
||||||
|
'<button onclick="window.emojiAdmin.renamePack(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.07);color:#fff;cursor:pointer;font-size:0.8em;">Rename</button>' +
|
||||||
|
'<button onclick="window.emojiAdmin.togglePackEmojis(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.07);color:#fff;cursor:pointer;font-size:0.8em;">View</button>' +
|
||||||
|
'<button onclick="window.emojiAdmin.deletePack(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(180,40,40,0.5);background:rgba(180,40,40,0.15);color:#e55;cursor:pointer;font-size:0.8em;">Delete</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="sp-emojis-' + p.id + '" style="display:none;margin-top:12px;"></div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPackGrid(packId) {
|
||||||
|
var container = document.getElementById('sp-emojis-' + packId);
|
||||||
|
if (!container) return;
|
||||||
|
// Preserve scroll position of the inner grid div
|
||||||
|
var prevScroll = (container.querySelector('.emoji-grid') || {}).scrollTop || 0;
|
||||||
|
var pack = (window.emojiAdmin.packs || []).find(function(p) { return p.id === packId; });
|
||||||
|
var currentThumb = pack ? (pack.thumb_url || '') : '';
|
||||||
|
var packEmojis = (window.emojiAdmin.emojis || []).filter(function(e) { return e.pack_id === packId; });
|
||||||
|
if (packEmojis.length === 0) {
|
||||||
|
container.innerHTML = '<p style="opacity:0.5;font-size:0.82em;padding:8px 0;">No stickers.</p>';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="emoji-grid" style="max-height:320px;overflow-y:auto;">' +
|
||||||
|
packEmojis.map(function(e) {
|
||||||
|
var isCurrent = currentThumb && e.url === currentThumb;
|
||||||
|
var btnBg = isCurrent ? 'rgba(40,167,69,0.18)' : 'rgba(255,200,0,0.15)';
|
||||||
|
var btnClr = isCurrent ? '#4caf50' : '#ffd700';
|
||||||
|
var btnBdr = isCurrent ? 'rgba(40,167,69,0.35)' : 'rgba(255,200,0,0.3)';
|
||||||
|
var btnLbl = isCurrent ? '\u2713 Current Icon' : '\u2B50 Set as icon';
|
||||||
|
return '<div class="emoji-card" style="position:relative;">' +
|
||||||
|
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">x</button>' +
|
||||||
|
stickerEl(e.url, e.name, ' loading="lazy"') +
|
||||||
|
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||||
|
'<button onclick="window.emojiAdmin.setPackThumb(' + packId + ',' + e.id + ')"' +
|
||||||
|
' title="Use as tab icon" style="margin-top:5px;width:100%;padding:3px 0;font-size:0.72em;background:' + btnBg + ';color:' + btnClr + ';border:1px solid ' + btnBdr + ';border-radius:3px;cursor:pointer;">' + btnLbl + '</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('') +
|
||||||
|
'</div>';
|
||||||
|
// Restore scroll position
|
||||||
|
var newGrid = container.querySelector('.emoji-grid');
|
||||||
|
if (newGrid && prevScroll) newGrid.scrollTop = prevScroll;
|
||||||
|
}
|
||||||
|
container.dataset.loaded = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePackEmojis(packId) {
|
||||||
|
var container = document.getElementById('sp-emojis-' + packId);
|
||||||
|
if (!container) return;
|
||||||
|
if (container.style.display !== 'none') { container.style.display = 'none'; return; }
|
||||||
|
container.style.display = 'block';
|
||||||
|
renderPackGrid(packId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STANDALONE EMOJI ACTIONS ── */
|
||||||
|
function addEmoji(e) {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
const name = document.getElementById('emoji-name').value;
|
var name = document.getElementById('emoji-name').value;
|
||||||
const url = document.getElementById('emoji-url').value;
|
var fileInput = document.getElementById('emoji-file');
|
||||||
const fileInput = document.getElementById('emoji-file');
|
if (!name || !fileInput.files[0]) return alert('Fill Name and select a File');
|
||||||
|
|
||||||
if (!name || (!url && !fileInput.files[0])) return alert('Fill Name and either URL or File');
|
var btn = document.getElementById('add-emoji');
|
||||||
|
var oldText = btn.textContent;
|
||||||
const btn = document.getElementById('add-emoji');
|
|
||||||
const oldText = btn.textContent;
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = i18n.uploading || 'Uploading...';
|
btn.textContent = i18n.uploading || 'Uploading...';
|
||||||
|
|
||||||
const formData = new FormData();
|
var formData = new FormData();
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
formData.append('url', url);
|
|
||||||
if (fileInput.files[0]) {
|
|
||||||
formData.append('file', fileInput.files[0]);
|
formData.append('file', fileInput.files[0]);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
var headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||||
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
|
||||||
const csrf = '{{ csrf_token }}';
|
|
||||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
const res = await fetch('/api/v2/admin/emojis', {
|
fetch('/api/v2/admin/emojis', { method: 'POST', headers: headers, body: formData })
|
||||||
method: 'POST',
|
.then(function(r) { return r.json(); })
|
||||||
headers: headers,
|
.then(function(data) {
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('emoji-name').value = '';
|
document.getElementById('emoji-name').value = '';
|
||||||
document.getElementById('emoji-url').value = '';
|
|
||||||
document.getElementById('emoji-file').value = '';
|
document.getElementById('emoji-file').value = '';
|
||||||
loadEmojis();
|
loadAll();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed: ' + (data.message || data.msg || 'Unknown error'));
|
alert('Failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})
|
||||||
console.error('[EMOJI_ADMIN] Add Error:', e);
|
.catch(function(err) { alert('Error: ' + err.message); })
|
||||||
alert('Error: ' + e.message);
|
.finally(function() { btn.disabled = false; btn.textContent = oldText; });
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = oldText;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEmoji = async (id) => {
|
function deleteEmoji(id) {
|
||||||
if (!confirm('Delete this emoji?')) return;
|
if (!confirm('Delete this emoji?')) return;
|
||||||
try {
|
fetch('/api/v2/admin/emojis/' + id + '/delete', { method: 'POST', headers: { 'X-CSRF-Token': csrf } })
|
||||||
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', {
|
.then(function(r) { return r.json(); })
|
||||||
method: 'POST',
|
.then(function(data) { if (data.success) loadAll(); else alert('Delete failed'); })
|
||||||
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
|
.catch(function(err) { console.error(err); });
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
loadEmojis();
|
|
||||||
} else {
|
|
||||||
alert('Delete failed');
|
|
||||||
}
|
}
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global scope for onclick
|
function openEditModal(id) {
|
||||||
window.emojiAdmin = { deleteEmoji };
|
var emoji = (window.emojiAdmin.emojis || []).find(function(e) { return e.id === id; });
|
||||||
|
if (!emoji) return;
|
||||||
|
document.getElementById('edit-emoji-id').value = emoji.id;
|
||||||
|
document.getElementById('edit-emoji-name').value = emoji.name;
|
||||||
|
document.getElementById('edit-emoji-file').value = '';
|
||||||
|
var preview = document.getElementById('edit-emoji-preview');
|
||||||
|
preview.src = emoji.url;
|
||||||
|
preview.alt = ':' + emoji.name + ':';
|
||||||
|
var modal = document.getElementById('edit-emoji-modal');
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
const btnAddEmoji = document.getElementById('add-emoji');
|
function closeEditModal() {
|
||||||
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
|
var modal = document.getElementById('edit-emoji-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
document.getElementById('edit-emoji-file').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Reconvert all emojis to WebP
|
function saveEmoji() {
|
||||||
const reconvertEmojis = async () => {
|
var id = document.getElementById('edit-emoji-id').value;
|
||||||
const btn = document.getElementById('reconvert-webp');
|
var name = document.getElementById('edit-emoji-name').value.trim().toLowerCase();
|
||||||
const status = document.getElementById('reconvert-status');
|
var fileInput = document.getElementById('edit-emoji-file');
|
||||||
if (!btn || !status) return;
|
|
||||||
if (!confirm('Reconvert all non-WebP emojis to WebP? This will delete the originals.')) return;
|
if (!name) return alert('Emoji name is required');
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(name)) return alert('Invalid name. Use lowercase a-z, 0-9, _, - only.');
|
||||||
|
|
||||||
|
var btn = document.querySelector('#edit-emoji-modal .btn-save');
|
||||||
|
var oldText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
if (fileInput.files[0]) formData.append('file', fileInput.files[0]);
|
||||||
|
|
||||||
|
var headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||||
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
|
fetch('/api/v2/admin/emojis/' + id + '/edit', { method: 'POST', headers: headers, body: formData })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) { closeEditModal(); loadAll(); }
|
||||||
|
else alert('Save failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||||
|
})
|
||||||
|
.catch(function(err) { alert('Save failed: ' + err.message); })
|
||||||
|
.finally(function() { btn.disabled = false; btn.textContent = oldText; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STICKER PACK ACTIONS ── */
|
||||||
|
function importPack() {
|
||||||
|
var packUrl = (document.getElementById('sp-pack-url').value || '').trim();
|
||||||
|
var displayName = (document.getElementById('sp-display-name').value || '').trim();
|
||||||
|
var statusEl = document.getElementById('sp-import-status');
|
||||||
|
var btn = document.getElementById('sp-import-btn');
|
||||||
|
|
||||||
|
if (!packUrl) return alert('Please enter a sticker pack URL or name.');
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
status.textContent = '\u23F3 Converting\u2026';
|
btn.textContent = 'Importing...';
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
|
||||||
try {
|
fetch('/api/v2/admin/sticker-packs/import', {
|
||||||
const csrf = '{{ csrf_token }}';
|
|
||||||
const res = await fetch('/api/v2/admin/emojis/reconvert', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
const result = await res.json();
|
'X-CSRF-Token': csrf,
|
||||||
if (result.success) {
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
status.textContent = '\u2705 Done \u2014 converted: ' + result.converted + ', skipped: ' + result.skipped + ', errors: ' + result.errors;
|
},
|
||||||
if (result.converted > 0) loadEmojis();
|
body: JSON.stringify({ pack_name: packUrl, display_name: displayName })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
statusEl.style.display = 'block';
|
||||||
|
if (data.success) {
|
||||||
|
statusEl.style.background = 'rgba(40,167,69,0.15)';
|
||||||
|
statusEl.style.border = '1px solid rgba(40,167,69,0.4)';
|
||||||
|
statusEl.style.color = '#4caf50';
|
||||||
|
var packName = (data.pack && data.pack.name) ? data.pack.name : packUrl;
|
||||||
|
statusEl.innerHTML = 'Imported "' + esc(packName) + '" — ' + (data.imported || 0) + ' sticker(s) added' + (data.failed ? ', ' + data.failed + ' failed' : '') + '.';
|
||||||
|
document.getElementById('sp-pack-url').value = '';
|
||||||
|
document.getElementById('sp-display-name').value = '';
|
||||||
|
loadAll();
|
||||||
} else {
|
} else {
|
||||||
status.textContent = '\u274C Failed: ' + (result.message || 'Unknown error');
|
statusEl.style.background = 'rgba(180,40,40,0.15)';
|
||||||
|
statusEl.style.border = '1px solid rgba(180,40,40,0.4)';
|
||||||
|
statusEl.style.color = '#e55';
|
||||||
|
statusEl.textContent = data.message || 'Import failed';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
})
|
||||||
status.textContent = '\u274C Error: ' + err.message;
|
.catch(function(err) {
|
||||||
} finally {
|
statusEl.style.display = 'block';
|
||||||
|
statusEl.style.background = 'rgba(180,40,40,0.15)';
|
||||||
|
statusEl.style.border = '1px solid rgba(180,40,40,0.4)';
|
||||||
|
statusEl.style.color = '#e55';
|
||||||
|
statusEl.textContent = 'Error: ' + err.message;
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-download"></i> Import Pack';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deletePack(id) {
|
||||||
|
if (!confirm('Delete this sticker pack AND all its stickers? This cannot be undone.')) return;
|
||||||
|
fetch('/api/v2/admin/sticker-packs/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) { if (data.success) loadAll(); else alert('Delete failed: ' + (data.message || 'Unknown error')); })
|
||||||
|
.catch(function(err) { alert('Error: ' + err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var _renamePendingId = null;
|
||||||
|
var _renamePendingCurrentName = null;
|
||||||
|
|
||||||
|
function renamePack(id) {
|
||||||
|
var pack = (window.emojiAdmin.packs || []).find(function(p) { return p.id === id; });
|
||||||
|
_renamePendingId = id;
|
||||||
|
_renamePendingCurrentName = pack ? pack.name : '';
|
||||||
|
|
||||||
|
document.getElementById('rename-pack-input').value = _renamePendingCurrentName;
|
||||||
|
document.getElementById('rename-pack-backfill').checked = false;
|
||||||
|
document.getElementById('rename-pack-result').style.display = 'none';
|
||||||
|
document.getElementById('rename-pack-result').textContent = '';
|
||||||
|
document.getElementById('rename-pack-submit').disabled = false;
|
||||||
|
document.getElementById('rename-pack-submit').textContent = 'Rename';
|
||||||
|
document.getElementById('rename-pack-modal').style.display = 'flex';
|
||||||
|
setTimeout(function() { document.getElementById('rename-pack-input').focus(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('rename-pack-cancel').addEventListener('click', function() {
|
||||||
|
document.getElementById('rename-pack-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rename-pack-submit').addEventListener('click', function() {
|
||||||
|
var newName = (document.getElementById('rename-pack-input').value || '').trim();
|
||||||
|
var backfill = document.getElementById('rename-pack-backfill').checked;
|
||||||
|
if (!newName) return;
|
||||||
|
|
||||||
|
// Allow same name when backfill is requested
|
||||||
|
if (newName === _renamePendingCurrentName && !backfill) {
|
||||||
|
document.getElementById('rename-pack-modal').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('rename-pack-submit');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Renaming...';
|
||||||
|
|
||||||
|
fetch('/api/v2/admin/sticker-packs/' + _renamePendingId + '/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: JSON.stringify({ name: newName, backfill: backfill })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
if (backfill) {
|
||||||
|
var res = document.getElementById('rename-pack-result');
|
||||||
|
res.textContent = (data.emojiRenamed || 0) + ' sticker(s) renamed, ' + (data.commentRowsUpdated || 0) + ' comment row(s) updated.';
|
||||||
|
res.style.display = 'block';
|
||||||
|
setTimeout(function() {
|
||||||
|
document.getElementById('rename-pack-modal').style.display = 'none';
|
||||||
|
loadAll();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
document.getElementById('rename-pack-modal').style.display = 'none';
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var res = document.getElementById('rename-pack-result');
|
||||||
|
res.textContent = 'Rename failed: ' + (data.message || 'Unknown error');
|
||||||
|
res.style.background = 'rgba(220,53,69,0.15)';
|
||||||
|
res.style.borderColor = 'rgba(220,53,69,0.3)';
|
||||||
|
res.style.color = '#e55';
|
||||||
|
res.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Rename';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
var res = document.getElementById('rename-pack-result');
|
||||||
|
res.textContent = 'Error: ' + err.message;
|
||||||
|
res.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Rename';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.getElementById('rename-pack-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
function setPackThumb(packId, emojiId) {
|
||||||
|
var emoji = (window.emojiAdmin.emojis || []).find(function(e) { return e.id === emojiId; });
|
||||||
|
if (!emoji || !emoji.url) return alert('Could not find emoji URL.');
|
||||||
|
var thumbUrl = emoji.url;
|
||||||
|
fetch('/api/v2/admin/sticker-packs/' + packId + '/thumb', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: JSON.stringify({ thumb_url: thumbUrl })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
// Use the URL the server actually stored (may be a generated WebP for .webm stickers)
|
||||||
|
var pack = (window.emojiAdmin.packs || []).find(function(p) { return p.id === packId; });
|
||||||
|
if (pack) pack.thumb_url = data.thumb_url || thumbUrl;
|
||||||
|
// Re-render only this pack's grid — grid stays open
|
||||||
|
renderPackGrid(packId);
|
||||||
|
} else {
|
||||||
|
alert('Failed to set icon: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { alert('Error: ' + err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global scope for onclick handlers
|
||||||
|
window.emojiAdmin = {
|
||||||
|
deleteEmoji: deleteEmoji,
|
||||||
|
openEditModal: openEditModal,
|
||||||
|
closeEditModal: closeEditModal,
|
||||||
|
saveEmoji: saveEmoji,
|
||||||
|
importPack: importPack,
|
||||||
|
deletePack: deletePack,
|
||||||
|
renamePack: renamePack,
|
||||||
|
setPackThumb: setPackThumb,
|
||||||
|
togglePackEmojis: togglePackEmojis,
|
||||||
|
emojis: [],
|
||||||
|
packs: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnReconvert = document.getElementById('reconvert-webp');
|
document.getElementById('add-emoji').addEventListener('click', addEmoji);
|
||||||
if (btnReconvert) btnReconvert.addEventListener('click', reconvertEmojis);
|
document.getElementById('sp-import-btn').addEventListener('click', importPack);
|
||||||
|
|
||||||
// Live Update Listener (SSE dispatched via f0ckm.js)
|
// Reload when SSE emits emojis_updated
|
||||||
document.addEventListener('f0ck:emojis_updated', loadEmojis);
|
document.addEventListener('f0ck:emojis_updated', loadAll);
|
||||||
|
|
||||||
loadEmojis();
|
loadAll();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
110
views/admin/koepfe.html
Normal file
110
views/admin/koepfe.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
<div class="pagewrapper">
|
||||||
|
<div id="main" class="admin-container">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Köpfe Manager</h2>
|
||||||
|
|
||||||
|
<div class="admin-form-container" style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color); border-radius: 8px;">
|
||||||
|
<h4 style="margin: 0 0 12px;">Upload Neuer Kopf</h4>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Bild (PNG, JPG, GIF, WebP, AVIF)</label>
|
||||||
|
<input type="file" id="koepfe-file" style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
||||||
|
</div>
|
||||||
|
<button id="add-koepfe" class="btn-upload" style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="koepfe-list" class="emoji-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
var csrf = '{{ csrf_token }}';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return (s || '').toString()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
fetch('/api/v2/admin/koepfe', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
renderKoepfe(data.koepfe || []);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('[KOEPFE] Load Error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKoepfe(koepfe) {
|
||||||
|
var grid = document.getElementById('koepfe-list');
|
||||||
|
if (!grid) return;
|
||||||
|
if (koepfe.length === 0) {
|
||||||
|
grid.innerHTML = '<p style="opacity:0.5;font-size:0.85em;">Keine Köpfe vorhanden.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = koepfe.map(function(k) {
|
||||||
|
return '<div class="emoji-card">' +
|
||||||
|
'<button class="emoji-delete" onclick="deleteKoepfe("' + esc(k.filename) + '")" title="Delete">x</button>' +
|
||||||
|
'<img class="emoji-preview" src="' + esc(k.url) + '" alt="">' +
|
||||||
|
'<span class="emoji-label" style="font-size: 0.6em;">' + esc(k.filename) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKoepfe(e) {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
var fileInput = document.getElementById('koepfe-file');
|
||||||
|
if (!fileInput.files[0]) return alert('Bitte eine Datei auswählen.');
|
||||||
|
|
||||||
|
var btn = document.getElementById('add-koepfe');
|
||||||
|
var oldText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Lädt...';
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
formData.append('csrf_token', csrf);
|
||||||
|
|
||||||
|
var headers = { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrf };
|
||||||
|
|
||||||
|
fetch('/api/v2/admin/koepfe/upload', { method: 'POST', headers: headers, body: formData })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('koepfe-file').value = '';
|
||||||
|
loadAll();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.message || data.msg || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { alert('Error: ' + err.message); })
|
||||||
|
.finally(function() { btn.disabled = false; btn.textContent = oldText; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.deleteKoepfe = function(filename) {
|
||||||
|
if (!confirm('Diesen Kopf wirklich löschen?')) return;
|
||||||
|
fetch('/api/v2/admin/koepfe/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: filename })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) { if (data.success) loadAll(); else alert('Löschen fehlgeschlagen: ' + (data.msg || '')); })
|
||||||
|
.catch(function(err) { console.error(err); });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('add-koepfe').addEventListener('click', addKoepfe);
|
||||||
|
|
||||||
|
loadAll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@include(snippets/footer)
|
||||||
@@ -34,9 +34,53 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Meme Modal -->
|
||||||
|
<div id="edit-meme-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 500px; background: var(--dropdown-bg, #222); border: 1px solid var(--nav-border-color, #444); border-radius: 8px; padding: 20px;">
|
||||||
|
<h3 style="margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); padding-bottom: 10px; color: var(--white);">Edit Meme Template</h3>
|
||||||
|
|
||||||
|
<input type="hidden" id="edit-meme-id-db">
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; text-align: left;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Template ID (lowercase a-z, 0-9, - only)</label>
|
||||||
|
<input type="text" id="edit-meme-template-id" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Display Name</label>
|
||||||
|
<input type="text" id="edit-meme-name" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Category</label>
|
||||||
|
<input type="text" id="edit-meme-category" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Image URL</label>
|
||||||
|
<input type="text" id="edit-meme-url" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 4px 0; font-size: 0.8em; opacity: 0.5; color: var(--white);">- OR -</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Upload New Image File</label>
|
||||||
|
<input type="file" id="edit-meme-file" accept="image/*" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions" style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
|
<button onclick="window.memeAdmin.closeEditModal()" class="btn-cancel" style="padding: 8px 16px; background: rgba(255,255,255,0.1); color: var(--white); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; cursor: pointer;">Cancel</button>
|
||||||
|
<button onclick="window.memeAdmin.saveMeme()" class="btn-save" style="padding: 8px 16px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
window.f0ckDebug = window.f0ckDebug || (() => {});
|
||||||
(() => {
|
(() => {
|
||||||
var i18n = window.f0ckI18n || {};
|
var i18n = window.f0ckI18n || {};
|
||||||
window.f0ckDebug('[MEME_ADMIN] Initializing');
|
window.f0ckDebug('[MEME_ADMIN] Initializing');
|
||||||
@@ -46,6 +90,7 @@
|
|||||||
const res = await fetch('/api/v2/memes');
|
const res = await fetch('/api/v2/memes');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
window.memeAdmin.memes = data.memes;
|
||||||
const tbody = document.getElementById('meme-list');
|
const tbody = document.getElementById('meme-list');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = data.memes.map(m =>
|
tbody.innerHTML = data.memes.map(m =>
|
||||||
@@ -55,6 +100,7 @@
|
|||||||
'<td style="padding: 10px;"><span style="background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 10px; font-size: 0.85em;">' + esc(m.category || 'General') + '</span></td>' +
|
'<td style="padding: 10px;"><span style="background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 10px; font-size: 0.85em;">' + esc(m.category || 'General') + '</span></td>' +
|
||||||
'<td style="padding: 10px; font-family: monospace; opacity: 0.7;">' + esc(m.template_id) + '</td>' +
|
'<td style="padding: 10px; font-family: monospace; opacity: 0.7;">' + esc(m.template_id) + '</td>' +
|
||||||
'<td style="padding: 10px;">' +
|
'<td style="padding: 10px;">' +
|
||||||
|
'<button onclick="window.memeAdmin.openEditModal(' + m.id + ')" class="btn-edit" style="padding: 5px 15px; font-size: 0.8em; background: #28a745; color: white; border: none; cursor: pointer; border-radius: 2px; margin-right: 5px;">Edit</button>' +
|
||||||
'<button onclick="window.memeAdmin.deleteMeme(' + m.id + ')" class="btn-remove" style="padding: 5px 15px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer; border-radius: 2px;">Delete</button>' +
|
'<button onclick="window.memeAdmin.deleteMeme(' + m.id + ')" class="btn-remove" style="padding: 5px 15px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer; border-radius: 2px;">Delete</button>' +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'</tr>'
|
'</tr>'
|
||||||
@@ -140,8 +186,88 @@
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditModal = (id) => {
|
||||||
|
const meme = (window.memeAdmin.memes || []).find(m => m.id === id);
|
||||||
|
if (!meme) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-meme-id-db').value = meme.id;
|
||||||
|
document.getElementById('edit-meme-template-id').value = meme.template_id;
|
||||||
|
document.getElementById('edit-meme-name').value = meme.name;
|
||||||
|
document.getElementById('edit-meme-category').value = meme.category || 'General';
|
||||||
|
document.getElementById('edit-meme-url').value = meme.url;
|
||||||
|
document.getElementById('edit-meme-file').value = '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('edit-meme-modal');
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
const modal = document.getElementById('edit-meme-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
document.getElementById('edit-meme-file').value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMeme = async () => {
|
||||||
|
const id = document.getElementById('edit-meme-id-db').value;
|
||||||
|
const template_id = document.getElementById('edit-meme-template-id').value.trim().toLowerCase();
|
||||||
|
const name = document.getElementById('edit-meme-name').value.trim();
|
||||||
|
const category = document.getElementById('edit-meme-category').value.trim() || 'General';
|
||||||
|
const url = document.getElementById('edit-meme-url').value.trim();
|
||||||
|
const fileInput = document.getElementById('edit-meme-file');
|
||||||
|
|
||||||
|
if (!template_id || !name) {
|
||||||
|
return alert('Template ID and Display Name are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(template_id)) {
|
||||||
|
return alert('Invalid ID. Use lowercase a-z, 0-9, - only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.querySelector('#edit-meme-modal .btn-save');
|
||||||
|
const oldText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('template_id', template_id);
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('category', category);
|
||||||
|
formData.append('url', url);
|
||||||
|
if (fileInput.files[0]) {
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
};
|
||||||
|
const csrf = '{{ csrf_token }}';
|
||||||
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
|
const res = await fetch('/api/v2/admin/memes/' + id + '/edit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
closeEditModal();
|
||||||
|
loadMemes();
|
||||||
|
} else {
|
||||||
|
alert('Server Error: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MEME_ADMIN] Edit Error:', e);
|
||||||
|
alert('Save failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = oldText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Global scope for onclick handlers
|
// Global scope for onclick handlers
|
||||||
window.memeAdmin = { deleteMeme };
|
window.memeAdmin = { deleteMeme, openEditModal, closeEditModal, saveMeme, memes: [] };
|
||||||
|
|
||||||
const btnAddMeme = document.getElementById('add-meme');
|
const btnAddMeme = document.getElementById('add-meme');
|
||||||
if (btnAddMeme) {
|
if (btnAddMeme) {
|
||||||
|
|||||||
265
views/admin/nsfp.html
Normal file
265
views/admin/nsfp.html
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
|
||||||
|
<div class="pagewrapper">
|
||||||
|
<div id="main" class="admin-container">
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<h2 style="margin: 0;">NSFP Tag Manager</h2>
|
||||||
|
</div>
|
||||||
|
<p style="color: #aaa; margin-top: 4px; margin-bottom: 20px; font-size: 0.9em;">
|
||||||
|
Manage which tags are treated as <strong style="color: var(--accent);">Not Safe For Public</strong> — hidden from logged-out guests.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Current NSFP tags -->
|
||||||
|
<div style="background: rgba(0,0,0,0.15); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px; padding: 20px; margin-bottom: 24px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px;">
|
||||||
|
<p style="font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0;">Current NSFP Tags</p>
|
||||||
|
<span id="nsfp-blocked-stat" style="font-size: 0.8em; color: #888;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="nsfp-chips-area" id="nsfp-chips">
|
||||||
|
<span class="nsfp-chips-empty">Loading…</span>
|
||||||
|
</div>
|
||||||
|
<span id="chip-status" class="nsfp-status-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add tag by search -->
|
||||||
|
<div style="background: rgba(0,0,0,0.2); padding: 20px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<h4 style="color: var(--accent); margin-top: 0; margin-bottom: 14px;">Add Tag to NSFP List</h4>
|
||||||
|
<div class="nsfp-search-wrap">
|
||||||
|
<input type="text" id="nsfp-search" class="nsfp-search-input" placeholder="Search tags..." autocomplete="off">
|
||||||
|
<div class="nsfp-search-dropdown" id="search-dropdown" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
<span id="add-status" class="nsfp-status-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var csrf = '{{ csrf_token }}';
|
||||||
|
var currentIds = new Set();
|
||||||
|
|
||||||
|
var chipsEl = document.getElementById('nsfp-chips');
|
||||||
|
var chipStatus = document.getElementById('chip-status');
|
||||||
|
var addStatus = document.getElementById('add-status');
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(el, msg, ok) {
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = ok ? '#28a745' : '#d9534f';
|
||||||
|
if (ok) setTimeout(function () { el.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEmptyMsg() {
|
||||||
|
var existing = chipsEl.querySelector('.nsfp-tag-chip');
|
||||||
|
var emptyEl = chipsEl.querySelector('.nsfp-chips-empty');
|
||||||
|
if (!existing) {
|
||||||
|
if (!emptyEl) {
|
||||||
|
emptyEl = document.createElement('span');
|
||||||
|
emptyEl.className = 'nsfp-chips-empty';
|
||||||
|
emptyEl.textContent = 'No NSFP tags configured \u2014 all content is public.';
|
||||||
|
chipsEl.appendChild(emptyEl);
|
||||||
|
}
|
||||||
|
} else if (emptyEl) {
|
||||||
|
emptyEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChip(tag) {
|
||||||
|
var chip = document.createElement('span');
|
||||||
|
chip.className = 'nsfp-tag-chip';
|
||||||
|
chip.dataset.id = tag.id;
|
||||||
|
|
||||||
|
var idSpan = document.createElement('span');
|
||||||
|
idSpan.className = 'chip-id';
|
||||||
|
idSpan.textContent = '#' + tag.id;
|
||||||
|
|
||||||
|
var nameSpan = document.createElement('span');
|
||||||
|
nameSpan.textContent = tag.tag;
|
||||||
|
|
||||||
|
var removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'chip-remove';
|
||||||
|
removeBtn.title = 'Remove from NSFP list';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.addEventListener('click', function () { nsfpAdmin.remove(tag.id, removeBtn); });
|
||||||
|
|
||||||
|
chip.appendChild(idSpan);
|
||||||
|
chip.appendChild(nameSpan);
|
||||||
|
chip.appendChild(removeBtn);
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChip(tag) {
|
||||||
|
var emptyEl = chipsEl.querySelector('.nsfp-chips-empty');
|
||||||
|
if (emptyEl) emptyEl.remove();
|
||||||
|
chipsEl.appendChild(createChip(tag));
|
||||||
|
currentIds.add(tag.id);
|
||||||
|
rebuildDropdown(document.getElementById('nsfp-search').value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChip(tagId) {
|
||||||
|
var chip = chipsEl.querySelector('.nsfp-tag-chip[data-id="' + tagId + '"]');
|
||||||
|
if (chip) chip.remove();
|
||||||
|
currentIds.delete(tagId);
|
||||||
|
refreshEmptyMsg();
|
||||||
|
rebuildDropdown(document.getElementById('nsfp-search').value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNsfp() {
|
||||||
|
fetch('/api/v2/admin/nsfp')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
chipsEl.innerHTML = '';
|
||||||
|
currentIds.clear();
|
||||||
|
if (data.success && data.nsfp && data.nsfp.length > 0) {
|
||||||
|
data.nsfp.forEach(function (tag) {
|
||||||
|
chipsEl.appendChild(createChip(tag));
|
||||||
|
currentIds.add(tag.id);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var emptyEl = document.createElement('span');
|
||||||
|
emptyEl.className = 'nsfp-chips-empty';
|
||||||
|
emptyEl.textContent = 'No NSFP tags configured \u2014 all content is public.';
|
||||||
|
chipsEl.appendChild(emptyEl);
|
||||||
|
}
|
||||||
|
var statEl = document.getElementById('nsfp-blocked-stat');
|
||||||
|
if (statEl) {
|
||||||
|
var n = data.blocked_count || 0;
|
||||||
|
statEl.textContent = n.toLocaleString() + ' item' + (n !== 1 ? 's' : '') + ' hidden from guests';
|
||||||
|
statEl.style.color = n > 0 ? 'var(--accent)' : '#888';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
chipsEl.innerHTML = '<span class="nsfp-chips-empty" style="color:#d9534f;">Failed to load: ' + escapeHtml(e.message) + '</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var remove = function (tagId, btn) {
|
||||||
|
if (!confirm('Remove tag #' + tagId + ' from the NSFP list?')) return;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
fetch('/api/v2/admin/nsfp/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
|
||||||
|
body: 'tag_id=' + encodeURIComponent(tagId)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success) {
|
||||||
|
removeChip(tagId);
|
||||||
|
showStatus(chipStatus, 'Tag #' + tagId + ' removed.', true);
|
||||||
|
} else {
|
||||||
|
showStatus(chipStatus, 'Error: ' + (data.msg || 'Remove failed'), false);
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
showStatus(chipStatus, 'Network error: ' + e.message, false);
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var doAdd = function (tagId) {
|
||||||
|
tagId = parseInt(tagId, 10);
|
||||||
|
if (currentIds.has(tagId)) {
|
||||||
|
showStatus(addStatus, 'Tag #' + tagId + ' is already in the list.', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/v2/admin/nsfp/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
|
||||||
|
body: 'tag_id=' + encodeURIComponent(tagId)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success) {
|
||||||
|
addChip(data.added);
|
||||||
|
showStatus(addStatus, '"' + escapeHtml(data.added.tag) + '" (#' + data.added.id + ') added.', true);
|
||||||
|
document.getElementById('nsfp-search').value = '';
|
||||||
|
document.getElementById('search-dropdown').style.display = 'none';
|
||||||
|
lastResults = [];
|
||||||
|
} else {
|
||||||
|
showStatus(addStatus, 'Error: ' + (data.msg || 'Add failed'), false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) { showStatus(addStatus, 'Network error: ' + e.message, false); });
|
||||||
|
};
|
||||||
|
|
||||||
|
var lastResults = [];
|
||||||
|
|
||||||
|
function rebuildDropdown(query) {
|
||||||
|
var dropdown = document.getElementById('search-dropdown');
|
||||||
|
if (!lastResults.length || !query) { dropdown.style.display = 'none'; return; }
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
lastResults.forEach(function (tag) {
|
||||||
|
var already = currentIds.has(tag.id);
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'nsfp-search-result-item' + (already ? ' already-added' : '');
|
||||||
|
item.dataset.id = tag.id;
|
||||||
|
|
||||||
|
var idSpan = document.createElement('span');
|
||||||
|
idSpan.className = 'result-id';
|
||||||
|
idSpan.textContent = '#' + tag.id;
|
||||||
|
|
||||||
|
var nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'result-tag';
|
||||||
|
nameSpan.textContent = tag.tag;
|
||||||
|
|
||||||
|
item.appendChild(idSpan);
|
||||||
|
item.appendChild(nameSpan);
|
||||||
|
|
||||||
|
if (tag.normalized && tag.normalized !== tag.tag) {
|
||||||
|
var normSpan = document.createElement('span');
|
||||||
|
normSpan.className = 'result-norm';
|
||||||
|
normSpan.textContent = '(' + tag.normalized + ')';
|
||||||
|
item.appendChild(normSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!already) {
|
||||||
|
item.addEventListener('click', function () { doAdd(tag.id); });
|
||||||
|
}
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchTimer = null;
|
||||||
|
document.getElementById('nsfp-search').addEventListener('input', function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (!q) { lastResults = []; document.getElementById('search-dropdown').style.display = 'none'; return; }
|
||||||
|
var self = this;
|
||||||
|
searchTimer = setTimeout(function () {
|
||||||
|
fetch('/api/v2/admin/nsfp/search?q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success) { lastResults = data.tags; rebuildDropdown(self.value.trim()); }
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!e.target.closest('.nsfp-search-wrap')) {
|
||||||
|
document.getElementById('search-dropdown').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.nsfpAdmin = { remove: remove, doAdd: doAdd };
|
||||||
|
|
||||||
|
// Defer so the DOM is fully settled whether loaded normally or via AJAX PJAX
|
||||||
|
setTimeout(loadNsfp, 0);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include(snippets/footer)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<label for="rules-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Rules Page Content (Markdown supported)</label>
|
<label for="rules-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Rules Page Content (Markdown supported)</label>
|
||||||
<textarea id="rules-text" name="rules_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! rules_text !!}</textarea>
|
<textarea id="rules-text" name="rules_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;"></textarea>
|
||||||
|
<script>document.getElementById('rules-text').value = new TextDecoder('utf-8').decode(Uint8Array.from(atob('{{ rules_text_b64 }}'), function(c) { return c.charCodeAt(0); }));</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center;">
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user