Compare commits

383 Commits

Author SHA1 Message Date
ab1ea7b368 soy2 2026-06-29 23:50:03 +02:00
6f2afc992f soy 2026-06-29 22:27:29 +02:00
81c0afc534 fix user api keys in schema 2026-06-29 19:09:12 +02:00
977cab8ad2 adding working metadata extract butto for yt embeds 2026-06-21 20:26:44 +02:00
f190ff073a add automatic inactivity bans 2026-06-21 15:47:36 +02:00
e30ead0174 nsfp manager #1 2026-06-19 14:52:02 +02:00
17ee7a3b2d add counter how many items are hidden from public 2026-06-19 14:47:08 +02:00
8a24564cd9 add nsfp tag manager 2026-06-19 14:44:56 +02:00
06564af203 QoL v0ck and tagging 2026-06-19 14:20:15 +02:00
c051df18c2 fix rehost button mutation 2026-06-19 14:13:52 +02:00
78d08aa751 comment input form validation required 2026-06-19 14:07:40 +02:00
a748cca833 v0ck QoL 2026-06-19 14:02:20 +02:00
1b8860d8ff add phash repost detection 2026-06-13 19:09:36 +02:00
4c742aaf66 bump nginx upload size 2026-06-13 16:51:36 +02:00
6b6ed9d42b add direct url to upload response via api 2026-06-13 16:11:10 +02:00
9df81105e2 update private_society to allow direct urls 2026-06-13 16:05:54 +02:00
fb2489812e api upload min tags 2026-06-13 15:16:12 +02:00
d29edd735e umlaut fix 2026-06-12 10:30:41 +02:00
f06a7ffe55 less aggressive html sanitize 2026-06-12 03:02:00 +02:00
8b29ee6722 hfgd 2026-06-12 02:58:53 +02:00
34ed0e4621 fixing about/terms/rules pages 2026-06-12 02:56:02 +02:00
83f3980300 add flash fullscreen button 2026-06-12 01:58:28 +02:00
7b599e3afa update attachment logic 2026-06-12 01:48:04 +02:00
7dcd7005e4 sidebar hidden modern layout same size 2026-06-12 01:30:22 +02:00
c093e9703d ruffle scrolling 2026-06-12 01:12:23 +02:00
9ca60629a0 stabilizing layout 2026-06-12 01:04:41 +02:00
075adcc8c6 center content in modern layout 2026-06-12 00:54:01 +02:00
5f2fe0c732 scroll to file in shitpost mode 2026-06-12 00:38:17 +02:00
28f35861c3 cccc 2026-06-10 10:27:14 +02:00
4e45e0fd66 turn splash screen back on and fix regen script 2026-06-08 20:05:04 +02:00
69e90f8d2d overhaul rethumbing flashs 2026-06-08 16:37:15 +02:00
c615676465 mobile qol 2026-06-08 15:09:38 +02:00
bdc78fc5a5 ++++++ 2026-06-08 14:09:50 +02:00
7fb049e1ed video shortcuts 2026-06-08 13:53:55 +02:00
7f71285c80 texttop 2026-06-08 13:50:41 +02:00
a6997bc4b4 add clickable video timestamps 2026-06-08 13:43:28 +02:00
219cab1b03 j 2026-06-08 13:34:20 +02:00
8c3556fd68 QoL for images 2026-06-08 13:29:33 +02:00
c9892ec62f qol 2026-06-08 13:16:47 +02:00
018428d236 more blur bg! 2026-06-08 13:05:31 +02:00
f4dcfe0e50 arab bunny party 2026-06-08 09:28:47 +02:00
25a878cb7a bunny party 2026-06-08 09:26:51 +02:00
2f048c0105 pipi kaka :D 2026-06-05 12:58:57 +02:00
e3822254a3 0000 2026-06-05 12:47:44 +02:00
5b193bc001 ererere 2026-06-04 21:01:34 +02:00
f36c10b428 gfdhfgd 2026-06-04 20:55:41 +02:00
4943a87e13 hgf 2026-06-04 20:42:22 +02:00
a868e9f94b fix notis 2026-06-04 20:29:30 +02:00
e281484b8a notification thumbnails according to rating blurred or unblurred 2026-06-04 15:44:57 +02:00
39dfcad52f f 2026-06-04 13:47:32 +02:00
fe3af86c87 yt fix #2 2026-06-03 13:43:26 +02:00
f5101da85c fix broken yt dests... 2026-06-03 13:37:41 +02:00
d5de02511b add option to have public nsfw/nsfl 2026-06-03 13:07:24 +02:00
adc8431522 update emoji manager 2026-06-03 12:50:21 +02:00
d30642ca4a QoL fixes 2026-06-03 12:25:57 +02:00
5bb86f7028 Testing: fixing user comments page and profile respects now sidebar width 2026-06-03 11:08:37 +02:00
33411fc5ed adding missing stuff for fresh deployments 2026-06-03 10:58:32 +02:00
e72f9a6ef3 QoL fixes 2026-06-03 08:54:56 +02:00
066fa97c43 rrrrrrrrr 2026-06-02 16:55:59 +02:00
dceadf7113 jgh 2026-06-02 16:45:21 +02:00
a0ef658684 new filenames + backfill 2026-06-02 16:35:34 +02:00
2c88953d97 jhgf 2026-06-02 10:26:53 +02:00
9d54e03ae0 bhgds 2026-06-02 07:54:45 +02:00
c20e54c11b gfds 2026-06-01 22:27:23 +02:00
bbbb4397f0 padding 2026-06-01 18:26:01 +02:00
0bbbf5ce13 ölkj 2026-06-01 18:21:46 +02:00
7ebb74a295 g 2026-06-01 13:48:52 +02:00
0dd1f30777 nginx 2026-06-01 13:40:11 +02:00
2bc0f9c5fd jghf 2026-06-01 13:28:44 +02:00
a8ef68fee6 update nginx compose 2026-06-01 13:24:26 +02:00
39e6d58e18 add approval message 2026-06-01 13:14:31 +02:00
1557d59300 nginx test 2026-06-01 11:32:15 +02:00
1df8caa940 adding nginx reverse proxy in docker 2026-06-01 11:09:45 +02:00
0f69365b02 ffmpeg fallback for specific vp9 videos 2026-06-01 10:33:04 +02:00
8d8416e650 add tooltips for sidebar 2026-06-01 10:26:26 +02:00
412995ece6 removing alt=av 2026-06-01 10:20:30 +02:00
df3405ac9b f 2026-06-01 08:43:46 +02:00
9146e37039 update readme 2026-06-01 08:43:02 +02:00
bb6322e187 hgfd 2026-06-01 08:30:50 +02:00
064bd51c64 gdf 2026-06-01 08:10:37 +02:00
f6de7f72cf hngfdhfgd 2026-05-31 22:08:02 +02:00
d594ac2edd hgfdhgfd 2026-05-31 22:02:38 +02:00
09fcf8d8ec ghfd 2026-05-31 21:52:46 +02:00
1efd3b18ad errrr 2026-05-31 20:51:24 +02:00
b2128ef0f8 jjj 2026-05-31 20:38:45 +02:00
8e4e40d92f update example config 2026-05-31 20:26:13 +02:00
e0c29f203b gfdsgfds 2026-05-31 19:54:34 +02:00
994039370c scroll to top button for mobile 2026-05-31 18:48:45 +02:00
08cdada5bc silent scroll :) 2026-05-31 18:37:45 +02:00
8a3a77d273 gfds 2026-05-31 18:24:22 +02:00
235f1b6d14 better timestamps 2026-05-31 18:10:35 +02:00
52a18acf40 gfdsgfds 2026-05-31 17:30:44 +02:00
c8f0982f22 hgfd 2026-05-31 17:22:07 +02:00
1f97701779 jfgh 2026-05-31 17:19:10 +02:00
f863580f20 jhgf 2026-05-31 17:10:30 +02:00
d299117eb0 jhgf 2026-05-31 17:07:46 +02:00
62588a3a4b hhhhhhhhh 2026-05-31 17:00:57 +02:00
d50a8b5965 gfds 2026-05-31 16:56:39 +02:00
ec95f548ff obermainbrücke 2026-05-31 16:55:30 +02:00
379298acc7 hfgd 2026-05-31 16:42:44 +02:00
7b80a3434c gfds 2026-05-31 16:03:48 +02:00
23427f009f gfds 2026-05-31 15:57:55 +02:00
c4a571a714 gfds 2026-05-31 12:25:35 +02:00
db6344d055 gfds 2026-05-31 12:23:14 +02:00
f97de44a0c gfds 2026-05-31 12:21:19 +02:00
448efc69f8 hgfd 2026-05-31 12:18:59 +02:00
c2cb67c51c hgfdhfd 2026-05-31 12:18:50 +02:00
89548e1105 magnet und hydrodynamik 2026-05-31 12:17:12 +02:00
7c23c646fe gfdsgdfs 2026-05-31 12:15:36 +02:00
7671e8c0cf gfds 2026-05-31 12:14:43 +02:00
7a57f4897f fdfdd 2026-05-31 06:59:05 +02:00
0b89e446e7 hausdavid 2026-05-31 06:56:45 +02:00
83bf04e965 hackinga to tha gato 2026-05-31 06:21:42 +02:00
69bf968a4b hgfdhgfdhd 2026-05-30 18:59:07 +02:00
85cf4e0fc6 long covid 2026-05-30 18:44:27 +02:00
86a08bb76e gfdsgdfsgdfs 2026-05-30 17:33:53 +02:00
73e328c0b6 gfds 2026-05-30 17:21:30 +02:00
57c12057d9 gfds 2026-05-30 17:05:16 +02:00
0ae82ce433 huo 2026-05-30 13:19:12 +02:00
067f202c08 hgfd 2026-05-30 13:07:38 +02:00
9177b993fc jhgf 2026-05-30 12:55:22 +02:00
1a0a5d7679 fdas 2026-05-30 12:50:15 +02:00
ae2a9b76cb hgfd 2026-05-30 12:38:47 +02:00
47f1b5bb41 pipi kaka uga aga 2026-05-30 11:24:18 +02:00
6fdfed5cae gfds 2026-05-30 09:01:52 +02:00
c949453c9e im already tracer 2026-05-30 08:51:15 +02:00
0e8e26237e ff 2026-05-30 08:46:31 +02:00
4a0784746d ghf 2026-05-30 08:41:28 +02:00
c98e797d4f add alternative controls 2026-05-30 08:35:20 +02:00
3ff61f4e36 eierfon gay fix? 2026-05-30 07:29:13 +02:00
26b4081984 fjnf 2026-05-30 06:57:58 +02:00
80457014c1 ina ina 2026-05-30 06:55:43 +02:00
8dd1ed22a2 dönerkebab 2026-05-30 06:48:12 +02:00
db0b28fe0c hgfd 2026-05-29 21:23:35 +02:00
44bf46f02c gfds 2026-05-29 21:20:44 +02:00
d9b49b1e21 hgdf 2026-05-29 21:17:07 +02:00
a7e4d5b0dd hgf 2026-05-29 20:53:38 +02:00
67643f17a9 hgfd 2026-05-29 20:49:30 +02:00
e0e0456768 gfhgfd 2026-05-29 20:49:26 +02:00
7e7da4030d jghf 2026-05-29 20:18:44 +02:00
754fc95d56 add polls 2026-05-29 20:15:00 +02:00
9365cb21c8 big oppai 2026-05-29 19:41:34 +02:00
264b6c3e6d gfds 2026-05-29 19:38:18 +02:00
78fe42ef3a jghf 2026-05-29 19:33:45 +02:00
45df561e9d kkkkkkkkk 2026-05-29 19:20:33 +02:00
f38d77a4d8 gdfs 2026-05-29 19:17:11 +02:00
beb5460797 hgfd 2026-05-29 19:08:01 +02:00
37460ba224 regression for filter setting possible fix 2026-05-29 19:05:44 +02:00
f79e4d6f32 prevent comment attachment to be abused 2026-05-29 18:38:26 +02:00
86085c435a gfds 2026-05-29 12:24:29 +02:00
697d62f89b beautifying the tags 2026-05-29 09:12:21 +02:00
18add9f21a gfd 2026-05-29 09:00:46 +02:00
5e298383a3 f 2026-05-29 08:59:01 +02:00
d5f118f2fc test 2026-05-29 08:56:21 +02:00
63bb86defc gfds 2026-05-29 05:38:56 +02:00
066ca99dd3 sidebar 2026-05-28 21:50:34 +02:00
d1b0e3542c gfdsgfds 2026-05-28 21:46:47 +02:00
a8978e232f sexy grabbing finally!!! 2026-05-28 21:25:37 +02:00
ddd87b6336 hdf 2026-05-28 21:22:11 +02:00
6be580dc92 fdsagfds 2026-05-28 21:15:47 +02:00
420f58c85a fds 2026-05-28 20:48:45 +02:00
62a6a345cb fdsa 2026-05-28 20:43:10 +02:00
700e705bee gfdsgfds 2026-05-28 20:40:16 +02:00
80240ccb66 gfds 2026-05-28 20:34:26 +02:00
eabf1585b9 border 2026-05-28 20:28:45 +02:00
4125db98ba spicy queen 2026-05-28 20:14:31 +02:00
98075423f0 bhi 2026-05-28 19:28:22 +02:00
b8024acf12 uga aga 2026-05-28 18:41:35 +02:00
4ed87cd331 fix hall creation 2026-05-28 18:33:23 +02:00
dcdea5e9ea kjhg 2026-05-28 16:42:05 +02:00
1cfb001148 jhgf 2026-05-28 16:34:02 +02:00
9a003be98f kjhg 2026-05-28 16:22:53 +02:00
2a73a00e98 add dimensions 2026-05-28 16:02:16 +02:00
9804376d30 gfds 2026-05-28 13:21:18 +02:00
006ee727ec jhgf 2026-05-28 13:15:45 +02:00
1fe506da65 h 2026-05-28 12:47:43 +02:00
df997db8eb slightly bigger pagination 2026-05-28 12:33:45 +02:00
4d1d5d4332 gurkenwasser 2026-05-28 11:52:18 +02:00
03f751954d hfgd 2026-05-28 08:21:31 +02:00
33abde6f79 j 2026-05-28 08:17:39 +02:00
3e427b7b58 pagination now correctly centers in available space 2026-05-28 08:10:30 +02:00
ac1d710811 chud 2026-05-28 08:06:32 +02:00
93bb36883f eeeeeeeeeee 2026-05-28 08:04:14 +02:00
a6bf8fe6df tinkering with the dms 2026-05-28 07:57:09 +02:00
090c0b8016 easier fullscreen for mobile 2026-05-28 07:26:40 +02:00
7593033ab9 kjhg 2026-05-28 06:43:12 +02:00
991a31ff35 f 2026-05-28 06:38:59 +02:00
aff3153a84 lkjh 2026-05-28 06:34:29 +02:00
a4cd858114 fds 2026-05-27 20:32:36 +02:00
ebaea76b90 hgdf 2026-05-27 20:29:27 +02:00
b705cd3a9c hbg 2026-05-27 20:21:42 +02:00
57a59dcda0 n 2026-05-27 20:02:54 +02:00
8ec4f1c1b3 fdsa 2026-05-27 20:02:14 +02:00
181c30e9ee fd 2026-05-27 19:40:35 +02:00
ab8d751330 f 2026-05-27 19:38:29 +02:00
603b3f37b9 add total amount of users to stats 2026-05-27 19:33:38 +02:00
9f6215706d idk fuck this 2026-05-27 19:28:00 +02:00
3e1657ec34 hgfd 2026-05-27 19:25:58 +02:00
8909e02ddc fdsa 2026-05-27 19:21:28 +02:00
61754e058f attachment fix 2026-05-27 19:12:35 +02:00
514dae7906 uga aga pipi kaka 2026-05-27 18:48:54 +02:00
ffb328ab96 drachenfotze 2026-05-27 16:27:53 +02:00
8ddcff61e4 133 2026-05-27 16:14:45 +02:00
c4c311c541 testing: adding smooth animation for comment hding 2026-05-27 15:51:05 +02:00
b575a07921 filter update 2026-05-27 15:25:42 +02:00
236e540204 fav gap and ugly horizontal scrollbar 1050px width... 2026-05-27 07:01:53 +02:00
ef08f85d25 fges 2026-05-27 06:44:20 +02:00
635afe9f9f scroller filename as metadata 2026-05-27 06:33:28 +02:00
25dfa6c8a2 fa 2026-05-26 21:49:16 +02:00
cfd446597d fuck this shit 2026-05-26 21:44:24 +02:00
9c42e8ea2b gfdsgfds 2026-05-26 21:39:57 +02:00
4f9fc25ef2 gfbds 2026-05-26 21:28:16 +02:00
8693e16802 fix dynamic thumb for mme+rating filter 2026-05-26 21:19:14 +02:00
9732b30aa5 change mode display 2026-05-26 19:56:22 +02:00
7c2619e492 Testing: allow selecting multiple ratings for better filtering 2026-05-26 19:10:24 +02:00
0f7eced14d cockvore 2026-05-26 15:13:24 +02:00
924dcc0641 xdddd 2026-05-26 15:10:21 +02:00
54bdbad25e fuck italic 2026-05-26 14:52:45 +02:00
1174cd6947 feat: add support for encrypted DM attachments, per-user upload API keys, and configurable feed layouts via new database migrations and API documentation. 2026-05-26 14:30:02 +02:00
6f0b62cf8d feat: implement API documentation, add database migrations for site features, and include comment file attachments in API responses 2026-05-26 14:24:52 +02:00
ef3a9bd3b0 feat: add database migration scripts and supplementary tooling for data imports and site management 2026-05-26 14:17:05 +02:00
dd4e56c8fb feat: implement database migrations for wordfilter, DMs, user invites, and site settings while adding video title import utilities. 2026-05-26 14:14:05 +02:00
0ed609c8ce legacy layout scrollbutton fix 2026-05-26 14:12:30 +02:00
3f92e62820 feat: implement database migrations for word filter, DM attachments, message editing, and user invite tracking, plus add API documentation and dev scripts. 2026-05-26 14:02:28 +02:00
cb3bd4358c same height for attachments in sidebar than comments 2026-05-26 13:50:17 +02:00
be499ddb36 gfds 2026-05-25 20:25:18 +02:00
df8797b92a empty title still reserves the same space as set title for layout consistency 2026-05-25 15:24:47 +02:00
6622ea93aa gtfds 2026-05-25 13:10:00 +02:00
cce4eb3d57 ehehehe 2026-05-25 13:06:37 +02:00
49a1365cf9 xdddd 2026-05-25 12:24:48 +02:00
0dad6924b5 bvgfd 2026-05-25 12:12:46 +02:00
7ca88f6416 search testing xd 2026-05-25 12:09:35 +02:00
f2cebddd4d update search to include video titles 2026-05-25 10:08:32 +02:00
fda2ed36bd option to enable/disable item titles 2026-05-25 09:55:53 +02:00
1adc0f4ee2 remove title 2026-05-25 09:50:42 +02:00
9b64feb8ed adding back tooltip for timeago on comments 2026-05-25 09:48:59 +02:00
c6dd2a7ff8 geilify admin pages 2026-05-25 09:46:21 +02:00
8977c072f2 rating switch more visual 2026-05-25 09:32:44 +02:00
d4a68f59fd add apu bingo 2026-05-25 09:20:40 +02:00
92cc474ca3 eeee 2026-05-25 09:03:58 +02:00
3a436304bd meme generator update 2026-05-25 08:57:13 +02:00
96b13db79c center thumb 2026-05-25 08:48:53 +02:00
b0d53d34ac eee 2026-05-25 08:45:03 +02:00
107a184c04 test 2026-05-25 08:41:30 +02:00
29d33fe277 meme test 2026-05-25 08:38:14 +02:00
8257c8d021 update env example 2026-05-24 23:24:09 +02:00
fa8ed5e354 fix yt links 2026-05-24 23:16:01 +02:00
95e37c1dd1 fix xss... 2026-05-24 23:11:31 +02:00
613f099a8b add item titles 2026-05-24 23:02:49 +02:00
187f35227b no margin 2026-05-24 22:10:45 +02:00
8a679c2fe6 fix tooltip breakout calculation position 2026-05-24 22:08:26 +02:00
b2584763ee cd score layout modern fixes 2026-05-24 22:04:07 +02:00
f9e45327bf hgfd 2026-05-24 21:27:33 +02:00
096720c266 fdf 2026-05-24 21:24:48 +02:00
83fdada12d updating compose with latest mounts and adding missing gitkeeps 2026-05-24 21:19:24 +02:00
0714b0a68c use correct class for quicknav 2026-05-24 21:16:25 +02:00
78c28b4734 remove iconset.svg 2026-05-24 21:10:26 +02:00
34fa51a6e9 bghd 2026-05-24 21:09:50 +02:00
060d73122b fd 2026-05-24 21:07:05 +02:00
026bf4a421 hgfd 2026-05-24 20:58:13 +02:00
6c85a86959 prevent image modal for emojis and make the emojis bigger 2026-05-24 20:53:33 +02:00
5ce2371b41 fix image expansion 2026-05-24 20:41:24 +02:00
503c131f0b t 2026-05-24 19:00:05 +02:00
5bbcb5be41 hgfd 2026-05-24 18:49:47 +02:00
126bf41d9a eeee 2026-05-24 18:39:12 +02:00
5ae397bb0c g 2026-05-24 18:37:12 +02:00
d8a8626dae update usermanager 2026-05-24 18:31:35 +02:00
3abefe64de quick nav style update 2026-05-24 18:15:07 +02:00
d77936e58f add missing i18n for sidebar 2026-05-24 18:03:30 +02:00
88fc872df6 display reposts in info modal 2026-05-24 17:51:16 +02:00
18e07e43b6 only load captcha resources if not logged in. 2026-05-24 17:37:22 +02:00
f735a144af fix layout modern image expansion 2026-05-24 17:17:08 +02:00
fa350e8f1c gf 2026-05-24 17:00:07 +02:00
ad72c053d9 hgfd 2026-05-24 16:58:01 +02:00
7e458e4450 f 2026-05-24 16:56:24 +02:00
4a155bc5f4 add recaptcha option 2026-05-24 16:44:20 +02:00
393db5fe2a add possibility to create account without email and token 2026-05-24 16:35:07 +02:00
a5e79cca0c custom navbar 2026-05-24 16:09:02 +02:00
cdd415a52f fd 2026-05-24 15:55:48 +02:00
0b9b049f82 correct nsfw badge color for shitpost uploading 2026-05-24 10:22:05 +02:00
ca4a722029 comment safe guards 2026-05-24 10:11:41 +02:00
bb4125601f option for users to delete their own comments 2026-05-24 10:05:40 +02:00
375e1a85d4 xd score fix 2026-05-24 09:51:10 +02:00
18cac93bf1 space to enlarge 2026-05-24 09:40:44 +02:00
2ab4ae06af make quick cycling great again 2026-05-24 09:25:05 +02:00
2bce856153 speed up shitpost mode upload 2026-05-24 08:56:09 +02:00
8e6011785a higher quality tag and hall images 2026-05-24 08:49:18 +02:00
a87123cb43 unify sidebar emoji height 2026-05-24 08:27:33 +02:00
28d5b9364f notis on blur aswell 2026-05-24 00:11:05 +02:00
6688db6145 require item revealing when user wants it 2026-05-23 23:23:31 +02:00
a3bec09864 update wordfilter func 2026-05-23 23:09:24 +02:00
229cfacd5c remove dashboard link 2026-05-23 23:06:29 +02:00
0c246aab30 wordfilter update 2026-05-23 23:01:28 +02:00
a0ac4607cc add wordfilter 2026-05-23 22:55:53 +02:00
9a9b787fd7 add shitpost mode config options 2026-05-23 22:38:28 +02:00
e61654c567 update xd score scoring system 2026-05-23 22:23:51 +02:00
6137545cab do not show chat manager in admin dashboard if disabled 2026-05-23 22:13:39 +02:00
e3ba7d3b10 gfds 2026-05-23 22:07:29 +02:00
0945f780a3 gfdgdf 2026-05-23 22:06:32 +02:00
ac98e292e9 fix shitpost mode ugly padding 2026-05-23 22:02:43 +02:00
4e10aec872 utf8 2026-05-23 21:51:00 +02:00
1aaa0493a9 rules,about,tos markdown 2026-05-23 21:48:47 +02:00
7155b3a7da updating meme manager to update memes 2026-05-23 21:25:46 +02:00
2f044a8d02 generate blur thumbnail for all items! 2026-05-23 21:05:40 +02:00
8c9e89c771 fix quick nav highlight 2026-05-23 20:55:41 +02:00
d7377c108a better 2026-05-23 20:47:57 +02:00
cbccac6e22 hide thumb higher 2026-05-23 20:46:40 +02:00
046ecf8321 gjgjg 2026-05-23 20:41:47 +02:00
a2dd32989e add get sharex config button to api key 2026-05-23 20:37:47 +02:00
b608208cf9 fix for api upload when private_society is enabled 2026-05-23 20:34:18 +02:00
e0c435009b better filters 2026-05-23 20:21:47 +02:00
0f3b80f0c1 making settings more readable and navigatable 2026-05-23 20:10:57 +02:00
c6ff4fa703 fix inviting 2026-05-23 19:32:50 +02:00
bf92d53620 implement user invites 2026-05-23 19:14:13 +02:00
dd6cda2b44 fd 2026-05-23 18:32:53 +02:00
dbe0859750 sidebar blurring 2026-05-23 18:22:46 +02:00
c480b82db6 content preferences 2026-05-23 15:41:40 +02:00
3e6298f81c previews and make sure blurry thumb gets always generated when rating is changed later on 2026-05-23 15:35:42 +02:00
97cc69b337 better blur 2026-05-23 15:29:50 +02:00
4b50e56eb8 add option to blur any thumb 2026-05-23 15:26:35 +02:00
c488b93290 fix fullscreen control and cursor hide again... 2026-05-23 13:05:34 +02:00
aebb20b37f speed up review process 2026-05-23 13:01:14 +02:00
fcfe73178b fix player flickering when entering with mouse 2026-05-23 12:57:49 +02:00
224af7e8cb 2.5s are better 2026-05-23 12:34:43 +02:00
29f73c9271 hide mouse cursor in fullscreen after 5 seconds aswell 2026-05-23 12:30:33 +02:00
0e04e9f49f add file info button 2026-05-23 12:19:51 +02:00
ca9ccab697 double speed also when paused 2026-05-23 11:54:42 +02:00
74f1c31df2 fdfdfdfdfd 2026-05-23 09:54:56 +02:00
fdf54e4513 x2 speed for v0ck 2026-05-23 09:49:57 +02:00
867304bcb1 agayn 2026-05-23 09:44:38 +02:00
3b5144f475 hide controls after 5 seconds and remove unnecesarry min-height 2026-05-23 09:40:58 +02:00
422f80cabd gs 2026-05-23 09:34:50 +02:00
f3b2887df3 uga aga 2026-05-23 09:31:09 +02:00
1be9216624 hgfd 2026-05-23 08:45:59 +02:00
0d85ff0535 auto 2026-05-23 08:43:03 +02:00
1410200cf2 ye 2026-05-23 08:39:58 +02:00
0745637229 hehe 2026-05-22 21:28:01 +02:00
832581ad68 fuck border radius 2026-05-22 21:24:04 +02:00
748da77678 eeeeeee 2026-05-22 21:23:06 +02:00
fe1a29c2a6 add config option for api keys 2026-05-22 21:20:52 +02:00
013bdce1db settings padding... 2026-05-22 21:05:20 +02:00
615aacae9a fuck formatting!!! 2026-05-22 20:59:11 +02:00
a1ef06e573 Revert "ff"
This reverts commit 0450d9e2ed.
2026-05-22 20:53:08 +02:00
6ca29806b0 Revert "gfds"
This reverts commit 9694a560f7.
2026-05-22 20:52:48 +02:00
9694a560f7 gfds 2026-05-22 20:46:18 +02:00
0450d9e2ed ff 2026-05-22 20:44:02 +02:00
71f292f243 add api key for uploading via 3rd party tools 2026-05-22 20:42:48 +02:00
d44fb1ac05 dynamic comment length 2026-05-22 20:21:15 +02:00
b836ce37f3 gf 2026-05-22 18:59:35 +02:00
29551f9d27 lets see if this works out 2026-05-22 18:49:10 +02:00
de2302b589 gfd 2026-05-22 18:27:29 +02:00
126923f2b7 fd 2026-05-22 18:19:21 +02:00
bc89c1d7a8 zomg indicator 2026-05-22 18:15:31 +02:00
e07fb589c5 better filter visualization 2026-05-22 18:12:37 +02:00
3ec97f4451 fooooo 2026-05-22 15:43:12 +02:00
c569cde8cf keep keyboard open when opening emoji selector 2026-05-22 15:37:29 +02:00
df312009b8 attempting to fix legacy user accounts with less than 20 characters. 2026-05-22 15:34:23 +02:00
6c6764202e must select template before using text 2026-05-22 08:35:46 +02:00
b093f7618a goo goo ga ga 2026-05-22 08:22:25 +02:00
7e00c090e2 adding custom meme template instead of fixed library 2026-05-22 08:18:30 +02:00
9c46396a39 attempting to fix mobile replies/quotes 2026-05-22 08:09:41 +02:00
9ef3207cd4 fixing random api endpoint 2026-05-21 19:51:57 +02:00
72b8140f77 fix meme manager text overflowing 2026-05-21 15:53:53 +02:00
948e6461fd convenience for shitpost mode link uploader 2026-05-21 15:41:42 +02:00
eb209f6d27 add option to have unencrypted direct messages 2026-05-21 15:31:57 +02:00
d0a014705b updating phash/dhash generation 2026-05-20 19:10:50 +02:00
07edfcb71d fixing legacy password login 2026-05-20 18:53:57 +02:00
0074355df8 lflf 2026-05-18 18:32:19 +02:00
3ac1489d1f lazyloading 2026-05-18 18:26:46 +02:00
f87642341b scrollbottom for chat 2026-05-18 18:17:32 +02:00
e445169456 37 2026-05-18 18:15:03 +02:00
8f8bda1d0d adding online presence for dms 2026-05-18 18:02:11 +02:00
bcb17dc48b gonna hate race conditions 2026-05-18 17:56:14 +02:00
ab566fc126 lets see 2026-05-18 17:52:45 +02:00
aabc33a6cd fix bug lol 2026-05-18 17:39:50 +02:00
9c129b7a37 add decent replying to direct messages 2026-05-18 17:36:07 +02:00
0393878c9f add option to edit/delete direct mssages and dynamic expiry date for attachments via config 2026-05-18 17:22:44 +02:00
313cbeddc4 fix attachment button and logic 2026-05-18 16:49:31 +02:00
e97698877d possible fix for boot crash and image expansion 2026-05-18 16:44:29 +02:00
ec8c423304 add encrypted dm attachments 2026-05-18 16:26:53 +02:00
ad325c085a yippi ya ohhhh 2026-05-17 14:24:30 +02:00
385b731ee8 abyss metadata preview #2 2026-05-17 14:19:21 +02:00
82574466ee abyss internal link shortening, removal of # for ids and external preview 2026-05-17 14:16:13 +02:00
123 changed files with 14535 additions and 2153 deletions

View File

@@ -1,3 +1,10 @@
POSTGRES_USER=
POSTGRES_DB=
POSTGRES_PASSWORD=
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
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

View File

@@ -1,18 +1,30 @@
# 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
first things
`cp .env.example .env`
fill with for example: f0ckm
fill with for example: f0ckm (prefilled)
`cp config_example.json config.json`
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": {
@@ -42,6 +54,8 @@ now vist http://localhost:1337 in your browser
## 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`
on dev machine:
@@ -49,8 +63,18 @@ on dev machine:
`npm i`
`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
`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
## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.

3
config/nginx/f0ck.conf Normal file
View File

@@ -0,0 +1,3 @@
client_max_body_size 10000M;
client_body_timeout 120s;
client_header_timeout 120s;

View File

@@ -56,11 +56,15 @@
"default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [],
"custom_navbar_brand_text": "",
"show_koepfe": false,
"koepfe": [],
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
"dm_attachments": true,
"dm_unencrypted": false,
"dm_attachment_expiry_days": 90,
"halls_enabled": true,
"userhalls_enabled": true,
"enable_userhall_image_upload": true,
@@ -68,12 +72,24 @@
"meme_creator": true,
"enable_cleanup": false,
"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,
"web_url_upload": true,
"enable_youtube_upload": true,
"web_meta_extraction": true,
"bypass_duplicate_check": true,
"shitpost_mode": false,
"shitpost_require_rating": false,
"shitpost_min_tags": 0,
"protect_files": false,
"enable_dynamic_thumbs": false,
"allowed_comment_images": [
@@ -83,6 +99,14 @@
],
"show_mime_picker": 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,
"default_comment_display_mode": 1,
"phrases": [
@@ -94,12 +118,16 @@
"enable_swiping": true,
"enable_profile_description": true,
"user_alternative_infobox": false,
"user_alternative_steuerung": false,
"enable_swf": false,
"swf_thumb": "/s/img/swf.png",
"enable_item_title": true,
"open_registration": true,
"open_registration_web_toggle": false,
"open_registration_require_mail_andor_token": false,
"private_society": false,
"private_society_gate": "cloudflare",
"public_nsfw": false,
"paths": {
"images": "/b",
"thumbnails": "/t",
@@ -189,5 +217,10 @@
"password": "smtp_password",
"from": "admin@example.com",
"mail_reset_password": false
},
"recaptcha": {
"enabled": false,
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
}
}

View File

@@ -11,12 +11,14 @@ services:
networks:
- f0ckm-net
volumes:
- ./config.json:/opt/f0ckm/config.json:Z
- ./config.json:/opt/f0ckm/config.json:ro,Z
- ./src/:/opt/f0ckm/src/:Z
- ./views/:/opt/f0ckm/views/:Z
- ./scripts/:/opt/f0ckm/scripts/:Z
- ./f0ckm-data/a/:/opt/f0ckm/public/a/: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/deleted/:/opt/f0ckm/deleted/:Z
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
@@ -33,6 +35,10 @@ services:
environment:
- GIT_HASH=${f0ckm_TAG:-unknown}
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
- VIRTUAL_PORT=1337
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
ports:
- "1337:1337"
restart: unless-stopped
@@ -78,6 +84,49 @@ services:
# - f0ckm-net
# 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:
f0ckm-net:
driver: bridge
volumes:
certs:
vhost:
html:
acme:

0
f0ckm-data/c/.gitkeep Normal file
View File

0
f0ckm-data/e/.gitkeep Normal file
View File

View File

@@ -20,6 +20,9 @@ SET client_min_messages = warning;
SET row_security = off;
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_admin_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
NULL::integer AS id,
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::integer AS size,
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_assign_item;
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_type;
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_dm_keyvault;
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";
DROP SEQUENCE IF EXISTS public.user_id_seq;
@@ -588,7 +593,7 @@ CREATE TABLE public.comment_files (
id integer NOT NULL,
comment_id integer,
user_id integer NOT NULL,
dest character varying(40) NOT NULL,
dest character varying(60) NOT NULL,
mime character varying(100) NOT NULL,
size integer 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 ONLY public.comment_files REPLICA IDENTITY DEFAULT;
--
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--
@@ -820,12 +827,25 @@ CREATE TABLE public.invite_tokens (
used_by integer,
is_used boolean DEFAULT false,
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;
--
-- 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
--
@@ -869,7 +889,7 @@ ALTER SEQUENCE public.items_id_seq OWNER TO f0ckm;
CREATE TABLE public.items (
id integer DEFAULT nextval('public.items_id_seq'::regclass) 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,
size integer NOT NULL,
checksum character varying(255) NOT NULL,
@@ -887,7 +907,10 @@ CREATE TABLE public.items (
is_pinned boolean DEFAULT false,
is_oc boolean DEFAULT false,
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;
--
-- 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
--
@@ -1455,12 +1493,12 @@ CREATE TABLE public.user_options (
hide_koepfe boolean DEFAULT false NOT NULL,
language text,
use_alternative_infobox boolean DEFAULT false,
use_alternative_steuerung boolean DEFAULT NULL,
receive_system_notifications boolean DEFAULT true,
receive_user_notifications boolean DEFAULT true,
do_not_disturb boolean DEFAULT false,
comment_display_mode integer DEFAULT 1,
force_comment_display_mode integer DEFAULT 0,
feed_layout smallint DEFAULT 0 NOT NULL
force_comment_display_mode integer DEFAULT 0
);
@@ -1587,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);
--
-- 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
--
@@ -1694,6 +1739,14 @@ ALTER TABLE ONLY public.comment_subscriptions
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
--
@@ -2242,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);
--
-- 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
--
@@ -2386,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;
--
-- 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
--
@@ -2722,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;
--
-- 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
--
@@ -2760,4 +2844,66 @@ 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
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);
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG

File diff suppressed because it is too large Load Diff

View File

@@ -185,6 +185,8 @@ canvas#memeCanvas {
.meme-layout-wrapper input[type="range"] {
width: 100%;
accent-color: var(--accent, #9f0);
touch-action: pan-x;
cursor: pointer;
}
.meme-layout-wrapper .layer-input-group {
@@ -234,7 +236,7 @@ canvas#memeCanvas {
.meme-editor-layout {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 0.6fr 1fr !important;
grid-template-rows: 0.6fr auto !important;
gap: 20px;
}
.meme-controls {
@@ -260,3 +262,45 @@ canvas#memeCanvas {
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;
}
}

View File

@@ -118,7 +118,7 @@
flex-direction: column;
/* Stacked */
align-items: center;
gap: 1rem;
gap: 0.3rem;
width: 100%;
}
@@ -219,7 +219,6 @@
.upload-form:not(.shitpost-mode-active) .preview-media-small {
width: 100%;
max-width: 400px;
min-height: auto;
height: auto;
aspect-ratio: 16 / 9;
@@ -247,7 +246,7 @@
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 */
}
@@ -257,6 +256,7 @@
flex-direction: column;
overflow: hidden;
min-width: 0;
width: 100%;
}
.file-info-small {
@@ -406,23 +406,25 @@
}
.item-rating-option input:checked + .item-rating-label.sfw {
background: #40c057;
background: var(--badge-sfw);
color: #fff;
border-color: #40c057;
border-color: var(--badge-sfw);
}
.item-rating-option input:checked + .item-rating-label.nsfw {
background: #fd7e14;
background: var(--badge-nsfw);
color: #fff;
border-color: #fd7e14;
border-color: var(--badge-nsfw);
}
.item-rating-option input:checked + .item-rating-label.nsfl {
background: #fa5252;
background: var(--badge-nsfl);
color: #fff;
border-color: #fa5252;
border-color: var(--badge-nsfl);
}
.item-rating-label:hover {
background: rgba(255, 255, 255, 0.1);
}
@@ -440,7 +442,7 @@
.preview-media-small {
flex: 0 0 50%;
width: 50% !important;
width: 100% !important;
height: auto !important;
min-height: 120px;
max-height: 350px;
@@ -453,6 +455,7 @@
.item-tags-container {
margin-top: 10px;
width: 100%;
position: relative;
}
.item-tags-list {
@@ -549,6 +552,34 @@
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 {
display: flex;
gap: 1rem;
@@ -676,6 +707,29 @@
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 {
font-weight: normal;
font-size: 0.85rem;
@@ -735,9 +789,9 @@
.upload-form .tag-suggestions {
position: absolute !important;
min-width: 220px !important;
max-width: 320px !important;
max-height: 260px !important;
min-width: 220px;
max-width: 320px;
max-height: 260px;
overflow-y: auto !important;
background: var(--dropdown-bg, #1a1a1a) !important;
border: 1px solid var(--black, #000) !important;
@@ -1243,7 +1297,7 @@
flex-shrink: 0;
}
@media (max-width: 600px) {
@media (max-width: 768px) {
.upload-form.shitpost-mode-active .file-preview-item {
flex-direction: column;
align-items: stretch;
@@ -1260,4 +1314,169 @@
.upload-form.shitpost-mode-active .item-media-col iframe {
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); }
}

View File

@@ -138,13 +138,11 @@
transform: translateY(100%) translateY(-3px);
}
.v0ck:hover .v0ck_player_controls,
.v0ck.v0ck_hover .v0ck_player_controls,
.v0ck.v0ck_swf_active .v0ck_player_controls {
transform: translateY(0);
}
.v0ck:hover .v0ck_progress,
.v0ck.v0ck_hover .v0ck_progress,
.v0ck.v0ck_swf_active .v0ck_progress {
height: 8px;
@@ -512,3 +510,38 @@
from { transform: translateX(calc(100vw + 100%)); }
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;
}

View File

@@ -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

View File

@@ -60,7 +60,7 @@
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge", "mr-2");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
@@ -199,7 +199,6 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {
@@ -379,40 +378,91 @@
window.adminSetPassword = async (btn) => {
const id = btn.dataset.id;
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 });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
} else {
alert(data.msg || 'Failed to set password');
throw new Error(data.msg || 'Failed to set password');
}
} catch (err) {
alert('Network error');
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true });
};
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) => {
const id = btn.dataset.id;
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 });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
document.getElementById(`user-row-${id}`)?.remove();
} else {
alert(data.msg || 'Failed to delete user');
}
} catch (err) {
alert('Network error');
throw new Error(data.msg || 'Failed to delete user');
}
},
{ hideReason: true, confirmText: 'Delete User' }
);
};
window.adminResetLoginAttempts = async (btn) => {
@@ -422,8 +472,13 @@
try {
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
if (data.success) {
alert(data.msg);
window.location.reload(); // Quickest way to refresh badges
showFlash(data.msg, 'success');
// 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 {
alert(data.msg || 'Failed to reset attempts');
}
@@ -435,18 +490,22 @@
window.adminBulkDeleteHalls = async (btn) => {
const id = btn.dataset.id;
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 });
if (data.success) {
alert(data.msg);
showFlash(data.msg, 'success');
} else {
alert(data.msg || 'Failed to delete halls');
}
} catch (err) {
alert('Network error');
throw new Error(data.msg || 'Failed to delete halls');
}
},
{ hideReason: true, confirmText: 'Delete Everything' }
);
};
window.adminReassignUploads = async (btn) => {
@@ -473,7 +532,7 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,22 @@
let chatFocused = document.hasFocus();
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() {
const badge = document.getElementById('gchat-badge');
const bubble = document.getElementById('gchat-reopen-bubble');
@@ -187,7 +203,7 @@
'gi'
);
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>
@@ -270,7 +286,7 @@
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>`;
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))
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
}
@@ -310,11 +326,73 @@
</div>`;
}
function scrollToBottom(force = false) {
function scrollToBottom(force = false, smooth = false) {
const el = document.getElementById('gchat-messages');
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (force || nearBottom) el.scrollTop = el.scrollHeight;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
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) {
@@ -417,9 +495,19 @@
s.addEventListener('click', () => s.classList.toggle('revealed'));
});
// Embedded images: scroll to bottom when loaded + open modal on click
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
img.addEventListener('load', () => scrollToBottom(scrollForce));
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
// 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.style.cursor = 'zoom-in';
});
@@ -444,11 +532,14 @@
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('gchat-messages');
if (container) container.innerHTML = '';
(data.messages || []).forEach(m => appendMsg(m));
scrollToBottom(true);
// Also scroll after images have had time to paint
setTimeout(() => scrollToBottom(true), 600);
if (!container) return;
container.innerHTML = '';
(data.messages || []).forEach(m => appendMsg(m, false));
// Double rAF: wait for the browser to commit the layout (panel just became
// visible from display:none) before reading scrollHeight.
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
} catch (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 (!isMinimized) {
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)
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
}

View File

@@ -18,9 +18,69 @@
let draggingLayer = null;
let hoveredLayer = null;
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';
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
img.crossOrigin = "anonymous";
img.onload = () => {
@@ -29,11 +89,23 @@
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 = [
{ 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 }
];
} 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();
draw();
@@ -47,6 +119,56 @@
});
}
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() {
layersContainer.innerHTML = '';
textLayers.forEach((layer, index) => {
@@ -64,7 +186,7 @@
<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>
<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>
`;
@@ -74,11 +196,11 @@
draw();
});
const fsInput = div.querySelector('.layer-fs-input');
const fsSlider = div.querySelector('.layer-fs-slider');
const fsVal = div.querySelector('.layer-fs-val');
fsInput.addEventListener('input', (e) => {
layer.fontSize = parseInt(e.target.value);
fsVal.textContent = layer.fontSize;
createSlider(fsSlider, 10, 200, layer.fontSize, (val) => {
layer.fontSize = val;
fsVal.textContent = val;
draw();
});
@@ -94,6 +216,10 @@
}
addTextBtn.addEventListener('click', () => {
if (!hasLoadedImage) {
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
return;
}
textLayers.push({
id: Date.now(),
text: 'NEW TEXT',
@@ -127,7 +253,7 @@
ctx.font = `bold ${fontSize}px ${memeFont}`;
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 w = canvas.width * 0.9;
@@ -173,7 +299,10 @@
const isInsideText = (pt, layer) => {
if (!layer.text) return false;
const fontSize = layer.fontSize || 40;
const lines = layer.text.split('\n');
ctx.save();
ctx.font = `bold ${fontSize}px ${memeFont}`;
const lines = wrapText(ctx, layer.text.toUpperCase(), canvas.width * 0.9);
ctx.restore();
const w = canvas.width * 0.95;
const h = lines.length * fontSize * 1.2;
@@ -232,16 +361,22 @@
canvas.addEventListener('mousedown', onStart);
// Upload
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 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 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;
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
if (isOrakelNormal || isOrakelUser || isOrakelVon10 || isOrakelBingoApu) {
// Create an off-screen canvas to apply the orakel answer silently
uploadCanvas = document.createElement('canvas');
uploadCanvas.width = canvas.width;
@@ -252,6 +387,7 @@
uCtx.drawImage(canvas, 0, 0);
let result = '';
let selectedCorner = null;
if (isOrakelNormal) {
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)];
@@ -265,10 +401,32 @@
}
} else if (isOrakelVon10) {
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
uCtx.save();
if (isOrakelBingoApu) {
// Draw "KOPS" in normal meme text style on the selected corner
uCtx.font = `bold 28px ${memeFont}`;
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.textAlign = 'center';
uCtx.textBaseline = 'middle';
@@ -352,6 +510,7 @@
uCtx.strokeText(result, xPos, yPos);
uCtx.fillText(result, xPos, yPos);
}
}
uCtx.restore();
}
@@ -384,6 +543,14 @@
const result = await res.json();
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';
if (window.loadItemAjax) {
window.loadItemAjax(dest);
@@ -393,6 +560,7 @@
window.location.href = dest;
}
}
}
else {
window.flashMessage('Error: ' + result.msg, 3000, 'error');
uploadBtn.disabled = false;
@@ -404,6 +572,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
setTimeout(draw, 300);
}

File diff suppressed because it is too large Load Diff

View File

@@ -209,6 +209,12 @@
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function decodeHtmlEntities(str) {
if (!str) return '';
const txt = document.createElement('textarea');
txt.innerHTML = String(str);
return txt.value;
}
function timeAgo(iso) {
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
const i = window.f0ckI18n || {};
@@ -229,28 +235,33 @@
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
}
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();
if (/^\d+$/.test(raw)) return raw;
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
return '';
}
let lastPushedHash = location.hash;
let lastPushedUrl = location.pathname + location.hash;
function pushHash(id) {
if (!id) return;
const newHash = '#' + id;
if (newHash === lastPushedHash) return;
lastPushedHash = newHash;
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
const newUrl = '/abyss/' + id;
if (newUrl === lastPushedUrl) return;
lastPushedUrl = newUrl;
history.pushState({ scrollerId: id }, '', newUrl);
updateCacheActiveId(id);
}
// Handle back/forward within abyss — scroll to the target slide
window.addEventListener('popstate', (e) => {
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;
lastPushedHash = '#' + id;
lastPushedUrl = '/abyss/' + id;
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
if (slide) {
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -707,9 +718,12 @@
const id = contextLink.dataset.id;
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
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.classList.add('highlight-comment');
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
// Flash the animation for attention, then keep the persistent highlight
target.classList.add('highlight-comment', 'comment-linked');
setTimeout(() => target.classList.remove('highlight-comment'), 2500);
}
}
});
@@ -1366,18 +1380,15 @@
${window.scrollerLoggedIn ? `
<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>
<span class="scroll-btn-label"></span>
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
</button>` : ''}
<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>
<span class="scroll-btn-label"></span>
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
</button>
${window.scrollerLoggedIn ? `
<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>
<span class="scroll-btn-label"></span>
</button>` : ''}
<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>
@@ -1385,7 +1396,7 @@
</button>
${item.is_external ? (
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>
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
</a>`
@@ -1546,24 +1557,13 @@
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
if (rBadge) {
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
e.stopPropagation();
const slideEl = rBadge.closest('.scroll-slide');
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
try {
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 {}
cycleRatingOptimistic(rBadge, id, false);
});
}
@@ -1694,7 +1694,8 @@
is_audio: false,
comment_count: p.replies || 0,
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);
@@ -1825,7 +1826,7 @@
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
const first = feed.querySelector('.scroll-slide:not([data-lock])');
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) {
console.error('[SCROLLER] Fetch error:', err);
@@ -1859,7 +1860,8 @@
url: item.external_media_url || item.dest,
rating: rating,
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();
@@ -1885,7 +1887,7 @@
// Update button to link to the new site-internal post
setTimeout(() => {
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>
<span class="scroll-btn-label">View</span>
</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() {
clearCache();
@@ -2736,7 +2805,7 @@
avatar: null,
username: window.scrollerUsername,
display_name: displayName,
content: content,
content: data.comment?.content ?? content,
created_at: null
}, !!window.scrollerLoggedIn);
// Set avatar from global
@@ -3136,26 +3205,12 @@
else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
if (!currentSlide || !window.scrollerLoggedIn) return;
const itemId = currentSlide.dataset.id;
const itemId = currentSlide.dataset.localId || currentSlide.dataset.id;
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]');
if (badge) {
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
badge.textContent = data.rating_label;
badge.dataset.rating = data.rating_class;
cycleRatingOptimistic(badge, itemId, true);
}
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 === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
@@ -3441,7 +3496,7 @@
// Tab type arrays
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 sCachedNotifs = [];
@@ -3545,6 +3600,9 @@
} else if (n.type === 'report') {
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
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 {
link = `/${n.item_id}#c${n.reference_id}`;
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 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 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}">
${thumb}
<div class="notif-content">

View File

@@ -551,13 +551,11 @@
});
}
// Feed Layout Select
const feedLayoutSelect = document.getElementById('feed_layout_select');
if (feedLayoutSelect) {
feedLayoutSelect.addEventListener('change', async () => {
const feed_layout = parseInt(feedLayoutSelect.value, 10);
const prev = feedLayoutSelect.dataset.prev ?? feedLayoutSelect.value;
feedLayoutSelect.dataset.prev = feedLayoutSelect.value;
// New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) {
layoutToggle.addEventListener('change', async () => {
const use_new_layout = layoutToggle.checked;
try {
const res = await fetch('/api/v2/settings/layout', {
method: 'PUT',
@@ -565,24 +563,23 @@
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ feed_layout })
body: JSON.stringify({ use_new_layout })
});
const data = await res.json();
if (data.success) {
window.location.reload();
} else {
alert(data.msg || 'Error saving preference');
feedLayoutSelect.value = prev; // Revert
layoutToggle.checked = !use_new_layout; // Revert
}
} catch (err) {
console.error(err);
alert('Failed to save layout preference');
feedLayoutSelect.value = prev; // Revert
alert('Failed to save Layout preference');
layoutToggle.checked = !use_new_layout; // Revert
}
});
}
// Disable Autoplay Toggle
const autoplayToggle = document.getElementById('disable_autoplay_toggle');
if (autoplayToggle) {
@@ -673,6 +670,37 @@
});
}
// Alternative Steuerung Toggle (icon-only nav style)
const alternativeSteuerungToggle = document.getElementById('alternative_steuerung_toggle');
if (alternativeSteuerungToggle) {
alternativeSteuerungToggle.addEventListener('change', async () => {
const use_alternative_steuerung = alternativeSteuerungToggle.checked;
try {
const res = await fetch('/api/v2/settings/alternative_steuerung', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ use_alternative_steuerung })
});
const data = await res.json();
if (data.success) {
showStatus('Navigation style updated!', 'success');
if (window.f0ckSession) window.f0ckSession.use_alternative_steuerung = use_alternative_steuerung;
} else {
alert(data.msg || 'Error saving preference');
alternativeSteuerungToggle.checked = !use_alternative_steuerung;
}
} catch (err) {
console.error(err);
alert('Failed to save navigation style preference');
alternativeSteuerungToggle.checked = !use_alternative_steuerung;
}
});
}
// Notification Preferences Toggles
const setupPreferenceToggle = (id, sessionKey) => {
const el = document.getElementById(id);
@@ -719,6 +747,86 @@
imageExpandToggle.checked = localStorage.getItem('imageExpandOnClick') !== 'false';
imageExpandToggle.addEventListener('change', () => {
localStorage.setItem('imageExpandOnClick', imageExpandToggle.checked);
if (imageExpandToggle.checked) {
document.documentElement.classList.add('image-expand-active');
} else {
document.documentElement.classList.remove('image-expand-active');
}
});
}
// Granular Thumbnail Blur Toggles
const blurNsfwToggle = document.getElementById('blur_nsfw_toggle');
if (blurNsfwToggle) {
blurNsfwToggle.checked = localStorage.getItem('blurNsfw') === 'true';
blurNsfwToggle.addEventListener('change', () => {
const enabled = blurNsfwToggle.checked;
localStorage.setItem('blurNsfw', enabled ? 'true' : 'false');
if (enabled) {
document.documentElement.classList.add('blur-nsfw-active');
} else {
document.documentElement.classList.remove('blur-nsfw-active');
}
showStatus(enabled ? 'NSFW blurring enabled!' : 'NSFW blurring disabled!', 'success');
});
}
const blurNsflToggle = document.getElementById('blur_nsfl_toggle');
if (blurNsflToggle) {
blurNsflToggle.checked = localStorage.getItem('blurNsfl') === 'true';
blurNsflToggle.addEventListener('change', () => {
const enabled = blurNsflToggle.checked;
localStorage.setItem('blurNsfl', enabled ? 'true' : 'false');
if (enabled) {
document.documentElement.classList.add('blur-nsfl-active');
} else {
document.documentElement.classList.remove('blur-nsfl-active');
}
showStatus(enabled ? 'NSFL blurring enabled!' : 'NSFL blurring disabled!', 'success');
});
}
const blurSfwToggle = document.getElementById('blur_sfw_toggle');
if (blurSfwToggle) {
blurSfwToggle.checked = localStorage.getItem('blurSfw') === 'true';
blurSfwToggle.addEventListener('change', () => {
const enabled = blurSfwToggle.checked;
localStorage.setItem('blurSfw', enabled ? 'true' : 'false');
if (enabled) {
document.documentElement.classList.add('blur-sfw-active');
} else {
document.documentElement.classList.remove('blur-sfw-active');
}
showStatus(enabled ? 'SFW blurring enabled!' : 'SFW blurring disabled!', 'success');
});
}
const blurUntaggedToggle = document.getElementById('blur_untagged_toggle');
if (blurUntaggedToggle) {
blurUntaggedToggle.checked = localStorage.getItem('blurUntagged') === 'true';
blurUntaggedToggle.addEventListener('change', () => {
const enabled = blurUntaggedToggle.checked;
localStorage.setItem('blurUntagged', enabled ? 'true' : 'false');
if (enabled) {
document.documentElement.classList.add('blur-untagged-active');
} else {
document.documentElement.classList.remove('blur-untagged-active');
}
showStatus(enabled ? 'Untagged blurring enabled!' : 'Untagged blurring disabled!', 'success');
});
}
const blurDetailToggle = document.getElementById('blur_detail_toggle');
if (blurDetailToggle) {
blurDetailToggle.checked = localStorage.getItem('blurDetail') !== 'false';
blurDetailToggle.addEventListener('change', () => {
const enabled = blurDetailToggle.checked;
localStorage.setItem('blurDetail', enabled ? 'true' : 'false');
if (enabled) {
document.documentElement.classList.add('blur-detail-active');
} else {
document.documentElement.classList.remove('blur-detail-active');
}
showStatus(enabled ? 'Detail page blurring enabled!' : 'Detail page blurring disabled!', 'success');
});
}
@@ -1274,11 +1382,11 @@
const getXdTier = (score) => {
score = +score;
if (score <= 0) return 0;
if (score < 5) return 1;
if (score < 15) return 2;
if (score < 30) return 3;
if (score < 60) return 4;
if (score < 1) return 0;
if (score < 200) return 1;
if (score < 1000) return 2;
if (score < 100000) return 3;
if (score < 200000000) return 4;
return 5;
};
@@ -1783,5 +1891,151 @@
});
}
// ============================================================
// Upload API Key Management
// ============================================================
const apiKeyStatusBox = document.getElementById('api-key-status-box');
const apiKeyRevealBox = document.getElementById('api-key-reveal');
const apiKeyFullDisplay = document.getElementById('api-key-full-display');
const btnCopyApiKey = document.getElementById('btn-copy-api-key');
const btnRegenApiKey = document.getElementById('btn-regen-api-key');
const btnRevokeApiKey = document.getElementById('btn-revoke-api-key');
const btnShareXDownload = document.getElementById('btn-sharex-download');
const apiKeyActionStatus = document.getElementById('api-key-action-status');
const showApiKeyStatus = (msg, type) => {
if (!apiKeyActionStatus) return;
apiKeyActionStatus.textContent = msg;
apiKeyActionStatus.className = 'avatar-status ' + (type || '');
};
const renderApiKeyState = (hasKey, preview, createdAt) => {
if (!apiKeyStatusBox) return;
if (hasKey) {
const date = createdAt ? new Date(createdAt).toLocaleString() : 'unknown';
apiKeyStatusBox.innerHTML =
`<span>Active key: <code style="font-size:0.9em;">${escHTML(preview)}</code></span>` +
`<span style="color:var(--text-muted); margin-left:12px; font-size:0.85em;">Created: ${escHTML(date)}</span>`;
if (btnRevokeApiKey) btnRevokeApiKey.style.display = '';
if (btnShareXDownload) btnShareXDownload.style.display = '';
} else {
apiKeyStatusBox.innerHTML = '<span class="text-muted">No API key generated yet.</span>';
if (btnRevokeApiKey) btnRevokeApiKey.style.display = 'none';
if (btnShareXDownload) btnShareXDownload.style.display = 'none';
}
};
// Load current state
if (apiKeyStatusBox) {
(async () => {
try {
const res = await fetch('/api/v2/settings/api-key');
const data = await res.json();
if (data.success) {
renderApiKeyState(data.has_key, data.preview, data.created_at);
} else {
apiKeyStatusBox.innerHTML = '<span class="text-muted">Could not load key info.</span>';
}
} catch (e) {
apiKeyStatusBox.innerHTML = '<span class="text-muted">Could not load key info.</span>';
}
})();
}
// Generate / Regenerate
if (btnRegenApiKey) {
btnRegenApiKey.addEventListener('click', async () => {
if (btnRevokeApiKey && btnRevokeApiKey.style.display !== 'none') {
// Key already exists — warn the user
if (!confirm('Regenerating will immediately invalidate your current API key. Continue?')) return;
}
btnRegenApiKey.disabled = true;
btnRegenApiKey.textContent = 'Generating…';
showApiKeyStatus('', '');
try {
const res = await fetch('/api/v2/settings/api-key/regenerate', {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const data = await res.json();
if (data.success) {
// Show one-time reveal box
if (apiKeyFullDisplay) apiKeyFullDisplay.textContent = data.api_key;
if (apiKeyRevealBox) apiKeyRevealBox.style.display = '';
// Update status row to masked preview
const preview = '****' + data.api_key.slice(-8);
renderApiKeyState(true, preview, new Date().toISOString());
showApiKeyStatus('Key generated. Copy it now — it will not be shown in full again.', 'success');
} else {
showApiKeyStatus(data.msg || 'Failed to generate key.', 'error');
}
} catch (e) {
showApiKeyStatus('Request failed.', 'error');
} finally {
btnRegenApiKey.disabled = false;
btnRegenApiKey.textContent = 'Generate / Regenerate Key';
}
});
}
// Copy key to clipboard
if (btnCopyApiKey) {
btnCopyApiKey.addEventListener('click', async () => {
const key = apiKeyFullDisplay?.textContent?.trim();
if (!key) return;
try {
await navigator.clipboard.writeText(key);
const orig = btnCopyApiKey.textContent;
btnCopyApiKey.textContent = 'Copied!';
setTimeout(() => { btnCopyApiKey.textContent = orig; }, 2000);
} catch (e) {
// Fallback: select the text
const range = document.createRange();
range.selectNodeContents(apiKeyFullDisplay);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
});
}
// Revoke key
if (btnRevokeApiKey) {
btnRevokeApiKey.addEventListener('click', async () => {
if (!confirm('Revoke your API key? This cannot be undone — you will need to generate a new one.')) return;
btnRevokeApiKey.disabled = true;
btnRevokeApiKey.textContent = 'Revoking…';
try {
const res = await fetch('/api/v2/settings/api-key', {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const data = await res.json();
if (data.success) {
btnRevokeApiKey.disabled = false;
btnRevokeApiKey.textContent = 'Revoke Key';
renderApiKeyState(false, null, null);
if (apiKeyRevealBox) apiKeyRevealBox.style.display = 'none';
if (apiKeyFullDisplay) apiKeyFullDisplay.textContent = '';
showApiKeyStatus('API key revoked.', 'success');
} else {
showApiKeyStatus(data.msg || 'Failed to revoke key.', 'error');
btnRevokeApiKey.disabled = false;
btnRevokeApiKey.textContent = 'Revoke Key';
}
} catch (e) {
showApiKeyStatus('Request failed.', 'error');
btnRevokeApiKey.disabled = false;
btnRevokeApiKey.textContent = 'Revoke Key';
}
});
}
})();

View File

@@ -40,6 +40,7 @@
const ytOembedCache = new Map(); // videoId -> meta object
const ytOembedPending = new Map(); // videoId -> Promise
const fetchSidebarYoutubeTitles = async (container) => {
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
if (links.length === 0) return;
@@ -128,7 +129,7 @@
const hostsRegexPart = allowedHosts.join('|');
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
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 rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
@@ -247,10 +248,27 @@
return `[video](${fullUrl})`;
});
// Use marked for each line individually
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
const bs = String.fromCharCode(92);
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
// Use marked for each line individually.
// Protect URLs and already-formed Markdown link/image tokens from the
// italic-prevention pass so that underscores in query params
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
const mdProtected = [];
// Match [text](url) / ![alt](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, '');
@@ -290,6 +308,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)
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mediaHosts = [escapedSiteHost];
@@ -367,9 +393,34 @@
const SIDEBAR_MAX_CHARS = 200;
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 rawContent = c.content || c.body || '';
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
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
let avatarSrc = '/a/default.png';
@@ -383,18 +434,38 @@
const timeStr = c.created_at
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
: (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 = '';
if (c.item_id) {
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`;
if (isBlurred) {
thumbUrl = `/t/${c.item_id}_blur.webp`;
}
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'" />`;
itemPreview = `
<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'} &raquo;</a>
</div>`;
}
@@ -405,18 +476,19 @@
<div class="comment-header">
<div class="comment-header-left">
<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 href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
</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 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"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
${itemPreview}
</div>
</div>`;
};
const checkOverflow = () => {
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
const container = inner.parentElement;
@@ -561,13 +633,13 @@
// Also check after a delay to account for image/emoji loading shifts
setTimeout(checkOverflow, 500);
} 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;
}
} catch (e) {
console.error("Sidebar Activity: Failed to load activity", e);
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;
} finally {
@@ -589,7 +661,7 @@
const sentinel = document.createElement('div');
sentinel.id = 'sidebar-load-more-sentinel';
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);
try {
@@ -638,7 +710,7 @@
// 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 = '─ end of activity ─';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} catch (e) {
@@ -684,8 +756,18 @@
};
const init = async () => {
await loadEmojis();
loadActivity();
// Run emoji loading and activity fetching in parallel — avatars appear
// 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();
}
};
// Listen for live activity from f0ckm.js

View File

@@ -71,11 +71,13 @@ window.TagAutocomplete = (() => {
// Flag to prevent focusout from destroying dropdown while touching it
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('touchend', () => {
dropdownTouching = false;
// Re-focus input so user can keep typing after scrolling
input.focus();
// Note: do NOT re-focus input here — that would reopen the mobile keyboard.
// The keyboard only comes back when the user explicitly taps the input.
}, { passive: true });
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
@@ -267,18 +269,51 @@ window.TagAutocomplete = (() => {
open(opts);
});
// Close when clicking/tapping outside
const onDocClick = (e) => {
// Close when clicking/tapping outside.
// 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) {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('touchstart', onDocClick);
document.removeEventListener('mousedown', onDocMousedown);
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(() => {
document.addEventListener('mousedown', onDocClick);
document.addEventListener('touchstart', onDocClick, { passive: true });
document.addEventListener('mousedown', onDocMousedown);
document.addEventListener('touchstart', onDocTouchstart, { passive: true });
}, 0);
// Click on the wrapper area should refocus the input
@@ -293,6 +328,7 @@ window.TagAutocomplete = (() => {
// Delay to allow suggestion tap/scroll to complete first
setTimeout(() => {
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
if (activeInstance && wrapper.contains(document.activeElement)) return;
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {

View File

@@ -7,6 +7,101 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
.replace(/'/g, "&#039;");
});
// Throttled queue to capture the first frame of video files asynchronously without blocking the browser
class VideoThumbnailQueue {
constructor(concurrency = 3) {
this.concurrency = concurrency;
this.activeCount = 0;
this.queue = [];
}
add(file, callback) {
this.queue.push({ file, callback });
this.next();
}
next() {
if (this.activeCount >= this.concurrency || this.queue.length === 0) return;
const { file, callback } = this.queue.shift();
this.activeCount++;
this.capture(file)
.then(dataUrl => callback(dataUrl))
.catch(err => {
console.warn('[VideoThumbnailQueue] Error capturing thumbnail:', err);
callback(null);
})
.finally(() => {
this.activeCount--;
this.next();
});
}
capture(file) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
const objectUrl = URL.createObjectURL(file);
video.src = objectUrl;
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
video.src = '';
video.load();
URL.revokeObjectURL(objectUrl);
};
video.onloadeddata = () => {
video.currentTime = 0.1;
};
video.onseeked = () => {
try {
const canvas = document.createElement('canvas');
const maxDim = 320;
let width = video.videoWidth || 160;
let height = video.videoHeight || 120;
if (width > maxDim || height > maxDim) {
if (width > height) {
height = Math.round((height * maxDim) / width);
width = maxDim;
} else {
width = Math.round((width * maxDim) / height);
height = maxDim;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
cleanup();
resolve(dataUrl);
} catch (e) {
cleanup();
reject(e);
}
};
video.onerror = () => {
cleanup();
reject(new Error('Video loading failed'));
};
setTimeout(() => {
if (!cleanedUp) {
cleanup();
reject(new Error('Capture timeout'));
}
}, 8000);
});
}
}
const videoThumbnailQueue = new VideoThumbnailQueue(3);
window.initUploadForm = (selector) => {
const form = (typeof selector === 'string') ? document.querySelector(selector) : selector;
if (!form) return;
@@ -15,6 +110,8 @@ window.initUploadForm = (selector) => {
if (form._f0ckInit) return form._f0ckUploader;
form._f0ckInit = true;
let isUploading = false;
// Use querySelector to find elements within this specific form instance
const fileInput = form.querySelector('.file-input');
const dropZone = form.querySelector('.drop-zone');
@@ -70,8 +167,13 @@ window.initUploadForm = (selector) => {
// Dynamically get min tags requirement from DOM
const minTags = parseInt(form.getAttribute('data-min-tags') || '3');
const commentMaxLenAttr = form.getAttribute('data-comment-max-length');
const commentMaxLen = (commentMaxLenAttr && commentMaxLenAttr !== 'null') ? parseInt(commentMaxLenAttr) : null;
const isShitpost = form.classList.contains('shitpost-mode-active') || !!window.f0ckShitpostMode;
// Config-driven shitpost overrides
const shitpostRequireRating = isShitpost && !!window.f0ckShitpostRequireRating;
const shitpostMinTags = isShitpost ? (parseInt(window.f0ckShitpostMinTags) || 0) : 0;
let tags = [];
let autoTags = []; // Track tags suggested from metadata
let selectedFiles = []; // Array of files for shitpost_mode
@@ -488,7 +590,7 @@ window.initUploadForm = (selector) => {
}
lines.forEach(url => {
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', is_oc: false });
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
}
});
urlInput.value = '';
@@ -511,7 +613,7 @@ window.initUploadForm = (selector) => {
const val = urlInput.value.trim();
if (!val || !/^https?:\/\//i.test(val)) return;
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', is_oc: false });
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
}
urlInput.value = '';
if (urlBadge) urlBadge.style.display = 'none';
@@ -543,17 +645,31 @@ window.initUploadForm = (selector) => {
};
const updateSubmitButton = () => {
if (isUploading) {
if (submitBtn) submitBtn.disabled = true;
return;
}
const isShitpost = !!window.f0ckShitpostMode;
const rating = form.querySelector('input[name="rating"]:checked');
// In Shitpost Mode, ratings are per-item (optional) and tags are optional — just need files
const hasRating = (isShitpost && activeMode === 'file') ? true : (rating !== null);
// In Shitpost Mode, ratings are per-item. If require rating is true, every item must be rated.
let hasRating = true;
if (isShitpost && activeMode === 'file') {
if (shitpostRequireRating) {
hasRating = selectedFiles.length > 0 && selectedFiles.every(item => ['sfw', 'nsfw', 'nsfl'].includes(item.rating));
}
} else {
hasRating = (rating !== null);
}
let hasTags = true;
if (!isShitpost) {
hasTags = tags.length >= minTags;
} else if (shitpostMinTags > 0 && activeMode === 'file') {
// In shitpost file mode with min-tags enforced: every queued item must meet the threshold.
hasTags = selectedFiles.length === 0 || selectedFiles.every(item => (item.tags || []).length >= shitpostMinTags);
}
// In shitpost file mode: hasTags is always true (untagged is allowed)
// Toggle visibility of global rating/comment/tag sections
const ratingSec = form.querySelector('.global-rating-section');
@@ -604,19 +720,28 @@ window.initUploadForm = (selector) => {
? (ssrSelectFileText || i18n.select_file || 'Select a file')
: (i18n.enter_url || 'Enter a URL');
} else if (!hasTags) {
// non-shitpost only
// non-shitpost or shitpost with min-tags
if (isShitpost && shitpostMinTags > 0) {
const remaining = shitpostMinTags - Math.min(...selectedFiles.map(item => (item.tags || []).length));
btnText.textContent = `${remaining} more tag${remaining !== 1 ? 's' : ''} required per item`;
} else {
const remaining = minTags - tags.length;
const tpl = i18n.tags_required || '{n} more tag{s} required';
btnText.textContent = tpl
.replace('{n}', remaining)
.replace('{s}', remaining !== 1 ? 's' : '');
}
} else if (!hasRating) {
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
if (isShitpost && shitpostRequireRating) {
btnText.textContent = 'Select a rating for each item';
} else {
if (nsflEnabled) {
btnText.textContent = i18n.select_rating_nsfl || 'Select SFW, NSFW or NSFL';
} else {
btnText.textContent = i18n.select_rating || 'Select SFW or NSFW';
}
}
} else {
if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
btnText.textContent = i18n.embed_youtube || 'Embed YouTube Video';
@@ -645,7 +770,11 @@ window.initUploadForm = (selector) => {
// If files were provided, process them (append or replace)
if (files && files.length > 0) {
const filesToProcess = isShitpost ? Array.from(files) : [files[0]];
if (!isShitpost) selectedFiles = []; // Reset for normal mode
if (!isShitpost) {
selectedFiles = []; // Reset for normal mode — replace, not append
// Also wipe the preview DOM so the old card doesn't linger
if (filePreview) filePreview.innerHTML = '';
}
for (const file of filesToProcess) {
if (!file) continue;
@@ -653,6 +782,7 @@ window.initUploadForm = (selector) => {
// Basic validation (MIME/Extension/Size)
const container = form.closest('.upload-container');
const mimesSource = form.getAttribute('data-mimes') || (container ? container.getAttribute('data-mimes') : null);
const swfEnabled = form.getAttribute('data-enable-swf') !== '0';
let allowedMimes = [];
let allowedExts = [];
try {
@@ -664,6 +794,19 @@ window.initUploadForm = (selector) => {
}
const fileExt = file.name.split('.').pop().toLowerCase();
const isSwfFile = fileExt === 'swf' ||
file.type === 'application/x-shockwave-flash' ||
file.type === 'application/vnd.adobe.flash.movie';
// Reject SWF when Flash uploads are disabled
if (isSwfFile && !swfEnabled) {
const errorMsg = 'Flash (.swf) uploads are disabled.';
if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error');
else if (window.showFlash) window.showFlash(errorMsg, 'error');
else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; }
continue;
}
const mimeOk = !file.type || allowedMimes.includes(file.type);
const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt);
@@ -687,7 +830,7 @@ window.initUploadForm = (selector) => {
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
if (isShitpost) {
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', is_oc: false });
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
} else {
selectedFiles.push(file); // Legacy single file mode uses raw File
}
@@ -733,6 +876,8 @@ window.initUploadForm = (selector) => {
activeMode = 'file';
}
let lastNewPreviewItem = null;
// Build preview items — skip items already rendered (append-only)
selectedFiles.forEach((item, index) => {
if (item._rendered) return; // already in DOM, don't touch it
@@ -795,9 +940,25 @@ window.initUploadForm = (selector) => {
mediaElem = document.createElement('video');
mediaElem.src = URL.createObjectURL(file);
mediaElem.muted = true;
mediaElem.autoplay = true;
mediaElem.controls = true;
mediaElem.loop = true;
if (isShitpost) {
mediaElem.autoplay = false;
mediaElem.preload = 'none';
mediaElem.classList.add('video-thumbnail-loading');
videoThumbnailQueue.add(file, (dataUrl) => {
if (dataUrl) {
mediaElem.poster = dataUrl;
mediaElem.classList.remove('video-thumbnail-loading');
} else {
mediaElem.classList.remove('video-thumbnail-loading');
}
});
} else {
mediaElem.autoplay = true;
}
} else if (file.type.startsWith('audio/')) {
mediaElem = document.createElement('audio');
mediaElem.src = URL.createObjectURL(file);
@@ -805,8 +966,147 @@ window.initUploadForm = (selector) => {
mediaElem.style.width = '100%';
} else if (file.type === 'application/x-shockwave-flash' || file.type === 'application/vnd.adobe.flash.movie' || fileExt === 'swf') {
mediaElem = document.createElement('div');
mediaElem.className = 'generic-file-icon swf-preview-icon';
mediaElem.innerHTML = '<span style="font-size:1.5em;">⚡</span>';
mediaElem.className = 'swf-upload-preview';
mediaElem.dataset.swfFile = 'pending';
// Placeholder shown while Ruffle loads
mediaElem.innerHTML = `<div class="swf-upload-placeholder"><span class="swf-upload-placeholder-icon">⚡</span><span class="swf-upload-placeholder-text">Loading Flash preview…</span></div>`;
// Load Ruffle asynchronously once the element is in the DOM
const swfObjectUrl = URL.createObjectURL(file);
const ensureRuffleUpload = (cb) => {
if (window.RufflePlayer && typeof window.RufflePlayer.newest === 'function') { cb(); return; }
if (document.querySelector('script[src*="/s/ruffle/ruffle.js"]')) {
// Script is loading, poll for it
let attempts = 0;
const poll = setInterval(() => {
attempts++;
if ((window.RufflePlayer && typeof window.RufflePlayer.newest === 'function') || attempts >= 80) {
clearInterval(poll);
cb();
}
}, 100);
return;
}
const s = document.createElement('script');
s.src = '/s/ruffle/ruffle.js';
s.onload = () => cb();
s.onerror = () => cb(); // proceed even if fail
document.head.appendChild(s);
};
// Defer init until next microtask so mediaElem is appended to DOM first
Promise.resolve().then(() => {
ensureRuffleUpload(() => {
if (!mediaElem.isConnected) { URL.revokeObjectURL(swfObjectUrl); return; }
const ruffle = window.RufflePlayer && window.RufflePlayer.newest ? window.RufflePlayer.newest() : null;
if (!ruffle) {
mediaElem.innerHTML = `<div class="swf-upload-placeholder"><span class="swf-upload-placeholder-icon">⚡</span><span class="swf-upload-placeholder-text">Flash preview unavailable</span></div>`;
return;
}
try {
// Patch getContext BEFORE creating the player so that Ruffle's WebGL
// context is created with preserveDrawingBuffer:true.
// Without this, WebGL clears the drawing buffer after each frame
// presentation, making canvas readback produce solid black.
const _origGetCtx = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true });
}
return _origGetCtx.call(this, type, attrs);
};
const player = ruffle.createPlayer();
player.style.cssText = 'width:100%;height:100%;display:block;border-radius:8px;';
const placeholder = mediaElem.querySelector('.swf-upload-placeholder');
if (placeholder) placeholder.remove();
mediaElem.appendChild(player);
player.load({ url: swfObjectUrl, config: { volume: 0.5 } });
// Restore getContext after Ruffle's WASM finishes creating its GL context
// (typically within ~2s of load; 6s is a safe upper bound)
setTimeout(() => { HTMLCanvasElement.prototype.getContext = _origGetCtx; }, 6000);
mediaElem._rufflePlayer = player;
mediaElem._swfObjectUrl = swfObjectUrl;
// Inject snapshot button directly below the Ruffle player
// (inside the .swf-upload-preview, so it travels with each file-preview-item)
if (!mediaElem.querySelector('.btn-ruffle-snapshot')) {
const snapBtn = document.createElement('button');
snapBtn.type = 'button';
snapBtn.className = 'btn-ruffle-snapshot';
snapBtn.textContent = 'Capture Thumbnail';
snapBtn.title = 'Capture the current frame of the Flash preview as the thumbnail';
mediaElem.appendChild(snapBtn);
}
// Wire snapshot button — scoped to this previewItem / mediaElem
const wireSnapshot = () => {
const snapBtn = mediaElem.querySelector('.btn-ruffle-snapshot');
if (!snapBtn) return;
snapBtn.onclick = async (e) => {
e.preventDefault();
try {
// Try to find Ruffle's internal canvas via shadow DOM
let canvas = null;
const tryFindCanvas = (root) => {
if (!root) return null;
const c = root.querySelector('canvas');
if (c) return c;
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
const found = tryFindCanvas(el.shadowRoot);
if (found) return found;
}
}
return null;
};
canvas = tryFindCanvas(player.shadowRoot || player);
if (!canvas) canvas = tryFindCanvas(player);
if (canvas && canvas.width > 0 && canvas.height > 0) {
const out = document.createElement('canvas');
const MAX = 640;
const w = canvas.width, h = canvas.height;
if (w > MAX || h > MAX) {
const ratio = Math.min(MAX / w, MAX / h);
out.width = Math.round(w * ratio);
out.height = Math.round(h * ratio);
} else {
out.width = w || 320;
out.height = h || 240;
}
const ctx = out.getContext('2d');
ctx.drawImage(canvas, 0, 0, out.width, out.height);
out.toBlob((blob) => {
if (!blob) { snapBtn.textContent = '❌ Capture failed'; return; }
const snapFile = new File([blob], 'ruffle-snapshot.jpg', { type: 'image/jpeg' });
const dt = new DataTransfer();
dt.items.add(snapFile);
if (thumbInput) {
thumbInput.files = dt.files;
thumbInput.dispatchEvent(new Event('change', { bubbles: true }));
}
// Replace snapshot preview in its grid row (between player and button)
const existingPrev = mediaElem.querySelector('.ruffle-snapshot-preview');
if (existingPrev) existingPrev.remove();
const prevImg = document.createElement('img');
prevImg.src = URL.createObjectURL(blob);
prevImg.className = 'ruffle-snapshot-preview';
mediaElem.insertBefore(prevImg, snapBtn);
}, 'image/jpeg', 0.92);
} else {
snapBtn.textContent = 'Capture Thumbnail';
}
} catch(err) {
console.warn('[Ruffle snapshot]', err);
snapBtn.textContent = 'Capture Thumbnail';
}
};
};
// Wire after a short delay (Ruffle may not be fully ready)
setTimeout(wireSnapshot, 200);
} catch(err) {
console.warn('[Ruffle upload preview]', err);
mediaElem.innerHTML = `<div class="swf-upload-placeholder"><span class="swf-upload-placeholder-icon">⚡</span><span class="swf-upload-placeholder-text">Flash preview error</span></div>`;
}
});
});
} else if (file.type === 'application/pdf' || fileExt === 'pdf') {
mediaElem = document.createElement('div');
mediaElem.className = 'generic-file-icon pdf-preview-icon';
@@ -835,21 +1135,24 @@ window.initUploadForm = (selector) => {
let tagsUI = '';
let ocUI = '';
let commentUI = '';
let titleUI = '';
if (isShitpost) {
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
// Build per-item rating HTML
const ratingValue = item.rating;
ratingSwitch = `
<div class="item-rating-container">
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="sfw" ${item.rating === 'sfw' ? 'checked' : ''}>
<input type="radio" name="rating_${index}" value="sfw" ${ratingValue === 'sfw' ? 'checked' : ''}>
<span class="item-rating-label sfw">SFW</span>
</label>
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="nsfw" ${item.rating === 'nsfw' ? 'checked' : ''}>
<input type="radio" name="rating_${index}" value="nsfw" ${ratingValue === 'nsfw' ? 'checked' : ''}>
<span class="item-rating-label nsfw">NSFW</span>
</label>
${nsflEnabled ? `
<label class="item-rating-option">
<input type="radio" name="rating_${index}" value="nsfl" ${item.rating === 'nsfl' ? 'checked' : ''}>
<input type="radio" name="rating_${index}" value="nsfl" ${ratingValue === 'nsfl' ? 'checked' : ''}>
<span class="item-rating-label nsfl">NSFL</span>
</label>
` : ''}
@@ -857,20 +1160,29 @@ window.initUploadForm = (selector) => {
`;
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
tagsUI = `
<div class="item-tags-container">
<div class="item-tags-list"></div>
<input type="text" class="item-tag-input" placeholder="${window.escapeHtmlUpload(tagsPlaceholder)}" enterkeyhint="done">
<input type="text" class="item-tag-input" placeholder="${window.escapeHtmlUpload(tagsPlaceholder + minTagsHint)}" enterkeyhint="done">
<div class="tag-suggestions" style="display:none;"></div>
<div class="item-meta-suggestions" style="display:none; margin-top:5px; font-size:0.7rem; opacity:0.6;"></div>
</div>
`;
if (window.f0ckEnableItemTitle !== false) {
titleUI = `
<div class="item-title-container">
<input type="text" class="item-title-input" placeholder="Add Title..." maxlength="500" value="${window.escapeHtmlUpload(item.title || '')}">
</div>
`;
}
const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'Comment (optional)...';
const commentPlaceholder = window.f0ckI18n?.upload_comment_placeholder || 'AddComment...';
const maxLenHtml = (commentMaxLen !== null && !isNaN(commentMaxLen)) ? ` maxlength="${commentMaxLen}"` : '';
commentUI = `
<div class="item-comment-container">
<textarea class="item-comment-input" placeholder="${window.escapeHtmlUpload(commentPlaceholder)}">${window.escapeHtmlUpload(item.comment || '')}</textarea>
<textarea class="item-comment-input" placeholder="${window.escapeHtmlUpload(commentPlaceholder)}"${maxLenHtml}>${window.escapeHtmlUpload(item.comment || '')}</textarea>
<div class="item-comment-actions">
<button type="button" class="item-emoji-trigger" title="Emoji">&#x263A;</button>
</div>
@@ -888,6 +1200,7 @@ window.initUploadForm = (selector) => {
<span class="file-name-small" title="${window.escapeHtmlUpload(fileNameStr)}">${window.escapeHtmlUpload(fileNameStr)}</span>
<span class="file-size-small">${fileSizeStr}</span>
</div>
${titleUI}
${ratingSwitch}
${tagsUI}
${commentUI}
@@ -896,7 +1209,10 @@ window.initUploadForm = (selector) => {
if (isShitpost) {
// Handle Rating
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
radio.onchange = () => { item.rating = radio.value; };
radio.onchange = () => {
item.rating = radio.value;
updateSubmitButton();
};
});
// Handle Comment
@@ -907,6 +1223,12 @@ window.initUploadForm = (selector) => {
if (emojiTrigger) setupItemEmojiPicker(commentInput, emojiTrigger);
}
// Handle Title
const titleInput = infoRow.querySelector('.item-title-input');
if (titleInput) {
titleInput.oninput = () => { item.title = titleInput.value.trim(); };
}
// Handle Tags
const tagList = infoRow.querySelector('.item-tags-list');
const tagInput = infoRow.querySelector('.item-tag-input');
@@ -1112,6 +1434,12 @@ window.initUploadForm = (selector) => {
const idx = selectedFiles.indexOf(item);
if (idx !== -1) selectedFiles.splice(idx, 1);
item._rendered = false;
// Clean up Ruffle player and blob URL if this was a SWF preview
const swfPreview = previewItem.querySelector('.swf-upload-preview');
if (swfPreview) {
if (swfPreview._rufflePlayer) { try { swfPreview._rufflePlayer.pause(); swfPreview._rufflePlayer.remove(); } catch {} swfPreview._rufflePlayer = null; }
if (swfPreview._swfObjectUrl) { URL.revokeObjectURL(swfPreview._swfObjectUrl); swfPreview._swfObjectUrl = null; }
}
previewItem.remove();
if (selectedFiles.length === 0) {
@@ -1138,6 +1466,10 @@ window.initUploadForm = (selector) => {
previewItem.appendChild(infoRow);
previewItem.appendChild(removeBtn);
if (filePreview) filePreview.appendChild(previewItem);
if (isShitpost) {
lastNewPreviewItem = previewItem;
}
});
// "Add more" button for Shitpost Mode — reuse existing or create once, always move to end
@@ -1220,17 +1552,20 @@ window.initUploadForm = (selector) => {
}
}
// Toggle custom thumbnail for single SWF batch
// Hide thumbSection for SWF (snapshot button now lives inside each file-preview-item)
if (thumbSection) {
const firstItem = selectedFiles[0];
const firstFile = (firstItem && firstItem.file) || firstItem;
const isSingleSwf = firstFile && selectedFiles.length === 1 &&
(firstFile.type === 'application/x-shockwave-flash' || firstFile.type === 'application/vnd.adobe.flash.movie' || (firstFile.name && firstFile.name.endsWith('.swf')));
thumbSection.style.display = isSingleSwf ? 'block' : 'none';
thumbSection.style.display = 'none';
}
updateSubmitButton();
form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } }));
if (lastNewPreviewItem) {
setTimeout(() => {
lastNewPreviewItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
return true;
};
@@ -1267,6 +1602,11 @@ window.initUploadForm = (selector) => {
removeFile.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Clean up any Ruffle preview players and blob URLs
filePreview?.querySelectorAll('.swf-upload-preview').forEach(el => {
if (el._rufflePlayer) { try { el._rufflePlayer.pause(); el._rufflePlayer.remove(); } catch {} el._rufflePlayer = null; }
if (el._swfObjectUrl) { URL.revokeObjectURL(el._swfObjectUrl); el._swfObjectUrl = null; }
});
selectedFiles = [];
form.querySelector('.gps-privacy-warning')?.remove();
if (fileInput) fileInput.value = '';
@@ -1276,7 +1616,11 @@ window.initUploadForm = (selector) => {
const media = filePreview?.querySelector('.preview-media');
if (media) media.remove();
if (thumbSection) thumbSection.style.display = 'none';
if (thumbSection) {
thumbSection.style.display = 'none';
thumbSection.querySelector('.btn-ruffle-snapshot')?.remove();
thumbSection.querySelector('.ruffle-snapshot-preview')?.remove();
}
if (thumbInput) thumbInput.value = '';
updateSubmitButton();
@@ -1633,6 +1977,15 @@ window.initUploadForm = (selector) => {
window.f0ckInitTagAutocomplete(tagInput, tagSuggestions, addTag, () => tags);
}
// Prevent Enter in the title input from submitting the form and
// accidentally flushing whatever is currently typed in the tag input as a tag.
const titleInputEl = form.querySelector('.upload-title-input');
if (titleInputEl) {
titleInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') e.preventDefault();
});
}
form.querySelectorAll('input[name="rating"]').forEach(radio => {
radio.addEventListener('change', updateSubmitButton);
});
@@ -1641,7 +1994,7 @@ window.initUploadForm = (selector) => {
if (e && e.preventDefault) e.preventDefault();
// If already uploading, don't start again
if (submitBtn && submitBtn.disabled && submitBtn.querySelector('.btn-loading')?.style.display === 'inline') {
if (isUploading) {
return;
}
@@ -1670,8 +2023,10 @@ window.initUploadForm = (selector) => {
const dragModal = form.closest('#upload-drag-modal');
const comment = form.querySelector('.upload-comment')?.value.trim() || '';
const isOc = form.querySelector('#upload-oc-checkbox')?.checked || false;
const titleVal = form.querySelector('.upload-title-input')?.value.trim() || '';
const setBtnLoading = (text) => {
isUploading = true;
if (!submitBtn) return;
submitBtn.disabled = true;
const btnText = submitBtn.querySelector('.btn-text');
@@ -1684,12 +2039,14 @@ window.initUploadForm = (selector) => {
};
const restoreBtn = () => {
isUploading = false;
if (!submitBtn) return;
submitBtn.disabled = false;
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
if (btnText) btnText.style.display = 'inline';
if (btnLoading) btnLoading.style.display = 'none';
updateSubmitButton();
};
if (activeMode === 'url') {
@@ -1729,7 +2086,8 @@ window.initUploadForm = (selector) => {
rating: globalRatingEl.value,
tags: tags.join(','),
comment: comment,
is_oc: isOc
is_oc: isOc,
title: titleVal || undefined
})
});
@@ -1777,15 +2135,18 @@ window.initUploadForm = (selector) => {
form._f0ckUploader.reset();
if (isShitpost) {
// Flash message removed as requested
if (lastData?.manual_approval && typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else {
if (!dragModal && statusDiv) {
if (lastData?.manual_approval) {
if (typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful');
statusDiv.className = 'upload-status success';
}
if (lastData?.manual_approval && typeof window.showFlash === 'function') {
window.showFlash('Upload awaits approval, please be patient', 'info');
}
}
setTimeout(() => {
@@ -1816,6 +2177,7 @@ window.initUploadForm = (selector) => {
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
const fileTags = isShitpost ? item.tags : tags;
const fileComment = isShitpost ? item.comment : comment;
const fileTitle = isShitpost ? (item.title || '') : titleVal;
if (isShitpost) {
const statusMsg = window.f0ckI18n?.upload_shitposting_status || 'Shitposting';
@@ -1832,6 +2194,7 @@ window.initUploadForm = (selector) => {
formData.append('tags', fileTags.join(','));
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
if (isShitpost) formData.append('is_shitpost', 'true');
if (fileTitle) formData.append('title', fileTitle);
// Add custom thumbnail if provided (only for single SWF files)
if (selectedFiles.length === 1 && thumbInput && thumbInput.files && thumbInput.files[0]) {
@@ -1882,7 +2245,8 @@ window.initUploadForm = (selector) => {
tags: fileTags.join(','),
is_oc: (isShitpost ? item.is_oc : isOc),
comment: fileComment,
is_shitpost: isShitpost ? true : undefined
is_shitpost: isShitpost ? true : undefined,
title: fileTitle || undefined
}));
} else {
xhr.send(formData);
@@ -1908,8 +2272,19 @@ window.initUploadForm = (selector) => {
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
} catch(e) {}
}
} else if (!isShitpost) {
throw new Error(res.msg || 'Upload failed');
} else {
// Server returned an error — always surface it visibly
const errMsg = res.msg || 'Upload failed';
if (isShitpost) {
// In shitpost mode there's no persistent statusDiv — use flash
if (typeof window.flashMessage === 'function') {
window.flashMessage(`${errMsg}`, 5000, 'error');
} else if (typeof window.showFlash === 'function') {
window.showFlash(errMsg, 'error');
}
} else {
throw new Error(errMsg);
}
}
} catch (err) {
console.error('[UPLOAD ERROR]', err);
@@ -1919,6 +2294,13 @@ window.initUploadForm = (selector) => {
if (progressContainer) progressContainer.style.display = 'none';
restoreBtn();
return;
} else {
// Shitpost mode: show via flash toast
if (typeof window.flashMessage === 'function') {
window.flashMessage(`${err.message}`, 5000, 'error');
} else if (typeof window.showFlash === 'function') {
window.showFlash(err.message, 'error');
}
}
}
}
@@ -1927,11 +2309,19 @@ window.initUploadForm = (selector) => {
if (dragModal) dragModal.classList.remove('show');
form._f0ckUploader.reset();
if (isShitpost) {
// Flash message removed as requested
if (lastData?.manual_approval && typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else {
if (lastData?.manual_approval) {
if (typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful');
statusDiv.className = 'upload-status success';
}
}
setTimeout(() => {
if (typeof window.loadPageAjax === 'function') window.loadPageAjax('/');
@@ -1952,6 +2342,7 @@ window.initUploadForm = (selector) => {
handleFile: handleFile,
performUpload: performUpload,
reset: () => {
isUploading = false;
form.reset();
tags = [];
selectedFiles = [];

View File

@@ -56,7 +56,7 @@
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge", "mr-2");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
@@ -189,7 +189,6 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {

View File

@@ -1,4 +1,5 @@
class UserCommentSystem {
if (!window.UserCommentSystem) {
window.UserCommentSystem = class UserCommentSystem {
constructor() {
this.container = document.getElementById('user-comments-container');
this.username = this.container ? this.container.dataset.user : null;
@@ -8,6 +9,8 @@ class UserCommentSystem {
this.userColor = null;
this.customEmojis = UserCommentSystem.emojiCache || {};
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>`,
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,7 +26,10 @@ class UserCommentSystem {
}
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);
if (el && this.container.contains(el)) {
const contentEl = el.querySelector('.comment-content');
@@ -37,7 +43,7 @@ class UserCommentSystem {
}
async init() {
this.loadEmojis();
await this.loadEmojis();
this.loadMore();
this.loadMore();
this.bindEvents();
@@ -129,11 +135,74 @@ class UserCommentSystem {
renderEmoji(match, name) {
if (this.customEmojis && this.customEmojis[name]) {
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
return `<img src="${this.customEmojis[name]}" class="emoji" alt="${match}" title="${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) {
if (!content) return '';
@@ -152,7 +221,7 @@ class UserCommentSystem {
let escaped = this.escapeHtml(content).replace(/&gt;/g, ">");
// 2. Mentions
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
const siteOrigin = window.location.origin;
const renderer = new marked.Renderer();
@@ -191,6 +260,32 @@ class UserCommentSystem {
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
const renderedLines = escaped.split('\n').map(line => {
const trimmed = line.trimStart();
@@ -203,7 +298,13 @@ class UserCommentSystem {
if (line.length > 10000) return line;
if (!line.trim()) return '&nbsp;';
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
let processedLine = line;
// Handle Mentions
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
const user = g1 || g2;
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
});
// Handle Comment Context Links (>>ID)
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
@@ -211,6 +312,28 @@ class UserCommentSystem {
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 `![image](${fullUrl})`;
});
// 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, '\\*');
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
@@ -264,35 +387,7 @@ class UserCommentSystem {
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content, c.item_id);
// Replicating the structure of comments.js but adapting for the list view
// 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>
`;
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>`;
}
startLiveTimestamps() {
@@ -336,15 +431,21 @@ class UserCommentSystem {
return div.innerHTML;
}
}
}
// Initializer for AJAX and standard load
window.initUserComments = () => {
// Prevent multiple instances if already running on this container
if (document.getElementById('user-comments-container')) {
const container = document.getElementById('user-comments-container');
if (container && !container.dataset.initialized) {
container.dataset.initialized = 'true';
new UserCommentSystem();
}
};
window.addEventListener('DOMContentLoaded', () => {
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', () => {
window.initUserComments();
});
});
} else {
window.initUserComments();
}

View File

@@ -53,13 +53,23 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
</button>
</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">
<svg style="width: 60px; height: 60px;">
<use href="${svg}#play"></use>
</svg>
</div>
<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"></div>
</div>
@@ -111,10 +121,7 @@ class v0ck {
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"]')) {
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 overlay = player.querySelector('.v0ck_overlay');
const volumeButton = player.querySelector('.v0ck_volume');
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
const volumeSymbols = volumeButton.querySelectorAll('use');
const defaultVolume = 0.5;
let mousedown = false;
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) {
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
switch (true) {
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
[...volumeSymbols].forEach(s => s.classList.add('v0ck_hidden'));
let targetId = 'v0ck_svg_volume_full';
if (vol === 0) {
targetId = 'v0ck_svg_volume_mute';
} 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);
}
@@ -262,14 +286,31 @@ class v0ck {
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 => {
if (ignoreNextClick) {
e.stopPropagation();
e.preventDefault();
ignoreNextClick = false;
return;
}
const path = e.path || (e.composedPath && e.composedPath());
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
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');
return;
}
controlsJustShown = false;
togglePlay(e);
}
});
@@ -325,11 +366,21 @@ class v0ck {
hud.classList.remove('v0ck_hidden');
hudBar.style.width = `${vol * 100}%`;
// Update HUD icon based on volume
let icon = 'volume_full';
if (vol === 0) icon = 'volume_mute';
else if (vol <= 0.5) icon = 'volume_mid';
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
// Update HUD icon based on volume by toggling hidden class
const hudSymbols = hud.querySelectorAll('.v0ck_hud_icon');
hudSymbols.forEach(s => s.classList.add('v0ck_hidden'));
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);
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
@@ -348,7 +399,7 @@ class v0ck {
startY = touch.clientY;
startVol = video.volume;
}
}, { passive: true });
}, { passive: false });
player.addEventListener('touchmove', e => {
if (!isMobile || !isRightSide || gestureType === 'other') return;
@@ -361,6 +412,8 @@ class v0ck {
if (gestureType === 'none') {
if (dy > dx && dy > 5) {
gestureType = 'volume';
clearTimeout(speedUpTimeout);
endSpeedUp();
} else if (dx > dy && dx > 5) {
gestureType = 'other'; // Probably seeking or horizontal swipe
return;
@@ -370,6 +423,9 @@ class v0ck {
}
if (gestureType === 'volume') {
clearTimeout(speedUpTimeout);
endSpeedUp();
const deltaY = startY - touch.clientY; // swipe up is positive
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
let newVol = startVol + (deltaY / sensitivity);
@@ -384,9 +440,86 @@ class v0ck {
if (e.cancelable) e.preventDefault();
}
}, { 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));
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
ranges.forEach(range => range.addEventListener('input', 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('touchstart', scrub, { passive: false });
progress.addEventListener('touchmove', scrub, { passive: false });
@@ -397,8 +530,28 @@ class v0ck {
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
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;
}
}
// Attempt autoplay and show overlay if blocked
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
if (shouldAutoplay) {
const playPromise = togglePlay();
if (playPromise !== undefined) {
@@ -541,6 +694,162 @@ class v0ck {
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.enterFullScreen = enterFullScreen;

View 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);
});

View 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);
});

View File

@@ -1,7 +1,6 @@
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
import cfg from "../src/inc/config.mjs";
import { getDefaultLayout } from "../src/inc/settings.mjs";
const [username, password] = process.argv.slice(2);
@@ -48,7 +47,7 @@ async function createAdmin() {
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'}, 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 ---`);

View 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);
});

View File

@@ -8,6 +8,10 @@
* node regen.mjs --all - Regenerate ALL items
* node regen.mjs --audio - Regenerate all audio 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";
@@ -26,14 +30,40 @@ if (args.length === 0) {
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 --youtube - Regenerate all YouTube thumbnails');
console.log(' node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items');
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 blurOnly = args.includes('--blur');
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
const regen = async (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})`);
try {
@@ -49,23 +79,23 @@ const regen = async (item) => {
console.log(`[${id}] ✓ Thumbnail regenerated`);
}
// Regenerate blurred thumbnail if item has NSFW tag
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
if (nsfw.length > 0) {
// Regenerate blurred thumbnail unconditionally
await queue.genBlurredThumbnail(id, false);
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
}
} catch (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 {
let items;
if (args.includes('--all')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
console.log(`Regenerating ALL ${items.length} items...\n`);
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} non-Flash items...\n`);
} 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`;
console.log(`Regenerating ${items.length} audio items...\n`);
@@ -75,6 +105,14 @@ try {
} 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`;
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 {
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
@@ -97,3 +135,4 @@ try {
console.error('Fatal error:', err);
process.exit(1);
}

View File

@@ -19,7 +19,8 @@ const sendJson = (res, data, code = 200) => {
// Generate UUID using the same method as video uploads
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) => {

View File

@@ -12,7 +12,6 @@ const sendJson = (res, data, code = 200) => {
res.end(JSON.stringify(data));
};
// One-time migration: ensure comment_files table exists
db`CREATE TABLE IF NOT EXISTS public.comment_files (
id SERIAL PRIMARY KEY,
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,
created_at TIMESTAMPTZ DEFAULT NOW()
)`.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_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.
@@ -92,12 +95,19 @@ const parseMultipartFiles = (buffer, boundary) => {
};
/**
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
* Filters from cfg.mimes, excluding PDF, SWF, etc.
* Build the allowed MIME list for comment uploads.
* 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 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 =>
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);
}
const allowedMimes = getAllowedCommentMimes();
const results = [];
@@ -374,14 +385,10 @@ export const handleCommentUpload = async (req, res) => {
try {
phash = await queue.generatePHash(tmpPath);
if (phash && !linkedToExisting) {
// Check comment_files for visual duplicate
const cfItems = await db`
SELECT id, phash, dest FROM comment_files
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
`;
for (const cf of cfItems) {
if (isPhashMatch(phash, cf.phash)) {
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
// Check comment_files for visual duplicate using fast SQL query
const commentMatch = await queue.checkcommentrepostphash(phash);
if (commentMatch) {
const existingAbsPath = path.join(cfg.paths.c, commentMatch.dest);
try {
const realTarget = await fs.realpath(existingAbsPath);
const destPath = path.join(cfg.paths.c, filename);
@@ -392,11 +399,9 @@ export const handleCommentUpload = async (req, res) => {
} catch (e) {
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) {
const phashMatch = await queue.checkrepostphash(phash);
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.
* 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 seeks = ['20%', '40%', '60%', '80%'];
for (const seek of seeks) {
try {
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 {
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
if (parseFloat(stdout.trim()) > 0.05) break;
@@ -541,41 +640,4 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
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;
}

View 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);
}
}

View File

@@ -7,6 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
import crypto from "crypto";
const execFile = promisify(_execFile);
@@ -80,11 +81,11 @@ export const handleEmojiUpload = async (req, res) => {
const file = parts.file;
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 originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
const webpFilename = `${name}_${randSuffix}.webp`;
const webpFilename = `${randSuffix}.webp`;
const webpPath = path.join(cfg.paths.emojis, webpFilename);
if (originalExt === 'webp') {
@@ -135,3 +136,133 @@ export const handleEmojiUpload = async (req, res) => {
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);
}
};

View File

@@ -269,6 +269,9 @@ export const handleHallUpdate = async (req, res) => {
// POST /api/v2/admin/halls — create a new hall
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
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
if (!token || token !== session.csrf_token) {

View File

@@ -52,6 +52,7 @@ config.paths = {
emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'),
memes: resolvePath('public/memes'),
e: resolvePath('e'),
pending: resolvePath('pending'),
deleted: resolvePath('deleted'),
logs: resolvePath('logs'),

View File

@@ -81,6 +81,37 @@ export default new class {
}
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() {
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
};
@@ -102,8 +133,14 @@ export default new class {
// Build suffix with query params
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 {
main: tmp,
mainDisplay,
path: env.path ? env.path : '',
suffix: suffix
};
@@ -183,10 +220,30 @@ export default new class {
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
};
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 derivedKey = await scrypt(str, salt, 64);
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) {
const tags = await db`
@@ -314,6 +371,67 @@ export default new class {
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) {
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
let options = "Path=/; SameSite=Lax";

View File

@@ -120,6 +120,7 @@
"switching": "Wird umgeschaltet...",
"generating": "Wird generiert...",
"title": "Einstellungen",
"profile": "Profil",
"avatar": "Avatar",
"current_avatar": "Aktueller Avatar",
"upload_custom_avatar": "Eigenen Avatar hochladen",
@@ -134,16 +135,15 @@
"clear": "Löschen",
"preferences": "Einstellungen",
"ui_section": "Benutzeroberfläche",
"content_preferences_section": "Inhaltseinstellungen",
"appearance_section": "Erscheinungsbild",
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
"feed_layout": "Feed-Layout",
"feed_layout_hint": "Wähle, wie die Hauptseite Beiträge anzeigt",
"feed_layout_grid": "Raster (Kompakt)",
"feed_layout_modern": "Raster (3-spaltig Modern)",
"feed_layout_feed": "Feed (X / Instagram-Stil)",
"feed_layout_youtube": "YouTube-Stil",
"modern_layout": "Modernes Layout",
"modern_layout_hint": "3-Spalten-Layout",
"alternative_infobox": "Alternativer Autor-Infoblock",
"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",
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
"disable_swiping": "Wischen deaktivieren",
@@ -152,6 +152,16 @@
"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_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_hint": ":emoji:-Bilder in >zitierten Zeilen anzeigen",
"embed_yt": "YouTube-Videos in Kommentaren einbetten",
@@ -160,7 +170,7 @@
"hide_koepfe_hint": "Die Köpfe deaktivieren",
"comment_display_mode": "Kommentar-Anzeigemodus",
"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.",
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
"language": "Sprache",
@@ -315,7 +325,20 @@
"attach_file": "Datei anhängen",
"uploading_file": "Wird hochgeladen...",
"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": {
"select_file": "Datei auswählen",
@@ -410,6 +433,10 @@
},
"sidebar": {
"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",
"read_more": "mehr sehen",
"see_less": "weniger anzeigen",
@@ -434,13 +461,13 @@
},
"ranking": {
"title": "Rangliste",
"top_contributors": "Top-Mitwirkende",
"top_contributors": "Größte Etikettierer",
"col_rank": "Rang",
"col_avatar": "Avatar",
"col_username": "Nutzername",
"col_tagged": "Markiert",
"tag_stats": "Statistiken",
"stat_total": "Gesamt",
"stat_total": "Gesamt Inhalte",
"stat_tagged": "Markiert",
"stat_untagged": "Unmarkiert",
"stat_sfw": "SFW-Inhalte",
@@ -450,6 +477,7 @@
"stat_comments": "Gesamt Kommentare",
"stat_favs": "Gesamt Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"most_favorited": "Meiste Favs",
"favs": "Favs",
"top_xd": "Top xD-Score"
@@ -522,6 +550,24 @@
"found": "Gefundene Metadaten:",
"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": {
"add_text_layer": "Textebene hinzufügen",
"tags_label": "Tags (kommagetrennt)",
@@ -532,7 +578,12 @@
"text_layer": "Textebene",
"enter_text": "Text eingeben...",
"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": {
"just_now": "gerade eben",
@@ -697,5 +748,30 @@
"left_hand_desc": "Du weißt bescheid.",
"replying_to": "Antwort an {user}",
"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."
}
}

View File

@@ -64,8 +64,8 @@
"remove_file": "Remove File",
"cancel_upload": "Cancel Upload",
"shitpost_success": "Successfully shitposted {n} items!",
"shitposting_status": "Shitposting",
"item_comment_placeholder": "Comment (optional)...",
"shitposting_status": "Uploading",
"item_comment_placeholder": "Write a Comment...",
"item_tags_placeholder": "Tags...",
"btn_add_urls": "Add URL(s)",
"tags_required_shitpost": "All items need tags",
@@ -120,6 +120,7 @@
"switching": "Switching...",
"generating": "Generating...",
"title": "Settings",
"profile": "Profile",
"avatar": "Avatar",
"current_avatar": "Current Avatar",
"upload_custom_avatar": "Upload Custom Avatar",
@@ -134,16 +135,15 @@
"clear": "Clear",
"preferences": "Preferences",
"ui_section": "User Interface",
"content_preferences_section": "Content Preferences",
"appearance_section": "Appearance",
"show_motd": "Show Message of the Day (MOTD)",
"feed_layout": "Feed Layout",
"feed_layout_hint": "Choose how the main page displays posts",
"feed_layout_grid": "Grid (Compact)",
"feed_layout_modern": "Grid (3-column Modern)",
"feed_layout_feed": "Feed (X / Instagram style)",
"feed_layout_youtube": "YouTube Style",
"modern_layout": "Modern layout",
"modern_layout_hint": "3 Column Layout",
"alternative_infobox": "Alternative Author Infobox",
"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",
"disable_autoplay": "Disable Autoplay",
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
"disable_swiping": "Disable Swiping",
@@ -152,6 +152,16 @@
"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_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_hint": "Show :emoji: images inside >quoted lines",
"embed_yt": "Embed YouTube links in comments",
@@ -160,7 +170,7 @@
"hide_koepfe_hint": "Disable the Köpfe",
"comment_display_mode": "Comment Display Mode",
"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.",
"forced_mode_notice": "This setting is managed by an administrator.",
"language": "Language",
@@ -315,7 +325,20 @@
"attach_file": "Attach file",
"uploading_file": "Uploading...",
"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": {
"select_file": "Select a file",
@@ -414,6 +437,10 @@
},
"sidebar": {
"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",
"read_more": "read more",
"see_less": "see less",
@@ -454,6 +481,7 @@
"stat_comments": "Total Comments",
"stat_favs": "Total Favorites",
"stat_disk_usage": "Total File Size",
"stat_users": "Total Users",
"most_favorited": "Most Favorited",
"favs": "favs",
"top_xd": "Top xD Scores"
@@ -526,6 +554,24 @@
"found": "Found in metadata:",
"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": {
"add_text_layer": "Add Text Layer",
"tags_label": "Tags (comma separated)",
@@ -536,7 +582,12 @@
"text_layer": "Text Layer",
"enter_text": "Enter text...",
"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": {
"just_now": "just now",
@@ -699,5 +750,30 @@
"left_hand_desc": "You know why.",
"replying_to": "Replying to {user}",
"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."
}
}

View File

@@ -120,6 +120,7 @@
"switching": "Omschakelen...",
"generating": "Genereren...",
"title": "Instellingen",
"profile": "Profiel",
"avatar": "Avatar",
"current_avatar": "Huidige Avatar",
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
@@ -134,16 +135,15 @@
"clear": "Wissen",
"preferences": "Voorkeuren",
"ui_section": "Gebruikersinterface",
"content_preferences_section": "Inhoudsvoorkeuren",
"appearance_section": "Uiterlijk",
"show_motd": "Toon Bericht van de Dag (MOTD)",
"feed_layout": "Feed-indeling",
"feed_layout_hint": "Kies hoe de hoofdpagina berichten weergeeft",
"feed_layout_grid": "Raster (Compact)",
"feed_layout_modern": "Raster (3-koloms Modern)",
"feed_layout_feed": "Feed (X / Instagram-stijl)",
"feed_layout_youtube": "YouTube-stijl",
"modern_layout": "Moderne layout",
"modern_layout_hint": "Indeling met 3 kolommen",
"alternative_infobox": "Alternatief auteur-informatievak",
"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",
"disable_autoplay": "Automatisch afspelen uitschakelen",
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
"disable_swiping": "Swipen uitschakelen",
@@ -152,6 +152,16 @@
"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_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_hint": "Toon :emoji: afbeeldingen binnen >geciteerde regels",
"embed_yt": "YouTube-links insluiten in opmerkingen",
@@ -160,7 +170,7 @@
"hide_koepfe_hint": "De Köpfe uitschakelen",
"comment_display_mode": "Reactie-weergavemodus",
"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.",
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
"language": "Taal",
@@ -315,7 +325,20 @@
"attach_file": "Bestand bijvoegen",
"uploading_file": "Uploaden...",
"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": {
"select_file": "Selecteer een bestand",
@@ -410,6 +433,10 @@
},
"sidebar": {
"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",
"read_more": "lees meer",
"see_less": "zie minder",
@@ -450,6 +477,7 @@
"stat_comments": "Totaal aantal reacties",
"stat_favs": "Totaal aantal favorieten",
"stat_disk_usage": "Totale Bestandsgrootte",
"stat_users": "Totaal Gebruikers",
"most_favorited": "Meest Gefavoriet",
"favs": "favorieten",
"top_xd": "Top xD-scores"
@@ -522,6 +550,24 @@
"found": "Gevonden in metadata:",
"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": {
"add_text_layer": "Tekstlaag Toevoegen",
"tags_label": "Tags (gescheiden door komma's)",
@@ -532,7 +578,12 @@
"text_layer": "Tekstlaag",
"enter_text": "Voer tekst in...",
"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": {
"just_now": "zojuist",
@@ -695,5 +746,30 @@
"left_hand_desc": "Je weet wel waarom.",
"replying_to": "Antwoord aan {user}",
"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."
}
}

View File

@@ -89,7 +89,7 @@
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
"confirm_password": "Kennwort bestätigen",
"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_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
"tos_terms": "Nutzungsbedingungen",
@@ -120,6 +120,7 @@
"switching": "Umschaltung wird vorgenommen...",
"generating": "Generierung wird angestoßen...",
"title": "Einstellungen",
"profile": "Profil",
"avatar": "Profilbild",
"current_avatar": "Aktuelles Profilbild",
"upload_custom_avatar": "Benutzerdefiniertes Profilbild aufladieren",
@@ -134,16 +135,15 @@
"clear": "Leeren",
"preferences": "Präferenzen",
"ui_section": "Benutzeroberfläche",
"content_preferences_section": "Inhaltseinstellungen",
"appearance_section": "Erscheinungsbild",
"show_motd": "Nachricht des Tages (NdT) anzeigen",
"feed_layout": "Feed-Layout",
"feed_layout_hint": "Wählze, wie die Hauptzeite Beiträge anzeigt",
"feed_layout_grid": "Raster (Kompakt)",
"feed_layout_modern": "Raster (3-spaltig Modern)",
"feed_layout_feed": "Feed (X / Instagram-Stil)",
"feed_layout_youtube": "YouTube-Stil",
"modern_layout": "Modernes Layout",
"modern_layout_hint": "3-Spalten-Layout",
"alternative_infobox": "Alternativer Autor-Infoblock",
"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",
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
"disable_swiping": "Wischen deaktivieren",
@@ -152,6 +152,14 @@
"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_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_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
@@ -160,7 +168,7 @@
"hide_koepfe_hint": "Die Köpfe deaktivieren",
"comment_display_mode": "Kommentar-Anzeigemodus",
"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.",
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
"language": "Sprache",
@@ -315,7 +323,20 @@
"attach_file": "Datei anflanschen",
"uploading_file": "Wird aufladiert...",
"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": {
"select_file": "Datei auswählen",
@@ -413,6 +434,10 @@
},
"sidebar": {
"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",
"read_more": "mehr sehen",
"see_less": "weniger sehen",
@@ -453,6 +478,7 @@
"stat_comments": "Gesamtanzahl Kommentare",
"stat_favs": "Gesamtanzahl Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"most_favorited": "Am häufigsten favorisiert",
"favs": "Favoriten",
"top_xd": "Beste xD-Punktestände"
@@ -525,6 +551,24 @@
"found": "In den Metadaten 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": {
"add_text_layer": "Textebene hinzufügen",
"tags_label": "Etiketten (kommagetrennt)",
@@ -535,7 +579,12 @@
"text_layer": "Textebene",
"enter_text": "Text eingeben...",
"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": {
"just_now": "gerade eben",
@@ -700,5 +749,30 @@
"left_hand_desc": "Sie wissen schon wieso.",
"replying_to": "Antwort an {user}",
"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."
}
}

View File

@@ -6,6 +6,28 @@ import cfg from "./config.mjs";
import path from "path";
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 {
constructor() {
@@ -85,31 +107,52 @@ export default new class queue {
async generatePHash(source) {
try {
// Temporal dHash implementation:
// 1. Get duration.
// 2. Extract 3 frames: 10%, 50%, 90%.
// 3. Generate dHash for each.
// 4. Return combined hash "hash1_hash2_hash3".
// 1. Check if source is image/video and get duration.
// 2. For videos: Extract 3 frames (10%, 50%, 90% of duration).
// For static images: Extract 1 frame.
// 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")
if (source.toLowerCase().endsWith('.pdf')) {
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 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 = [];
for (const ts of timestamps) {
let buffer;
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;
} catch (err) {
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) {
@@ -117,6 +160,12 @@ export default new class queue {
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 currentByte = 0;
let bitCount = 0;
@@ -151,72 +200,122 @@ export default new class queue {
async checkrepostphash(newHash) {
if (!newHash) return false;
const newHashes = newHash.split('_');
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
if (newHashes.length === 0) return false;
// Fetch all phashes, filtering out "all zero" failed hashes
const items = await db`
SELECT id, phash FROM items
WHERE phash IS NOT NULL
AND phash != ''
AND phash NOT LIKE '00000000%'
const h1 = newHashes[0] || '';
const h2 = newHashes[1] || '';
const h3 = newHashes[2] || '';
const results = await db`
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.
// 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;
return results.length > 0 ? results[0].id : false;
};
// We want at least 2 out of 3 frames to match
const REQUIRED_MATCHES = 2;
async findallrepostphash(newHash, excludeId = null) {
if (!newHash) return [];
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
if (newHashes.length === 0) return [];
for (const item of items) {
// Handle legacy single hashes vs new multi-hashes
const dbHashes = item.phash.split('_');
const h1 = newHashes[0] || '';
const h2 = newHashes[1] || '';
const h3 = newHashes[2] || '';
let matches = 0;
// Compare corresponding frames: 0vs0, 1vs1, 2vs2
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
const results = await db`
SELECT id, username, stamp FROM items
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++) {
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
if (dist <= THRESHOLD) {
matches++;
}
}
return results.map(r => ({ id: r.id, username: r.username, stamp: r.stamp }));
};
// If we have 3 frames, require 2 out of 3 matches.
// If we are comparing against a legacy 1-frame hash, require that single frame to match.
if (framesToCompare >= 3 && matches >= REQUIRED_MATCHES) {
return item.id;
} else if (framesToCompare === 1 && matches === 1) {
return item.id;
} else if (framesToCompare === 2 && matches >= 2) {
return item.id;
}
}
async checkcommentrepostphash(newHash) {
if (!newHash) return false;
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
if (newHashes.length === 0) return false;
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() {
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);
};
async checkrepostlink(link) {
@@ -273,6 +372,10 @@ export default new class queue {
}
} catch (e) {}
const outPath = path.join(tDir, itemid + '.webp');
try {
if (mime === 'video/youtube') {
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
if (videoId) {
@@ -306,15 +409,51 @@ export default new class queue {
const ffThumbSize = Math.max(thumbSize, 512);
const seeks = ['20%', '40%', '60%', '80%'];
for (const seek of seeks) {
try {
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 {
const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
if (parseFloat(stdout.trim()) > 0.05) break;
} catch (e) { break; }
}
}
else if (mime.startsWith('image/') && mime != 'image/gif')
await this.spawn('magick', [sourcePath + '[0]', tmpFile]);
else if (mime.startsWith('image/') && mime != 'image/gif') {
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/')) {
let coverExtracted = false;
this._lastCoverExtracted = false; // Reset state for this call
@@ -422,10 +561,53 @@ export default new class queue {
}
}
await this.spawn('magick', [tmpFile, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', path.join(tDir, itemid + '.webp')]);
// Determine if we should use a checkerboard background for transparency
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(tmpJpg).catch(_ => { });
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 +616,7 @@ export default new class queue {
const src = path.join(tDir, `${itemid}.webp`);
const dst = path.join(tDir, `${itemid}_blur.webp`);
try {
await this.spawn('magick', [src, '-blur', '0x20', dst]);
await this.spawn('magick', [src, '-blur', '0x48', dst]);
return true;
} catch (err) {
console.error(`[QUEUE] Failed to generate blurred thumbnail for ${itemid}:`, err);

View File

@@ -2,14 +2,55 @@ import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
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)
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) => {
if (!comments || comments.length === 0) return comments;
@@ -79,25 +120,31 @@ const computeXdScore = (comments) => {
for (const c of comments) {
if (!c.content || c.is_deleted) continue;
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;
};
const xdScoreMeta = (score) => {
if (score <= 0) return { tier: 0, label: '' };
if (score < 5) return { tier: 1, label: 'xD' };
if (score < 15) return { tier: 2, label: 'xDD' };
if (score < 30) return { tier: 3, label: 'xDDD' };
if (score < 60) return { tier: 4, label: 'xDDDD' };
if (score < 1) return { tier: 0, label: '' };
if (score < 200) return { tier: 1, label: 'xD' };
if (score < 1000) return { tier: 2, label: 'xDD' };
if (score < 100000) return { tier: 3, label: 'xDDD' };
if (score < 20000000) return { tier: 4, label: 'xDDDD' };
return { tier: 5, label: 'xDDDDD+' };
};
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 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 hallObj = null;
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 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 baseMode = lib.getMode(mode ?? 0);
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 };
// 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;
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);
if (terms.length > 0) {
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})`;
}
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`
select count(distinct items.id) as total
from items
@@ -188,17 +245,20 @@ export default {
${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 && 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``}
${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}
`;
const total = Number(totalRows[0].total);
total = Number(totalRows[0].total);
if (total > 0) setCachedCount(cacheKey, total);
}
if (!total || total === 0) {
return {
@@ -211,7 +271,49 @@ export default {
const act_page = Math.min(page || 1, pages);
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``}
${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
items.id,
items.mime,
@@ -234,36 +336,23 @@ export default {
from items
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 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 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``}
where
${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}
where items.id = any(${pageIds})
group by items.id
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
offset ${newerThan ? 0 : offset}
limit ${eps}
`;
`).sort((a, b) => pageOrder[a.id] - pageOrder[b.id]);
// ─────────────────────────────────────────────────────────────────────────
// Dynamic thumb sizing: only on the unfiltered main feed.
// Profile pages, tag searches, halls, favorites, mime filters all use tier 1 (1×1).
for (const row of rows) {
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
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !rawMime && !fav;
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !fav;
if (isMainFeed) {
for (const row of rows) {
@@ -285,10 +374,19 @@ export default {
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
if (userHallObj && userHallOwner) {
const ownerName = userHallObj.owner_name || userHallOwner;
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
link.path = 'p/';
link.suffix = '';
}
@@ -313,9 +411,14 @@ export default {
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 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;
if (hall) {
const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`;
@@ -352,10 +455,11 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
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 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) {
return {
@@ -365,7 +469,10 @@ export default {
}
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);
if (terms.length > 0) {
if (isStrict) {
@@ -402,12 +509,13 @@ export default {
${db.unsafe(modequery)}
and items.active = true
${tagFilter}
${titleFilter}
${hallFilter}
${userHallFilter}
${fav ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${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``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
`;
};
@@ -440,7 +548,7 @@ export default {
where
items.id = ${itemid} and
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``}
limit 1
`;
@@ -458,18 +566,23 @@ export default {
if (!actitem) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
if (!session && globalfilter) {
if (!session && getGlobalfilter()) {
const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1
`;
if (unfilteredItem[0]) {
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
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 +669,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}`
: [];
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
if (userHallObj && userHallOwner) {
const ownerName = userHallObj.owner_name || userHallOwner;
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
link.path = '';
link.suffix = '';
}
@@ -571,6 +692,50 @@ export default {
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
const coverartUrl = actitem.has_coverart
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
@@ -596,12 +761,17 @@ export default {
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
if (modeBlocked) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
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`
}
};
}
@@ -625,6 +795,7 @@ export default {
author_avatar: actitem.author_avatar,
author_avatar_file: actitem.author_avatar_file,
author_description: actitem.author_description,
title: actitem.title || null,
src: {
long: actitem.src,
@@ -632,10 +803,30 @@ export default {
},
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.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,
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,
size: lib.formatSize(actitem.size),
checksum: actitem.checksum,
timestamp: {
timeago: lib.timeAgo(new Date(actitem.stamp * 1e3).toISOString(), lang),
timefull: new Date(actitem.stamp * 1e3).toISOString()
@@ -649,7 +840,11 @@ export default {
is_sfw: isSfw,
is_pinned: actitem.is_pinned || 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
},
title: `${actitem.id} - ${cfg.websrv.domain}`,
pagination: {
@@ -665,10 +860,16 @@ export default {
tmp
};
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 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 userHallSlug = rawUserHall || null;
const userHallOwner = rawUserHallOwner || null;
@@ -699,12 +900,29 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
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;
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``}
${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
item = await db`
select
@@ -719,7 +937,7 @@ export default {
and "user".user ilike ${user}
and items.active = true
${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``}
group by items.id
order by random()
limit 1
@@ -761,7 +979,7 @@ export default {
${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``}
${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``}
${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
order by random()
@@ -780,7 +998,7 @@ export default {
and h.slug = ${hall}
and items.active = true
${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``}
${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
@@ -801,10 +1019,13 @@ export default {
limit 1
`;
} else {
// Uniform random logic for global requests (no user/tag)
const baseMode = lib.getMode(mode ?? 0);
const modequery = baseMode;
const tagId = (mode === 0 || mode === 1 || mode === 4) ? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1)) : null;
// Uniform random logic for global requests (no user/tag/hall)
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
// 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
const useTagIdOpt = tagId && !mimeParts.includes('audio');
const nsfpIds = cfg.nsfp || [];
@@ -821,7 +1042,7 @@ export default {
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : 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()
LIMIT 1
`;
@@ -884,6 +1105,73 @@ export default {
// Table might not exist yet, gracefully degrade
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`);
@@ -972,14 +1260,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``;
// Build mode condition using alias 'i' (getMode uses raw 'items' table name, incompatible with subquery alias)
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 3=all and mode 2=untagged show all halls
const hallRating = modeNum === 0 ? 'sfw' : modeNum === 1 ? 'nsfw' : modeNum === 4 ? 'nsfl' : null;
@@ -1005,7 +1291,6 @@ export default {
FROM halls_assign ha
JOIN items i ON i.id = ha.item_id
WHERE i.active = true
${modeFilter}
${userExcludeFilter}
GROUP BY ha.hall_id
) counts ON counts.hall_id = h.id
@@ -1267,5 +1552,7 @@ export default {
},
computeXdScore,
xdScoreMeta
xdScoreMeta,
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
clearCountCache: () => countCache.clear()
};

View File

@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode, getDefaultFeedLayout, setDefaultFeedLayout } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -45,7 +45,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: msg });
}
if (!username || !password || password.length < 20) {
if (!username || !password) {
return fail("Invalid username or password.");
}
@@ -287,7 +287,6 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
shitpost_mode: getShitpostMode(),
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
default_feed_layout: getDefaultFeedLayout(),
tmp: null
}, req)
});
@@ -619,8 +618,6 @@ export default (router, tpl) => {
const registration_open = req.post.registration_open === 'on' ? 'true' : 'false';
const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags));
const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3);
const raw_feed_layout = parseInt(req.post.default_feed_layout, 10);
const default_feed_layout = (!isNaN(raw_feed_layout) && raw_feed_layout >= 0 && raw_feed_layout <= 3) ? raw_feed_layout : getDefaultFeedLayout();
await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
@@ -629,14 +626,13 @@ export default (router, tpl) => {
setRegistrationOpen(registration_open === 'true');
}
await db`INSERT INTO site_settings (key, value) VALUES ('min_tags', ${min_tags.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('default_feed_layout', ${default_feed_layout.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setManualApproval(manual_approval === 'true');
setMinTags(min_tags);
setTrustedUploads(trusted_uploads);
setDefaultFeedLayout(default_feed_layout);
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.setHeader('Content-Type', 'application/json');
@@ -646,8 +642,7 @@ export default (router, tpl) => {
manual_approval: getManualApproval(),
registration_open: getRegistrationOpen(),
min_tags: getMinTags(),
trusted_uploads: getTrustedUploads(),
default_feed_layout: getDefaultFeedLayout()
trusted_uploads: getTrustedUploads()
})
});
}
@@ -808,8 +803,11 @@ export default (router, tpl) => {
// User Management Routes
router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => {
const q = req.url.qs?.q || '';
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const rawQ = req.url.qs?.q || '';
// 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 offset = (page - 1) * limit;
@@ -821,7 +819,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
FROM "user" u
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 (
SELECT
@@ -830,7 +831,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
FROM items i
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
),
all_users AS (
@@ -872,13 +876,19 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const totalCountActual = await db`
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`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
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);
@@ -1262,6 +1272,71 @@ 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 }));
}
});
// About page text editor
router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => {
const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
@@ -1427,6 +1502,9 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
// Chat Manager
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.redirect("/admin");
}
res.reply({
body: tpl.render('admin/chat', {
session: req.session,

View File

@@ -31,11 +31,14 @@ export default (router, tpl) => {
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 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 data = await f0cklib.getf0ck({
itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: !!req.session,
url: contextUrl,
user: query.user,
@@ -126,7 +129,7 @@ export default (router, tpl) => {
const item = data.item;
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_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);
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.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
@@ -138,6 +141,7 @@ export default (router, tpl) => {
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_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
@@ -191,14 +195,19 @@ export default (router, tpl) => {
const page = parseInt(query.page) || 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({
page: page,
tag: query.tag || null,
hall: query.hall || null,
user: query.user || null,
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,

View File

@@ -10,7 +10,7 @@ import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
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 MAX_META_CACHE = 2000;
@@ -496,7 +496,9 @@ export default router => {
const userHallOwner = req.url.qs.userHallOwner || null;
const isFav = req.url.qs.fav === 'true';
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({
user,
@@ -507,6 +509,7 @@ export default router => {
mime,
fav: isFav,
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
exclude: req.session?.excluded_tags || []
@@ -519,41 +522,61 @@ export default router => {
});
}
// API expects { success: true, items: { id: ... } } (based on f0ck.js usage)
// The old query returned full item row. f0cklib.getRandom returns { itemid: ... } or { itemid: ... } (actually it returns { itemid: ... } on success)
const rows = await db`
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?
// Looking at f0ck.js:
// if (data.success && data.items && data.items.id) { loadItemAjax(`/${data.items.id}`, true); }
// So it only really needs the ID.
if (!item) {
return res.json({
success: false,
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({
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) => {
try {
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):
// Tier 0 — active in last 15 minutes
// Tier 1 — active in last 24 hours
// Tier 2 — active in last 30 days (includes lurkers — anyone who visited the site)
// Flat random pick from all users seen in the last 7 days.
// No tiered bias — gives a proper pool of recently-active users
// rather than always favouring whoever is online right now.
// Banned users are always excluded.
let activeUsers = await db`
SELECT "user"."user", "user".id, uo.display_name
FROM "user"
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
ORDER BY (CASE
WHEN "user".last_seen > ${now - 900} THEN 0
WHEN "user".last_seen > ${now - 86400} THEN 1
ELSE 2
END), RANDOM()
ORDER BY RANDOM()
LIMIT 1
`;
@@ -748,6 +771,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
group.put(/\/tags\/rename\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
@@ -798,6 +843,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) => {
if (req.post.postid === undefined || req.post.postid === null) {
return res.json({
@@ -1045,6 +1117,15 @@ export default router => {
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
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) {
newRatingId = 2; // SFW -> NSFW
} else if (currentRatingId === 2) {
@@ -1052,6 +1133,7 @@ export default router => {
} else {
newRatingId = 1; // NSFL or none -> SFW
}
}
await db.begin(async sql => {
// Remove old rating tags
@@ -1063,12 +1145,10 @@ export default router => {
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
// If switching to NSFW/NSFL, ensure blurred thumbnail exists
if (newRatingId === 2 || newRatingId === nsfl_id) {
// Ensure blurred thumbnail exists
await queue.genBlurredThumbnail(itemid).catch(err => {
console.error(`[RATING_TOGGLE] Blurred thumbnail generation failed for ${itemid}:`, err);
});
}
});
const newRating = newRatingId === 1 ? 'sfw' : (newRatingId === 2 ? 'nsfw' : 'nsfl');

View File

@@ -3,6 +3,7 @@ import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -295,34 +296,19 @@ export default router => {
}
});
// Update feed layout preference (0=grid, 1=modern, 2=feed/instagram, 3=youtube)
// Update New Layout visibility preference
group.put(/\/layout/, lib.loggedin, async (req, res) => {
const raw = req.post.feed_layout !== undefined ? req.post.feed_layout : req.post.use_new_layout;
let feed_layout;
// Backward compat: if old boolean use_new_layout was sent, map to int
if (req.post.feed_layout === undefined && req.post.use_new_layout !== undefined) {
feed_layout = (req.post.use_new_layout === true || req.post.use_new_layout === 'true') ? 1 : 0;
} else {
feed_layout = parseInt(raw, 10);
}
if (isNaN(feed_layout) || feed_layout < 0 || feed_layout > 3) {
return res.json({ success: false, msg: 'Invalid layout value: must be 03' }, 400);
}
const use_new_layout = req.post.use_new_layout === true || req.post.use_new_layout === 'true';
try {
await db`
update user_options
set feed_layout = ${feed_layout},
use_new_layout = ${feed_layout === 1}
set use_new_layout = ${use_new_layout}
where user_id = ${+req.session.id}
`;
if (req.session) {
req.session.feed_layout = feed_layout;
req.session.use_new_layout = feed_layout === 1;
}
return res.json({ success: true, feed_layout }, 200);
// Sync session immediately
if (req.session) req.session.use_new_layout = use_new_layout;
return res.json({ success: true, use_new_layout }, 200);
} catch (e) {
console.error('Update Layout pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
@@ -659,6 +645,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
group.put(/\/language/, lib.loggedin, async (req, res) => {
if (cfg.websrv.allow_language_change === false) {
@@ -741,6 +745,330 @@ 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.' });
}
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',
URL: '$json:url$',
ErrorMessage: '$json:msg$'
};
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;
});

View File

@@ -105,8 +105,19 @@ export default router => {
const cycle = [1, 2, nsflId];
const currentTags = await lib.getTags(postid);
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 nextTagId = cycle[(cycleIdx + 1) % cycle.length];
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
try {
// 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 })}`;
}
// 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 { label, cls } = labels[nextTagId];
@@ -170,9 +189,7 @@ export default router => {
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
// Generate blurred thumbnail if toggling TO NSFW
if (hasSFW && !hasNSFW) {
// Was SFW, now NSFW - check if blur exists and generate if not
// Ensure blurred thumbnail exists on toggle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
@@ -180,7 +197,6 @@ export default router => {
// Doesn't exist - generate it
await queue.genBlurredThumbnail(postid, false);
}
}
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' (toggle) for item ${postid}`);

View File

@@ -2,6 +2,7 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
@@ -82,12 +83,13 @@ export default router => {
const saveComment = async (itemid, userid, content) => {
if (!content || !content.trim()) return;
try {
const filteredContent = await applyWordFilter(content);
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: userid,
parent_id: null,
content: content.trim()
content: filteredContent.trim()
}, 'item_id', 'user_id', 'parent_id', 'content')}
`;
} catch (err) {
@@ -202,7 +204,13 @@ export default router => {
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()) {
return res.json({ success: false, msg: 'URL is required' }, 400);
@@ -219,8 +227,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 minTags = getMinTags();
// In shitpost mode tags are optional
if (!is_shitpost && tags.length < minTags) {
// In shitpost mode tags are optional; skip entirely when minTags is 0
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
}
@@ -273,8 +281,9 @@ export default router => {
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
RETURNING id
`;
@@ -296,10 +305,8 @@ export default router => {
if (effectiveRating) {
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`;
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
}
}
// Assign user tags + auto-tags
const autoTags = autoTagsFromUrl(ytUrl); // always includes 'youtube' + 'youtube.com'
@@ -559,8 +566,9 @@ export default router => {
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
RETURNING id
`;
@@ -570,7 +578,7 @@ export default router => {
try {
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) {
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(() => {});

View File

@@ -4,6 +4,7 @@ import cfg from "../config.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
export default (router, tpl) => {
@@ -42,6 +43,24 @@ export default (router, tpl) => {
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
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -145,7 +164,14 @@ export default (router, tpl) => {
mode = parseInt(req.url.qs.mode);
}
/* </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`
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
const mentionsProcessed = await f0cklib.processMentions(comments);
const processedComments = mentionsProcessed.map(c => {
let processedComments = mentionsProcessed.map(c => {
return {
...c,
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) {
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -252,14 +387,20 @@ export default (router, tpl) => {
const body = req.post || {};
const item_id = parseInt(body.item_id, 10);
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)))
? parseFloat(body.video_time)
: null;
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" }) });
}
@@ -281,7 +422,7 @@ export default (router, tpl) => {
item_id,
user_id: req.session.id,
parent_id: parent_id || null,
content: content
content: content || ''
};
if (video_time !== null) insertData.video_time = video_time;
@@ -293,6 +434,7 @@ export default (router, tpl) => {
const commentId = parseInt(newComment[0].id, 10);
// Link uploaded files to this comment (if any)
let activityFiles = [];
const fileIdsRaw = body.file_ids || '';
if (fileIdsRaw) {
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 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) {
console.error('[COMMENTS] Failed to link files to comment:', err);
}
@@ -418,8 +567,23 @@ export default (router, tpl) => {
}
// Notify for live updates
// Fetch the trigger-updated xd_score from the DB (trigger runs synchronously before we get here)
const [xdRow] = await db`SELECT xd_score FROM items WHERE id = ${item_id}`;
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
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.
// Large comments would silently drop the notification. The client fetches
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
@@ -438,7 +602,8 @@ export default (router, tpl) => {
username_color: req.session.username_color,
display_name: req.session.display_name || 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
@@ -450,7 +615,15 @@ export default (router, tpl) => {
item_id: item_id,
type: 'comment',
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
}));
// Automatically subscribe user to the thread
@@ -466,7 +639,11 @@ export default (router, tpl) => {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comment: newComment[0],
comment: {
...newComment[0],
content,
files: activityFiles
},
xd_score: xdRow?.xd_score ?? null,
is_new_subscription
})
@@ -519,9 +696,15 @@ export default (router, tpl) => {
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 (!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" }) });
}
}
// Log all deletions in audit log
const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided');
@@ -691,7 +874,8 @@ export default (router, tpl) => {
const commentId = req.params.id;
const body = req.post || {};
const content = body.content;
let content = body.content;
content = await applyWordFilter(content);
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
@@ -796,7 +980,14 @@ export default (router, tpl) => {
mode = parseInt(req.url.qs.mode);
}
/* </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 excludedTags = req.session ? (req.session.excluded_tags || []) : [];
@@ -806,6 +997,9 @@ export default (router, tpl) => {
i.mime,
i.id as item_id,
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,
uo.avatar,
uo.avatar_file,
@@ -826,11 +1020,84 @@ export default (router, tpl) => {
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 => {
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'; }
return {
...c,
content: (c.content || '').trim(),
username_color: c.username_color
username_color: c.username_color,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
files: filesMap.get(c.id) || [],
poll: pollMap.get(c.id) || null
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
};
});
@@ -890,5 +1157,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;
};

View File

@@ -26,7 +26,7 @@ export default (router, tpl) => {
// List all emojis (Public)
router.get('/api/v2/emojis', async (req, res) => {
try {
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id DESC`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojis })

View File

@@ -319,7 +319,7 @@ export default (router) => {
// POST /api/v2/scroller/rehost
// Downloads an external item and adds it to the platform
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' }) });
@@ -436,8 +436,9 @@ export default (router) => {
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
is_oc: !!is_oc,
original_filename: original_filename || null
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
RETURNING id
`;
@@ -449,7 +450,7 @@ export default (router) => {
// Process thumbnail
try {
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) {
console.error('[REHOST] Thumbnail error:', err);
}

View File

@@ -94,8 +94,7 @@ export default (router, tpl) => {
const util = await import('util');
const execFilePromise = util.promisify(execFile);
// If only 1 or 2 items, just use what we have
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]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));

View File

@@ -2,7 +2,6 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { getDefaultFeedLayout } from "../settings.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -63,9 +62,14 @@ export default (router, tpl) => {
};
try {
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({
user: user,
mode: req.mode,
ratings: ratingsArr,
mime: mime,
fav: false,
session: !!req.session,
@@ -84,9 +88,14 @@ export default (router, tpl) => {
if (!userData.is_ghost) {
try {
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({
user: user,
mode: req.mode,
ratings: ratingsArr,
mime: mime,
fav: true,
session: !!req.session,
@@ -138,7 +147,7 @@ export default (router, tpl) => {
userData.timestamp = {
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);
@@ -213,15 +222,19 @@ export default (router, tpl) => {
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)({
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),
page: req.params.page,
itemid: req.params.itemid,
hall: req.params.hall,
fav: req.params.mode == 'favs',
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,
user_id: req.session?.id,
exclude: req.session ? (req.session.excluded_tags || []) : [],
@@ -244,7 +257,7 @@ export default (router, tpl) => {
data.success = true;
if (!data.link) {
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: '' };
}
data.tmp = data.tmp || {};
@@ -252,7 +265,7 @@ export default (router, tpl) => {
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;
}
if (req.params.tag && !data.tmp.tag) data.tmp.tag = req.params.tag;
if (reqTag && !data.tmp.tag) data.tmp.tag = reqTag;
} else {
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
// Return 404 only for truly missing items
@@ -321,15 +334,6 @@ export default (router, tpl) => {
// Only inject session for authenticated users to avoid showing member UI to guests
data.session = (req.session && req.session.user) ? { ...req.session } : false;
// Pre-compute feed layout class (avoids template engine issues with complex ternaries)
// Logic: use user's own feed_layout if they explicitly set one (> 0),
// otherwise fall back to the site-wide default set in the admin dashboard.
const userFeedLayout = data.session ? parseInt(data.session.feed_layout, 10) : 0;
const siteFeedLayout = getDefaultFeedLayout();
const rawFeedLayout = (userFeedLayout > 0) ? userFeedLayout : siteFeedLayout;
const feedLayoutNum = (!isNaN(rawFeedLayout) && rawFeedLayout >= 0 && rawFeedLayout <= 3) ? rawFeedLayout : 0;
data.feed_layout_class = 'layout-' + feedLayoutNum;
// Precompute boolean helpers for template @if() — the flummpress template engine uses a
// non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters.
// This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if()
@@ -343,7 +347,8 @@ export default (router, tpl) => {
// 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));
// 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);
// Has the current user favorited this item?
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
// Hall columns for display
@@ -357,6 +362,7 @@ export default (router, tpl) => {
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_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');

View File

@@ -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
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
if (!cfg.websrv.meme_creator) {

View File

@@ -209,7 +209,8 @@ export default (router, tpl) => {
pm.ciphertext,
pm.iv,
pm.is_read,
pm.created_at
pm.created_at,
pm.edited_at
FROM private_messages pm
WHERE (
(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)
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
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)
router.get('/api/dm/unread', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });

View File

@@ -16,6 +16,7 @@ function broadcastChatPresence() {
if (!seen.has(client.userId)) {
seen.add(client.userId);
users.push({
id: client.userId,
username: client.username,
display_name: client.display_name,
avatar_file: client.avatar_file,
@@ -56,7 +57,8 @@ db.listen('notifications', (payload) => {
if (client.do_not_disturb === true) 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 });
}
}
@@ -342,7 +344,9 @@ db.listen('global_chat_topic', (payload) => {
export default (router, tpl) => {
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) {
const offset = (page - 1) * limit;
@@ -354,7 +358,13 @@ export default (router, tpl) => {
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
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
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
@@ -362,7 +372,7 @@ export default (router, tpl) => {
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${userId}
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
LIMIT ${limit + 1}
OFFSET ${offset}
@@ -373,14 +383,20 @@ export default (router, tpl) => {
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
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
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
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
LIMIT ${limit + 1}
OFFSET ${offset}
@@ -418,14 +434,20 @@ export default (router, tpl) => {
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
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
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
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 (
${req.session.do_not_disturb !== true} AND (
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
@@ -433,7 +455,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
LIMIT 1000
`;
@@ -495,7 +517,7 @@ export default (router, tpl) => {
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
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}`);
try {
await db`
@@ -645,7 +667,9 @@ export default (router, tpl) => {
next: data.hasMore ? 2 : null
};
data.link = { main: '/notifications', path: '/' };
data.activeTab = tab;
data.domain = cfg.main.url.domain; // For header
data.active_mode = req.session?.mode ?? 0;
return res.html(tpl.render('notifications', data, req));
});
@@ -658,7 +682,7 @@ export default (router, tpl) => {
const tab = req.url.qs.tab || null;
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({
success: true,

136
src/inc/routes/nsfp.mjs Normal file
View 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;
};

View File

@@ -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({
user: opts.user,
tag: opts.tag,
@@ -47,6 +51,7 @@ export default (router, tpl) => {
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
ratings: ratingsArr,
strict: opts.strict,
session: !!req.session
});

View File

@@ -78,6 +78,11 @@ export default (router, tpl) => {
where items.active = true
`)[0].total;
const totalUsers = +(await db`
select count(*) as total
from "user"
`)[0].total;
const hoster = await db`
with t as (
select
@@ -135,6 +140,7 @@ export default (router, tpl) => {
xdtop,
totalComments,
totalFavs,
totalUsers,
enable_nsfl: config.enable_nsfl,
diskSize: cachedDiskSize,
tmp: null,

View File

@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import security from "../security.mjs";
import { getRegistrationOpen, getDefaultLayout, getDefaultFeedLayout } from "../settings.mjs";
import { getRegistrationOpen, getRegistrationRequireMailAndorToken, getDefaultLayout } from "../settings.mjs";
import { sendMail } from "../../lib/smtp.mjs";
import cfg from "../config.mjs";
import crypto from "crypto";
@@ -88,26 +88,48 @@ export default (router, tpl) => {
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 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.");
}
if (token) {
// Invite token path — validate and activate immediately
const tokenRow = await db`
select * from invite_tokens where token = ${token} and is_used = false
`;
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
// Token used, so it will be activated by default
} else {
// No token, Open Registration
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
// Token is valid; account activated immediately
} else if (requireMailOrToken) {
// Open registration but email/token required — email path
if (!email || !email.includes('@')) return renderError("A valid email is required for registration.");
activated = false;
activationToken = crypto.randomBytes(32).toString('hex');
}
// else: open registration, no mail/token required — just username+password, activated immediately
// Check user existence
const existing = await db`
@@ -145,8 +167,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, feed_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultFeedLayout() === 1}, ${getDefaultFeedLayout()}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);
@@ -186,7 +208,7 @@ export default (router, tpl) => {
if (tokenRow.length > 0) {
await db`
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}
`;
}

View File

@@ -4,22 +4,57 @@ import lib from "../lib.mjs";
export default (router, tpl) => {
// 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.private_society && !req.session) {
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({
body: tpl.render('scroller', {
tmp: null,
session: req.session ? { ...req.session } : false,
enable_nsfl: !!cfg.enable_nsfl,
enable_swf: !!cfg.websrv.enable_swf,
page_meta: {
title: 'doomscroll',
description: 'Scroll through content endlessly',
url: `https://${cfg.main.url.domain}/abyss`
}
page_meta
}, req)
});
});
@@ -263,6 +298,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 isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
const isYouTube = row.mime === 'video/youtube';
@@ -270,7 +307,16 @@ export default (router, tpl) => {
const isImage = row.mime && row.mime.startsWith('image');
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`;
let ratingLabel = '?'; let ratingClass = 'untagged';

View File

@@ -56,6 +56,48 @@ export default (router, tpl) => {
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') {
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);

View File

@@ -50,6 +50,8 @@ export default (router, tpl) => {
joined: user?.created_at || null,
enable_swf: cfg.enable_swf,
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,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {

View File

@@ -40,7 +40,7 @@ export async function regenerateTagImage(tag, mode) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
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;
}
} catch (err) {
@@ -123,17 +123,17 @@ function generateFallbackSvg(tag) {
const n2 = parseInt(hash.substring(20, 22), 16);
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>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="300" height="150" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
<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>
<rect width="600" height="300" fill="url(#grad)" />
<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) / 1.5}" fill="${c3}" fill-opacity="0.15" />
<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>
`.trim();
}

View File

@@ -163,7 +163,7 @@ export default (router, tpl) => {
const item = data.item;
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_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);
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.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
@@ -174,6 +174,7 @@ export default (router, tpl) => {
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_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
}
// Precompute hall display
@@ -268,7 +269,7 @@ export default (router, tpl) => {
await fs.mkdir(CACHE_DIR, { recursive: true });
await execFile('magick', [
...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' });
return res.end(await fs.readFile(cachePath));

View File

@@ -21,6 +21,11 @@ export default (router, tpl) => {
// Broadcast to SSE clients instantly
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({
user_id: +user_id,
warning_id: result[0].id,

View 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;
};

View File

@@ -7,8 +7,9 @@ let trusted_uploads = 0;
let bypass_duplicate_check = false;
let protect_files = false;
let private_messages = true;
let dm_attachments = true;
let dm_unencrypted = false;
let default_layout = 'modern';
let default_feed_layout = 0;
let enable_pdf = false;
let enable_cleanup = false;
let cleanup_start_date = '';
@@ -48,6 +49,11 @@ export const getRegistrationOpen = () => {
};
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 setTrustedUploads = (val) => trusted_uploads = Math.max(0, parseInt(val) ?? 3);
@@ -60,17 +66,36 @@ export const setProtectFiles = (val) => protect_files = !!val;
export const getPrivateMessages = () => private_messages;
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 setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
export const getDefaultFeedLayout = () => default_feed_layout;
export const setDefaultFeedLayout = (val) => {
const parsed = parseInt(val, 10);
default_feed_layout = (!isNaN(parsed) && parsed >= 0 && parsed <= 3) ? parsed : 0;
};
export const getLogUserIps = () => !!cfg.websrv.log_user_ips;
export const setLogUserIps = (val) => {}; // No-op, strictly config-based
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
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];
};

View File

@@ -2,7 +2,7 @@ import cfg from "../config.mjs";
import db from "../sql.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 => {
@@ -12,9 +12,10 @@ export default async bot => {
active: true,
f: async e => {
const dat = e.message.match(regex)[0].split(/\//).pop();
const nsflId = cfg.nsfl_tag_id || 3;
const rows = await db`
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
${dat.includes('.')
? db`where i.dest = ${dat}`
@@ -32,15 +33,6 @@ export default async bot => {
if (e.type === 'irc') {
const color = rating === 'sfw' ? 'green' : (rating === 'nsfw' ? 'red' : 'brown');
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://');

View File

@@ -705,7 +705,7 @@ export default async bot => {
// Generate Thumbnail
try {
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
await queue.genBlurredThumbnail(itemid, manualApproval);
} catch (err) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
@@ -815,7 +815,7 @@ export default async bot => {
// Generate Thumbnail
try {
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
await queue.genBlurredThumbnail(itemid, manualApproval);
} catch (err) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);

44
src/inc/wordfilter.mjs Normal file
View 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;
}
}

View File

@@ -11,13 +11,14 @@ import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
import { handleRethumbUpload } from "./rethumb_handler.mjs";
import { handleMemeUpload } from "./meme_upload_handler.mjs";
import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload } from "./comment_upload_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getDefaultFeedLayout, setDefaultFeedLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.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 { createI18n } from "./inc/i18n.mjs";
import security from "./inc/security.mjs";
@@ -351,9 +352,10 @@ process.on('uncaughtException', err => {
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
];
for (const dir of initDirs) {
if (!fs.existsSync(dir)) {
console.log(`[BOOT] Creating directory: ${dir}`);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (e) {
if (e.code !== 'EEXIST') console.warn(`[BOOT] Could not create directory ${dir}: ${e.message}`);
}
}
@@ -485,14 +487,18 @@ process.on('uncaughtException', err => {
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
return;
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));
req.url.pathname = '/private_society_media_bypass';
return;
}
if (getProtectFiles() && !req.cookies?.session) {
} else {
res.writeHead(401).end('Unauthorized');
req.url.pathname = '/protect_files_bypass';
}
return;
}
return;
@@ -504,7 +510,7 @@ process.on('uncaughtException', err => {
if (req.cookies.session) {
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".feed_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"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
@@ -631,9 +637,10 @@ process.on('uncaughtException', err => {
hide_koepfe: user[0].hide_koepfe ?? false,
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_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),
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
theme = excluded.theme,
@@ -650,6 +657,7 @@ process.on('uncaughtException', err => {
hide_koepfe = excluded.hide_koepfe,
language = excluded.language,
use_alternative_infobox = excluded.use_alternative_infobox,
use_alternative_steuerung = excluded.use_alternative_steuerung,
comment_display_mode = excluded.comment_display_mode,
force_comment_display_mode = excluded.force_comment_display_mode,
user_id = excluded.user_id
@@ -659,14 +667,19 @@ process.on('uncaughtException', err => {
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));
// 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) {
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
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)) {
// For AJAX requests, return 502 so it looks like the backend is down
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
@@ -712,6 +725,8 @@ process.on('uncaughtException', err => {
app.use(async (req, res) => {
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;
// 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
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
@@ -771,6 +786,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
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/emojis') {
@@ -779,6 +804,16 @@ 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 hall image uploads (multipart — needs raw body)
app.use(async (req, res) => {
if (cfg.websrv.halls_enabled === false) return;
@@ -832,6 +867,28 @@ process.on('uncaughtException', err => {
await handleCommentUpload(req, res);
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";
@@ -983,25 +1040,20 @@ process.on('uncaughtException', err => {
setPrivateMessages(cfg.websrv.private_messages !== false);
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)
if (cfg.websrv.default_layout) {
setDefaultLayout(cfg.websrv.default_layout);
console.log(`[BOOT] Default layout set to: ${getDefaultLayout()}`);
}
// Fetch default_feed_layout from DB site_settings
try {
const dflSetting = await db`SELECT value FROM site_settings WHERE key = 'default_feed_layout' LIMIT 1`;
if (dflSetting.length > 0) {
setDefaultFeedLayout(parseInt(dflSetting[0].value, 10));
console.log(`[BOOT] Default feed layout loaded: ${getDefaultFeedLayout()}`);
} else {
console.log(`[BOOT] No default_feed_layout setting found, defaulting to 0 (Grid)`);
}
} catch (e) {
console.warn(`[BOOT] default_feed_layout fetch failed:`, e.message);
}
// Fetch about_text from database
try {
const aboutSetting = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
@@ -1035,6 +1087,22 @@ process.on('uncaughtException', err => {
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 = {
lul: cfg.websrv.lul,
themes: cfg.websrv.themes,
@@ -1050,17 +1118,25 @@ process.on('uncaughtException', err => {
get min_tags() { return getMinTags(); },
get registration_open() { return getRegistrationOpen(); },
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 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 rules_text() { return getRulesText(); },
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(); },
halls_enabled: cfg.websrv.halls_enabled !== false,
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
abyss_enabled: cfg.websrv.abyss_enabled !== false,
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,
allowed_mimes: Object.keys(cfg.mimes).concat([...new Set(Object.values(cfg.mimes))].map(ext => `.${ext}`)).join(','),
mimes_json: JSON.stringify(cfg.mimes),
@@ -1075,6 +1151,7 @@ process.on('uncaughtException', err => {
meme_creator: !!cfg.websrv.meme_creator,
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_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1082,6 +1159,9 @@ process.on('uncaughtException', err => {
themes_json: JSON.stringify(cfg.websrv.themes || []),
enable_profile_description: !!cfg.websrv.enable_profile_description,
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_cleanup() { return getEnableCleanup(); },
get cleanup_start_date() { return getCleanupStartDate(); },
@@ -1089,7 +1169,6 @@ process.on('uncaughtException', err => {
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
ts: Date.now(),
get default_layout() { return getDefaultLayout(); },
get default_feed_layout() { return getDefaultFeedLayout(); },
show_koepfe: !!cfg.websrv.show_koepfe,
allow_language_change: cfg.websrv.allow_language_change !== false,
enable_xd_score: !!cfg.websrv.enable_xd_score,
@@ -1097,6 +1176,7 @@ process.on('uncaughtException', err => {
comment_max_length: cfg.main.comment_max_length ?? null,
enable_swf: !!cfg.websrv.enable_swf,
enable_danmaku: cfg.websrv.enable_danmaku !== false,
enable_item_title: cfg.websrv.enable_item_title !== false,
enable_global_chat: !!cfg.websrv.enable_global_chat,
embed_youtube_in_comments: cfg.websrv.embed_youtube_in_comments !== false,
koepfe_json: JSON.stringify(cfg.websrv.koepfe || []),
@@ -1110,6 +1190,8 @@ process.on('uncaughtException', err => {
fileupload_comments_size: cfg.websrv.fileupload_comments_size || (10 * 1024 * 1024),
fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5,
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() {
try {
@@ -1168,16 +1250,28 @@ process.on('uncaughtException', err => {
globals.lang = perRequestLang;
// Resolve per-request infobox preference
// Guests always get false — the alternative infobox is a logged-in user preference only
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
? req.session.use_alternative_infobox
: (req && !req.session
? false
: (data && typeof data.user_alternative_infobox === 'boolean'
? 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 || {}, {
t: perRequestT,
lang: perRequestLang,
user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung,
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
? req.session.comment_display_mode
: (data && typeof data.comment_display_mode === 'number'
@@ -1237,4 +1331,60 @@ process.on('uncaughtException', err => {
setTimeout(cleanupStaleSessions, 30_000);
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);
}
})();

View File

@@ -107,3 +107,117 @@ export const handleMemeUpload = async (req, res) => {
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);
}
};

View File

@@ -104,7 +104,8 @@ export const handleRethumbUpload = async (req, res, itemId) => {
}
// 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}`);
await fs.mkdir(cfg.paths.tmp, { recursive: true });
@@ -130,16 +131,8 @@ export const handleRethumbUpload = async (req, res, itemId) => {
try {
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
await queue.genBlurredThumbnail(item.id, !item.active);
}
} catch (err) {
console.error('[RETHUMB HANDLER] Magick error:', err);

View File

@@ -2,11 +2,13 @@ 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 { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
@@ -17,6 +19,21 @@ const sendJson = (res, data, code = 200) => {
// 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(() => {});
// 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) => {
// Manual session lookup is required here because this handler is called from a
// bypass middleware that runs in parallel with the main session middleware.
@@ -37,15 +54,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) {
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'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
}
try {
const contentType = req.headers['content-type'] || '';
@@ -79,37 +122,72 @@ export const handleUpload = async (req, res, self) => {
const rating = parts.rating;
const tagsRaw = parts.tags;
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_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) {
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)
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : (is_shitpost ? null : null);
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged).
// 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) {
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) {
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 minTags = getMinTags();
// In shitpost mode, tags are optional — items without tags enter as untagged
if (!is_shitpost && tags.length < minTags) {
return sendJson(res, { success: false, msg: `At least ${minTags} tags are required` }, 400);
// In shitpost mode, tags are optional by default — unless shitpost_min_tags is configured.
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
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
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;
// 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)) {
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
}
@@ -152,10 +230,11 @@ export const handleUpload = async (req, res, self) => {
AND stamp > ${twelveHoursAgo}
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, {
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);
}
}
@@ -173,7 +252,7 @@ export const handleUpload = async (req, res, self) => {
// Save temporarily to detect actual MIME
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();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
@@ -296,6 +375,44 @@ export const handleUpload = async (req, res, self) => {
// Suffix it so the INSERT can proceed — the file is genuinely a new item entry.
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
// Probe pixel dimensions for images and videos (null for audio/flash/pdf/youtube)
let itemWidth = null;
let itemHeight = null;
try {
if (actualMime.startsWith('image/')) {
// Use magick identify — handles all image formats, already present for thumbnailing
const { stdout: magickOut } = await queue.spawn('magick', [
'identify', '-format', '%wx%h\n', destPath + '[0]'
], { quiet: true, ignoreExitCode: true });
const line = magickOut.trim().split('\n')[0];
const match = line.match(/^(\d+)x(\d+)$/);
if (match) {
itemWidth = parseInt(match[1], 10);
itemHeight = parseInt(match[2], 10);
}
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
// Use ffprobe for videos — reads first video stream dimensions
const { stdout: probeOut } = await queue.spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0',
destPath
], { quiet: true, ignoreExitCode: true });
const parts = probeOut.trim().split(',');
if (parts.length >= 2) {
const w = parseInt(parts[0], 10);
const h = parseInt(parts[1], 10);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
itemWidth = w;
itemHeight = h;
}
}
}
} catch (dimErr) {
console.warn(`[UPLOAD] Dimension probe failed for ${actualMime} (non-fatal):`, dimErr.message);
}
// Insert
const originalFilename = file.filename || null;
await db`
@@ -312,9 +429,11 @@ export const handleUpload = async (req, res, self) => {
stamp: ~~(Date.now() / 1000),
active: !manualApproval,
is_oc: is_oc,
original_filename: originalFilename
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')
}
original_filename: originalFilename,
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);
@@ -374,19 +493,18 @@ export const handleUpload = async (req, res, self) => {
}
}
// Generate blurred thumbnail for NSFW/NSFL
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
// Generate blurred thumbnail for all posts (SFW, NSFW, NSFL, Untagged)
await queue.genBlurredThumbnail(itemid, isPending);
}
// Insert optional first comment
if (comment && comment.length > 0 && comment.length <= 2000) {
if (comment && comment.length > 0) {
try {
const filteredComment = await applyWordFilter(comment);
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: req.session.id,
content: comment
content: filteredComment
})}
`;
} catch (err) {
@@ -459,6 +577,8 @@ export const handleUpload = async (req, res, self) => {
// Action if auto-approved
if (!manualApproval) {
// Bust the count cache so page totals update immediately
f0cklib.clearCountCache();
if (!linkedToExisting) {
// Move logic: Handles both real files and symlinks (reposts) correctly
const moveSafe = async (src, dst) => {
@@ -521,15 +641,13 @@ export const handleUpload = async (req, res, self) => {
console.error(`[BACKGROUND ERROR] genThumbnail failed for item ${itemid}:`, err);
}
// Ensure blurred thumbnail exists if needed
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
// Ensure blurred thumbnail exists
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const blurPath = path.join(tDir, `${itemid}_blur.webp`);
const blurExists = await fs.access(blurPath).then(() => true).catch(() => false);
if (!blurExists) {
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.
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
@@ -624,12 +742,15 @@ export const handleUpload = async (req, res, self) => {
? 'Upload successful! Your upload is pending admin approval.'
: 'Upload successful! Your upload is now live.';
const imagesPath = cfg.websrv.paths?.images || '/b';
return sendJson(res, {
success: true,
msg: successMsg,
itemid: itemid,
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
});
} catch (err) {

View File

@@ -4,15 +4,42 @@
<div class="rules">
@if(about_text)
<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>
(function() {
var raw = document.getElementById('about-raw-data');
var el = document.getElementById('about-dynamic-content');
function escapeHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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() {
if (raw && el && typeof marked !== 'undefined') {
el.innerHTML = marked.parse(raw.value || '', { gfm: true, breaks: true });
raw.remove();
var bytes = Uint8Array.from(atob(raw.textContent.trim()), function(c) { return c.charCodeAt(0); });
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') {

View File

@@ -20,13 +20,17 @@
<li><a href="/admin/memes">Meme 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/wordfilter">Wordfilter Manager</a></li>
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li>
@if(enable_cleanup)
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
@endif
<li><a href="/admin/about">About Page</a></li>
<li><a href="/admin/rules">Rules 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>
@endif
</ul>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
@@ -86,19 +90,6 @@
</div>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Default Feed Layout</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Default layout for new users and guests on the main page.</p>
</div>
<select id="default_feed_layout_select" onchange="saveAdminSettings()" style="background: #333; border: 1px solid #444; color: #fff; padding: 5px 8px; border-radius: 4px; font-size: 0.85em;">
<option value="0" {{ default_feed_layout === 0 ? 'selected' : '' }}>Grid (Compact)</option>
<option value="1" {{ default_feed_layout === 1 ? 'selected' : '' }}>Grid (3-column Modern)</option>
<option value="2" {{ default_feed_layout === 2 ? 'selected' : '' }}>Feed (X / Instagram)</option>
<option value="3" {{ default_feed_layout === 3 ? 'selected' : '' }}>YouTube Style</option>
</select>
</div>
<span id="settings-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold; text-align: right;"></span>
</div>
</div>
@@ -120,7 +111,6 @@
const registrationToggle = document.getElementById('registration_open_toggle');
const minTagsInput = document.getElementById('min_tags_input');
const trustedUploadsInput = document.getElementById('trusted_uploads_input');
const feedLayoutSelect = document.getElementById('default_feed_layout_select');
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
@@ -137,7 +127,6 @@
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
min_tags: minTagsInput.value,
trusted_uploads: trustedUploadsInput.value,
default_feed_layout: feedLayoutSelect ? feedLayoutSelect.value : '0',
csrf_token: '{{ csrf_token }}'
}).toString()
});

View File

@@ -10,7 +10,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<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 style="display: flex; gap: 10px; align-items: center;">

View File

@@ -21,7 +21,7 @@
@each(pending as post)
<tr>
<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 }}">
</video>
</td>

View File

@@ -16,43 +16,66 @@
<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);">
</div>
<div style="flex-grow: 1;">
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">OR Image URL</label>
<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 style="margin-bottom: 20px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<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 id="emoji-list" class="emoji-grid">
<!-- Populated by JS -->
</div>
</div>
<!-- Edit Emoji Modal -->
<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>
<script>
(() => {
var i18n = window.f0ckI18n || {};
const esc = (s) => (s || '').toString().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
const loadEmojis = async () => {
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success) {
window.emojiAdmin.emojis = data.emojis;
const grid = document.getElementById('emoji-list');
if (!grid) return;
grid.innerHTML = data.emojis.reverse().map(e =>
grid.innerHTML = data.emojis.map(e =>
'<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>' +
'<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('');
}
@@ -62,10 +85,9 @@
const addEmoji = async (e) => {
if (e) e.preventDefault();
const name = document.getElementById('emoji-name').value;
const url = document.getElementById('emoji-url').value;
const fileInput = document.getElementById('emoji-file');
if (!name || (!url && !fileInput.files[0])) return alert('Fill Name and either URL or File');
if (!name || !fileInput.files[0]) return alert('Fill Name and select a File');
const btn = document.getElementById('add-emoji');
const oldText = btn.textContent;
@@ -74,7 +96,6 @@
const formData = new FormData();
formData.append('name', name);
formData.append('url', url);
if (fileInput.files[0]) {
formData.append('file', fileInput.files[0]);
}
@@ -93,7 +114,6 @@
const data = await res.json();
if (data.success) {
document.getElementById('emoji-name').value = '';
document.getElementById('emoji-url').value = '';
document.getElementById('emoji-file').value = '';
loadEmojis();
} else {
@@ -124,44 +144,79 @@
} catch (e) { console.error(e); }
};
// Global scope for onclick
window.emojiAdmin = { deleteEmoji };
const openEditModal = (id) => {
const emoji = (window.emojiAdmin.emojis || []).find(e => e.id === id);
if (!emoji) return;
const btnAddEmoji = document.getElementById('add-emoji');
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
document.getElementById('edit-emoji-id').value = emoji.id;
document.getElementById('edit-emoji-name').value = emoji.name;
document.getElementById('edit-emoji-file').value = '';
// Reconvert all emojis to WebP
const reconvertEmojis = async () => {
const btn = document.getElementById('reconvert-webp');
const status = document.getElementById('reconvert-status');
if (!btn || !status) return;
if (!confirm('Reconvert all non-WebP emojis to WebP? This will delete the originals.')) return;
const preview = document.getElementById('edit-emoji-preview');
preview.src = emoji.url;
preview.alt = ':' + emoji.name + ':';
const modal = document.getElementById('edit-emoji-modal');
if (modal) modal.style.display = 'flex';
};
const closeEditModal = () => {
const modal = document.getElementById('edit-emoji-modal');
if (modal) modal.style.display = 'none';
document.getElementById('edit-emoji-file').value = '';
};
const saveEmoji = async () => {
const id = document.getElementById('edit-emoji-id').value;
const name = document.getElementById('edit-emoji-name').value.trim().toLowerCase();
const fileInput = document.getElementById('edit-emoji-file');
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.');
const btn = document.querySelector('#edit-emoji-modal .btn-save');
const oldText = btn.textContent;
btn.disabled = true;
status.textContent = '\u23F3 Converting\u2026';
btn.textContent = 'Saving...';
const formData = new FormData();
formData.append('name', name);
if (fileInput.files[0]) {
formData.append('file', fileInput.files[0]);
}
try {
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
const csrf = '{{ csrf_token }}';
const res = await fetch('/api/v2/admin/emojis/reconvert', {
if (csrf) headers['X-CSRF-Token'] = csrf;
const res = await fetch('/api/v2/admin/emojis/' + id + '/edit', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }
headers: headers,
body: formData
});
const result = await res.json();
if (result.success) {
status.textContent = '\u2705 Done \u2014 converted: ' + result.converted + ', skipped: ' + result.skipped + ', errors: ' + result.errors;
if (result.converted > 0) loadEmojis();
const data = await res.json();
if (data.success) {
closeEditModal();
loadEmojis();
} else {
status.textContent = '\u274C Failed: ' + (result.message || 'Unknown error');
alert('Save failed: ' + (data.message || data.msg || 'Unknown error'));
}
} catch (err) {
status.textContent = '\u274C Error: ' + err.message;
} catch (e) {
console.error('[EMOJI_ADMIN] Edit Error:', e);
alert('Save failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
};
const btnReconvert = document.getElementById('reconvert-webp');
if (btnReconvert) btnReconvert.addEventListener('click', reconvertEmojis);
// Global scope for onclick handlers
window.emojiAdmin = { deleteEmoji, openEditModal, closeEditModal, saveEmoji, emojis: [] };
const btnAddEmoji = document.getElementById('add-emoji');
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
// Live Update Listener (SSE dispatched via f0ckm.js)
document.addEventListener('f0ck:emojis_updated', loadEmojis);

View File

@@ -34,9 +34,53 @@
</tbody>
</table>
</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>
<script>
window.f0ckDebug = window.f0ckDebug || (() => {});
(() => {
var i18n = window.f0ckI18n || {};
window.f0ckDebug('[MEME_ADMIN] Initializing');
@@ -46,6 +90,7 @@
const res = await fetch('/api/v2/memes');
const data = await res.json();
if (data.success) {
window.memeAdmin.memes = data.memes;
const tbody = document.getElementById('meme-list');
if (!tbody) return;
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; font-family: monospace; opacity: 0.7;">' + esc(m.template_id) + '</td>' +
'<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>' +
'</td>' +
'</tr>'
@@ -140,8 +186,88 @@
} 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
window.memeAdmin = { deleteMeme };
window.memeAdmin = { deleteMeme, openEditModal, closeEditModal, saveMeme, memes: [] };
const btnAddMeme = document.getElementById('add-meme');
if (btnAddMeme) {

265
views/admin/nsfp.html Normal file
View 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> &mdash; 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&hellip;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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)

View File

@@ -10,7 +10,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<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 style="display: flex; gap: 10px; align-items: center;">

View File

@@ -1,6 +1,7 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="session-grid">
<div id="main">
<div class="container session-grid">
<h2 class="session-page-title">
Sessions
<span class="session-stats">
@@ -83,4 +84,5 @@
</script>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -10,7 +10,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<label for="terms-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Terms Page Content (Markdown supported)</label>
<textarea id="terms-text" name="terms_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;">{!! terms_text !!}</textarea>
<textarea id="terms-text" name="terms_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('terms-text').value = new TextDecoder('utf-8').decode(Uint8Array.from(atob('{{ terms_text_b64 }}'), function(c) { return c.charCodeAt(0); }));</script>
</div>
<div style="display: flex; gap: 10px; align-items: center;">

View File

@@ -1,6 +1,7 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div id="main">
<div class="container">
<div class="admin-header-flex">
<h2>Invite Tokens</h2>
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New Token</button>
@@ -15,6 +16,7 @@
<th>Source</th>
<th>Used By</th>
<th>Created</th>
<th>Used At</th>
<th>Actions</th>
</tr>
</thead>
@@ -25,6 +27,7 @@
</div>
<script>
window.f0ckDebug = window.f0ckDebug || (() => {});
const loadTokens = async () => {
try {
window.f0ckDebug('Loading tokens...');
@@ -43,10 +46,11 @@
'<td data-label="Source">' +
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
(t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) +
(t.created_by_name ? '<span style="color: var(--accent)">' + t.created_by_name + '</span>' : '<span style="color: var(--text-muted)">Admin</span>'))) +
'</td>' +
'<td data-label="Used By">' + (t.used_by_name || '-') + '</td>' +
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '</td>' +
'<td data-label="Used By">' + (t.used_by_name || '') + '</td>' +
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '') + '</td>' +
'<td data-label="Used At">' + (t.used_at ? new Date(t.used_at).toLocaleString() : '—') + '</td>' +
'<td data-label="Actions">' +
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
'</td>' +
@@ -98,4 +102,5 @@
</script>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -106,14 +106,14 @@
}
</style>
<div class="container">
<div style="padding: 0 15px;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
</div>
<div style="flex-grow: 1; max-width: 400px; position: relative;">
<input type="text" id="user-search" placeholder="Search by name or email..."
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
value="{{ q }}">
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
@@ -128,10 +128,10 @@
<table class="admin-users-table responsive-table">
<thead>
<tr>
<th>User & Contact</th>
<th>User</th>
<th>Activity</th>
<th>Registration</th>
<th>Account Age</th>
<th>Date</th>
<th>Age</th>
<th>Status</th>
<th style="text-align: right;">Actions</th>
</tr>
@@ -283,7 +283,7 @@
var hint = currentDisplay
? 'Current nick: <strong style="color: var(--accent);">' + escHTML(currentDisplay) + '</strong><br>Enter a new stylized name, or leave empty to clear it.'
: 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong> (e.g. <code>F.O.O</code>). Leave empty to clear.';
: 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong>. Leave empty to clear.';
ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', {
@@ -310,7 +310,7 @@
} else {
throw new Error(data.msg || 'Failed to set display name');
}
}, { hideReason: false, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'e.g. F.O.O' });
}, { hideReason: false, singleLine: true, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'Set nickname' });
}
async function adminLockLayout(btn) {

View File

@@ -16,17 +16,17 @@
<td data-label="Activity">
<div style="display: flex; align-items: center; gap: 15px; white-space: nowrap;">
<a href="/user/{{ u.login }}" target="_blank" class="stat-box" title="Uploads" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
<i class="fa fa-upload" style="opacity: 0.6; font-size: 13px;"></i>
<strong>{{ u.upload_count }}</strong>
</a>
<a href="/user/{{ u.login }}/comments" target="_blank" class="stat-box" title="Comments" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
<i class="fa fa-comment" style="opacity: 0.6; font-size: 13px;"></i>
<strong>{{ u.comment_count }}</strong>
</a>
</div>
</td>
<td data-label="Registration">
<div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method }}">{{ new Date(u.created_at).toLocaleDateString() }}</div>
<div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method === 'Legacy' ? 'Legacy Account' : (u.reg_method ? 'Invite Token: ' + u.reg_method.slice(0, 8) + '…' : (u.activated ? 'Open Registration' : 'Email Verification')) }}">{{ new Date(u.created_at).toLocaleDateString() }}</div>
</td>
<td data-label="Account Age">
<div style="font-size: 0.85rem; font-weight: 600; color: #aaa;">{{ Math.floor(u.age_days) }} Days</div>
@@ -85,6 +85,7 @@
@if(u.id && u.login !== 'deleted_user')
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminRenameUser(this)" class="btn-modern" style="background: rgba(255, 165, 0, 0.1); color: #ffa500; border: 1px solid rgba(255, 165, 0, 0.2);" title="Rename username (updates all uploads)"><i class="fa fa-at"></i> Rename</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-locked="{{ u.force_comment_display_mode }}" data-mode="{{ u.comment_display_mode }}" onclick="adminLockLayout(this)" class="btn-modern" style="background: rgba(255, 100, 0, 0.1); color: #ff6400; border: 1px solid rgba(255, 100, 0, 0.2);" title="{{ u.force_comment_display_mode ? 'Unlock Layout' : 'Lock Layout' }}"><i class="fa fa-{{ u.force_comment_display_mode ? 'lock-open' : 'lock' }}"></i> {{ u.force_comment_display_mode ? 'Unlock' : 'Lock' }}</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button>

157
views/admin/wordfilter.html Normal file
View File

@@ -0,0 +1,157 @@
@include(snippets/header)
<style>
@media (min-width: 1200px) {
.container:not(:has(.item-layout-container)) {
max-width: 1140px;
}
}
</style>
<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: 25px;">
<h2>Wordfilter Manager</h2>
</div>
<!-- Rule Add Section -->
<div class="wordfilter-form-container" style="background: rgba(0,0,0,0.2); padding: 20px; border-radius: 6px; margin-bottom: 30px; border: 1px solid rgba(255,255,255,0.05);">
<h4 style="color: var(--accent); margin-top: 0; margin-bottom: 15px;">Create Wordfilter Rule</h4>
<form id="wordfilter-form" onsubmit="event.preventDefault(); window.wordfilterAdmin.addRule(this);" style="display: flex; gap: 15px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label for="word" style="display: block; margin-bottom: 6px; font-size: 0.9em; color: #ccc;">Target Word</label>
<input type="text" id="word" name="word" required placeholder="Word to match..." style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 10px; border-radius: 4px; font-size: 1em;">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="replacement" style="display: block; margin-bottom: 6px; font-size: 0.9em; color: #ccc;">Replacement Word</label>
<input type="text" id="replacement" name="replacement" required placeholder="Replace with..." style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 10px; border-radius: 4px; font-size: 1em;">
</div>
<button type="submit" class="btn-upload" style="width: auto; padding: 11px 25px; margin-bottom: 1px;">Add Rule</button>
</form>
<span id="form-status" style="margin-top: 10px; display: block; font-weight: bold; font-size: 0.9em;"></span>
</div>
<!-- Active Rules List -->
<div class="wordfilter-table-container" style="background: rgba(0,0,0,0.1); border-radius: 6px; padding: 5px; border: 1px solid rgba(255,255,255,0.05);">
<table class="responsive-table">
<thead>
<tr>
<th>Original Word</th>
<th>Replacement</th>
<th>Created</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="filter-list">
<!-- Dynamically loaded -->
</tbody>
</table>
<div id="no-rules-msg" style="text-align: center; padding: 40px; color: #aaa; display: none;">
No wordfilter rules active. Add one above!
</div>
</div>
<script>
(function() {
const escapeHtml = (str) => {
if (!str) return '';
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
};
const loadRules = async () => {
try {
const res = await fetch('/api/v2/admin/wordfilter');
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('filter-list');
const noRulesMsg = document.getElementById('no-rules-msg');
if (!tbody) return;
if (!data.filters || data.filters.length === 0) {
tbody.innerHTML = '';
noRulesMsg.style.display = 'block';
return;
}
noRulesMsg.style.display = 'none';
tbody.innerHTML = data.filters.map(f =>
'<tr>' +
'<td data-label="Original Word" style="font-weight: bold; color: var(--accent);">' + escapeHtml(f.word) + '</td>' +
'<td data-label="Replacement" style="font-family: monospace; color: #fff;">' + escapeHtml(f.replacement) + '</td>' +
'<td data-label="Created">' + (f.created_at ? new Date(f.created_at).toLocaleString() : '—') + '</td>' +
'<td data-label="Actions" style="text-align: right;">' +
'<button onclick="window.wordfilterAdmin.deleteRule(' + f.id + ')" class="btn-remove" style="padding: 5px 12px; font-size: 0.85em; border-radius: 4px; border: 0; cursor: pointer;">Delete</button>' +
'</td>' +
'</tr>'
).join('');
}
} catch (e) {
console.error('Failed to load rules:', e);
}
};
const addRule = async (form) => {
const status = document.getElementById('form-status');
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
try {
const res = await fetch('/api/v2/admin/wordfilter', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams(new FormData(form))
});
const data = await res.json();
if (data.success) {
status.textContent = 'Rule added successfully!';
status.style.color = '#28a745';
form.reset();
loadRules();
setTimeout(() => status.textContent = '', 3000);
} else {
throw new Error(data.msg || 'Save failed');
}
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.style.color = '#d9534f';
}
};
const deleteRule = async (id) => {
if (!confirm('Are you sure you want to delete this wordfilter rule?')) return;
try {
const res = await fetch('/api/v2/admin/wordfilter/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
loadRules();
} else {
alert('Delete failed: ' + data.msg);
}
} catch (e) {
alert('Error deleting: ' + e.message);
}
};
window.wordfilterAdmin = {
loadRules,
addRule,
deleteRule
};
// Initialize view
loadRules();
})();
</script>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -25,4 +25,4 @@
</div>
</div>
<!-- Include local script for this page -->
<script src="/s/js/user_comments.js?v=1"></script>
<script src="/s/js/user_comments.js?v=2"></script>

View File

@@ -1,7 +1,9 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@include(comments_user-partial)
</div>
</div>
@include(snippets/footer)

View File

@@ -1,5 +1,5 @@
<div id="main">
<div class="container">
<div class="pagewrapper">
<div id="main">
<div class="_error_wrapper">
<div class="err">
<div class="_error_topbar">

View File

@@ -1,6 +1,6 @@
@include(snippets/header)
<div id="main">
<div class="container">
<div class="pagewrapper">
<div id="main">
<div class="_error_wrapper">
<div class="err">
<div class="_error_topbar">

View File

@@ -1,8 +1,10 @@
<div class="container" style="padding-top: 20px;">
<div class="index-layout-wrapper">
<div class="container" style="padding-top: 20px;">
<h3 style="text-align: center;">{{ t('nav.halls') }}</h3>
<div class="tags-grid no-infinite-scroll" id="halls-container">
@include(hall-cards)
</div>
</div>
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>

View File

@@ -1,7 +1,7 @@
<div class="index-layout-wrapper">
<div class="index-container">
@include(snippets/page-title)
<div class="posts {{ feed_layout_class }}" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators">
@@ -11,8 +11,8 @@
@if(item.is_oc)
<span class="oc-indicator anim">OC</span>
@endif
@if(enable_xd_score && item.xd_score > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_score >= 60 ? 5 : (item.xd_score >= 30 ? 4 : (item.xd_score >= 15 ? 3 : (item.xd_score >= 5 ? 2 : 1))) }}" title="xD Score: {{ item.xd_score }}">xD</span>
@if(enable_xd_score && item.xd_tier > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}" title="xD Score: {{ item.xd_score }}">xD</span>
@endif
</div>
<p></p>

View File

@@ -6,9 +6,12 @@
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
@if(enable_item_title)
<div class="item_title">{!! item.title || '' !!}</div>
@endif
<div class="content">
<div class="previous-post">
@@ -22,7 +25,7 @@
</div>
@endif
</div>
<div class="media-object">
<div class="media-object" data-mode="@if(item.is_nsfl)nsfl@elseif(item.is_nsfw)nsfw@elseif(item.is_sfw)sfw@elseuntagged@endif">
@include(snippets/item-media)
</div>
<div class="next-post">
@@ -42,6 +45,7 @@
<div class="kontrollelement">
<div class="einheit">
@if(typeof pagination !== "undefined")
@if(!user_alternative_steuerung)
<nav class="steuerung">
@if(pagination.next)
<a class="nav-prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}">← {{ t('nav.prev') }}</a>
@@ -59,6 +63,21 @@
<a class="nav-next" href="#" style="visibility: hidden">{{ t('nav.next') }} →</a>
@endif
</nav>
@else
<nav class="steuerung steuerung-icon">
@if(pagination.next)
<a class="nav-prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}"><i class="fa-solid fa-chevron-left"></i></a>
@else
<a class="nav-prev" href="#" style="visibility: hidden"><i class="fa-solid fa-chevron-left"></i></a>
@endif
<button class="steuerung-scroll-down"><i class="fa-solid fa-chevron-down"></i></button>
@if(pagination.prev)
<a class="nav-next" href="{{ link.main }}{{ pagination.prev }}{{ link.suffix }}"><i class="fa-solid fa-chevron-right"></i></a>
@else
<a class="nav-next" href="#" style="visibility: hidden"><i class="fa-solid fa-chevron-right"></i></a>
@endif
</nav>
@endif
@endif
</div>
</div>
@@ -95,26 +114,25 @@
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link" @if(user_alternative_infobox)style="display:none"@endif>{{ item.id }}</a>
@if(item.src.short)@if(!user_alternative_infobox) — @endif<a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a>@endif
@if(session && !user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" @if(item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
@if(item.is_oc)@if(!user_alternative_infobox || item.src.short) — @endif<span class="oc-badge" tooltip="Original Content">OC</span>@endif
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
@if(item.is_oc)@if(!user_alternative_infobox) — @endif<span class="oc-badge" tooltip="Original Content">OC</span>@endif
</span>
@if(!user_alternative_infobox) — <span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span> — @endif
@if(!user_alternative_infobox) — <span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>@if(halls_enabled && item.primaryHall) — @endif @endif
@if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary">{{ item.primaryHall.name }}</a>@if(is_mod_or_admin)&nbsp;<a class="remove-from-hall" href="#" data-hall="{{ item.primaryHall.slug }}" title="Remove from hall"><i class="fa-solid fa-xmark"></i></a>@endif@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>@if(!user_alternative_infobox) —@endif
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>
@endif
@if(session)
<div class="gapRight">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(halls_enabled)
@@ -123,7 +141,7 @@
@if(can_manage_item)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" title="Extract Metadata"></i>
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@endif
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@@ -133,13 +151,15 @@
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
</div>
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@endif
</div>
<span class="badge badge-dark" id="tags">
<span class="tags-inner">
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }} mr-2">
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endeach
@@ -154,7 +174,7 @@
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating">{{ item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')) }}</button>
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif
</div>
@endif
@@ -185,6 +205,7 @@
</div>
@endif
</div>
<button class="mobile-scroll-to-top" title="Back to top" aria-label="Scroll to top"><i class="fa-solid fa-chevron-up"></i></button>
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
</div>
@@ -192,3 +213,80 @@
{{-- RIGHT SIDEBAR: recent activity (fixed to viewport) --}}
</div>
<div id="info-modal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-body" style="padding: 20px 0; text-align: left;">
<table class="info-table">
@if(enable_item_title && can_manage_item)
<tr class="info-title-row">
<th>Title</th>
<td>
<div class="info-title-edit-wrap">
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
</div>
<span id="info-title-status" class="info-title-status" style="display:none"></span>
</td>
</tr>
@elseif(enable_item_title && item.title)
<tr>
<th>Title</th>
<td>{!! item.title !!}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
</tr>
@if(item_has_dimensions)
<tr>
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
<td>{{ item.width }} × {{ item.height }}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
<td><code>{{ item.mime }}</code></td>
</tr>
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
<tr class="info-repost-row">
<th>Repost</th>
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
@endif
@endeach
</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
<td>
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
{{ t('info_modal.view_file') || 'View File' }}
</a>
</td>
</tr>
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
</tr>
@endif
</table>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
</div>
</div>
</div>

View File

@@ -5,6 +5,14 @@
{{-- LEFT SIDEBAR: comments + tags --}}
<div class="item-sidebar-left">
@if(enable_xd_score && item.xd_score > 0)
<div class="xd-score-wrapper">
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
</span>
</div>
@endif
@if(session || !hide_comments_from_public)
<div id="comments-container"
data-item-id="{{ item.id }}"
@@ -25,7 +33,7 @@
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating">{{ item_rating_label }}</button>
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif
</div>
@endif
@@ -43,15 +51,20 @@
</span>
</div>
<button class="mobile-scroll-to-top" title="Back to top" aria-label="Scroll to top"><i class="fa-solid fa-chevron-up"></i></button>
</div>
{{-- MAIN CONTENT: media + navigation + metadata --}}
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
@if(enable_item_title)
<div class="item_title">{!! item.title || '' !!}</div>
@endif
<div class="content">
<div class="previous-post">
@@ -65,7 +78,7 @@
</div>
@endif
</div>
<div class="media-object">
<div class="media-object" data-mode="@if(item.is_nsfl)nsfl@elseif(item.is_nsfw)nsfw@elseif(item.is_sfw)sfw@elseuntagged@endif">
@include(snippets/item-media)
</div>
<div class="next-post">
@@ -107,26 +120,27 @@
</div>
<div class="blahlol">
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>@if(item.src.short) — <a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a>@endif @if(session) — [<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ item_username_lower }}" @if(item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif @if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif
</span>
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif
</span>@if(halls_enabled && item.primaryHall) — @endif
@if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary">{{ item.primaryHall.name }}</a>@if(is_mod_or_admin)&nbsp;<a class="remove-from-hall" href="#" data-hall="{{ item.primaryHall.slug }}" title="Remove from hall"><i class="fa-solid fa-xmark"></i></a>@endif@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>
@endif
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>
@if(session)
<div class="gapRight">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(can_manage_item)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" title="Extract Metadata"></i>
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@if(is_flash_item)
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@endif
@@ -138,21 +152,16 @@
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
</div>
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@endif
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px; display: block;">
</div>
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
@each(item.favorites as fav)
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@endeach
</span>
@if(enable_xd_score && item.xd_score > 0)
<div class="xd-score-wrapper">
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
</span>
</div>
@endif
</div>
</div>
@@ -163,3 +172,80 @@
</div>
<div id="info-modal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-body" style="padding: 20px 0; text-align: left;">
<table class="info-table">
@if(enable_item_title && can_manage_item)
<tr class="info-title-row">
<th>Title</th>
<td>
<div class="info-title-edit-wrap">
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
</div>
<span id="info-title-status" class="info-title-status" style="display:none"></span>
</td>
</tr>
@elseif(enable_item_title && item.title)
<tr>
<th>Title</th>
<td>{!! item.title !!}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
</tr>
@if(item_has_dimensions)
<tr>
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
<td>{{ item.width }} × {{ item.height }}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
<td><code>{{ item.mime }}</code></td>
</tr>
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
<tr class="info-repost-row">
<th>Repost</th>
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
@endif
@endeach
</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
<td>
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
{{ t('info_modal.view_file') || 'View File' }}
</a>
</td>
</tr>
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
</tr>
@endif
</table>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More