mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-09 23:02:05 +00:00
Compare commits
747 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20daaa780f | ||
|
|
e917ea25eb | ||
|
|
efd5fe0465 | ||
|
|
6b40b9c500 | ||
|
|
03711e4726 | ||
|
|
dd1bf9d55b | ||
|
|
4ec1e83337 | ||
|
|
c102b90ef6 | ||
|
|
71b0a092ca | ||
|
|
60c6a50d9e | ||
|
|
c977cbd754 | ||
|
|
7f8afe23cd | ||
|
|
49623df29d | ||
|
|
2788dae3c8 | ||
|
|
36da4bdff1 | ||
|
|
6eeee342d7 | ||
|
|
12696452aa | ||
|
|
e3dd961430 | ||
|
|
03922e8484 | ||
|
|
73c90d8802 | ||
|
|
341b91aad1 | ||
|
|
b4e93cc63c | ||
|
|
c49ff4bac1 | ||
|
|
5209c1c10a | ||
|
|
8e9bd64e5b | ||
|
|
ae037c7795 | ||
|
|
1050a15ef6 | ||
|
|
25d4be7aae | ||
|
|
c4ee655d15 | ||
|
|
56ab732f6b | ||
|
|
17f38874d1 | ||
|
|
4d61a68e8b | ||
|
|
2bdd975c3c | ||
|
|
0c15e06975 | ||
|
|
8fb51e6dc3 | ||
|
|
aa5d4a1850 | ||
|
|
dc70e1236b | ||
|
|
914605f393 | ||
|
|
2a62e6df90 | ||
|
|
6281b0ead3 | ||
|
|
e7111689f1 | ||
|
|
9d313e1375 | ||
|
|
6efb8902fa | ||
|
|
80cd107aa0 | ||
|
|
f3cfe74cfa | ||
|
|
917b227ff5 | ||
|
|
589f999a9c | ||
|
|
eac1547aea | ||
|
|
c1e050c26e | ||
|
|
1c3025ceee | ||
|
|
e13d5b69c8 | ||
|
|
7de1894a0c | ||
|
|
2aabaf6b6d | ||
|
|
f12115159e | ||
|
|
bb5173fd12 | ||
|
|
6416b4a2b6 | ||
|
|
9738c3f8c8 | ||
|
|
adc0ea27a9 | ||
|
|
4c437efb5d | ||
|
|
227244e2d0 | ||
|
|
6f47ed42db | ||
|
|
98bfb6736e | ||
|
|
2c541448a2 | ||
|
|
21d7f16413 | ||
|
|
87198bd4e7 | ||
|
|
986207b46b | ||
|
|
ed410fdebe | ||
|
|
1a9d920884 | ||
|
|
c78ef333da | ||
|
|
fad1da7ab4 | ||
|
|
d37e69e1a6 | ||
|
|
1e2dcee4fa | ||
|
|
6ec2f3d491 | ||
|
|
306e3adde8 | ||
|
|
99740a3673 | ||
|
|
913348d46e | ||
|
|
ae5dbf5f48 | ||
|
|
8338fe2f25 | ||
|
|
7921f41174 | ||
|
|
50e2692896 | ||
|
|
9e0f7b8efa | ||
|
|
fd9586e0da | ||
|
|
f185e6c3ea | ||
|
|
2cf26cdc4c | ||
|
|
008c24f892 | ||
|
|
a398e3a6fa | ||
|
|
aa04f0d034 | ||
|
|
e7f0aa98be | ||
|
|
0f9d778a27 | ||
|
|
119b6a62b8 | ||
|
|
9d00d9666d | ||
|
|
f6ba5b71e8 | ||
|
|
9b05e2eb8c | ||
|
|
911558760f | ||
|
|
45217ccad8 | ||
|
|
aeb5de85b6 | ||
|
|
53911ab9f0 | ||
|
|
a2c4ea5036 | ||
|
|
517058bef3 | ||
|
|
1790d5b569 | ||
|
|
97b8d1b4b7 | ||
|
|
25ddc336e0 | ||
|
|
498272b128 | ||
|
|
26f6611ca8 | ||
|
|
6b831bc367 | ||
|
|
ffd01fe30b | ||
|
|
8774dc89e7 | ||
|
|
16f183c117 | ||
|
|
ba80c1591d | ||
|
|
4fada9a8d2 | ||
|
|
7441892235 | ||
|
|
f929758bfd | ||
|
|
500f295506 | ||
|
|
de1f37735b | ||
|
|
9f9bbfa022 | ||
|
|
d516c5ebfc | ||
|
|
3668c1b3da | ||
|
|
0e3307b9f4 | ||
|
|
3ea16944d2 | ||
|
|
dcfcee9a23 | ||
|
|
fd451fe9d2 | ||
|
|
cc283c0be9 | ||
|
|
c9da64107f | ||
|
|
5b92ea0660 | ||
|
|
facc72b22d | ||
|
|
578c0f0ddc | ||
|
|
e099781686 | ||
|
|
3dfa587739 | ||
|
|
0d9f4a5f03 | ||
|
|
ab8faf7c99 | ||
|
|
7c3f3070f9 | ||
|
|
1bab65bb13 | ||
|
|
01063c2623 | ||
|
|
bd63013524 | ||
|
|
af62fbaef4 | ||
|
|
f41e0bda82 | ||
|
|
0d8dcc41b2 | ||
|
|
d179cd896f | ||
|
|
1f10f0f09c | ||
|
|
edb5f94b7c | ||
|
|
d563a85092 | ||
|
|
394f03ee1c | ||
|
|
7214b7c474 | ||
|
|
1b7e7c74f5 | ||
|
|
11a0cd79bb | ||
|
|
5f799fe1a1 | ||
|
|
c717a55c2d | ||
|
|
9a008d4623 | ||
|
|
47d268335e | ||
|
|
f136a02240 | ||
|
|
a33d1e12d2 | ||
|
|
337e8cd1d3 | ||
|
|
adfe26aad1 | ||
|
|
f84892dc6a | ||
|
|
c290f9fcca | ||
|
|
d85c4ec84b | ||
|
|
bce5d0d878 | ||
|
|
a3c17ea8ea | ||
|
|
982c6fbfab | ||
|
|
709963fd81 | ||
|
|
1f4f9a9c3e | ||
|
|
b621a1b327 | ||
|
|
d28be04416 | ||
|
|
db08272416 | ||
|
|
8562b2173e | ||
|
|
da53decdd5 | ||
|
|
05107ce13f | ||
|
|
56b4ec8f3a | ||
|
|
a1c9ae3626 | ||
|
|
08c0cfcd58 | ||
|
|
5f3d0859fd | ||
|
|
988029e6c7 | ||
|
|
0b57f528bf | ||
|
|
99559d8fda | ||
|
|
811a7c4d48 | ||
|
|
182e6f0816 | ||
|
|
9e5a63d880 | ||
|
|
bb165606d6 | ||
|
|
7b56f3f0e7 | ||
|
|
e391a80d65 | ||
|
|
a75917d4e4 | ||
|
|
00e9acbe4d | ||
|
|
3262f7822f | ||
|
|
a8d9781821 | ||
|
|
18fd611c91 | ||
|
|
7c3d2f74ed | ||
|
|
9e3c23c58a | ||
|
|
d678fa56d1 | ||
|
|
66fadab492 | ||
|
|
750509eaf1 | ||
|
|
8fc951350e | ||
|
|
3f9a0aaf05 | ||
|
|
801e54afa2 | ||
|
|
6f8bde91e1 | ||
|
|
df82d2d4f1 | ||
|
|
f08cce5aed | ||
|
|
962998c543 | ||
|
|
80d3d14c85 | ||
|
|
f081bc782a | ||
|
|
99af92ed2c | ||
|
|
c148c991cd | ||
|
|
d4f75146c7 | ||
|
|
4598a6a58c | ||
|
|
248c200a74 | ||
|
|
b70194c8f2 | ||
|
|
ffa10648e4 | ||
|
|
4f5cd7d741 | ||
|
|
a85b379f17 | ||
|
|
9e5fcf4904 | ||
|
|
529a3561ca | ||
|
|
88365612da | ||
|
|
f2adbe18da | ||
|
|
a53f65a1d5 | ||
|
|
47bb3e47a2 | ||
|
|
83fd8f11b2 | ||
|
|
5a386d0f81 | ||
|
|
d2358924a4 | ||
|
|
106065184f | ||
|
|
e3a9915b45 | ||
|
|
b80a532f9e | ||
|
|
46311bd661 | ||
|
|
58e4e09840 | ||
|
|
c809b1994a | ||
|
|
842d0bb4be | ||
|
|
40b5a0fe83 | ||
|
|
4d3c90f5ee | ||
|
|
9aa73bee7c | ||
|
|
b0b22a7579 | ||
|
|
06b3916a6c | ||
|
|
c4a1d4b18c | ||
|
|
08f9feef74 | ||
|
|
1ec3eab0dc | ||
|
|
27e8885285 | ||
|
|
77b7af7fd9 | ||
|
|
5c51d73c4e | ||
|
|
959ef89c27 | ||
|
|
5a2494adcf | ||
|
|
6b2dfa483c | ||
|
|
df934f401c | ||
|
|
60c348a905 | ||
|
|
2a1f1df17b | ||
|
|
97266b6dfc | ||
|
|
a3a2daff4c | ||
|
|
5493a81611 | ||
|
|
13c468c768 | ||
|
|
12924b9b5a | ||
|
|
c5b122bcf8 | ||
|
|
96bf3df928 | ||
|
|
8c136c563a | ||
|
|
b25560c4a9 | ||
|
|
cb95aaa4e8 | ||
|
|
367df3d70b | ||
|
|
c6f9b1611e | ||
|
|
66d81ffb22 | ||
|
|
a81e4d1d16 | ||
|
|
dfb7177a6d | ||
|
|
5c76eaf68a | ||
|
|
8d0c1a03d1 | ||
|
|
c85be71f23 | ||
|
|
9c44488d8e | ||
|
|
693c0e8673 | ||
|
|
b68ed4d77a | ||
|
|
aa2348656d | ||
|
|
37c6fa3f79 | ||
|
|
fe4030a247 | ||
|
|
4c9e85b293 | ||
|
|
8456b6a125 | ||
|
|
027b27c1b0 | ||
|
|
3620b07816 | ||
|
|
8c9622f1b2 | ||
|
|
4ccdca6dca | ||
|
|
bfff2900ca | ||
|
|
b85406716b | ||
|
|
f7cc00d16b | ||
|
|
a9fac9d6d0 | ||
|
|
cd94c8b83d | ||
|
|
60a39890f0 | ||
|
|
2d6af31c00 | ||
|
|
f6a58d00b2 | ||
|
|
1f28c0b87d | ||
|
|
801d3d9be1 | ||
|
|
5b86fb3187 | ||
|
|
0bc866dbfa | ||
|
|
1923af16a9 | ||
|
|
ce44bfea9e | ||
|
|
24a13c12cf | ||
|
|
13585a5e6a | ||
|
|
0c100b1dec | ||
|
|
c7fcd11e53 | ||
|
|
d9e2a62f77 | ||
|
|
7b0427afa2 | ||
|
|
553052f901 | ||
|
|
c90d9c0ddc | ||
|
|
0bd11c3bba | ||
|
|
db48104b80 | ||
|
|
f19efdb859 | ||
|
|
c5c4fba7ce | ||
|
|
a9a644460f | ||
|
|
cb687fc078 | ||
|
|
d54707c9c7 | ||
|
|
3d520ecf57 | ||
|
|
878b30bdb2 | ||
|
|
67b1c97d89 | ||
|
|
db2361aee9 | ||
|
|
3db751b65f | ||
|
|
7acae30875 | ||
|
|
aca40dde0c | ||
|
|
dd23564c15 | ||
|
|
fa49921866 | ||
|
|
3413c3bdaa | ||
|
|
90b5e5e09f | ||
|
|
125a781cc7 | ||
|
|
a632a4cafa | ||
|
|
fdab26b792 | ||
|
|
bfe0d75278 | ||
|
|
976b0a2168 | ||
|
|
e9a183bf9a | ||
|
|
62417f7fb8 | ||
|
|
953428cad5 | ||
|
|
ef7bf1a319 | ||
|
|
c1e78fd4dc | ||
|
|
2087921072 | ||
|
|
6070f7fc06 | ||
|
|
304a6c9cfa | ||
|
|
34eaca7b84 | ||
|
|
fcfc45dd70 | ||
|
|
54bf7f1c5b | ||
|
|
8340bf2c81 | ||
|
|
81e1947656 | ||
|
|
247cf770d0 | ||
|
|
726a5bf7c4 | ||
|
|
79556d9365 | ||
|
|
d5f5c91b05 | ||
|
|
49661a95ab | ||
|
|
03f30a82b9 | ||
|
|
966da1ac58 | ||
|
|
aeab31825e | ||
|
|
e7781b5c09 | ||
|
|
8821de0e7d | ||
|
|
0f9bc44925 | ||
|
|
8399eab33f | ||
|
|
326e67893c | ||
|
|
1797e11b43 | ||
|
|
46a738b7f4 | ||
|
|
d706bf63b1 | ||
|
|
fec1372e4e | ||
|
|
c07cf7c13a | ||
|
|
e350eb731b | ||
|
|
cf9b95a265 | ||
|
|
67fbc8e267 | ||
|
|
4b48966e1d | ||
|
|
6d0498987a | ||
|
|
78bffad888 | ||
|
|
7b328b10c3 | ||
|
|
8a8532fc84 | ||
|
|
95e147b5a0 | ||
|
|
0b6106a89e | ||
|
|
fa74ee0538 | ||
|
|
24322d3b52 | ||
|
|
b7bc93f194 | ||
|
|
0c330a82ce | ||
|
|
a4e72a002a | ||
|
|
7fbd62142e | ||
|
|
29be9233e9 | ||
|
|
1e969117c4 | ||
|
|
fbee6d2ab7 | ||
|
|
c4cc22dd05 | ||
|
|
a9062159ed | ||
|
|
64350cc492 | ||
|
|
4e8c97bfb5 | ||
|
|
39587a8448 | ||
|
|
9886f648f2 | ||
|
|
60f77d4eb9 | ||
|
|
6a0608bf7e | ||
|
|
aa5066762b | ||
|
|
342e5d406a | ||
|
|
a260f79c7d | ||
|
|
85169fbb56 | ||
|
|
875337d9a6 | ||
|
|
b876c8907a | ||
|
|
b453aecee5 | ||
|
|
3cd8bfa8c7 | ||
|
|
a2be65aead | ||
|
|
014f3f008e | ||
|
|
c4ad9099c2 | ||
|
|
f975f7ef85 | ||
|
|
9cfe71d4c4 | ||
|
|
071def0838 | ||
|
|
8db22ad924 | ||
|
|
bfc7cfc193 | ||
|
|
9868a97dbd | ||
|
|
c159fa8060 | ||
|
|
4e1bce6a24 | ||
|
|
de5cc3352a | ||
|
|
9a1d50dcd3 | ||
|
|
4db78deda3 | ||
|
|
9e3426633d | ||
|
|
5b6f86668a | ||
|
|
3eb97bab6a | ||
|
|
97231e515c | ||
|
|
45d0e0b4c3 | ||
|
|
b76869e2d2 | ||
|
|
8b1b501bbd | ||
|
|
33b2bc2d30 | ||
|
|
269aa6bfe6 | ||
|
|
162f8fd9b5 | ||
|
|
3d50b8f52e | ||
|
|
cc69b3c225 | ||
|
|
cacde7f72d | ||
|
|
712a8c228b | ||
|
|
0810591fe3 | ||
|
|
7e6312f9d1 | ||
|
|
a48cab81b9 | ||
|
|
7c897d91db | ||
|
|
0885a619b9 | ||
|
|
9f0444a962 | ||
|
|
ae7098085c | ||
|
|
791a712a68 | ||
|
|
d16cfb7328 | ||
|
|
9ee650461f | ||
|
|
2990d83c02 | ||
|
|
99076412b6 | ||
|
|
c6c3bafca2 | ||
|
|
82004aab73 | ||
|
|
82bd645781 | ||
|
|
70b875c0e9 | ||
|
|
4102d6eaf2 | ||
|
|
ba8088b678 | ||
|
|
a90d88ad65 | ||
|
|
8a8ed0a932 | ||
|
|
d0c1e8cbd9 | ||
|
|
92f0a956b9 | ||
|
|
04c9d48779 | ||
|
|
a6de8731b3 | ||
|
|
f4ce2fe69d | ||
|
|
8b7cdfd4c3 | ||
|
|
c7f7dcfed3 | ||
|
|
ea6e3f921f | ||
|
|
331a4626a0 | ||
|
|
0b560f15a9 | ||
|
|
dac2e41488 | ||
|
|
cb6cfc8455 | ||
|
|
6043647cb7 | ||
|
|
0118a6fb15 | ||
|
|
107155a661 | ||
|
|
7bd9934e58 | ||
|
|
f593f7283c | ||
|
|
5a78056c91 | ||
|
|
e80613c7ec | ||
|
|
9dd0ee4446 | ||
|
|
08a42f6739 | ||
|
|
282ad986b6 | ||
|
|
52030506b5 | ||
|
|
a8f1e48157 | ||
|
|
ffde151ebd | ||
|
|
964feb7243 | ||
|
|
ff3ececc36 | ||
|
|
e780e7dadb | ||
|
|
c7bec6251e | ||
|
|
dd770137e5 | ||
|
|
7efa3d4704 | ||
|
|
d9813e6244 | ||
|
|
c152a19624 | ||
|
|
b7ceee8ef4 | ||
|
|
30a5657d62 | ||
|
|
fc66e758ac | ||
|
|
637bcad816 | ||
|
|
07179d6c83 | ||
|
|
486ce04a3e | ||
|
|
54045766f2 | ||
|
|
00901f9cdb | ||
|
|
860775a90b | ||
|
|
5500054b84 | ||
|
|
d36bc160ca | ||
|
|
18bf1b946b | ||
|
|
7ebf3c18ab | ||
|
|
76e0d1b7ec | ||
|
|
9cffd7dde8 | ||
|
|
2427b3ef4b | ||
|
|
9fc399c200 | ||
|
|
5f71c4d368 | ||
|
|
a96f7976d8 | ||
|
|
6161f4ad44 | ||
|
|
6633e23aa3 | ||
|
|
53cee986c6 | ||
|
|
efae9c4774 | ||
|
|
be8318f014 | ||
|
|
33f632036e | ||
|
|
2dc6504a77 | ||
|
|
0f5193c700 | ||
|
|
6d4558c978 | ||
|
|
f89832a6d1 | ||
|
|
df0fc769d9 | ||
|
|
00a65a1584 | ||
|
|
b23a858a8c | ||
|
|
ed811db6ec | ||
|
|
b80a87ba01 | ||
|
|
8ad9b4e543 | ||
|
|
58c65a5bac | ||
|
|
830486bc4f | ||
|
|
07c801a12d | ||
|
|
699aa2abe1 | ||
|
|
d42de93d74 | ||
|
|
668477d711 | ||
|
|
8769ca1dd9 | ||
|
|
d0712d007e | ||
|
|
7fcf31dec6 | ||
|
|
290f802b7c | ||
|
|
e02bc46ed2 | ||
|
|
2a694e73af | ||
|
|
22a9acfc90 | ||
|
|
f968521936 | ||
|
|
e9c519c6e2 | ||
|
|
995ab142e3 | ||
|
|
5163c2acb1 | ||
|
|
97f94dd3ac | ||
|
|
1d65eb036e | ||
|
|
93ef067b8c | ||
|
|
d23b5278b1 | ||
|
|
55b03d51d7 | ||
|
|
02587dbb5c | ||
|
|
9cfd97088e | ||
|
|
8434d20826 | ||
|
|
8e3ce4e1c3 | ||
|
|
5f6176b18c | ||
|
|
929e1b2c69 | ||
|
|
dd97c244f2 | ||
|
|
071170dd90 | ||
|
|
35a01b6127 | ||
|
|
8dae6e66cc | ||
|
|
c6065dbd95 | ||
|
|
8b95b9fc41 | ||
|
|
de309d675e | ||
|
|
6bfbbc0c01 | ||
|
|
e92afcb203 | ||
|
|
a0af0ccab5 | ||
|
|
089ac75e9a | ||
|
|
8e74b0c765 | ||
|
|
fac94d46a6 | ||
|
|
5b58c30011 | ||
|
|
ef9c744003 | ||
|
|
8d40c87dda | ||
|
|
25c663c110 | ||
|
|
8306d2d1b6 | ||
|
|
b1a328d2e0 | ||
|
|
f42e3bf2b7 | ||
|
|
5bdf8b4aaf | ||
|
|
0ce6fbba20 | ||
|
|
5e537fa8db | ||
|
|
61f872bb84 | ||
|
|
cc23fd5273 | ||
|
|
7595faf11d | ||
|
|
309cd40da2 | ||
|
|
0613083eb0 | ||
|
|
9dc82ad444 | ||
|
|
41a538c655 | ||
|
|
d8b9e3dab6 | ||
|
|
88044e11d5 | ||
|
|
ab1358df36 | ||
|
|
a594b19745 | ||
|
|
f6500ff745 | ||
|
|
9239c2d465 | ||
|
|
8f266ccd44 | ||
|
|
d62d5e0cab | ||
|
|
c721d67080 | ||
|
|
f8183bea1b | ||
|
|
d93e42a71c | ||
|
|
4701e767b6 | ||
|
|
d65cf1beef | ||
|
|
a56f0d5b10 | ||
|
|
aea456436e | ||
|
|
6672f5f75e | ||
|
|
70be35e3fa | ||
|
|
20326194f7 | ||
|
|
d4db459ff9 | ||
|
|
a80512aa60 | ||
|
|
5487d15bdf | ||
|
|
3020060627 | ||
|
|
022fda3d1c | ||
|
|
97de993055 | ||
|
|
d7c3edfac5 | ||
|
|
2c57719318 | ||
|
|
b0ff4d5ef0 | ||
|
|
bec55bc3d1 | ||
|
|
27e168ba8b | ||
|
|
e1ad7c63af | ||
|
|
ee8cf35cdf | ||
|
|
5321996c64 | ||
|
|
dfdc07cbfa | ||
|
|
34ca5e12af | ||
|
|
6e61a13354 | ||
|
|
e2abb90d14 | ||
|
|
5f4e9076df | ||
|
|
31880fa625 | ||
|
|
b0daa58874 | ||
|
|
f6c201f3ba | ||
|
|
d21943ecc7 | ||
|
|
041d50cb23 | ||
|
|
8719527a31 | ||
|
|
53d385f53e | ||
|
|
453ed607ba | ||
|
|
e8d39850c5 | ||
|
|
a624f45493 | ||
|
|
aa06884bd6 | ||
|
|
632ffdfa8f | ||
|
|
9302a271d0 | ||
|
|
bfad626b2d | ||
|
|
afa18c4749 | ||
|
|
d159a16aca | ||
|
|
654d57b53e | ||
|
|
7117cd0a5e | ||
|
|
d2cce4f166 | ||
|
|
3c11ac6cf5 | ||
|
|
d0d2002a5f | ||
|
|
7c3f2d0a8b | ||
|
|
ad4ee4bd02 | ||
|
|
99760b6989 | ||
|
|
014eb28e0d | ||
|
|
35a8e2b52a | ||
|
|
c88c63a422 | ||
|
|
e1120455b2 | ||
|
|
b4b23f748f | ||
|
|
489c0933e8 | ||
|
|
83987afd73 | ||
|
|
edff85dfb0 | ||
|
|
1b1d2596f8 | ||
|
|
cbfbf396dd | ||
|
|
f62d9bc271 | ||
|
|
7b4126c32f | ||
|
|
5b60a48c7f | ||
|
|
8b94c54d25 | ||
|
|
ced2719f0e | ||
|
|
84fa7972e3 | ||
|
|
d821fc6ccd | ||
|
|
e17dac58fd | ||
|
|
b34a8fce3c | ||
|
|
377512340a | ||
|
|
af6e958c49 | ||
|
|
459ae4dec8 | ||
|
|
5a81ab7ce7 | ||
|
|
578d3fbb23 | ||
|
|
8d3b2e59df | ||
|
|
ba9fbea1a1 | ||
|
|
4feee02e33 | ||
|
|
d51722c466 | ||
|
|
6ebd4af490 | ||
|
|
050dec4d0f | ||
|
|
af663bfbcf | ||
|
|
d9d385f85e | ||
|
|
17aad006f7 | ||
|
|
33f775051d | ||
|
|
05b40b8091 | ||
|
|
0327b3de2e | ||
|
|
74cb1b3efc | ||
|
|
8305c235eb | ||
|
|
701d470494 | ||
|
|
f9ccb1509b | ||
|
|
e99bfcd47b | ||
|
|
6245dc84da | ||
|
|
016b125f49 | ||
|
|
7b95777d99 | ||
|
|
96a5d657a5 | ||
|
|
ac94d6ba22 | ||
|
|
d06c614ccc | ||
|
|
da99ea8288 | ||
|
|
38c3883c01 | ||
|
|
c0fc4c7a86 | ||
|
|
8ebfb431ce | ||
|
|
88c42af139 | ||
|
|
6aebe82298 | ||
|
|
285dab9ed7 | ||
|
|
71c5fe2a05 | ||
|
|
9655d2635a | ||
|
|
670cb97e79 | ||
|
|
6e416fea8a | ||
|
|
9559035118 | ||
|
|
aaa21aad05 | ||
|
|
aded7b1f38 | ||
|
|
31a392cea9 | ||
|
|
29a4834baa | ||
|
|
9e00bb133e | ||
|
|
d01d558ed6 | ||
|
|
ce260e0f5c | ||
|
|
97cb751573 | ||
|
|
b2b034d9df | ||
|
|
5e399b96cf | ||
|
|
5eebd88e13 | ||
|
|
44cc6336b9 | ||
|
|
2a2ed7ce1c | ||
|
|
aa6066dfd5 | ||
|
|
0de5f88eee | ||
|
|
0bafe9f2d7 | ||
|
|
2ae5af096b | ||
|
|
0aa73a4b14 | ||
|
|
292efd2b71 | ||
|
|
32bb63e06b | ||
|
|
c3cd84f7af | ||
|
|
9cb2f2f0d3 | ||
|
|
5896a1c0eb | ||
|
|
42cf772dc3 | ||
|
|
7025a70034 | ||
|
|
9f4d2c7ffb | ||
|
|
df5c5cd54f | ||
|
|
f75d40d278 | ||
|
|
edb5fb6f4e | ||
|
|
312892e56b | ||
|
|
c70dc83504 | ||
|
|
e9fdb1a7e5 | ||
|
|
8ede986d22 | ||
|
|
6f56862307 | ||
|
|
056b2a48ea | ||
|
|
e4decbc34f | ||
|
|
77d84d5b76 | ||
|
|
b6bb0aa56d | ||
|
|
5b9948f709 | ||
|
|
6e772c6837 | ||
|
|
7faf2829b2 | ||
|
|
a360cd8808 | ||
|
|
77465e6b49 | ||
|
|
5f773d46c9 | ||
|
|
0a94da4d13 | ||
|
|
594a9e17da | ||
|
|
5a2aa396fe | ||
|
|
aedd0df228 | ||
|
|
beb99c5632 | ||
|
|
ae3f4bbf0b | ||
|
|
f8a4652533 | ||
|
|
0922ce8e66 | ||
|
|
7e623daebb | ||
|
|
a00820a4c6 | ||
|
|
75245e4d98 | ||
|
|
8ed50d0b08 | ||
|
|
d357b30f9d | ||
|
|
b35b2a6e7e | ||
|
|
fe37cb198e | ||
|
|
58a193b63b | ||
|
|
d61005e419 | ||
|
|
59c7571ad5 | ||
|
|
46eaa7e090 | ||
|
|
e7866cabc8 | ||
|
|
3b4800d045 | ||
|
|
72bd3e4c98 | ||
|
|
a33f3d8bb0 | ||
|
|
b69bd82a72 | ||
|
|
295c2a41a8 | ||
|
|
0ee7f05213 | ||
|
|
20538e328f |
325 changed files with 151706 additions and 39448 deletions
45
.eslintrc.js
Normal file
45
.eslintrc.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* ESLint Config */
|
||||
module.exports = {
|
||||
env: {
|
||||
'es2017': true,
|
||||
// others envs defined by cascading .eslintrc files
|
||||
},
|
||||
extends: 'eslint:recommended',
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
'sourceType': 'module',
|
||||
},
|
||||
rules: {
|
||||
'brace-style': ['error','1tbs',{ 'allowSingleLine': true }],
|
||||
'indent': [
|
||||
'off', // temporary... a lot of stuff needs to be reformatted | 2020-08-21: I guess it's not so temporary...
|
||||
4,
|
||||
{ 'SwitchCase': 1 }
|
||||
],
|
||||
'linebreak-style': ['error','unix'],
|
||||
'no-control-regex': ['off'],
|
||||
'no-prototype-builtins': ['off'], // should consider cleaning up the code and turning this back on at some point
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-unused-vars': [
|
||||
'error', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_|^Promise$'
|
||||
}
|
||||
],
|
||||
'semi': ['error','always'],
|
||||
'quotes': ['off'] // Old code uses double quotes, new code uses single / template
|
||||
},
|
||||
ignorePatterns: [
|
||||
// These are not ours
|
||||
'www/js/dash.all.min.js',
|
||||
'www/js/jquery-1.12.4.min.js',
|
||||
'www/js/jquery-ui.js',
|
||||
'www/js/peertube.js',
|
||||
'www/js/playerjs-0.0.12.js',
|
||||
'www/js/sc.js',
|
||||
'www/js/video.js',
|
||||
'www/js/videojs-contrib-hls.min.js',
|
||||
'www/js/videojs-dash.js',
|
||||
'www/js/videojs-resolution-switcher.js',
|
||||
],
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -12,3 +12,13 @@ torlist
|
|||
www/cache
|
||||
google-drive-subtitles
|
||||
lib/
|
||||
integration-test-config.json
|
||||
conf/*.toml
|
||||
www/js/cytube-google-drive.user.js
|
||||
www/js/cytube-google-drive.meta.js
|
||||
www/js/player.js
|
||||
tor-exit-list.json
|
||||
*.patch
|
||||
examples/demo-bot/.env
|
||||
mysql/
|
||||
peertube-hosts.json
|
||||
|
|
|
|||
14
.travis.yml
Normal file
14
.travis.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
language: node_js
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- gcc-9
|
||||
- g++-9
|
||||
env:
|
||||
- CXX="g++-9"
|
||||
node_js:
|
||||
- "15"
|
||||
- "14"
|
||||
- "12"
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:20
|
||||
|
||||
RUN apt update && apt install -y build-essential git wget
|
||||
|
||||
COPY ./ /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN rm -rf node_modules lib package-lock.json
|
||||
RUN npm cache clean --force
|
||||
RUN npm install
|
||||
RUN /bin/sh /app/postinstall.sh
|
||||
RUN npm run build-server
|
||||
RUN npm rebuild
|
||||
|
||||
RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp
|
||||
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2013-2015 Calvin Montgomery and contributors
|
||||
Copyright (c) 2013-2022 Calvin Montgomery and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
|
|
|||
412
NEWS.md
412
NEWS.md
|
|
@ -1,3 +1,415 @@
|
|||
2022-09-21
|
||||
==========
|
||||
|
||||
**Upgrade intervention required**
|
||||
|
||||
This release adds a feature to ban channels, replacing the earlier (hastily
|
||||
added) configuration-based `channel-blacklist`. If you have any entries in
|
||||
`channel-blacklist` in your `config.yaml`, you will need to migrate them to the
|
||||
new bans table by using a command after upgrading (the ACP web interface hasn't
|
||||
been updated for this feature):
|
||||
|
||||
./bin/admin.js ban-channel <channel-name> <external-reason> <internal-reason>
|
||||
|
||||
The external reason will be displayed when users attempt to join the banned
|
||||
channel, while the internal reason is only displayed when using the
|
||||
`show-channel-ban` command.
|
||||
|
||||
You can later use `unban-channel` to remove a ban. The owner of the banned
|
||||
channel can still delete it, but the banned state will persist, so the channel
|
||||
cannot be re-registered later.
|
||||
|
||||
2022-08-28
|
||||
==========
|
||||
|
||||
This release integrates Xaekai's added support for Bandcamp, BitChute, Odysee,
|
||||
and Nicovideo playback support into the main repository. The updated support
|
||||
for custom fonts and audio tracks in custom media manifests is also included,
|
||||
but does not work out of the box -- it requires a separate channel script; this
|
||||
may be addressed in the future.
|
||||
|
||||
2021-08-14
|
||||
==========
|
||||
|
||||
CyTube has been upgraded to socket.io v4 (from v2).
|
||||
|
||||
**Breaking change:** Newer versions of socket.io require CORS to validate the
|
||||
origin initiating the socket connection. CyTube allows the origins specified in
|
||||
the `io.domain` and `https.domain` configuration keys by default, which should
|
||||
work for many use cases, however, if you host your website on a different domain
|
||||
than the socket connection, you will need to configure the allowed origins (see
|
||||
config.template.yaml under `io.cors`).
|
||||
|
||||
CyTube enables the `allowEIO3` configuration in socket.io by default, which
|
||||
means that existing clients and bots using socket.io-client v2 should continue
|
||||
to work.
|
||||
|
||||
2021-08-12
|
||||
==========
|
||||
|
||||
The legacy metrics recorder (`counters.log` file) has been removed. For over 4
|
||||
years now, CyTube has integrated with [Prometheus](https://prometheus.io/),
|
||||
which provides a superior way to monitor the application. Copy
|
||||
`conf/example/prometheus.toml` to `conf/prometheus.toml` and edit it to
|
||||
configure CyTube's Prometheus support.
|
||||
|
||||
2021-08-12
|
||||
==========
|
||||
|
||||
Due to changes in Soundcloud's authorization scheme, support has been dropped
|
||||
from core due to requiring each server owner to register an API key (which is
|
||||
currently impossible as they have not accepted new API key registrations for
|
||||
*years*).
|
||||
|
||||
If you happen to already have an API key registered, or if Soundcloud reopens
|
||||
registration at some point in the future, feel free to reach out to me for
|
||||
patches to reintroduce support for it.
|
||||
|
||||
2020-08-21
|
||||
==========
|
||||
|
||||
Some of CyTube's dependencies depends on features in newer versions of node.js.
|
||||
Accordingly, node 10 is no longer supported. Administrators are recommended to
|
||||
use node 12 (the active LTS), or node 14 (the current version).
|
||||
|
||||
2020-06-22
|
||||
==========
|
||||
|
||||
Twitch has [updated their embed
|
||||
player](https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588),
|
||||
which adds new requirements for embedding Twitch:
|
||||
|
||||
1. The origin website must be served over HTTPS
|
||||
2. The origin website must be served over the default port (i.e., the hostname
|
||||
cannot include a port; https://example.com:8443 won't work)
|
||||
|
||||
Additionally, third-party cookies must be enabled for whatever internal
|
||||
subdomains Twitch is using.
|
||||
|
||||
CyTube now sets the parameters expected by Twitch, and displays an error message
|
||||
if it detects (1) or (2) above are not met.
|
||||
|
||||
2020-02-15
|
||||
==========
|
||||
|
||||
Old versions of CyTube defaulted to storing channel state in flatfiles located
|
||||
in the `chandump` directory. The default was changed a while ago, and the
|
||||
flatfile storage mechanism has now been removed.
|
||||
|
||||
Admins who have not already migrated their installation to the "database"
|
||||
channel storage type can do so by following these instructions:
|
||||
|
||||
1. Run `git checkout e3a9915b454b32e49d3871c94c839899f809520a` to temporarily
|
||||
switch to temporarily revert to the previous version of the code that
|
||||
supports the "file" channel storage type
|
||||
2. Run `npm run build-server` to build the old version
|
||||
3. Run `node lib/channel-storage/migrator.js |& tee migration.log` to migrate
|
||||
channel state from files to the database
|
||||
4. Inspect the output of the migration tool for errors
|
||||
5. Set `channel-storage`/`type` to `"database"` in `config.yaml` and start the
|
||||
server. Load a channel to verify the migration worked as expected
|
||||
6. Upgrade back to the latest version with `git checkout 3.0` and `npm run
|
||||
build-server`
|
||||
7. Remove the `channel-storage` block from `config.yaml` and remove the
|
||||
`chandump` directory since it is no longer needed (you may wish to archive
|
||||
it somewhere in case you later discover the migration didn't work as
|
||||
expected).
|
||||
|
||||
If you encounter any errors during the process, please file an issue on GitHub
|
||||
and attach the output of the migration tool (which if you use the above commands
|
||||
will be written to `migration.log`).
|
||||
|
||||
2019-12-01
|
||||
==========
|
||||
|
||||
In accordance with node v8 LTS becoming end-of-life on 2019-12-31, CyTube no
|
||||
longer supports v8.
|
||||
|
||||
Please upgrade to v10 or v12 (active LTS); refer to
|
||||
https://nodejs.org/en/about/releases/ for the node.js support timelines.
|
||||
|
||||
2018-12-07
|
||||
==========
|
||||
|
||||
Users can now self-service request their account to be deleted, and it will be
|
||||
automatically purged after 7 days. In order to send a notification email to
|
||||
the user about the request, copy the [email
|
||||
configuration](https://github.com/calzoneman/sync/blob/3.0/conf/example/email.toml#L43)
|
||||
to `conf/email.toml` (the same file used for password reset emails).
|
||||
|
||||
2018-10-21
|
||||
==========
|
||||
|
||||
The `sanitize-html` dependency has made a change that results in `"` no longer
|
||||
being replaced by `"` when not inside an HTML attribute value. This
|
||||
potentially breaks any chat filters matching quotes as `"` (on my
|
||||
particular instance, this seems to be quite rare). These filters will need to
|
||||
be updated in order to continue matching quotes.
|
||||
|
||||
2018-08-27
|
||||
==========
|
||||
|
||||
Support for node.js 6.x has been dropped, in order to bump the babel preset to
|
||||
generate more efficient code (8.x supports async-await and other ES6+ features
|
||||
natively and is the current node.js LTS).
|
||||
|
||||
If you are unable to upgrade to node.js 8.x, you can revert the changes to
|
||||
package.json in this commit, however, be warned that I no longer test on 6.x.
|
||||
|
||||
2018-06-03
|
||||
==========
|
||||
|
||||
## Dependency upgrades
|
||||
|
||||
In order to support node.js 10, the `bcrypt` dependency has been upgraded to
|
||||
version 2. `bcrypt` version 2 defaults to the `$2b$` algorithm, whereas version
|
||||
1 defaults to the `$2a$` algorithm. Existing password hashes will continue to
|
||||
be readable, however hashes created with version 2 will not be readable by
|
||||
version 1. See https://github.com/kelektiv/node.bcrypt.js for details.
|
||||
|
||||
In addition, the optional dependency on `v8-profiler` has been removed, since
|
||||
this is not compatible with newer versions of v8.
|
||||
|
||||
## Supported node.js versions
|
||||
|
||||
In accordance with the node.js release schedule, node.js 4.x, 5.x, 7.x, and 9.x
|
||||
are end-of-life and are no longer maintained upstream. Accordingly, these
|
||||
versions are no longer supported by CyTube.
|
||||
|
||||
Please upgrade to 8.x (LTS) or 10.x (current). 6.x is still supported, but is
|
||||
in the "maintenance" phase upstream, and should be phased out.
|
||||
|
||||
2018-01-07
|
||||
==========
|
||||
|
||||
**Build changes:** When the `babel` dependency was first added to transpile ES6
|
||||
code to ES5, an interactive prompt was added to the `postinstall` script before
|
||||
transpilation, in case the user had made local modifications to the files in
|
||||
`lib` which previously would have been detected as a git conflict when pulling.
|
||||
|
||||
It has now been sufficiently long that this is no longer needed, so I've removed
|
||||
it. As always, users wishing to make local modifications (or forks) should edit
|
||||
the code in `src/` and run `npm run build-server` to regenerate `lib/`.
|
||||
|
||||
This commit also removes the bundled `www/js/player.js` file in favor of having
|
||||
`postinstall` generate it from the sources in `player/`.
|
||||
|
||||
2017-12-24
|
||||
==========
|
||||
|
||||
As of December 2017, Vid.me is no longer in service. Accordingly, Vid.me
|
||||
support in CyTube has been deprecated.
|
||||
|
||||
2017-11-27
|
||||
==========
|
||||
|
||||
The Google Drive userscript has been updated once again. Violentmonkey is
|
||||
now explicitly supported. Google login redirects are caught and handled.
|
||||
See directly below on how to regenerate the user script again.
|
||||
|
||||
2017-11-15
|
||||
==========
|
||||
|
||||
The Google Drive userscript has been updated due to breaking changes in
|
||||
Greasemonkey 4.0. Remember to generate the script by running:
|
||||
|
||||
$ npm run generate-userscript "Your Site Name" http://your-site.example.com/r/*
|
||||
|
||||
2017-11-05
|
||||
==========
|
||||
|
||||
The latest commit introduces a referrer check in the account page handlers.
|
||||
This is added as a short-term mitigation for a recent report that account
|
||||
management functions (such as deleting channels) can be executed without the
|
||||
user's consent if placed in channel JS.
|
||||
|
||||
Longer term options are being considered, such as moving account management to a
|
||||
separate subdomain to take advantage of cross-origin checks in browsers, and
|
||||
requiring the user to re-enter their password to demonstrate intent. As always,
|
||||
I recommend admins take extreme caution when accepting channel JS.
|
||||
|
||||
2017-09-26
|
||||
==========
|
||||
|
||||
**Breaking change:** the `nodemailer` dependency has been upgraded to version
|
||||
4.x. I also took this opportunity to make some modifications to the email
|
||||
configuration and move it out of `config.yaml` to `conf/email.toml`.
|
||||
|
||||
To upgrade:
|
||||
|
||||
* Run `npm upgrade` (or `rm -rf node_modules; npm install`)
|
||||
* Copy `conf/example/email.toml` to `conf/email.toml`
|
||||
* Edit `conf/email.toml` to your liking
|
||||
* Remove the `mail:` block from `config.yaml`
|
||||
|
||||
This feature only supports sending via SMTP for now. If there is demand for
|
||||
other transports, feel free to open an issue or submit a pull request.
|
||||
|
||||
2017-09-19
|
||||
==========
|
||||
|
||||
The `/useragreement` default page has been removed. Server administrators can
|
||||
substitute their own terms of service page by editing `templates/footer.pug`
|
||||
|
||||
2017-09-19
|
||||
==========
|
||||
|
||||
This commit removes an old kludge that redirected users to HTTPS (when enabled)
|
||||
specifically for the account authorization pages (e.g., `/login`). The code for
|
||||
doing this was to work around limitations that no longer exist, and does not
|
||||
represent current security best practices.
|
||||
|
||||
The recommended solution to ensure that users are logged in securely (assuming
|
||||
you've configured support for HTTPS) is to use
|
||||
[Strict-Transport-Security](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security)
|
||||
to direct browsers to access the HTTPS version of the website at all times. You
|
||||
can enable this by configuring a reverse proxy (e.g. nginx) in front of CyTube
|
||||
to intercept HTTP traffic and redirect it to HTTPS, and add the
|
||||
`Strict-Transport-Security` header when returning the response from CyTube.
|
||||
|
||||
2017-07-22
|
||||
==========
|
||||
|
||||
Support for the old version of Vimeo's OAuth API (the `vimeo-oauth`
|
||||
configuration block) has been dropped. It's unlikely anyone was using this,
|
||||
since you haven't been able to register new API keys for it in years (it was
|
||||
superseded by a newer OAuth API, which CyTube does not support), and in fact I
|
||||
lost my credentials for this API and no longer have a way to test it.
|
||||
|
||||
Vimeo videos can still be added -- the metadata will be queried from the
|
||||
anonymous API which has been the default since the beginning.
|
||||
|
||||
2017-07-17
|
||||
==========
|
||||
|
||||
The `stats` database table and associated ACP subpage have been removed in favor
|
||||
of integration with [Prometheus](https://prometheus.io/). You can enable
|
||||
Prometheus reporting by copying `conf/example/prometheus.toml` to
|
||||
`conf/prometheus.toml` and editing it to your liking. I recommend integrating
|
||||
Prometheus with [Grafana](https://grafana.com/) for dashboarding needs.
|
||||
|
||||
The particular metrics that were saved in the `stats` table are reported by the
|
||||
following Prometheus metrics:
|
||||
|
||||
* Channel count: `cytube_channels_num_active` gauge.
|
||||
* User count: `cytube_sockets_num_connected` gauge (labeled by socket.io
|
||||
transport).
|
||||
* CPU/Memory: default metrics emitted by the
|
||||
[`prom-client`](https://github.com/siimon/prom-client) module.
|
||||
|
||||
More Prometheus metrics will be added in the future to make CyTube easier to
|
||||
monitor :)
|
||||
|
||||
2017-07-15
|
||||
==========
|
||||
|
||||
The latest commit upgrades `socket.io` to version 2.0, a major version change
|
||||
from 1.4. This release improves performance by switching to `uws` for the
|
||||
websocket transport, and fixes several bugs; you can read about it
|
||||
[here](https://github.com/socketio/socket.io/releases/tag/2.0.0).
|
||||
|
||||
For browser clients, the upgrade should basically just work with no
|
||||
intervention. For node.js clients, all that is needed is to upgrade
|
||||
`socket.io-client` to 2.0. For other clients, work required may vary depending
|
||||
on whether the implementation has compatibility problems with 2.0.
|
||||
|
||||
2017-06-20
|
||||
==========
|
||||
|
||||
The latest commit drops support for node.js versions below 6 (the [current
|
||||
LTS](https://github.com/nodejs/LTS#lts-schedule1)). This is to allow the babel
|
||||
preset to avoid generating inefficient code to polyfill ES2015+ features that
|
||||
are now implemented in the node.js core.
|
||||
|
||||
New versions of node.js can be downloaded from the [node.js
|
||||
website](https://nodejs.org/en/download/), if they are not already available in
|
||||
your distribution's package manager.
|
||||
|
||||
2017-03-20
|
||||
==========
|
||||
|
||||
Polls are now more strictly validated, including the number of options. The
|
||||
default limit is 50 options, which you can configure via `poll.max-options`.
|
||||
|
||||
2017-03-11
|
||||
==========
|
||||
|
||||
Commit f8183bea1b37154d79db741ac2845adf282e7514 modifes the schema of the
|
||||
`users` table to include a new column (`name_dedupe`) which has a `UNIQUE`
|
||||
constraint. This column is populated with a modified version of the user's name
|
||||
to prevent the registration of usernames which are bitwise distinct but visually
|
||||
similar. 'l', 'L', and '1' are all mapped to '1'; 'o', 'O', and '0' are all
|
||||
mapped to '0'; '\_' and '-' are mapped to '\_'. On first startup after
|
||||
upgrading, the new column will be added and populated.
|
||||
|
||||
This replaces the earlier solution which was put in place to mitigate PR#489 but
|
||||
was overly-restrictive since it wildcarded these characters against *any*
|
||||
character, not just characters in the same group.
|
||||
|
||||
2017-03-03
|
||||
==========
|
||||
|
||||
The dependency on `sanitize-html`, which previously pointed to a fork, has now
|
||||
been switched back to the upstream module. XSS filtering has been turned off
|
||||
for the chat filter replacement itself (since this provides no additional
|
||||
security), and is now only run on the final chat message after filtering.
|
||||
Certain chat filters and MOTDs which relied on syntactically incorrect HTML,
|
||||
such as unclosed tags, may have different behavior now, since `sanitize-html`
|
||||
fixes these.
|
||||
|
||||
2016-11-02
|
||||
==========
|
||||
|
||||
After upgrading the dependency on `yamljs`, you may see this error if you didn't
|
||||
notice and correct a typo in the config.yaml template:
|
||||
|
||||
Error loading config file config.yaml:
|
||||
{ [Error: Unexpected characters near ",".]
|
||||
message: 'Unexpected characters near ",".',
|
||||
parsedLine: 88,
|
||||
snippet: 'title: \'CyTube\',' }
|
||||
|
||||
The fix is to edit config.yaml and remove the trailing comma for the `title:`
|
||||
property under `html-template`. If there are other syntax errors that the old
|
||||
version didn't detect, you will need to correct those as well.
|
||||
|
||||
Longer term, I am looking to move away from using `yamljs` to parse
|
||||
configuration because it's a little buggy and the current configuration system
|
||||
is confusing.
|
||||
|
||||
2016-10-20
|
||||
==========
|
||||
|
||||
Google Drive changed the URL schema for retrieving video metadata, which broke
|
||||
CyTube's Google Drive support, even with the userscript. I have updated the
|
||||
userscript source with the new URL, so server administrators will have to
|
||||
regenerate the userscript for their site and users will be prompted to install
|
||||
the newer version.
|
||||
|
||||
Additionally, fixing Drive lookups required an update to the `mediaquery`
|
||||
module, so you will have to do an `npm install` to pull that fix in.
|
||||
|
||||
2016-08-23
|
||||
==========
|
||||
|
||||
A few weeks ago, the previous Google Drive player stopped working. This is
|
||||
nothing new; Google Drive has consistently broken a few times a year ever since
|
||||
support for it was added. However, it's becoming increasingly difficult and
|
||||
complicated to provide good support for Google Drive, so I've made the decision
|
||||
to phase out the native player and require a userscript for it, in order to
|
||||
bypass CORS and allow each browser to request the video stream itself.
|
||||
|
||||
See [the updated documentation](docs/gdrive-userscript-serveradmins.md) for
|
||||
details on how to enable this for your users.
|
||||
|
||||
2016-04-27
|
||||
==========
|
||||
|
||||
A new dependency has been added on `cytube-common`, a module that will hold
|
||||
common code shared between the current version of CyTube and the upcoming work
|
||||
around splitting it into multiple services. You will need to be sure to run
|
||||
`npm install` after pulling in this change to pull in the new dependency.
|
||||
|
||||
2016-01-06
|
||||
==========
|
||||
|
||||
|
|
|
|||
109
README.md
109
README.md
|
|
@ -1,91 +1,46 @@
|
|||
Read before submitting an issue: https://github.com/calzoneman/sync/wiki/Reporting-an-Issue
|
||||
===========================================================================================
|
||||
CyTube
|
||||
======
|
||||
|
||||
calzoneman/sync
|
||||
===============
|
||||
CyTube is a project I started in early 2013 as a hobby project to build my own
|
||||
clone of synchtube.com (which shut down in March 2013).
|
||||
|
||||
About
|
||||
-----
|
||||
The basic concept is that users register channels where connected viewers can
|
||||
watch videos from different video hosts (e.g., YouTube, Twitch) and the playback
|
||||
is synchronized for all the viewers in the channel.
|
||||
|
||||
CyTube is a web application providing media synchronization, chat, and more for an arbitrary number of channels.
|
||||
I began developing this as a hobby project, and when synchtube.com announced their closure, I
|
||||
began polishing it and readying it for the public.
|
||||
Each channel has a playlist where users can queue up videos to play, as well as
|
||||
an integrated chatroom for discussion.
|
||||
|
||||
I am hosting a CyTube server at http://cytu.be
|
||||
The official server is located at https://cytu.be, but there are other public
|
||||
servers hosted for various communities.
|
||||
|
||||
The serverside is written in JavaScript and runs on Node.JS. It makes use
|
||||
of a MySQL database to store user registrations, cached media metadata, and
|
||||
data about each channel.
|
||||
## Installation
|
||||
|
||||
The clientside is written in JavaScript and makes use of Socket.IO and
|
||||
jQuery as well as the APIs for various media providers.
|
||||
The web interface uses Bootstrap for layout and styling.
|
||||
The installation guide for server administrators is located [on the
|
||||
wiki](https://github.com/calzoneman/sync/wiki/CyTube-3.0-Installation-Guide).
|
||||
|
||||
Features
|
||||
--------
|
||||
- Standalone web/socket.io server
|
||||
- Optional SSL support for socket.io and the account API
|
||||
- Synchronized playback from the following sources:
|
||||
- YouTube (individual videos + playlists)
|
||||
- Google Docs videos
|
||||
- Vimeo
|
||||
- Dailymotion
|
||||
- Soundcloud
|
||||
- Raw video/audio files (via video.js)
|
||||
- Embedding of the following sources:
|
||||
- livestream.com
|
||||
- twitch.tv
|
||||
- justin.tv
|
||||
- ustream.tv
|
||||
- RTMP streams
|
||||
- Custom `<iframe>` and `<object>` embeds
|
||||
- Channel customization
|
||||
- HTML Message of the Day
|
||||
- CSS
|
||||
- JavaScript
|
||||
- Permissions
|
||||
- Tiered ranks (Site admin > Channel admin > Moderator > Leader > Member > Guest > Anonymous)
|
||||
- Chat filters (based on regular expressions)
|
||||
- Lock/unlock playlist to allow additions by non-moderators (configurable with permissions)
|
||||
- Searchable library of videos
|
||||
- Integrated YouTube search
|
||||
- Save/load playlists per user account
|
||||
- Polls
|
||||
- Voteskip (can be disabled by a channel moderator)
|
||||
- Auto-AFK status (can be configured per-channel)
|
||||
- Leader
|
||||
- Grants control of playback to a user (can pause/seek)
|
||||
- Can also be used to grant temporary mod-like powers to a user
|
||||
- Not necessary for synchronization as the server has an internal timer
|
||||
- Channel state saves/loads on restart
|
||||
- Account management
|
||||
- Password change
|
||||
- Password reset (via email)
|
||||
- Profile avatar and text
|
||||
- Moderation
|
||||
- Mute users
|
||||
- Kick users
|
||||
- Ban users by name
|
||||
- Ban users by IP address (and by /24 range)
|
||||
- Administration
|
||||
- Log viewer
|
||||
- Global bans
|
||||
- Search registered channels and users
|
||||
- Currently loaded channels
|
||||
- Stats (usercount, channelcount, RAM usage)
|
||||
## Contact
|
||||
|
||||
Installing
|
||||
----------
|
||||
**Please check if the
|
||||
[FAQ](https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions)
|
||||
answers your question already.**
|
||||
|
||||
Installation instructions are available here: https://github.com/calzoneman/sync/wiki/CyTube-3.0-Installation-Guide
|
||||
For bug reports and feature requests, please open a GitHub issue. To report a
|
||||
security vulnerability, or to discuss an issue with https://cytu.be itself
|
||||
(unrelated to the code), please send me an email: cyzon@cytu.be
|
||||
|
||||
Please be courteous and search through [the open and closed
|
||||
issues](https://github.com/calzoneman/sync/issues?utf8=%E2%9C%93&q=is%3Aissue)
|
||||
for your request before submitting a new one.
|
||||
|
||||
Feedback
|
||||
--------
|
||||
General help with the software and the website is also available on the IRC
|
||||
channel at [irc.esper.net#cytube](http://webchat.esper.net/?channels=cytube)
|
||||
during US daytime hours.
|
||||
|
||||
Please open a GitHub Issue.
|
||||
## License
|
||||
|
||||
License
|
||||
-------
|
||||
Original source code in this repository is provided under the MIT license
|
||||
(see the LICENSE file for the full text).
|
||||
|
||||
Licensed under MIT. See LICENSE for the full license text.
|
||||
Bundled source code, such as third-party CSS and JavaScript libraries, are
|
||||
provided under their respective licenses.
|
||||
|
|
|
|||
176
bin/admin.js
Executable file
176
bin/admin.js
Executable file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const Config = require('../lib/config');
|
||||
Config.load('config.yaml');
|
||||
|
||||
if (!Config.get('service-socket.enabled')){
|
||||
console.error('The Service Socket is not enabled.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const readline = require('node:readline/promises');
|
||||
|
||||
const socketPath = path.resolve(__dirname, '..', Config.get('service-socket.socket'));
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function doCommand(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = net.createConnection(socketPath);
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(JSON.stringify(params) + '\n');
|
||||
});
|
||||
|
||||
client.on('data', data => {
|
||||
client.end();
|
||||
resolve(JSON.parse(data));
|
||||
});
|
||||
|
||||
client.on('error', error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let commands = [
|
||||
{
|
||||
command: 'ban-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 3) {
|
||||
console.log('Usage: ban-channel <name> <externalReason> <internalReason>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name, externalReason, internalReason] = args;
|
||||
let answer = await rl.question(`Ban ${name} with external reason "${externalReason}" and internal reason "${internalReason}"? `);
|
||||
|
||||
if (!/^[yY]$/.test(answer)) {
|
||||
console.log('Aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'ban-channel',
|
||||
name,
|
||||
externalReason,
|
||||
internalReason
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
console.log('Ban succeeded.');
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
command: 'unban-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 1) {
|
||||
console.log('Usage: unban-channel <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name] = args;
|
||||
let answer = await rl.question(`Unban ${name}? `);
|
||||
|
||||
if (!/^[yY]$/.test(answer)) {
|
||||
console.log('Aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'unban-channel',
|
||||
name
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
console.log('Unban succeeded.');
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
command: 'show-banned-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 1) {
|
||||
console.log('Usage: show-banned-channel <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name] = args;
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'show-banned-channel',
|
||||
name
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
if (res.ban != null) {
|
||||
console.log(`Channel: ${name}`);
|
||||
console.log(`Ban issued: ${res.ban.createdAt}`);
|
||||
console.log(`Banned by: ${res.ban.bannedBy}`);
|
||||
console.log(`External reason:\n${res.ban.externalReason}`);
|
||||
console.log(`Internal reason:\n${res.ban.internalReason}`);
|
||||
} else {
|
||||
console.log(`Channel ${name} is not banned.`);
|
||||
}
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
let found = false;
|
||||
commands.forEach(cmd => {
|
||||
if (cmd.command === process.argv[2]) {
|
||||
found = true;
|
||||
cmd.handler(process.argv.slice(3)).then(() => {
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.log('Error in command:', error.stack);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
console.log('Available commands:');
|
||||
commands.forEach(cmd => {
|
||||
console.log(` * ${cmd.command}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
52
bin/build-player.js
Executable file
52
bin/build-player.js
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
var coffee = require('coffeescript');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var order = [
|
||||
'base.coffee',
|
||||
|
||||
'dailymotion.coffee',
|
||||
'niconico.coffee',
|
||||
'peertube.coffee',
|
||||
'soundcloud.coffee',
|
||||
'twitch.coffee',
|
||||
'vimeo.coffee',
|
||||
'youtube.coffee',
|
||||
|
||||
// playerjs-based players
|
||||
'playerjs.coffee',
|
||||
'iframechild.coffee',
|
||||
'odysee.coffee',
|
||||
'whepplayer.coffee',
|
||||
'streamable.coffee',
|
||||
|
||||
// iframe embed-based players
|
||||
'embed.coffee',
|
||||
'custom-embed.coffee',
|
||||
'livestream.com.coffee',
|
||||
'twitchclip.coffee',
|
||||
|
||||
// video.js-based players
|
||||
'videojs.coffee',
|
||||
'gdrive-player.coffee',
|
||||
'hls.coffee',
|
||||
'raw-file.coffee',
|
||||
'rtmp.coffee',
|
||||
|
||||
// mediaUpdate handler
|
||||
'update.coffee'
|
||||
];
|
||||
|
||||
var buffer = '';
|
||||
order.forEach(function (file) {
|
||||
buffer += fs.readFileSync(
|
||||
path.join(__dirname, '..', 'player', file)
|
||||
) + '\n';
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '..', 'www', 'js', 'player.js'),
|
||||
coffee.compile(buffer)
|
||||
);
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
var coffee = require('coffee-script');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var order = [
|
||||
'base.coffee',
|
||||
'vimeo.coffee',
|
||||
'youtube.coffee',
|
||||
'dailymotion.coffee',
|
||||
'videojs.coffee',
|
||||
'raw-file.coffee',
|
||||
'soundcloud.coffee',
|
||||
'embed.coffee',
|
||||
'twitch.coffee',
|
||||
'livestream.com.coffee',
|
||||
'custom-embed.coffee',
|
||||
'rtmp.coffee',
|
||||
'hitbox.coffee',
|
||||
'ustream.coffee',
|
||||
'imgur.coffee',
|
||||
'gdrive-youtube.coffee',
|
||||
'update.coffee'
|
||||
];
|
||||
|
||||
var buffer = '';
|
||||
order.forEach(function (file) {
|
||||
buffer += fs.readFileSync(path.join('player', file)) + '\n';
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join('www', 'js', 'player.js'), coffee.compile(buffer));
|
||||
15
conf/example/camo.toml
Normal file
15
conf/example/camo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Configuration for proxying images to a camo (or camo-compatible) proxy server.
|
||||
# To use, copy to conf/camo.toml.
|
||||
# More info on camo: https://github.com/atmos/camo
|
||||
[camo]
|
||||
enabled = true
|
||||
server = 'https://my-camo-server'
|
||||
# The key must match the `CAMO_KEY` environment variable passed to the camo server.
|
||||
key = 'ABCDEFGH'
|
||||
# Bypass the proxy for domains you trust that already support HTTPS and won't be harmful to users.
|
||||
whitelisted-domains = [
|
||||
'i.imgur.com',
|
||||
'i.4cdn.org'
|
||||
]
|
||||
# Whether to use URL encoding ("url") or hex encoding ("hex") for the target URL.
|
||||
encoding = 'url'
|
||||
9
conf/example/captcha.toml
Normal file
9
conf/example/captcha.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[hcaptcha]
|
||||
# Site key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||
site-key = "10000000-ffff-ffff-ffff-000000000001"
|
||||
# Secret key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||
secret = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
[register]
|
||||
# Whether to require a captcha for registration
|
||||
enabled = true
|
||||
66
conf/example/email.toml
Normal file
66
conf/example/email.toml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SMTP configuration for sending mail
|
||||
[smtp]
|
||||
host = 'smtp.gmail.com'
|
||||
port = 465
|
||||
secure = true
|
||||
user = 'some-user@example.com'
|
||||
password = 'secretpassword'
|
||||
|
||||
# Email configuration for password reset emails
|
||||
# Be sure to update both html-template AND text-template
|
||||
# nodemailer will send both and the email client will render whichever one is supported
|
||||
[password-reset]
|
||||
enabled = true
|
||||
|
||||
# Template to use for HTML-formatted emails
|
||||
# $user$ will be replaced by the username for which the reset was requested
|
||||
# $url$ will be replaced by the password reset confirmation link
|
||||
html-template = """
|
||||
Hi $user$,<br>
|
||||
<br>
|
||||
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: <a href="$url$">$url$</a><br>
|
||||
<br>
|
||||
This link will expire in 24 hours.<br>
|
||||
<br>
|
||||
This email address is not monitored for replies. For assistance with password resets, please <a href="http://example.com/contact">contact an administrator</a>.
|
||||
"""
|
||||
|
||||
# Template to use for plaintext emails
|
||||
# Same substitutions as the HTML template
|
||||
text-template = """
|
||||
Hi $user$,
|
||||
|
||||
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: $url$
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
This email address is not monitored for replies. For assistance with password resets, please contact an administrator. See http://example.com/contact for contact information.
|
||||
"""
|
||||
|
||||
from = "Example Website <website@example.com>"
|
||||
subject = "Password reset request"
|
||||
|
||||
# Email configuration for account deletion request notifications
|
||||
[delete-account]
|
||||
enabled = true
|
||||
|
||||
html-template = """
|
||||
Hi $user$,
|
||||
<br>
|
||||
<br>
|
||||
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
|
||||
<br>
|
||||
<br>
|
||||
This email address is not monitored for replies. For assistance, please <a href="http://example.com/contact">contact an administrator</a>.
|
||||
"""
|
||||
|
||||
text-template = """
|
||||
Hi $user$,
|
||||
|
||||
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
|
||||
|
||||
This email address is not monitored for replies. For assistance, please contact an administrator. See http://example.com/contact for contact information.
|
||||
"""
|
||||
|
||||
from = "Example Website <website@example.com>"
|
||||
subject = "Account deletion request"
|
||||
14
conf/example/prometheus.toml
Normal file
14
conf/example/prometheus.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Configuration for binding an HTTP server to export prometheus metrics.
|
||||
# See https://prometheus.io/ and https://github.com/siimon/prom-client
|
||||
# for more details.
|
||||
[prometheus]
|
||||
enabled = true
|
||||
# Host, port to bind. This is separate from the main CyTube HTTP server
|
||||
# because it may be desirable to bind a different IP/port for monitoring
|
||||
# purposes. Default: localhost port 19820 (arbitrary port chosen not to
|
||||
# conflict with existing prometheus exporters).
|
||||
host = '127.0.0.1'
|
||||
port = 19820
|
||||
# Request path to serve metrics. All other paths are rejected with
|
||||
# 400 Bad Request.
|
||||
path = '/metrics'
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
# MySQL server details
|
||||
# server: domain or IP of MySQL server
|
||||
# server: domain, IP or unix socket path of MySQL server. If a unix socket, it be like so `unix:/path/to/sock.sock`
|
||||
# database: a MySQL database that the user specified has read/write access to
|
||||
# user: username to authenticate as
|
||||
# password: password for user
|
||||
mysql:
|
||||
server: 'localhost'
|
||||
server: 'mysql'
|
||||
port: 3306
|
||||
database: 'cytube3'
|
||||
user: 'cytube3'
|
||||
password: ''
|
||||
password: 'strong-password-here'
|
||||
pool-size: 10
|
||||
|
||||
# Define IPs/ports to listen on
|
||||
# Each entry MUST define ip and port (ip can be '' to bind all available addresses)
|
||||
|
|
@ -47,7 +48,6 @@ http:
|
|||
# one port must be specified as default for the purposes of generating
|
||||
# links with the appropriate port
|
||||
default-port: 8080
|
||||
domain: 'http://localhost'
|
||||
# Specifies the root domain for cookies. If you have multiple domains
|
||||
# e.g. a.example.com and b.example.com, the root domain is example.com
|
||||
root-domain: 'localhost'
|
||||
|
|
@ -66,6 +66,12 @@ http:
|
|||
gzip-threshold: 1024
|
||||
# Secret used for signed cookies. Can be anything, but make it unique and hard to guess
|
||||
cookie-secret: 'change-me'
|
||||
index:
|
||||
# Maximum number of channels to display on the index page public channel list
|
||||
max-entries: 50
|
||||
# Configure trusted proxy addresses to map X-Forwarded-For to the client IP.
|
||||
# See also: https://github.com/jshttp/proxy-addr
|
||||
trust-proxies: ['loopback']
|
||||
|
||||
# HTTPS server details
|
||||
https:
|
||||
|
|
@ -80,15 +86,11 @@ https:
|
|||
certfile: 'localhost.cert'
|
||||
cafile: ''
|
||||
ciphers: 'HIGH:!DSS:!aNULL@STRENGTH'
|
||||
# Allow certain account pages to redirect to HTTPS if HTTPS is enabled.
|
||||
# You may want to set this to false if you are reverse proxying HTTPS to a
|
||||
# non-HTTPS address.
|
||||
redirect: true
|
||||
|
||||
# Page template values
|
||||
# title goes in the upper left corner, description goes in a <meta> tag
|
||||
html-template:
|
||||
title: 'CyTube',
|
||||
title: 'Sync'
|
||||
description: 'Free, open source synchtube'
|
||||
|
||||
# Socket.IO server details
|
||||
|
|
@ -105,50 +107,38 @@ io:
|
|||
default-port: 1337
|
||||
# limit the number of concurrent socket connections per IP address
|
||||
ip-connection-limit: 10
|
||||
# Whether or not to use zlib to compress each socket message (this option is
|
||||
# passed through to socket.io/engine.io).
|
||||
# Note that while this may save a little bandwidth, it also consumes a lot
|
||||
# more CPU and will bottleneck pretty quickly under heavy load.
|
||||
per-message-deflate: false
|
||||
|
||||
# Mailer details (used for sending password reset links)
|
||||
# see https://github.com/andris9/Nodemailer
|
||||
mail:
|
||||
enabled: false
|
||||
config:
|
||||
service: 'Gmail'
|
||||
auth:
|
||||
user: 'some.user@gmail.com'
|
||||
pass: 'supersecretpassword'
|
||||
from-address: 'some.user@gmail.com'
|
||||
from-name: 'CyTube Services'
|
||||
cors:
|
||||
# Additional origins to allow socket connections from (io.domain and
|
||||
# https.domain are included implicitly).
|
||||
allowed-origins: []
|
||||
|
||||
# YouTube v3 API key
|
||||
# See https://developers.google.com/youtube/registering_an_application
|
||||
# YouTube links will not work without this!
|
||||
# Instructions:
|
||||
# 1. Go to https://console.developers.google.com/project
|
||||
# 2. Create a new API project
|
||||
# 3. On the left sidebar, click "Credentials" under "APIs & auth"
|
||||
# 4. Click "Create new Key" under "Public API access"
|
||||
# 5. Click "Server key"
|
||||
# 6. Under "APIs & auth" click "YouTube Data API" and then click "Enable API"
|
||||
# 1. Go to https://console.developers.google.com/, create a new "project" (or choose an existing one)
|
||||
# 2. Make sure the YouTube Data v3 API is "enabled" for your project: https://console.developers.google.com/apis/library/youtube.googleapis.com
|
||||
# 3. Go to "Credentials" on the sidebar of https://console.developers.google.com/, click "Create credentials" and choose type "API key"
|
||||
# 4. Optionally restrict the key for security, or just copy the key.
|
||||
# 5. Test your key (may take a few minutes to become active):
|
||||
#
|
||||
# $ export YOUTUBE_API_KEY="your key here"
|
||||
# $ curl "https://www.googleapis.com/youtube/v3/search?key=$YOUTUBE_API_KEY&part=id&maxResults=1&q=test+video&type=video"
|
||||
youtube-v3-key: ''
|
||||
# Minutes between saving channel state to disk
|
||||
channel-save-interval: 5
|
||||
# Limit for the number of channels a user can register
|
||||
max-channels-per-user: 5
|
||||
# Limit for the number of accounts an IP address can register
|
||||
max-accounts-per-ip: 5
|
||||
# Minimum number of seconds between guest logins from the same IP
|
||||
guest-login-delay: 60
|
||||
# Maximum character length of a chat message, capped at 1000 characters
|
||||
max-chat-message-length: 320
|
||||
|
||||
# Configure statistics tracking
|
||||
stats:
|
||||
# Interval (in milliseconds) between data points - default 1h
|
||||
interval: 3600000
|
||||
# Maximum age of a datapoint (ms) before it is deleted - default 24h
|
||||
max-age: 86400000
|
||||
# Allows you to customize the path divider. The /r/ in http://localhost/r/yourchannel
|
||||
# Acceptable characters are a-z A-Z 0-9 _ and -
|
||||
channel-path: 'r'
|
||||
# Allows you to blacklist certain channels. Users will be automatically kicked
|
||||
# upon trying to join one.
|
||||
channel-blacklist: []
|
||||
# Minutes between saving channel state to disk
|
||||
channel-save-interval: 5
|
||||
|
||||
# Configure periodic clearing of old alias data
|
||||
aliases:
|
||||
|
|
@ -160,16 +150,6 @@ aliases:
|
|||
# Workaround for Vimeo blocking my domain
|
||||
vimeo-workaround: false
|
||||
|
||||
# OPTIONAL: Use Vimeo's OAuth API instead of the anonymous API.
|
||||
# This allows you to add private videos that have embedding enabled.
|
||||
# See https://developer.vimeo.com/apps/new to register for this API.
|
||||
# Note that in order to use this feature you must agree to Vimeo's
|
||||
# Terms of Service and License Agreement.
|
||||
vimeo-oauth:
|
||||
enabled: false
|
||||
consumer-key: ''
|
||||
secret: ''
|
||||
|
||||
# Regular expressions for defining reserved user and channel names and page titles
|
||||
# The list of regular expressions will be joined with an OR, and compared without
|
||||
# case sensitivity.
|
||||
|
|
@ -187,10 +167,12 @@ reserved-names:
|
|||
pagetitles: []
|
||||
|
||||
# Provide a contact list for the /contact page
|
||||
contacts:
|
||||
- name: 'calzoneman'
|
||||
title: 'Developer'
|
||||
email: 'cyzon@cytu.be'
|
||||
# Example:
|
||||
# contacts:
|
||||
# - name: 'my_name'
|
||||
# title: 'administrator
|
||||
# email: 'me@my.site'
|
||||
contacts: []
|
||||
|
||||
playlist:
|
||||
max-items: 4000
|
||||
|
|
@ -202,10 +184,6 @@ playlist:
|
|||
# The server must be invoked with node --expose-gc index.js for this to have any effect.
|
||||
aggressive-gc: false
|
||||
|
||||
# Allows you to blacklist certain channels. Users will be automatically kicked
|
||||
# upon trying to join one.
|
||||
channel-blacklist: []
|
||||
|
||||
# If you have ffmpeg installed, you can query metadata from raw files, allowing
|
||||
# server-synched raw file playback. This requires the following:
|
||||
# * ffmpeg must be installed on the server
|
||||
|
|
@ -225,12 +203,26 @@ setuid:
|
|||
# how long to wait in ms before changing uid/gid
|
||||
timeout: 15
|
||||
|
||||
# Determines channel data storage mechanism.
|
||||
# Defaults to 'file', in which channel data is JSON stringified and saved to a file
|
||||
# in the `chandump/` folder. This is the legacy behavior of CyTube.
|
||||
# The other possible option is 'database', in which case each key-value pair of
|
||||
# channel data is stored as a row in the `channel_data` database table.
|
||||
# To migrate legacy chandump files to the database, shut down CyTube (to prevent
|
||||
# concurrent updates), then run `node lib/channel-storage/migrate.js`.
|
||||
channel-storage:
|
||||
type: 'file'
|
||||
# Allows for external services to access the system commandline
|
||||
# Useful for setups where stdin isn't available such as when using PM2
|
||||
service-socket:
|
||||
enabled: false
|
||||
socket: 'service.sock'
|
||||
|
||||
# Twitch Client ID for the data API (used for VOD lookups)
|
||||
# https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup
|
||||
twitch-client-id: null
|
||||
|
||||
poll:
|
||||
max-options: 50
|
||||
|
||||
calendar-sync:
|
||||
enabled: false
|
||||
# 32-byte key encoded as base64. Prefer setting via env CALENDAR_SYNC_ENCRYPTION_KEY.
|
||||
encryption-key: ''
|
||||
google:
|
||||
client-id: ''
|
||||
client-secret: ''
|
||||
# Must exactly match Google OAuth redirect URI
|
||||
# Example: https://your-domain/api/v1/integrations/google/callback
|
||||
redirect-uri: ''
|
||||
|
|
|
|||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
version: "3.7"
|
||||
services:
|
||||
sync:
|
||||
build: .
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "1337:1337"
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- "./templates/head.pug:/app/templates/head.pug"
|
||||
- "./favicon.ico:/app/www/favicon.ico"
|
||||
- "./config.yaml:/app/config.yaml"
|
||||
mysql:
|
||||
image: docker.io/library/mariadb:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "mariadb-admin","ping","-h","localhost", "-u", "root", "--password=sync"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 100
|
||||
environment:
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
- MARIADB_ROOT_PASSWORD=sync
|
||||
- MARIADB_DATABASE=cytube3
|
||||
- MARIADB_USER=cytube3
|
||||
- MARIADB_PASSWORD=strong-password-here
|
||||
volumes:
|
||||
# This will create and mount the mysql files in the same folder as the docker-compose.yml file.
|
||||
# You can change this to be anywhere.
|
||||
# This will provide data persistence to your MariaDB database.
|
||||
- "./mysql:/var/lib/mysql"
|
||||
# If you are using a reverse proxy please do not forget to add the network here as well.
|
||||
# Refer to the Readme for more information regarding using a Reverse Proxy.
|
||||
570
docs/bot-api.md
Normal file
570
docs/bot-api.md
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
# Bot API
|
||||
|
||||
Bots connect to a channel in two ways:
|
||||
|
||||
- **WebSocket** (socket.io) — real-time events: chat, user list, playlist changes, media updates
|
||||
- **REST** (`/api/v1/...`) — commands: queue/delete/shuffle/clear playlist, manage emotes, read/write settings, kick/ban users
|
||||
|
||||
Chat can only be sent and received over the WebSocket. The REST API has no chat endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Ranks
|
||||
|
||||
| Name | Value |
|
||||
|-----------|-------|
|
||||
| Moderator | 2 |
|
||||
| Admin | 3 |
|
||||
| Owner | 4 |
|
||||
| Creator | 5 |
|
||||
|
||||
A bot's rank is capped at the rank of the user who issued its token. Every REST endpoint that modifies state has a minimum rank requirement listed in its description.
|
||||
|
||||
---
|
||||
|
||||
## Issuing a token
|
||||
|
||||
Tokens are issued in the channel settings modal on the **Bots** tab. You need at least moderator rank (2) to see this tab.
|
||||
|
||||
Fill in a name (1–20 alphanumeric, `-`, `_` characters), choose a rank, and click **Issue Token**. The token is shown **once** — copy it immediately. It looks like:
|
||||
|
||||
```
|
||||
cbt_a3f8e2...64 hex characters...
|
||||
```
|
||||
|
||||
To revoke a token, click **Revoke** next to it in the table. Any live WebSocket connections using that token are disconnected immediately.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket connection
|
||||
|
||||
The bot authenticates by passing the token in the socket.io `auth` object:
|
||||
|
||||
```js
|
||||
const io = require('socket.io-client');
|
||||
|
||||
const socket = io('http://your-server:1337', {
|
||||
auth: { token: process.env.BOT_TOKEN }
|
||||
});
|
||||
```
|
||||
|
||||
> **Port note:** socket.io runs on the port defined by `io.port` in your server config (default 1337), which is separate from the HTTP port used for the web UI and REST API.
|
||||
|
||||
### Join sequence
|
||||
|
||||
After connecting you must wait for the `login` event before emitting `joinChannel`, otherwise the server will ignore it:
|
||||
|
||||
```js
|
||||
socket.once('login', () => {
|
||||
socket.emit('joinChannel', { name: 'your-channel-name' });
|
||||
});
|
||||
```
|
||||
|
||||
### Sending chat
|
||||
|
||||
```js
|
||||
socket.emit('chatMsg', { msg: 'Hello from a bot!' });
|
||||
// Action message:
|
||||
socket.emit('chatMsg', { msg: '/me waves' });
|
||||
```
|
||||
|
||||
### Events the bot receives
|
||||
|
||||
| Event | Payload | Description |
|
||||
|------------------|----------------------------------------------|-------------------------------------|
|
||||
| `login` | `{ success, name, guest }` | Server accepted the connection |
|
||||
| `chatMsg` | `{ username, msg, meta, time }` | A chat message was sent |
|
||||
| `userlist` | `[{ name, rank, meta }, ...]` | Full user list on join |
|
||||
| `addUser` | `{ name, rank, meta }` | A user joined the channel |
|
||||
| `userLeave` | `{ name }` | A user left the channel |
|
||||
| `setUserMeta` | `{ name, meta }` | A user's meta (AFK, muted) changed |
|
||||
| `changeMedia` | `{ id, type, title, seconds, ... }` | A new video started playing |
|
||||
| `playlist` | `[{ uid, media, queueby, temp }, ...]` | Full playlist on join |
|
||||
| `queue` | `{ item, after }` | An item was added to the playlist |
|
||||
| `delete` | `{ uid }` | A playlist item was removed |
|
||||
| `errorMsg` | `{ msg }` | An error from the server |
|
||||
| `kick` | `{ reason }` | The bot was kicked |
|
||||
| `announcement` | `{ title, text }` | Server-wide announcement |
|
||||
|
||||
`meta.is_bot` is set to `true` in `chatMsg` and user list payloads for bots, which the web UI renders as a `[bot]` badge.
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
http://your-server:8080/api/v1
|
||||
```
|
||||
|
||||
All channel-scoped endpoints are under `/channels/:channel/`.
|
||||
|
||||
### Authentication
|
||||
|
||||
Every REST request must include the bot token as a Bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer cbt_...
|
||||
```
|
||||
|
||||
The token is validated against the channel in the URL — a token issued for `#general` will be rejected for `/channels/gaming/...`.
|
||||
|
||||
### Error responses
|
||||
|
||||
All errors return JSON:
|
||||
|
||||
```json
|
||||
{ "error": "Human readable message" }
|
||||
```
|
||||
|
||||
Common status codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|-------------------------------------------------------|
|
||||
| 400 | Bad request (missing/invalid fields) |
|
||||
| 401 | Missing or invalid token |
|
||||
| 403 | Token not authorized for this channel, or rank too low |
|
||||
| 404 | Resource not found |
|
||||
| 503 | Channel is not currently active (no users in it) |
|
||||
|
||||
---
|
||||
|
||||
### Playlist
|
||||
|
||||
Playlist endpoints require the channel to be active (at least one user present).
|
||||
|
||||
#### `GET /channels/:channel/playlist`
|
||||
|
||||
Returns the current playlist and which item is playing.
|
||||
|
||||
No minimum rank.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"uid": 1,
|
||||
"id": "dQw4w9WgXcQ",
|
||||
"type": "yt",
|
||||
"title": "Rick Astley - Never Gonna Give You Up",
|
||||
"seconds": 212,
|
||||
"duration": "3:32",
|
||||
"thumb": { "url": "..." },
|
||||
"meta": {}
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
"locked": false
|
||||
}
|
||||
```
|
||||
|
||||
`uid` is the server-assigned unique ID for the playlist slot. Use it for delete/jump operations.
|
||||
|
||||
#### `POST /channels/:channel/playlist`
|
||||
|
||||
Queue a new item. Minimum rank: **2 (Mod)**.
|
||||
|
||||
Returns `202 Accepted` immediately because media lookup is asynchronous. If the bot's permissions don't allow the add (e.g. playlist is locked and rank is too low), a `400` is returned synchronously.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "id": "dQw4w9WgXcQ", "type": "yt", "pos": "end" }
|
||||
```
|
||||
|
||||
| Field | Required | Values | Default |
|
||||
|-------|----------|---------------------|---------|
|
||||
| `id` | yes | media ID string | |
|
||||
| `type`| yes | see media types below | |
|
||||
| `pos` | no | `"end"` or `"next"` | `"end"` |
|
||||
|
||||
**Media types:**
|
||||
|
||||
| Type | Source |
|
||||
|------|----------------------|
|
||||
| `yt` | YouTube |
|
||||
| `sc` | SoundCloud |
|
||||
| `tw` | Twitch stream |
|
||||
| `tc` | Twitch clip |
|
||||
| `rt` | Dailymotion |
|
||||
| `vm` | Vimeo |
|
||||
| `dm` | Dailymotion |
|
||||
| `gd` | Google Drive |
|
||||
| `fi` | Direct file URL |
|
||||
| `cu` | Custom embed (HTML) |
|
||||
|
||||
#### `DELETE /channels/:channel/playlist/:uid`
|
||||
|
||||
Remove a playlist item by uid. Minimum rank: **3 (Admin)**.
|
||||
|
||||
#### `PUT /channels/:channel/playlist/playing`
|
||||
|
||||
Skip to a specific item. Minimum rank: **2 (Mod)**.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "uid": 3 }
|
||||
```
|
||||
|
||||
#### `POST /channels/:channel/playlist/shuffle`
|
||||
|
||||
Shuffle the playlist. Minimum rank: **3 (Admin)**.
|
||||
|
||||
#### `POST /channels/:channel/playlist/clear`
|
||||
|
||||
Clear the entire playlist. Minimum rank: **3 (Admin)**.
|
||||
|
||||
---
|
||||
|
||||
### Emotes
|
||||
|
||||
Emote endpoints read and write directly to the database and work even when the channel is offline. If the channel happens to be active, changes are broadcast live to connected users.
|
||||
|
||||
#### `GET /channels/:channel/emotes`
|
||||
|
||||
Returns the full emote list. No minimum rank.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "KEKW", "image": "https://cdn.example.com/kekw.png", "source": "..." }
|
||||
]
|
||||
```
|
||||
|
||||
#### `POST /channels/:channel/emotes`
|
||||
|
||||
Add a new emote. Minimum rank: **4 (Owner)**.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "name": "KEKW", "image": "https://cdn.example.com/kekw.png" }
|
||||
```
|
||||
|
||||
Returns `409 Conflict` if the name is already taken.
|
||||
|
||||
#### `PUT /channels/:channel/emotes/:name`
|
||||
|
||||
Update an emote's image or rename it. Minimum rank: **4 (Owner)**.
|
||||
|
||||
**Body** (all fields optional, but at least one must differ):
|
||||
|
||||
```json
|
||||
{ "image": "https://cdn.example.com/kekw-v2.png", "newName": "KEKWait" }
|
||||
```
|
||||
|
||||
Omit `newName` to keep the existing name. Returns `409` if the new name is already taken.
|
||||
|
||||
#### `DELETE /channels/:channel/emotes/:name`
|
||||
|
||||
Delete an emote by name. Minimum rank: **4 (Owner)**.
|
||||
|
||||
---
|
||||
|
||||
### Users / Moderation
|
||||
|
||||
These endpoints require the channel to be active.
|
||||
|
||||
#### `GET /channels/:channel/users`
|
||||
|
||||
List currently connected users. No minimum rank.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Alice", "rank": 3, "afk": false, "is_bot": false },
|
||||
{ "name": "MyBot", "rank": 2, "afk": false, "is_bot": true }
|
||||
]
|
||||
```
|
||||
|
||||
#### `POST /channels/:channel/users/:name/kick`
|
||||
|
||||
Kick a user. Minimum rank: **2 (Mod)**. Cannot kick users with equal or higher rank.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "reason": "Spamming" }
|
||||
```
|
||||
|
||||
`reason` is optional, defaults to `"Kicked by bot"`.
|
||||
|
||||
#### `POST /channels/:channel/users/:name/ban`
|
||||
|
||||
Ban a user by name. Minimum rank: **3 (Admin)**. Cannot ban users with equal or higher rank.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "reason": "Ban evasion" }
|
||||
```
|
||||
|
||||
`reason` is optional, defaults to `"Banned by bot"`.
|
||||
|
||||
#### `DELETE /channels/:channel/users/:name/ban`
|
||||
|
||||
Remove a ban. Minimum rank: **3 (Admin)**.
|
||||
|
||||
#### `PUT /channels/:channel/users/:name/rank`
|
||||
|
||||
Change a user's channel rank. Minimum rank: **4 (Owner)**. Cannot assign a rank equal to or higher than your own, and cannot target users with equal or higher rank.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "rank": 2 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Settings
|
||||
|
||||
Settings endpoints require the channel to be active. Minimum rank: **4 (Owner)** for both read and write.
|
||||
|
||||
#### `GET /channels/:channel/settings`
|
||||
|
||||
Returns current channel options.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"pagetitle": "My Channel",
|
||||
"allow_voteskip": true,
|
||||
"voteskip_ratio": 0.5,
|
||||
"maxlength": 0,
|
||||
"afk_timeout": 600,
|
||||
"password": "",
|
||||
"show_public": false,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Available keys:**
|
||||
|
||||
`allow_voteskip`, `allow_dupes`, `voteskip_ratio`, `maxlength`, `playlist_max_duration_per_user`, `afk_timeout`, `enable_link_regex`, `chat_antiflood`, `chat_antiflood_burst`, `chat_antiflood_sustained`, `new_user_chat_delay`, `new_user_chat_link_delay`, `pagetitle`, `password`, `externalcss`, `externaljs`, `show_public`, `torbanned`, `block_anonymous_users`, `allow_ascii_control`, `playlist_max_per_user`
|
||||
|
||||
#### `PUT /channels/:channel/settings`
|
||||
|
||||
Update one or more settings. Unknown keys are silently ignored.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "pagetitle": "Now Playing: Chill Beats", "allow_voteskip": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shows
|
||||
|
||||
Show endpoints manage scheduled playlist runs. These endpoints support bot Bearer auth and session auth.
|
||||
|
||||
#### `GET /channels/:channel/shows`
|
||||
|
||||
List shows for the channel. Minimum rank: **2 (Mod)**.
|
||||
|
||||
#### `GET /channels/:channel/shows/:id`
|
||||
|
||||
Get a single show. Minimum rank: **2 (Mod)**.
|
||||
|
||||
#### `GET /channels/:channel/shows/public`
|
||||
|
||||
List only public-facing shows (`scheduled`, `running`, `paused`, `completed`). No auth required.
|
||||
|
||||
#### `POST /channels/:channel/shows/resolve-media`
|
||||
|
||||
Resolve up to 50 media entries to display-ready titles before saving a show. Minimum rank: **2 (Mod)**.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "type": "yt", "id": "dQw4w9WgXcQ" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "type": "yt", "id": "dQw4w9WgXcQ", "title": "Rick Astley - Never Gonna Give You Up", "ok": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /channels/:channel/shows`
|
||||
|
||||
Create a show. Minimum rank: **2 (Mod)**.
|
||||
|
||||
#### `PUT /channels/:channel/shows/:id`
|
||||
|
||||
Update a show. Minimum rank: **2 (Mod)**.
|
||||
|
||||
#### `DELETE /channels/:channel/shows/:id`
|
||||
|
||||
Delete a show. Minimum rank: **3 (Admin)**.
|
||||
|
||||
#### `POST /channels/:channel/shows/:id/action`
|
||||
|
||||
Run control action.
|
||||
|
||||
| Action | Minimum rank |
|
||||
|------------|--------------|
|
||||
| `pause` | 2 |
|
||||
| `resume` | 2 |
|
||||
| `schedule` | 2 |
|
||||
| `run` | 3 |
|
||||
| `cancel` | 3 |
|
||||
|
||||
**Create/Update body schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Friday Prime",
|
||||
"notes": "Opening block",
|
||||
"color": "#337AB7",
|
||||
"scheduled_for": "2026-05-22T19:00:00.000Z",
|
||||
"estimated_end_at": "2026-05-22T21:00:00.000Z",
|
||||
"timezone": "America/New_York",
|
||||
"recurrence": "weekly",
|
||||
"fill_mode": "replace",
|
||||
"conflict_mode": "force",
|
||||
"start_playback": true,
|
||||
"playlist": [
|
||||
{ "type": "yt", "id": "dQw4w9WgXcQ", "pos": "end" }
|
||||
],
|
||||
"status": "scheduled"
|
||||
}
|
||||
```
|
||||
|
||||
**Field constraints:**
|
||||
|
||||
- `name`: required, 1-100 chars
|
||||
- `notes`: optional string, trimmed and capped to 20,000 chars
|
||||
- `color`: optional `#RRGGBB` hex color
|
||||
- `timezone`: required IANA timezone string (example: `Europe/Berlin`, `America/New_York`)
|
||||
- `scheduled_for`: required date string or unix timestamp (ms)
|
||||
- `estimated_end_at`: optional date string/timestamp, must be later than `scheduled_for` when present
|
||||
- `recurrence`: `none | daily | weekly`
|
||||
- `fill_mode`: `append | replace`
|
||||
- `conflict_mode`: `force | skip`
|
||||
- `playlist`: non-empty array of media entries (`type`, `id`, optional `pos: next|end`)
|
||||
- `status`: one of `draft | scheduled | paused | running | completed | failed | canceled` (`running` is accepted but normalized to `scheduled` on write)
|
||||
|
||||
**Action body schema:**
|
||||
|
||||
```json
|
||||
{ "action": "run" }
|
||||
```
|
||||
|
||||
**Show response shape** (`GET`, `POST`, `PUT`, and `POST /action`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"channel_name": "my-channel",
|
||||
"channel_id": 10,
|
||||
"name": "Friday Prime",
|
||||
"notes": "Opening block",
|
||||
"notes_html": "<p>Opening block</p>",
|
||||
"color": "#337AB7",
|
||||
"playlist": [{ "type": "yt", "id": "dQw4w9WgXcQ", "pos": "end" }],
|
||||
"timezone": "America/New_York",
|
||||
"scheduled_for": 1779476400000,
|
||||
"estimated_end_at": 1779483600000,
|
||||
"next_run_at": 1779476400000,
|
||||
"status": "scheduled",
|
||||
"recurrence": "weekly",
|
||||
"recurrence_meta": null,
|
||||
"fill_mode": "replace",
|
||||
"conflict_mode": "force",
|
||||
"start_playback": true,
|
||||
"run_count": 0,
|
||||
"last_run_at": null,
|
||||
"created_at": 1779400000000,
|
||||
"updated_at": 1779400000000,
|
||||
"created_by": "my-bot",
|
||||
"updated_by": "my-bot",
|
||||
"last_error": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bot management
|
||||
|
||||
These endpoints use **session cookie auth** (the normal logged-in web session), not a bot token. They are intended for the channel settings UI.
|
||||
|
||||
#### `GET /channels/:channel/bots`
|
||||
|
||||
List all bots for the channel. Returns id, name, rank, creator, creation time, last connection time. Requires channel rank ≥ 2.
|
||||
|
||||
#### `POST /channels/:channel/bots`
|
||||
|
||||
Issue a new bot token. Requires channel rank ≥ 2. The returned `token` is shown **once** and not stored.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "name": "MyBot", "rank": 2 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "id": 7, "name": "MyBot", "rank": 2, "token": "cbt_..." }
|
||||
```
|
||||
|
||||
#### `DELETE /channels/:channel/bots/:id`
|
||||
|
||||
Revoke a bot token. Any live socket connections for that bot are disconnected immediately. Requires channel rank ≥ 2 and your rank must be ≥ the bot's rank.
|
||||
|
||||
---
|
||||
|
||||
## Demo bot
|
||||
|
||||
A working example with a TUI is in `examples/demo-bot/`.
|
||||
|
||||
```
|
||||
cd examples/demo-bot
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Edit .env: fill in BOT_TOKEN, CHANNEL, SERVER_URL, API_BASE
|
||||
node bot.js
|
||||
```
|
||||
|
||||
**.env fields:**
|
||||
|
||||
| Variable | Description | Default |
|
||||
|--------------|---------------------------------------------------------|-----------------------------------|
|
||||
| `BOT_TOKEN` | Bot token from the channel settings Bots tab | — |
|
||||
| `CHANNEL` | Channel name (lowercase, no `#`) | — |
|
||||
| `SERVER_URL` | socket.io URL (use the io.port, not the HTTP port) | `http://localhost:1337` |
|
||||
| `API_BASE` | Base URL for REST requests | `http://localhost:8080/api/v1` |
|
||||
|
||||
**TUI keybindings:** Enter to send, PgUp/PgDn to scroll, Ctrl-C to quit.
|
||||
|
||||
**Bot commands** (prefix with `/` in the input box):
|
||||
|
||||
| Command | Description |
|
||||
|--------------------------|----------------------------------------|
|
||||
| `/help` | List commands |
|
||||
| `/playlist` | Show playlist via REST |
|
||||
| `/emotes` | List emotes via REST |
|
||||
| `/settings` | Show channel settings via REST |
|
||||
| `/users` | Show connected user list |
|
||||
| `/add <type> <id>` | Queue media, e.g. `/add yt dQw4w9WgXcQ` |
|
||||
| `/skip` | Skip to next item |
|
||||
| `/clear` | Clear the playlist |
|
||||
| `/kick <name> [reason]` | Kick a user |
|
||||
| `/me <text>` | Send an action message |
|
||||
210
docs/custom-media.md
Normal file
210
docs/custom-media.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
CyTube Custom Content Metadata
|
||||
==============================
|
||||
|
||||
*Last updated: 2022-02-12*
|
||||
|
||||
## Purpose ##
|
||||
|
||||
CyTube currently supports adding custom audio/video content by allowing the user
|
||||
to supply a direct URL to an audio/video file. The server uses `ffprobe` to
|
||||
probe the file for various metadata, including the codec/container format and
|
||||
the duration. This approach has a few disadvantages over the officially
|
||||
supported media providers, namely:
|
||||
|
||||
* Since it accepts a single file, it is not possible to provide multiple
|
||||
source URLs with varying formats or bitrates to allow viewers to select the
|
||||
best source for their computer.
|
||||
- It also means it is not possible to provide text tracks for subtitles or
|
||||
closed captioning, or to provide image URLs for thumbnails/previews.
|
||||
* Probing the file with `ffprobe` is slow, especially if the content is hosted
|
||||
in a far away network location, which at best is inconvenient and at worst
|
||||
results in timeouts and inability to add the content.
|
||||
* Parsing the `ffprobe` output is inexact, and may sometimes result in
|
||||
detecting the wrong format, or failing to detect the title.
|
||||
|
||||
This document specifies a new supported media provider which allows users to
|
||||
provide a JSON manifest specifying the metadata for custom content in a way that
|
||||
avoids the above issues and is more flexible for extension.
|
||||
|
||||
## Custom Manifest URLs ##
|
||||
|
||||
Custom media manifests are added to CyTube by adding a link to a public URL
|
||||
hosting the JSON metadata manifest. Pasting the JSON directly into CyTube is
|
||||
not supported. Valid JSON manifests must:
|
||||
|
||||
* Have a URL path ending with the file extension `.json` (not counting
|
||||
querystring parameters)
|
||||
* Be served with the `Content-Type` header set to `application/json`
|
||||
* Be retrievable at any time while the item is on the playlist (CyTube may
|
||||
re-request the metadata for an item already on the playlist to revalidate)
|
||||
* Respond to valid requests with a 200 OK HTTP response code (redirects are
|
||||
not supported)
|
||||
* Respond within 10 seconds
|
||||
* Not exceed 100 KiB in size
|
||||
|
||||
## Manifest Format ##
|
||||
|
||||
To add custom content, the user provides a JSON object with the following keys:
|
||||
|
||||
* `title`: A nonempty string specifying the title of the content. For legacy
|
||||
reasons, CyTube currently truncates this to 100 UTF-8 characters.
|
||||
* `duration`: A non-negative, finite number specifying the duration, in
|
||||
seconds, of the content. This is what the server will use for timing
|
||||
purposes. Decimals are allowed, but CyTube's timer truncates the value as
|
||||
an integer number of seconds, so including fractional seconds lends no
|
||||
advantage.
|
||||
* `live`: An optional boolean (default: `false`) indicating whether the
|
||||
content is live or pre-recorded. For live content, the `duration` is
|
||||
ignored, and the server won't advance the playlist automatically.
|
||||
* `thumbnail`: An optional string specifying a URL for a thumbnail image of
|
||||
the content. CyTube currently does not support displaying thumbnails in the
|
||||
playlist, but this functionality may be offered in the future.
|
||||
* `sources`: A nonempty list of playable sources for the content. The format
|
||||
is described below.
|
||||
* `audioTracks`: An optional list of audio tracks for using demuxed audio
|
||||
and providing multiple audio selections. The format is described below.
|
||||
* `textTracks`: An optional list of text tracks for subtitles or closed
|
||||
captioning. The format is described below.
|
||||
|
||||
### Source Format ###
|
||||
|
||||
Each source entry is a JSON object with the following keys:
|
||||
|
||||
* `url`: A valid URL that browsers can use to retrieve the content. The URL
|
||||
must resolve to a publicly-routed IP address, and must the `https:` scheme.
|
||||
* `contentType`: A string representing the MIME type of the content at `url`.
|
||||
A list of acceptable MIME types is provided below.
|
||||
* `quality`: A number representing the quality level of the source. The
|
||||
supported quality levels are `240`, `360`, `480`, `540`, `720`, `1080`,
|
||||
`1440`, and `2160`. This may be extended in the future.
|
||||
* `bitrate`: An optional number indicating the bitrate (in Kbps) of the
|
||||
content. It must be a positive, finite number if provided. The bitrate is
|
||||
not currently used by CyTube, but may be used by extensions or custom
|
||||
scripts to determine whether this source is feasible to play on the viewer's
|
||||
internet connection.
|
||||
|
||||
#### Acceptable MIME Types ####
|
||||
|
||||
The following MIME types are accepted for the `contentType` field:
|
||||
|
||||
* `video/mp4`
|
||||
* `video/webm`
|
||||
* `video/ogg`
|
||||
* `application/x-mpegURL` (HLS streams)
|
||||
- HLS is only tested with livestreams. VODs are accepted, but I do not test
|
||||
this functionality.
|
||||
* `application/dash+xml` (DASH streams)
|
||||
- Support for DASH is experimental
|
||||
* ~~`rtmp/flv`~~
|
||||
- In light of Adobe phasing out support for Flash, and many browsers
|
||||
already dropping support, RTMP is not supported by this feature.
|
||||
RTMP streams are only supported through the existing `rt:` media
|
||||
type.
|
||||
* `audio/aac`
|
||||
* `audio/mp4`
|
||||
* `audio/mpeg`
|
||||
* `audio/ogg`
|
||||
|
||||
Other audio or video formats, such as AVI, MKV, and FLAC, are not supported due
|
||||
to lack of common support across browsers for playing these formats. For more
|
||||
information, refer to
|
||||
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats#Browser_compatibility).
|
||||
|
||||
### Audio Track Format ###
|
||||
|
||||
Each audio track entry is a JSON object with the following keys:
|
||||
|
||||
* `label`: A label for the audio track. This is displayed in the menu for the
|
||||
viewer to select a text track.
|
||||
* `language`: A two or three letter IETF BCP 47 subtype code indicating the
|
||||
language of the audio track.
|
||||
* `url`: A valid URL that browsers can use to retrieve the track. The URL
|
||||
must resolve to a publicly-routed IP address, and must use the `https:` scheme.
|
||||
* `contentType`: A string representing the MIME type of the track at `url`.
|
||||
Any type starting with `audio` from the list above is acceptable. However
|
||||
the usage of audio/aac is known to cause audio syncrhonization problems
|
||||
for some users. It is recommended to use an m4a file to wrap aac streams.
|
||||
|
||||
**Important note regarding audio tracks:**
|
||||
|
||||
Because of browsers trying to be too smart for their own good, you should
|
||||
include a silent audio stream in the video sources when using separate audio
|
||||
tracks. If you do not, the browser will automatically pause the video whenever
|
||||
the browser detects the page as not visible. There is no way to instruct it to
|
||||
not do so. You can readily accomplish the inclusion of a silent audio track
|
||||
with ffmpeg using the anullsrc filter like so:
|
||||
`ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 -i input.mp4 -c:v copy -c:a aac -shortest output.mp4`
|
||||
It is recommended to match the sample rate and codec you intend to use in your
|
||||
audioTracks in your silent track.
|
||||
|
||||
### Text Track Format ###
|
||||
|
||||
Each text track entry is a JSON object with the following keys:
|
||||
|
||||
* `url`: A valid URL that browsers can use to retrieve the track. The URL
|
||||
must resolve to a publicly-routed IP address, and must the `https:` scheme.
|
||||
* `contentType`: A string representing the MIME type of the track at `url`.
|
||||
The only currently supported MIME type is
|
||||
[`text/vtt`](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API).
|
||||
* `name`: A name for the text track. This is displayed in the menu for the
|
||||
viewer to select a text track.
|
||||
* `default`: Enable track by default. Optional boolean attribute to enable
|
||||
a subtitle track to the user by default.
|
||||
|
||||
**Important note regarding text tracks and CORS:**
|
||||
|
||||
By default, browsers block requests for WebVTT tracks hosted on different
|
||||
domains than the current page. In order for text tracks to work cross-origin,
|
||||
the `Access-Control-Allow-Origin` header needs to be set by the remote server
|
||||
when serving the VTT file. See
|
||||
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)
|
||||
for more information about setting this header.
|
||||
|
||||
## Example ##
|
||||
|
||||
{
|
||||
"title": "Test Video",
|
||||
"duration": 10,
|
||||
"live": false,
|
||||
"thumbnail": "https://example.com/thumb.jpg",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://example.com/video.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"quality": 1080,
|
||||
"bitrate": 5000
|
||||
}
|
||||
],
|
||||
"textTracks": [
|
||||
{
|
||||
"url": "https://example.com/subtitles.vtt",
|
||||
"contentType": "text/vtt",
|
||||
"name": "English Subtitles",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Permissions ##
|
||||
|
||||
The permission node to allow users to add custom content is the same as the
|
||||
permission node for the existing raw file support. Custom content is considered
|
||||
as an extension of the existing feature.
|
||||
|
||||
## Unsupported/Undefined Behavior ##
|
||||
|
||||
The behavior under any the following circumstances is not defined by this
|
||||
specification, and any technical support in these cases is voided. This list is
|
||||
non-exhaustive.
|
||||
|
||||
* Source URLs or text track URLs are hosted on a third-party website that does
|
||||
not have knowledge of its content being played on CyTube.
|
||||
* The webserver hosting the source or text track URLs serves a different MIME
|
||||
type than the one specified in the manifest.
|
||||
* The webserver hosting the source or text track URLs serves a file that does
|
||||
not match the MIME type specified in the `Content-Type` HTTP header returned
|
||||
to the browser.
|
||||
* The manifest includes source URLs or text track URLs with expiration times,
|
||||
session IDs, etc. in the URL querystring.
|
||||
* The manifest provides source URLs with non-silent audio as well as a list
|
||||
of audioTracks.
|
||||
24
docs/gdrive-userscript-serveradmins.md
Normal file
24
docs/gdrive-userscript-serveradmins.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Google Drive Userscript Setup
|
||||
|
||||
In response to increasing difficulty and complexity of maintaining Google Drive
|
||||
support, the native player is being phased out in favor of requiring a
|
||||
userscript to allow each client to fetch the video stream links for themselves.
|
||||
Users will be prompted with a link to `/google_drive_userscript`, which explains
|
||||
the situation and instructs how to install the userscript.
|
||||
|
||||
As a server admin, you must generate the userscript from the template by using
|
||||
the following command:
|
||||
|
||||
```sh
|
||||
npm run generate-userscript <site name> <url> [<url>...]
|
||||
```
|
||||
|
||||
The first argument is the site name as it will appear in the userscript title.
|
||||
The remaining arguments are the URL patterns on which the script will run. For
|
||||
example, for cytu.be I use:
|
||||
|
||||
```sh
|
||||
npm run generate-userscript CyTube http://cytu.be/r/* https://cytu.be/r/*
|
||||
```
|
||||
|
||||
This will generate `www/js/cytube-google-drive.user.js`. If you've changed the channel path, be sure to take that into account.
|
||||
|
|
@ -12,4 +12,4 @@ This user guide is a work in progress rewrite of the old user guide. If you not
|
|||
## I need help! ##
|
||||
|
||||
1. Please read the [FAQ](https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions) and check whether that answers your question.
|
||||
2. If not, you can contact someone for help. IRC support is provided on `irc.6irc.net #cytube` ([webchat](https://webchat.6irc.net/?channels=cytube) available) for http://cytu.be, http://synchtu.be, and general questions about using the software. If nobody is available on IRC, or you want to speak privately, email one of the contacts on https://cytu.be/contact.
|
||||
2. If not, you can contact someone for help. IRC support is provided on `irc.esper.net #cytube` ([webchat](https://webchat.esper.net/?channels=cytube) available) for https://cytu.be and general questions about using the software. If nobody is available on IRC, or you want to speak privately, email one of the contacts on https://cytu.be/contact.
|
||||
|
|
|
|||
29
docs/new-account-chat-restrictions.md
Normal file
29
docs/new-account-chat-restrictions.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
Restricting New Accounts from Chat
|
||||
==================================
|
||||
|
||||
With the rising availability and popularity of VPNs and proxies, dedicated
|
||||
trolls may often come back again and again with a new proxy after being IP
|
||||
banned and continue spamming. In order to combat this, a new feature has been
|
||||
added to make it more difficult to rejoin quickly and continue spamming.
|
||||
|
||||
Channel moderators now have the ability to configure 2 different settings:
|
||||
|
||||
* How long an account must be active before the user can send any chat message
|
||||
* How long an account must be active before the user can send a chat message
|
||||
containing a link
|
||||
|
||||
This limit applies to both chat messages sent to the channel as well as private
|
||||
messages. Both of these settings can be configured from the Channel Settings
|
||||
menu at the top of the page, under the General Settings tab. By default,
|
||||
accounts must be at least 10 minutes old to chat, and 1 hour old to send links
|
||||
in chat. Setting either restriction to 0 will disable that restriction.
|
||||
|
||||
The age of an account is determined as follows:
|
||||
|
||||
* If the user is logged in as a registered account, the registration time of
|
||||
the account is used.
|
||||
* Otherwise, the timestamp of the session cookie is used.
|
||||
|
||||
The session cookie is set whenever a user first joins a channel, and is reset
|
||||
whenever the user's IP address changes. Different browsers will have different
|
||||
session cookies.
|
||||
59
docs/raw-videos.md
Normal file
59
docs/raw-videos.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Raw Videos / Audio #
|
||||
|
||||
Want to host your own video/audio files for use on CyTube? For servers with the
|
||||
ffprobe module enabled, CyTube supports this! However, in order to provide a
|
||||
consistent experience, there are limitations.
|
||||
|
||||
## Hosting the File ##
|
||||
|
||||
CyTube requires a direct link to the file in order to query it for metadata such
|
||||
as duration and encoding. The website where you host the file needs to be able
|
||||
to serve the video directly (rather than embedding it in a flash
|
||||
player/iframe/etc.). It also needs to serve the correct MIME type for the video
|
||||
in the `Content-Type` HTTP header, e.g. `video/mp4`.
|
||||
|
||||
I don't recommend hosting videos on Dropbox-type services, as they aren't built
|
||||
to distribute video to many users at a time and often have strict bandwidth
|
||||
limits. File hosting sites such as Putlocker also cause problems due to being
|
||||
unable to serve the file directly, or due to binding the link to the IP address
|
||||
of the user who retrieved it. For best results when using raw video, host the
|
||||
video yourself on a VPS or dedicated server with plenty of bandwidth.
|
||||
|
||||
Note that CyTube only queries the file for metadata, it does not proxy it for
|
||||
users! Every user watching the video will be downloading it individually.
|
||||
|
||||
## Encoding the Video ##
|
||||
|
||||
Current internet browsers are very limited in what codecs they can play
|
||||
natively. Accordingly, CyTube only supports a few codecs:
|
||||
|
||||
**Video**
|
||||
|
||||
* MP4 (AV1)
|
||||
* MP4 (H.264)
|
||||
* WebM (AV1)
|
||||
* WebM (VP8)
|
||||
* WebM (VP9)
|
||||
* Ogg/Theora
|
||||
|
||||
**Audio**
|
||||
|
||||
* MP3
|
||||
* Ogg/Vorbis
|
||||
|
||||
If your video is in some other format (such as MKV or AVI), then it will need to
|
||||
be re-encoded. There are plenty of free programs available to re-encode video
|
||||
files, such as [ffmpeg](http://ffmpeg.org/) and
|
||||
[handbrake](http://handbrake.fr/).
|
||||
|
||||
For best results, encode as an MP4 using H.264. This is natively supported by
|
||||
many browsers, and can also be played using a fallback flash player for older
|
||||
browsers that don't support it natively. Always encode with the
|
||||
[faststart](https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo)
|
||||
flag.
|
||||
|
||||
### Subtitles ###
|
||||
|
||||
Unfortunately, soft-subtitles are not supported right now. This is something
|
||||
that may be supported in the future, but currently if you need subtitles, they
|
||||
will have to be hardsubbed onto the video itself.
|
||||
|
|
@ -21,7 +21,6 @@ Setting | Description
|
|||
--------|------------
|
||||
Synchronize video playback | By default, CyTube attempts to synchronize the video so that everyone is watching at the same time. Some users with poor internet connections may wish to disable this in order to prevent excessive buffering due to constantly seeking forward.
|
||||
Synch threshold | The number of seconds your video is allowed to be ahead/behind before it is forcibly seeked to the correct position. Should be set to at least 2 seconds to avoid buffering problems and choppy playback.
|
||||
Set wmode=transparent | There's probably no reason to touch this unless you know what you're doing. Having a non-transparent wmode can cause modals to display behind the video player, but also can cause performance issues in some situations.
|
||||
Remove the video player | Automatically remove the video player on page load. Equivalent to manually clicking Layout->Remove Video every time you load a channel.
|
||||
Hide playlist buttons by default | Hides the control buttons from each video in the playlist, so that only the title is displayed. The control buttons can be shown by right clicking the video item in the playlist.
|
||||
Old style playlist buttons | Legacy feature introduced in CyTube 2.0 for those who preferred the old 1.0-style video control buttons.
|
||||
|
|
|
|||
11
examples/demo-bot/.env.example
Normal file
11
examples/demo-bot/.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Issue a bot token in the channel settings modal (Bots tab), then paste it here.
|
||||
BOT_TOKEN=cbt_your_token_here
|
||||
|
||||
# The channel this token was issued for.
|
||||
CHANNEL=yourchannel
|
||||
|
||||
# Base URL of the CyTube server (no trailing slash).
|
||||
SERVER_URL=http://localhost:1337
|
||||
|
||||
# REST API base (usually SERVER_URL + /api/v1).
|
||||
API_BASE=http://localhost:8080/api/v1
|
||||
594
examples/demo-bot/bot.js
Normal file
594
examples/demo-bot/bot.js
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CyTube Sync — Demo Bot
|
||||
*
|
||||
* Shows the two halves of the bot API:
|
||||
* • WebSocket (socket.io-client) — real-time events: chat, user list, playlist
|
||||
* • REST API (fetch) — queries and commands: playlist, emotes, settings, moderation
|
||||
*
|
||||
* Setup:
|
||||
* cp .env.example .env # fill in BOT_TOKEN and CHANNEL
|
||||
* npm install
|
||||
* node bot.js
|
||||
*
|
||||
* TUI keybindings:
|
||||
* Enter send message or run command
|
||||
* PgUp/PgDn scroll chat
|
||||
* Ctrl-C quit
|
||||
*
|
||||
* Commands (prefix with /):
|
||||
* /help list commands
|
||||
* /playlist show current playlist via REST
|
||||
* /emotes list emotes via REST
|
||||
* /settings show channel settings via REST
|
||||
* /users dump user list from in-memory state
|
||||
* /add <type> <id> add media e.g. /add yt dQw4w9WgXcQ
|
||||
* /skip skip to next playlist item
|
||||
* /clear clear the playlist
|
||||
* /kick <name> [reason] kick a user
|
||||
* /me <text> send an action (/me) message
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const io = require('socket.io-client');
|
||||
const fetch = require('node-fetch');
|
||||
const blessed = require('blessed');
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
BOT_TOKEN,
|
||||
CHANNEL,
|
||||
SERVER_URL = 'http://localhost:8080',
|
||||
API_BASE = 'http://localhost:8080/api/v1',
|
||||
} = process.env;
|
||||
|
||||
if (!BOT_TOKEN || !CHANNEL) {
|
||||
console.error('BOT_TOKEN and CHANNEL must be set. Copy .env.example to .env and fill it in.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!BOT_TOKEN.startsWith('cbt_')) {
|
||||
console.error('BOT_TOKEN does not look right — it should start with cbt_');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── REST helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiRequest(method, path, body) {
|
||||
const url = `${API_BASE}/channels/${CHANNEL}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${BOT_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((json && json.error) || `HTTP ${res.status}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: (path) => apiRequest('GET', path),
|
||||
post: (path, body) => apiRequest('POST', path, body),
|
||||
put: (path, body) => apiRequest('PUT', path, body),
|
||||
delete: (path) => apiRequest('DELETE', path),
|
||||
};
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function stripHtml(html) {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Escape blessed tag syntax so server content can't inject markup.
|
||||
function escBless(str) {
|
||||
return String(str).replace(/\{/g, '\\{');
|
||||
}
|
||||
|
||||
function hhmm(secs) {
|
||||
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ── In-memory state ───────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
users: [], // userlist entries from server
|
||||
nowPlaying: null, // current changeMedia payload
|
||||
connected: false,
|
||||
inChannel: false,
|
||||
};
|
||||
|
||||
// ── TUI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
title: `CyTube Bot :: #${CHANNEL}`,
|
||||
fullUnicode: true,
|
||||
forceUnicode: true,
|
||||
});
|
||||
|
||||
// Top bar — connection status + now playing
|
||||
const statusBar = blessed.box({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 1,
|
||||
tags: true,
|
||||
style: { bg: 'blue', fg: 'white', bold: true },
|
||||
content: ` CyTube Bot #${CHANNEL} Connecting...`,
|
||||
});
|
||||
|
||||
// Main chat log (left, scrollable)
|
||||
const chatLog = blessed.log({
|
||||
top: 1,
|
||||
left: 0,
|
||||
width: '70%',
|
||||
height: '100%-4',
|
||||
border: { type: 'line' },
|
||||
label: ' Chat ',
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
scrollbar: { ch: ' ', style: { bg: 'cyan' } },
|
||||
mouse: true,
|
||||
tags: true,
|
||||
wrap: true,
|
||||
style: { border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true } },
|
||||
});
|
||||
|
||||
// User list sidebar (right)
|
||||
const userList = blessed.list({
|
||||
top: 1,
|
||||
right: 0,
|
||||
width: '30%',
|
||||
height: '100%-4',
|
||||
border: { type: 'line' },
|
||||
label: ' Users ',
|
||||
scrollable: true,
|
||||
mouse: true,
|
||||
tags: true,
|
||||
style: {
|
||||
border: { fg: 'cyan' },
|
||||
label: { fg: 'cyan', bold: true },
|
||||
item: { fg: 'white' },
|
||||
selected: { fg: 'white' },
|
||||
},
|
||||
});
|
||||
|
||||
// Input box (bottom)
|
||||
const inputBox = blessed.textbox({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 3,
|
||||
border: { type: 'line' },
|
||||
label: ' Message — /help for commands — Ctrl-C to quit ',
|
||||
inputOnFocus: true,
|
||||
style: { border: { fg: 'green' }, label: { fg: 'green' } },
|
||||
});
|
||||
|
||||
screen.append(statusBar);
|
||||
screen.append(chatLog);
|
||||
screen.append(userList);
|
||||
screen.append(inputBox);
|
||||
|
||||
screen.key(['C-c'], () => process.exit(0));
|
||||
|
||||
// ── TUI helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function setStatus(msg) {
|
||||
statusBar.setContent(` CyTube Bot #${CHANNEL} ${msg}`);
|
||||
screen.render();
|
||||
}
|
||||
|
||||
function chat(line) {
|
||||
chatLog.log(line);
|
||||
screen.render();
|
||||
}
|
||||
|
||||
function info(line) {
|
||||
chat(`{cyan-fg}${line}{/cyan-fg}`);
|
||||
}
|
||||
|
||||
function warn(line) {
|
||||
chat(`{yellow-fg}${line}{/yellow-fg}`);
|
||||
}
|
||||
|
||||
function fail(line) {
|
||||
chat(`{red-fg}✗ ${line}{/red-fg}`);
|
||||
}
|
||||
|
||||
function rebuildUserList() {
|
||||
const items = state.users.map(u => {
|
||||
const isBot = u.meta && u.meta.is_bot;
|
||||
const afk = u.meta && u.meta.afk;
|
||||
let rankStr;
|
||||
if (u.rank >= 5) rankStr = '{red-fg}[creator]{/red-fg}';
|
||||
else if (u.rank >= 4) rankStr = '{yellow-fg}[owner]{/yellow-fg}';
|
||||
else if (u.rank >= 3) rankStr = '{magenta-fg}[admin]{/magenta-fg}';
|
||||
else if (u.rank >= 2) rankStr = '{green-fg}[mod]{/green-fg}';
|
||||
else rankStr = '';
|
||||
|
||||
const botTag = isBot ? ' {blue-fg}[bot]{/blue-fg}' : '';
|
||||
const afkTag = afk ? ' {grey-fg}[afk]{/grey-fg}' : '';
|
||||
return `${escBless(u.name)} ${rankStr}${botTag}${afkTag}`;
|
||||
});
|
||||
|
||||
userList.setLabel(` Users (${items.length}) `);
|
||||
userList.setItems(items);
|
||||
screen.render();
|
||||
}
|
||||
|
||||
// ── Socket.IO ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const socket = io(SERVER_URL, {
|
||||
// This is how the bot authenticates — token goes in socket.handshake.auth.token
|
||||
auth: { token: BOT_TOKEN },
|
||||
reconnection: true,
|
||||
reconnectionDelay: 3000,
|
||||
reconnectionAttempts: Infinity,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
state.connected = true;
|
||||
setStatus('Authenticating...');
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
state.connected = false;
|
||||
state.inChannel = false;
|
||||
state.users = [];
|
||||
rebuildUserList();
|
||||
warn(`Disconnected: ${reason}`);
|
||||
setStatus(`Disconnected — reconnecting...`);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
fail(`Connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Server confirms authentication and sends the bot's display name + rank.
|
||||
// We wait for this before joining the channel to avoid any ordering issues.
|
||||
socket.on('login', (data) => {
|
||||
if (data.success) {
|
||||
info(`Authenticated as: ${escBless(data.name)}`);
|
||||
socket.emit('joinChannel', { name: CHANNEL });
|
||||
} else {
|
||||
fail(`Authentication failed: ${escBless(data.error || 'unknown')}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('errorMsg', (data) => {
|
||||
fail(escBless(data.msg || 'Server error'));
|
||||
});
|
||||
|
||||
socket.on('kick', (data) => {
|
||||
fail(`Kicked: ${escBless(data.reason || '')}`);
|
||||
setStatus('Kicked from channel');
|
||||
});
|
||||
|
||||
// ── Channel events ────────────────────────────────────────────────────────────
|
||||
|
||||
// Channel options (title, password, etc.)
|
||||
socket.on('channelOpts', (opts) => {
|
||||
const title = opts.pagetitle || CHANNEL;
|
||||
const playing = state.nowPlaying ? ` — ${escBless(state.nowPlaying.title)}` : '';
|
||||
setStatus(`{bold}${escBless(title)}{/bold}${playing}`);
|
||||
});
|
||||
|
||||
// ── User list events ──────────────────────────────────────────────────────────
|
||||
|
||||
// Full user list, sent on join and refresh
|
||||
socket.on('userlist', (users) => {
|
||||
state.users = users;
|
||||
state.inChannel = true;
|
||||
rebuildUserList();
|
||||
info(`Joined #${CHANNEL} — ${users.length} user(s) present`);
|
||||
});
|
||||
|
||||
// New user joined
|
||||
socket.on('addUser', (user) => {
|
||||
state.users = state.users.filter(u => u.name !== user.name);
|
||||
state.users.push(user);
|
||||
rebuildUserList();
|
||||
chat(`{green-fg}→ ${escBless(user.name)} joined{/green-fg}`);
|
||||
});
|
||||
|
||||
// User left
|
||||
socket.on('userLeave', (data) => {
|
||||
state.users = state.users.filter(u => u.name !== data.name);
|
||||
rebuildUserList();
|
||||
chat(`{red-fg}← ${escBless(data.name)} left{/red-fg}`);
|
||||
});
|
||||
|
||||
// Rank changed for a user
|
||||
socket.on('setUserRank', (data) => {
|
||||
const user = state.users.find(u => u.name === data.name);
|
||||
if (user) user.rank = data.rank;
|
||||
rebuildUserList();
|
||||
});
|
||||
|
||||
// AFK / mute state changed
|
||||
socket.on('setUserMeta', (data) => {
|
||||
const user = state.users.find(u => u.name === data.name);
|
||||
if (user) Object.assign(user.meta, data.meta);
|
||||
rebuildUserList();
|
||||
});
|
||||
|
||||
// ── Chat events ───────────────────────────────────────────────────────────────
|
||||
|
||||
socket.on('chatMsg', (data) => {
|
||||
const time = timestamp();
|
||||
const name = escBless(data.username || '?');
|
||||
const msg = escBless(stripHtml(data.msg || ''));
|
||||
const isAction = data.meta && data.meta.addClass === 'action';
|
||||
|
||||
// is_bot is set by the server in the message meta; fall back to checking
|
||||
// the local user list in case the message arrives before the userlist does.
|
||||
const senderInList = state.users.find(u => u.name === data.username);
|
||||
const isBot = (data.meta && data.meta.is_bot) ||
|
||||
(senderInList && senderInList.meta && senderInList.meta.is_bot);
|
||||
const botTag = isBot ? ' {blue-fg}[bot]{/blue-fg}' : '';
|
||||
|
||||
if (isAction) {
|
||||
chat(`{grey-fg}[${time}]{/grey-fg}${botTag} {italic}* ${name} ${msg}{/italic}`);
|
||||
} else {
|
||||
chat(`{grey-fg}[${time}]{/grey-fg} {bold}${name}{/bold}${botTag}: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Private messages
|
||||
socket.on('pm', (data) => {
|
||||
const name = escBless(data.username || '?');
|
||||
const msg = escBless(stripHtml(data.msg || ''));
|
||||
chat(`{magenta-fg}[PM ← ${name}]{/magenta-fg} ${msg}`);
|
||||
});
|
||||
|
||||
// Chat cleared by a moderator
|
||||
socket.on('clearchat', () => {
|
||||
chatLog.setContent('');
|
||||
info('Chat was cleared by a moderator.');
|
||||
screen.render();
|
||||
});
|
||||
|
||||
// ── Playlist events ───────────────────────────────────────────────────────────
|
||||
|
||||
// Full playlist on join
|
||||
socket.on('playlist', (items) => {
|
||||
if (items.length > 0) {
|
||||
info(`Playlist loaded: ${items.length} item(s)`);
|
||||
}
|
||||
});
|
||||
|
||||
// New item added to playlist by someone
|
||||
socket.on('queue', (data) => {
|
||||
const item = data.item;
|
||||
if (item && item.media) {
|
||||
info(`Queued: [${item.media.type}] ${escBless(item.media.title)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Currently playing changed
|
||||
socket.on('changeMedia', (media) => {
|
||||
state.nowPlaying = media;
|
||||
const dur = media.seconds ? ` (${hhmm(media.seconds)})` : '';
|
||||
const title = escBless(media.title || media.id);
|
||||
setStatus(`▶ ${title}${dur}`);
|
||||
info(`Now playing: {bold}${title}{/bold}${dur}`);
|
||||
});
|
||||
|
||||
// ── Emote events ──────────────────────────────────────────────────────────────
|
||||
|
||||
socket.on('updateEmote', (emote) => {
|
||||
info(`Emote updated: :${escBless(emote.name)}:`);
|
||||
});
|
||||
|
||||
socket.on('removeEmote', (data) => {
|
||||
info(`Emote removed: :${escBless(data.name)}:`);
|
||||
});
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMMANDS = {
|
||||
help() {
|
||||
info('─── Commands ───────────────────────────────────────────────────');
|
||||
info(' /playlist fetch and show the playlist');
|
||||
info(' /emotes list emote names');
|
||||
info(' /settings show channel settings');
|
||||
info(' /users dump in-memory user list');
|
||||
info(' /add <type> <id> add media e.g. /add yt dQw4w9WgXcQ');
|
||||
info(' /skip skip to next playlist item');
|
||||
info(' /clear clear the playlist');
|
||||
info(' /kick <name> [reason] kick a user from the channel');
|
||||
info(' /me <text> send an action message');
|
||||
info(' <anything else> send as a chat message');
|
||||
info('───────────────────────────────────────────────────────────────');
|
||||
},
|
||||
|
||||
async playlist() {
|
||||
try {
|
||||
const data = await api.get('/playlist');
|
||||
info(`─── Playlist (${data.items.length} item${data.items.length !== 1 ? 's' : ''}) ──`);
|
||||
if (data.items.length === 0) {
|
||||
info(' (empty)');
|
||||
} else {
|
||||
data.items.forEach((item, i) => {
|
||||
const dur = item.seconds ? hhmm(item.seconds) : '?:??';
|
||||
const marker = i === data.currentIndex ? '{yellow-fg}▶{/yellow-fg}' : ' ';
|
||||
info(` ${marker} [${item.type}] ${escBless(item.title)} (${dur}) uid:${item.uid}`);
|
||||
});
|
||||
}
|
||||
if (data.locked) warn(' Playlist is locked');
|
||||
} catch (e) {
|
||||
fail(`playlist: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async emotes() {
|
||||
try {
|
||||
const emotes = await api.get('/emotes');
|
||||
info(`─── Emotes (${emotes.length}) ──`);
|
||||
if (emotes.length === 0) {
|
||||
info(' (none)');
|
||||
} else {
|
||||
// Print names in rows of 8
|
||||
for (let i = 0; i < emotes.length; i += 8) {
|
||||
info(' ' + emotes.slice(i, i + 8).map(e => `:${escBless(e.name)}:`).join(' '));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`emotes: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async settings() {
|
||||
try {
|
||||
const s = await api.get('/settings');
|
||||
info('─── Channel Settings ──');
|
||||
for (const [k, v] of Object.entries(s)) {
|
||||
if (v !== null && v !== '' && v !== false) {
|
||||
info(` ${k}: ${escBless(String(v))}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`settings: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
users() {
|
||||
info(`─── Users (${state.users.length}) ──`);
|
||||
state.users.forEach(u => {
|
||||
const rank = u.rank >= 5 ? 'creator' : u.rank >= 4 ? 'owner' : u.rank >= 3 ? 'admin' : u.rank >= 2 ? 'mod' : 'user';
|
||||
const isBot = u.meta && u.meta.is_bot ? ' [bot]' : '';
|
||||
info(` ${escBless(u.name)} (${rank} rank:${u.rank})${isBot}`);
|
||||
});
|
||||
},
|
||||
|
||||
async add(args) {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
fail('Usage: /add <type> <id> e.g. /add yt dQw4w9WgXcQ');
|
||||
return;
|
||||
}
|
||||
const [type, id] = parts;
|
||||
try {
|
||||
await api.post('/playlist', { type, id, pos: 'end' });
|
||||
info(`Added [${type}] ${escBless(id)} to playlist`);
|
||||
} catch (e) {
|
||||
fail(`add: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async skip() {
|
||||
try {
|
||||
const data = await api.get('/playlist');
|
||||
if (data.items.length === 0) { warn('Playlist is empty'); return; }
|
||||
const idx = data.currentIndex;
|
||||
if (idx < 0 || idx >= data.items.length - 1) {
|
||||
warn('No next item in playlist');
|
||||
return;
|
||||
}
|
||||
const next = data.items[idx + 1];
|
||||
await api.put('/playlist/playing', { uid: next.uid });
|
||||
info(`Skipped to: ${escBless(next.title)}`);
|
||||
} catch (e) {
|
||||
fail(`skip: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
await api.post('/playlist/clear');
|
||||
info('Playlist cleared');
|
||||
} catch (e) {
|
||||
fail(`clear: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async kick(args) {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const name = parts[0];
|
||||
const reason = parts.slice(1).join(' ') || 'Kicked by bot';
|
||||
if (!name) { fail('Usage: /kick <name> [reason]'); return; }
|
||||
try {
|
||||
await api.post(`/users/${encodeURIComponent(name)}/kick`, { reason });
|
||||
info(`Kicked ${escBless(name)}`);
|
||||
} catch (e) {
|
||||
fail(`kick: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
me(args) {
|
||||
const text = args.trim();
|
||||
if (!text) { fail('Usage: /me <text>'); return; }
|
||||
socket.emit('chatMsg', { msg: `/me ${text}` });
|
||||
},
|
||||
};
|
||||
|
||||
async function handleInput(line) {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
|
||||
if (line.startsWith('/')) {
|
||||
const space = line.indexOf(' ');
|
||||
const name = (space === -1 ? line.slice(1) : line.slice(1, space)).toLowerCase();
|
||||
const args = space === -1 ? '' : line.slice(space + 1);
|
||||
|
||||
if (COMMANDS[name]) {
|
||||
try {
|
||||
await COMMANDS[name](args);
|
||||
} catch (e) {
|
||||
fail(`Command error: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
fail(`Unknown command: /${name} (type /help for a list)`);
|
||||
}
|
||||
} else {
|
||||
// Regular chat message — sent over the WebSocket, not REST.
|
||||
// The server will echo it back as a chatMsg event.
|
||||
socket.emit('chatMsg', { msg: line });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input box wiring ──────────────────────────────────────────────────────────
|
||||
|
||||
inputBox.on('submit', async (value) => {
|
||||
inputBox.clearValue();
|
||||
inputBox.focus();
|
||||
screen.render();
|
||||
if (value && value.trim()) {
|
||||
await handleInput(value);
|
||||
}
|
||||
});
|
||||
|
||||
// Pressing Enter submits, but blessed's textbox needs this nudge
|
||||
inputBox.key('enter', () => inputBox.submit());
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
info(`Connecting to ${SERVER_URL} as bot in #${CHANNEL}...`);
|
||||
info('Type /help for available commands.');
|
||||
|
||||
inputBox.focus();
|
||||
screen.render();
|
||||
283
examples/demo-bot/package-lock.json
generated
Normal file
283
examples/demo-bot/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
{
|
||||
"name": "cytube-demo-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cytube-demo-bot",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"socket.io-client": "^4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||
},
|
||||
"node_modules/blessed": {
|
||||
"version": "0.1.81",
|
||||
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
|
||||
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
|
||||
"bin": {
|
||||
"blessed": "bin/tput.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||
},
|
||||
"blessed": {
|
||||
"version": "0.1.81",
|
||||
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
|
||||
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"requires": {
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="
|
||||
},
|
||||
"engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
}
|
||||
},
|
||||
"socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
}
|
||||
},
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"requires": {}
|
||||
},
|
||||
"xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
15
examples/demo-bot/package.json
Normal file
15
examples/demo-bot/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "cytube-demo-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Demo bot for the CyTube Sync bot API — TUI chat client + REST queries",
|
||||
"main": "bot.js",
|
||||
"scripts": {
|
||||
"start": "node bot.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"socket.io-client": "^4.7.0"
|
||||
}
|
||||
}
|
||||
32
examples/python-show-bot/README.md
Normal file
32
examples/python-show-bot/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Python Show Bot Example
|
||||
|
||||
Uses `veretube-bot==0.1.4` with `AsyncBot` and the built-in Shows API helpers.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd examples/python-show-bot
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Set environment variables:
|
||||
|
||||
- `BOT_TOKEN` (required)
|
||||
- `CHANNEL` (required)
|
||||
- `SOCKET_URL` (default: `http://localhost:1337`)
|
||||
- `API_BASE` (default: `http://localhost:8080/api/v1`)
|
||||
- `SHOW_TIMEZONE` (default: `UTC`, must be valid IANA timezone)
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python bot.py
|
||||
```
|
||||
|
||||
## Chat Commands
|
||||
|
||||
- `!shows` - list shows
|
||||
- `!mkshow` - create a demo show
|
||||
- `!runshow <id>` - run a show immediately
|
||||
116
examples/python-show-bot/bot.py
Normal file
116
examples/python-show-bot/bot.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Veretube Python Show Bot example (veretube-bot 0.1.4)
|
||||
|
||||
Commands in chat:
|
||||
!shows -> list existing shows
|
||||
!mkshow -> create an example show scheduled ~2 minutes from now
|
||||
!show <id> -> inspect a show from API
|
||||
!runshow <id> -> trigger immediate run of a show
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from veretube_bot import AsyncBot, BotAPIError
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN", "TOKEN_HERE")
|
||||
CHANNEL = os.getenv("CHANNEL", "CHANNEL_NAME_HERE")
|
||||
SOCKET_URL = os.getenv("SOCKET_URL", "http://localhost:1337")
|
||||
API_BASE = os.getenv("API_BASE", "http://localhost:8080/api/v1")
|
||||
SHOW_TIMEZONE = os.getenv("SHOW_TIMEZONE", "UTC")
|
||||
|
||||
bot = AsyncBot(
|
||||
token=BOT_TOKEN,
|
||||
channel=CHANNEL,
|
||||
socket_url=SOCKET_URL,
|
||||
api_url=API_BASE,
|
||||
)
|
||||
|
||||
|
||||
def create_example_show_payload() -> dict:
|
||||
# Schedule a couple minutes in the future to make testing easy.
|
||||
scheduled_for = datetime.now(timezone.utc) + timedelta(minutes=2)
|
||||
|
||||
return {
|
||||
"name": f"Python Demo Show {scheduled_for.strftime('%H:%M:%S')}",
|
||||
"scheduled_for": scheduled_for.isoformat(),
|
||||
"timezone": SHOW_TIMEZONE,
|
||||
"recurrence": "none",
|
||||
"fill_mode": "append",
|
||||
"conflict_mode": "force",
|
||||
"start_playback": False,
|
||||
"status": "scheduled",
|
||||
"playlist": [
|
||||
{"type": "yt", "id": "dQw4w9WgXcQ", "pos": "end"},
|
||||
{"type": "yt", "id": "9bZkp7q19f0", "pos": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
@bot.on("chatMsg")
|
||||
async def on_chat(data):
|
||||
msg = (data.get("msg") or "").strip()
|
||||
|
||||
if msg == "!shows":
|
||||
try:
|
||||
shows = await bot.list_shows()
|
||||
except BotAPIError as err:
|
||||
await bot.send_message(f"shows error: {err}")
|
||||
return
|
||||
|
||||
if not shows:
|
||||
await bot.send_message("No shows configured")
|
||||
return
|
||||
|
||||
summary = ", ".join([f"#{s['id']} {s['name']} ({s['status']})" for s in shows[:4]])
|
||||
await bot.send_message(f"Shows: {summary}")
|
||||
|
||||
elif msg == "!mkshow":
|
||||
try:
|
||||
show = await bot.create_show(create_example_show_payload())
|
||||
persisted = await bot.get_show(show["id"])
|
||||
await bot.send_message(
|
||||
f"Created show #{persisted['id']} status={persisted.get('status')} "
|
||||
f"scheduled_for={persisted.get('scheduled_for')} timezone={persisted.get('timezone')}"
|
||||
)
|
||||
except BotAPIError as err:
|
||||
await bot.send_message(f"create show error: {err}")
|
||||
|
||||
elif msg.startswith("!runshow "):
|
||||
parts = msg.split()
|
||||
if len(parts) != 2 or not parts[1].isdigit():
|
||||
await bot.send_message("Usage: !runshow <show_id>")
|
||||
return
|
||||
|
||||
show_id = int(parts[1])
|
||||
try:
|
||||
result = await bot.show_action(show_id, "run")
|
||||
await bot.send_message(f"Show #{result['id']} run action complete, status={result['status']}")
|
||||
except BotAPIError as err:
|
||||
await bot.send_message(f"run show error: {err}")
|
||||
|
||||
elif msg.startswith("!show "):
|
||||
parts = msg.split()
|
||||
if len(parts) != 2 or not parts[1].isdigit():
|
||||
await bot.send_message("Usage: !show <show_id>")
|
||||
return
|
||||
|
||||
show_id = int(parts[1])
|
||||
try:
|
||||
show = await bot.get_show(show_id)
|
||||
await bot.send_message(
|
||||
f"Show #{show['id']}: status={show.get('status')} "
|
||||
f"scheduled_for={show.get('scheduled_for')} timezone={show.get('timezone')}"
|
||||
)
|
||||
except BotAPIError as err:
|
||||
await bot.send_message(f"show lookup error: {err}")
|
||||
|
||||
|
||||
@bot.on("changeMedia")
|
||||
async def on_media(data):
|
||||
title = data.get("title", "(unknown)")
|
||||
await bot.send_message(f"Now playing: {title}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(bot.run())
|
||||
23
examples/python-show-bot/requirements.txt
Normal file
23
examples/python-show-bot/requirements.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
aiohappyeyeballs==2.6.2
|
||||
aiohttp==3.13.5
|
||||
aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
attrs==26.1.0
|
||||
bidict==0.23.1
|
||||
certifi==2026.5.20
|
||||
charset-normalizer==3.4.7
|
||||
frozenlist==1.8.0
|
||||
h11==0.16.0
|
||||
idna==3.15
|
||||
multidict==6.7.1
|
||||
propcache==0.5.2
|
||||
python-engineio==4.13.1
|
||||
python-socketio==5.16.1
|
||||
requests==2.33.1
|
||||
simple-websocket==1.1.0
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.7.0
|
||||
veretube-bot==0.1.4
|
||||
websocket-client==1.9.0
|
||||
wsproto==1.3.2
|
||||
yarl==1.24.2
|
||||
26
examples/python-simple-bot/bot.py
Normal file
26
examples/python-simple-bot/bot.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from veretube_bot import Bot, Rank
|
||||
|
||||
bot = Bot(
|
||||
token="TOKEN_HERE",
|
||||
channel="CHANNEL_NAME_HERE",
|
||||
socket_url="http://localhost:1337",
|
||||
api_url="http://localhost:8080/api/v1"
|
||||
)
|
||||
|
||||
@bot.on("chatMsg")
|
||||
def on_chat(data):
|
||||
if data["msg"] == "!playlist":
|
||||
playlist = bot.api.get_playlist()
|
||||
bot.send_message(f"{len(playlist['items'])} items in queue")
|
||||
elif data["msg"].startswith("!kick "):
|
||||
name = data["msg"].split(" ", 1)[1]
|
||||
bot.kick(name, "Kicked by command")
|
||||
elif data["msg"] == "!emotes":
|
||||
emotes = bot.get_emotes()
|
||||
print(emotes)
|
||||
|
||||
@bot.on("changeMedia")
|
||||
def on_media(data):
|
||||
bot.send_message(f"Now playing: {data['title']}")
|
||||
|
||||
bot.run()
|
||||
13
examples/python-simple-bot/requirements.txt
Normal file
13
examples/python-simple-bot/requirements.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
bidict==0.23.1
|
||||
certifi==2026.4.22
|
||||
charset-normalizer==3.4.7
|
||||
h11==0.16.0
|
||||
idna==3.13
|
||||
python-engineio==4.13.1
|
||||
python-socketio==5.16.1
|
||||
requests==2.33.1
|
||||
simple-websocket==1.1.0
|
||||
urllib3==2.6.3
|
||||
veretube-bot==0.1.0
|
||||
websocket-client==1.9.0
|
||||
wsproto==1.3.2
|
||||
243
gdrive-userscript/cytube-google-drive.user.js
Normal file
243
gdrive-userscript/cytube-google-drive.user.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// ==UserScript==
|
||||
// @name Google Drive Video Player for {SITENAME}
|
||||
// @namespace gdcytube
|
||||
// @description Play Google Drive videos on {SITENAME}
|
||||
// {INCLUDE_BLOCK}
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM.xmlHttpRequest
|
||||
// @connect docs.google.com
|
||||
// @run-at document-end
|
||||
// @version 1.7.0
|
||||
// ==/UserScript==
|
||||
|
||||
try {
|
||||
function debug(message) {
|
||||
try {
|
||||
unsafeWindow.console.log('[Drive]', message);
|
||||
} catch (error) {
|
||||
unsafeWindow.console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function httpRequest(opts) {
|
||||
if (typeof GM_xmlhttpRequest === 'undefined') {
|
||||
// Assume GM4.0
|
||||
debug('Using GM4.0 GM.xmlHttpRequest');
|
||||
GM.xmlHttpRequest(opts);
|
||||
} else {
|
||||
debug('Using old-style GM_xmlhttpRequest');
|
||||
GM_xmlhttpRequest(opts);
|
||||
}
|
||||
}
|
||||
|
||||
var ITAG_QMAP = {
|
||||
37: 1080,
|
||||
46: 1080,
|
||||
22: 720,
|
||||
45: 720,
|
||||
59: 480,
|
||||
44: 480,
|
||||
35: 480,
|
||||
18: 360,
|
||||
43: 360,
|
||||
34: 360
|
||||
};
|
||||
|
||||
var ITAG_CMAP = {
|
||||
43: 'video/webm',
|
||||
44: 'video/webm',
|
||||
45: 'video/webm',
|
||||
46: 'video/webm',
|
||||
18: 'video/mp4',
|
||||
22: 'video/mp4',
|
||||
37: 'video/mp4',
|
||||
59: 'video/mp4',
|
||||
35: 'video/flv',
|
||||
34: 'video/flv'
|
||||
};
|
||||
|
||||
function getVideoInfo(id, cb) {
|
||||
var url = 'https://docs.google.com/get_video_info?authuser='
|
||||
+ '&docid=' + id
|
||||
+ '&sle=true'
|
||||
+ '&hl=en';
|
||||
debug('Fetching ' + url);
|
||||
|
||||
httpRequest({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
onload: function (res) {
|
||||
try {
|
||||
debug('Got response ' + res.responseText);
|
||||
|
||||
if (res.status !== 200) {
|
||||
debug('Response status not 200: ' + res.status);
|
||||
return cb(
|
||||
'Google Drive request failed: HTTP ' + res.status
|
||||
);
|
||||
}
|
||||
|
||||
var data = {};
|
||||
var error;
|
||||
// Google Santa sometimes eats login cookies and gets mad if there aren't any.
|
||||
if(/accounts\.google\.com\/ServiceLogin/.test(res.responseText)){
|
||||
error = 'Google Docs request failed: ' +
|
||||
'This video requires you be logged into a Google account. ' +
|
||||
'Open your Gmail in another tab and then refresh video.';
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
res.responseText.split('&').forEach(function (kv) {
|
||||
var pair = kv.split('=');
|
||||
data[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
|
||||
});
|
||||
|
||||
if (data.status === 'fail') {
|
||||
error = 'Google Drive request failed: ' +
|
||||
unescape(data.reason).replace(/\+/g, ' ');
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
if (!data.fmt_stream_map) {
|
||||
error = (
|
||||
'Google has removed the video streams associated' +
|
||||
' with this item. It can no longer be played.'
|
||||
);
|
||||
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
data.links = {};
|
||||
data.fmt_stream_map.split(',').forEach(function (item) {
|
||||
var pair = item.split('|');
|
||||
data.links[pair[0]] = pair[1];
|
||||
});
|
||||
data.videoMap = mapLinks(data.links);
|
||||
|
||||
cb(null, data);
|
||||
} catch (error) {
|
||||
unsafeWindow.console.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
onerror: function () {
|
||||
var error = 'Google Drive request failed: ' +
|
||||
'metadata lookup HTTP request failed';
|
||||
error.reason = 'HTTP_ONERROR';
|
||||
return cb(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapLinks(links) {
|
||||
var videos = {
|
||||
1080: [],
|
||||
720: [],
|
||||
480: [],
|
||||
360: []
|
||||
};
|
||||
|
||||
Object.keys(links).forEach(function (itag) {
|
||||
itag = parseInt(itag, 10);
|
||||
if (!ITAG_QMAP.hasOwnProperty(itag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
videos[ITAG_QMAP[itag]].push({
|
||||
itag: itag,
|
||||
contentType: ITAG_CMAP[itag],
|
||||
link: links[itag]
|
||||
});
|
||||
});
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
/*
|
||||
* Greasemonkey 2.0 has this wonderful sandbox that attempts
|
||||
* to prevent script developers from shooting themselves in
|
||||
* the foot by removing the trigger from the gun, i.e. it's
|
||||
* impossible to cross the boundary between the browser JS VM
|
||||
* and the privileged sandbox that can run GM_xmlhttpRequest().
|
||||
*
|
||||
* So in this case, we have to resort to polling a special
|
||||
* variable to see if getGoogleDriveMetadata needs to be called
|
||||
* and deliver the result into another special variable that is
|
||||
* being polled on the browser side.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Browser side function -- sets gdUserscript.pollID to the
|
||||
* ID of the Drive video to be queried and polls
|
||||
* gdUserscript.pollResult for the result.
|
||||
*/
|
||||
function getGoogleDriveMetadata_GM(id, callback) {
|
||||
debug('Setting GD poll ID to ' + id);
|
||||
unsafeWindow.gdUserscript.pollID = id;
|
||||
var tries = 0;
|
||||
var i = setInterval(function () {
|
||||
if (unsafeWindow.gdUserscript.pollResult) {
|
||||
debug('Got result');
|
||||
clearInterval(i);
|
||||
var result = unsafeWindow.gdUserscript.pollResult;
|
||||
unsafeWindow.gdUserscript.pollResult = null;
|
||||
callback(result.error, result.result);
|
||||
} else if (++tries > 100) {
|
||||
// Took longer than 10 seconds, give up
|
||||
clearInterval(i);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sandbox side function -- polls gdUserscript.pollID for
|
||||
* the ID of a Drive video to be queried, looks up the
|
||||
* metadata, and stores it in gdUserscript.pollResult
|
||||
*/
|
||||
function setupGDPoll() {
|
||||
unsafeWindow.gdUserscript = cloneInto({}, unsafeWindow);
|
||||
var pollInterval = setInterval(function () {
|
||||
if (unsafeWindow.gdUserscript.pollID) {
|
||||
var id = unsafeWindow.gdUserscript.pollID;
|
||||
unsafeWindow.gdUserscript.pollID = null;
|
||||
debug('Polled and got ' + id);
|
||||
getVideoInfo(id, function (error, data) {
|
||||
unsafeWindow.gdUserscript.pollResult = cloneInto({
|
||||
error: error,
|
||||
result: data
|
||||
}, unsafeWindow);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
var TM_COMPATIBLES = [
|
||||
'Tampermonkey',
|
||||
'Violentmonkey' // https://github.com/calzoneman/sync/issues/713
|
||||
];
|
||||
|
||||
function isTampermonkeyCompatible() {
|
||||
try {
|
||||
return TM_COMPATIBLES.indexOf(GM_info.scriptHandler) >= 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTampermonkeyCompatible()) {
|
||||
unsafeWindow.getGoogleDriveMetadata = getVideoInfo;
|
||||
} else {
|
||||
debug('Using non-TM polling workaround');
|
||||
unsafeWindow.getGoogleDriveMetadata = exportFunction(
|
||||
getGoogleDriveMetadata_GM, unsafeWindow);
|
||||
setupGDPoll();
|
||||
}
|
||||
|
||||
unsafeWindow.console.log('Initialized userscript Google Drive player');
|
||||
unsafeWindow.hasDriveUserscript = true;
|
||||
// Checked against GS_VERSION from data.js
|
||||
unsafeWindow.driveUserscriptVersion = '1.7';
|
||||
} catch (error) {
|
||||
unsafeWindow.console.error(error);
|
||||
}
|
||||
37
gdrive-userscript/generate-userscript.js
Normal file
37
gdrive-userscript/generate-userscript.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var sitename = process.argv[2];
|
||||
var includes = process.argv.slice(3).map(function (include) {
|
||||
return '// @include ' + include;
|
||||
}).join('\n');
|
||||
|
||||
var lines = String(fs.readFileSync(
|
||||
path.resolve(__dirname, 'cytube-google-drive.user.js'))).split('\n');
|
||||
|
||||
var userscriptOutput = '';
|
||||
var metaOutput = '';
|
||||
lines.forEach(function (line) {
|
||||
if (line.match(/\{INCLUDE_BLOCK\}/)) {
|
||||
userscriptOutput += includes + '\n';
|
||||
} else if (line.match(/\{SITENAME\}/)) {
|
||||
line = line.replace(/\{SITENAME\}/, sitename) + '\n';
|
||||
userscriptOutput += line;
|
||||
metaOutput += line;
|
||||
} else {
|
||||
if (line.match(/==\/?UserScript|@name|@version/)) {
|
||||
metaOutput += line + '\n';
|
||||
}
|
||||
|
||||
userscriptOutput += line + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.user.js'),
|
||||
userscriptOutput
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.meta.js'),
|
||||
metaOutput
|
||||
);
|
||||
117
index.js
Normal file → Executable file
117
index.js
Normal file → Executable file
|
|
@ -1,10 +1,56 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const ver = process.version.match(/v(\d+)\.\d+\.\d+/);
|
||||
|
||||
if (parseInt(ver[1], 10) < 12) {
|
||||
console.error(
|
||||
`node.js ${process.version} is not supported. ` +
|
||||
'CyTube requires node v12 or later.'
|
||||
)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
checkPlayerExists();
|
||||
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.has('--daemonize')) {
|
||||
fork();
|
||||
} else {
|
||||
try {
|
||||
var Server = require("./lib/server");
|
||||
require('./lib/main');
|
||||
} catch (err) {
|
||||
console.error('FATAL: Failed to require() lib/server.js');
|
||||
console.error('FATAL: Failed to require() lib/main.js');
|
||||
handleStartupError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function fork() {
|
||||
try {
|
||||
console.log('Warning: --daemonize support is experimental. Use with caution.');
|
||||
|
||||
const spawn = require('child_process').spawn;
|
||||
const path = require('path');
|
||||
const main = path.resolve(__dirname, 'lib', 'main.js');
|
||||
|
||||
const child = spawn(process.argv[0], [main], {
|
||||
detached: true,
|
||||
stdio: 'ignore' // TODO: support setting stdout/stderr logfile
|
||||
});
|
||||
|
||||
child.unref();
|
||||
console.log('Forked with PID ' + child.pid);
|
||||
} catch (error) {
|
||||
console.error('FATAL: Failed to fork lib/main.js');
|
||||
handleStartupError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStartupError(err) {
|
||||
if (/module version mismatch/i.test(err.message)) {
|
||||
console.error('Module version mismatch, try running `npm rebuild` or removing ' +
|
||||
'the node_modules folder and re-running `npm install`');
|
||||
console.error('Module version mismatch, try running `npm rebuild` or ' +
|
||||
'removing the node_modules folder and re-running ' +
|
||||
'`npm install`');
|
||||
} else {
|
||||
console.error('Possible causes:\n' +
|
||||
' * You haven\'t run `npm run build-server` to regenerate ' +
|
||||
|
|
@ -14,52 +60,39 @@ try {
|
|||
' * A dependency failed to install correctly (check the output ' +
|
||||
'of `npm install` next time)');
|
||||
}
|
||||
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
var Config = require("./lib/config");
|
||||
var Logger = require("./lib/logger");
|
||||
require("source-map-support").install();
|
||||
|
||||
Config.load("config.yaml");
|
||||
var sv = Server.init();
|
||||
if (!Config.get("debug")) {
|
||||
process.on("uncaughtException", function (err) {
|
||||
Logger.errlog.log("[SEVERE] Uncaught Exception: " + err);
|
||||
Logger.errlog.log(err.stack);
|
||||
});
|
||||
function parseArgs() {
|
||||
const args = new Map();
|
||||
for (var i = 2; i < process.argv.length; i++) {
|
||||
if (/^--/.test(process.argv[i])) {
|
||||
var val;
|
||||
if (i+1 < process.argv.length) val = process.argv[i+1];
|
||||
else val = null;
|
||||
|
||||
process.on("SIGINT", function () {
|
||||
sv.shutdown();
|
||||
});
|
||||
args.set(process.argv[i], val);
|
||||
}
|
||||
}
|
||||
|
||||
var stdinbuf = "";
|
||||
process.stdin.on("data", function (data) {
|
||||
stdinbuf += data;
|
||||
if (stdinbuf.indexOf("\n") !== -1) {
|
||||
var line = stdinbuf.substring(0, stdinbuf.indexOf("\n"));
|
||||
stdinbuf = stdinbuf.substring(stdinbuf.indexOf("\n") + 1);
|
||||
handleLine(line);
|
||||
return args;
|
||||
}
|
||||
});
|
||||
|
||||
function handleLine(line) {
|
||||
if (line === "/reload") {
|
||||
Logger.syslog.log("Reloading config");
|
||||
Config.load("config.yaml");
|
||||
} else if (line === "/gc") {
|
||||
if (global && global.gc) {
|
||||
Logger.syslog.log("Running GC");
|
||||
global.gc();
|
||||
} else {
|
||||
Logger.syslog.log("Failed to invoke GC: node started without --expose-gc");
|
||||
}
|
||||
} else if (line === "/delete_old_tables") {
|
||||
require("./lib/database/update").deleteOldChannelTables(function (err) {
|
||||
if (!err) {
|
||||
Logger.syslog.log("Deleted old channel tables");
|
||||
}
|
||||
});
|
||||
function checkPlayerExists() {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const playerDotJs = path.join(__dirname, 'www', 'js', 'player.js');
|
||||
|
||||
if (!fs.existsSync(playerDotJs)) {
|
||||
console.error(
|
||||
'Missing video player: www/js/player.js. This should have been ' +
|
||||
'automatically generated by the postinstall step of ' +
|
||||
'`npm install`, but you can manually regenerate it by running ' +
|
||||
'`npm run build-player`'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
603
integration_test/channel/kickban.js
Normal file
603
integration_test/channel/kickban.js
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
const assert = require('assert');
|
||||
const KickbanModule = require('../../lib/channel/kickban');
|
||||
const database = require('../../lib/database');
|
||||
const Promise = require('bluebird');
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
|
||||
database.init(testDB);
|
||||
|
||||
describe('KickbanModule', () => {
|
||||
const channelName = `test_${Math.random().toString(31).substring(2)}`;
|
||||
|
||||
let mockChannel;
|
||||
let mockUser;
|
||||
let kickban;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChannel = {
|
||||
name: channelName,
|
||||
refCounter: {
|
||||
ref() { },
|
||||
unref() { }
|
||||
},
|
||||
logger: {
|
||||
log() { }
|
||||
},
|
||||
modules: {
|
||||
permissions: {
|
||||
canBan() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
users: []
|
||||
};
|
||||
|
||||
mockUser = {
|
||||
getName() {
|
||||
return 'The_Admin';
|
||||
},
|
||||
|
||||
getLowerName() {
|
||||
return 'the_admin';
|
||||
},
|
||||
|
||||
socket: {
|
||||
emit(frame) {
|
||||
if (frame === 'errorMsg') {
|
||||
throw new Error(arguments[1].msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
account: {
|
||||
effectiveRank: 3
|
||||
}
|
||||
};
|
||||
|
||||
kickban = new KickbanModule(mockChannel);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.getDB().runTransaction(async tx => {
|
||||
await tx.table('channel_bans')
|
||||
.where({ channel: channelName })
|
||||
.del();
|
||||
await tx.table('channel_ranks')
|
||||
.where({ channel: channelName })
|
||||
.del();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#handleCmdBan', () => {
|
||||
it('inserts a valid ban', done => {
|
||||
let kicked = false;
|
||||
|
||||
mockChannel.refCounter.unref = () => {
|
||||
assert(kicked, 'Expected user to be kicked');
|
||||
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const ban = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
name: 'test_user'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ban.ip, '*');
|
||||
assert.strictEqual(ban.reason, 'because reasons');
|
||||
assert.strictEqual(ban.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
mockChannel.users = [{
|
||||
getLowerName() {
|
||||
return 'test_user';
|
||||
},
|
||||
|
||||
kick(reason) {
|
||||
assert.strictEqual(reason, "You're banned!");
|
||||
kicked = true;
|
||||
}
|
||||
}];
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the username is invalid', done => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'Invalid username'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user<>%$# because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user does not have ban permission', done => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'You do not have ban permissions on this channel'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
mockChannel.modules.permissions.canBan = () => false;
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user tries to ban themselves', done => {
|
||||
let costanza = false;
|
||||
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'You cannot ban yourself'
|
||||
);
|
||||
|
||||
if (!costanza) {
|
||||
throw new Error('Expected costanza for banning self');
|
||||
}
|
||||
|
||||
done();
|
||||
} else if (frame === 'costanza') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
"You can't ban yourself"
|
||||
);
|
||||
|
||||
costanza = true;
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban the_Admin because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user is ranked below the ban recipient', done => {
|
||||
database.getDB().runTransaction(tx => {
|
||||
return tx.table('channel_ranks')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'test_user',
|
||||
rank: 5
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
"You don't have permission to ban test_user"
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if the the ban recipient is already banned', done => {
|
||||
database.getDB().runTransaction(tx => {
|
||||
return tx.table('channel_bans')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'test_user',
|
||||
ip: '*',
|
||||
bannedby: 'somebody',
|
||||
reason: 'I dunno'
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'test_user is already banned'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#handleCmdIPBan', () => {
|
||||
beforeEach(async () => {
|
||||
await database.getDB().runTransaction(async tx => {
|
||||
await tx.table('aliases')
|
||||
.insert([{
|
||||
name: 'test_user',
|
||||
ip: '1.2.3.4',
|
||||
time: Date.now()
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.getDB().runTransaction(async tx => {
|
||||
await tx.table('aliases')
|
||||
.where({ name: 'test_user' })
|
||||
.orWhere({ ip: '1.2.3.4' })
|
||||
.del();
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a valid ban', done => {
|
||||
let firstUserKicked = false;
|
||||
let secondUserKicked = false;
|
||||
|
||||
mockChannel.refCounter.unref = () => {
|
||||
assert(firstUserKicked, 'Expected banned user to be kicked');
|
||||
assert(
|
||||
secondUserKicked,
|
||||
'Expected user with banned IP to be kicked'
|
||||
);
|
||||
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const nameBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
name: 'test_user',
|
||||
ip: '*'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(nameBan.reason, 'because reasons');
|
||||
assert.strictEqual(nameBan.bannedby, mockUser.getName());
|
||||
|
||||
const ipBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
ip: '1.2.3.4'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ipBan.name, 'test_user');
|
||||
assert.strictEqual(ipBan.reason, 'because reasons');
|
||||
assert.strictEqual(ipBan.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
mockChannel.users = [{
|
||||
getLowerName() {
|
||||
return 'test_user';
|
||||
},
|
||||
|
||||
realip: '1.2.3.4',
|
||||
|
||||
kick(reason) {
|
||||
assert.strictEqual(reason, "You're banned!");
|
||||
firstUserKicked = true;
|
||||
}
|
||||
}, {
|
||||
getLowerName() {
|
||||
return 'second_user_same_ip';
|
||||
},
|
||||
|
||||
realip: '1.2.3.4',
|
||||
|
||||
kick(reason) {
|
||||
assert.strictEqual(reason, "You're banned!");
|
||||
secondUserKicked = true;
|
||||
}
|
||||
}];
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts a valid range ban', done => {
|
||||
mockChannel.refCounter.unref = () => {
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const ipBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
ip: '1.2.3'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ipBan.name, 'test_user');
|
||||
assert.strictEqual(ipBan.reason, 'because reasons');
|
||||
assert.strictEqual(ipBan.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user range because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts a valid wide-range ban', done => {
|
||||
mockChannel.refCounter.unref = () => {
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const ipBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
ip: '1.2'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ipBan.name, 'test_user');
|
||||
assert.strictEqual(ipBan.reason, 'because reasons');
|
||||
assert.strictEqual(ipBan.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user wrange because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts a valid IPv6 ban', done => {
|
||||
const longIP = require('../../lib/utilities').expandIPv6('::abcd');
|
||||
|
||||
mockChannel.refCounter.unref = () => {
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const ipBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
ip: longIP
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ipBan.name, 'test_user');
|
||||
assert.strictEqual(ipBan.reason, 'because reasons');
|
||||
assert.strictEqual(ipBan.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
database.getDB().runTransaction(async tx => {
|
||||
await tx.table('aliases')
|
||||
.insert({
|
||||
name: 'test_user',
|
||||
ip: longIP,
|
||||
time: Date.now()
|
||||
});
|
||||
}).then(() => {
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if the user does not have ban permission', done => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'You do not have ban permissions on this channel'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
mockChannel.modules.permissions.canBan = () => false;
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user tries to ban themselves', done => {
|
||||
let costanza = false;
|
||||
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'You cannot ban yourself'
|
||||
);
|
||||
|
||||
if (!costanza) {
|
||||
throw new Error('Expected costanza for banning self');
|
||||
}
|
||||
|
||||
done();
|
||||
} else if (frame === 'costanza') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
"You can't ban yourself"
|
||||
);
|
||||
|
||||
costanza = true;
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban the_Admin because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user is ranked below the ban recipient', done => {
|
||||
database.getDB().runTransaction(tx => {
|
||||
return tx.table('channel_ranks')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'test_user',
|
||||
rank: 5
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
"You don't have permission to ban IP " +
|
||||
"09l.TFb.5To.HBB"
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if the user is ranked below an alias of the ban recipient', done => {
|
||||
database.getDB().runTransaction(async tx => {
|
||||
await tx.table('channel_ranks')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'another_user',
|
||||
rank: 5
|
||||
});
|
||||
await tx.table('aliases')
|
||||
.insert({
|
||||
name: 'another_user',
|
||||
ip: '1.2.3.3', // different IP, same /24 range
|
||||
time: Date.now()
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
"You don't have permission to ban IP " +
|
||||
"09l.TFb.5To.*"
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user range because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects if the the ban recipient IP is already banned', done => {
|
||||
database.getDB().runTransaction(tx => {
|
||||
return tx.table('channel_bans')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'another_user',
|
||||
ip: '1.2.3.4',
|
||||
bannedby: 'somebody',
|
||||
reason: 'I dunno'
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'09l.TFb.5To.HBB is already banned'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still adds the IP ban even if the name is already banned', done => {
|
||||
mockChannel.refCounter.unref = () => {
|
||||
database.getDB().runTransaction(async tx => {
|
||||
const ipBan = await tx.table('channel_bans')
|
||||
.where({
|
||||
channel: channelName,
|
||||
ip: '1.2.3.4'
|
||||
})
|
||||
.first();
|
||||
|
||||
assert.strictEqual(ipBan.name, 'test_user');
|
||||
assert.strictEqual(ipBan.reason, 'because reasons');
|
||||
assert.strictEqual(ipBan.bannedby, mockUser.getName());
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
database.getDB().runTransaction(tx => {
|
||||
return tx.table('channel_bans')
|
||||
.insert({
|
||||
channel: channelName,
|
||||
name: 'test_user',
|
||||
ip: '*',
|
||||
bannedby: 'somebody',
|
||||
reason: 'I dunno'
|
||||
});
|
||||
}).then(() => {
|
||||
kickban.handleCmdIPBan(
|
||||
mockUser,
|
||||
'/ipban test_user because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
109
integration_test/controller/banned-channels.js
Normal file
109
integration_test/controller/banned-channels.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const assert = require('assert');
|
||||
const { BannedChannelsController } = require('../../lib/controller/banned-channels');
|
||||
const dbChannels = require('../../lib/database/channels');
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
require('../../lib/database').init(testDB);
|
||||
|
||||
const testBan = {
|
||||
name: 'ban_test_1',
|
||||
externalReason: 'because I said so',
|
||||
internalReason: 'illegal content',
|
||||
bannedBy: 'admin'
|
||||
};
|
||||
|
||||
async function cleanupTestBan() {
|
||||
return dbChannels.removeBannedChannel(testBan.name);
|
||||
}
|
||||
|
||||
describe('BannedChannelsController', () => {
|
||||
let controller;
|
||||
let messages;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestBan();
|
||||
messages = new EventEmitter();
|
||||
controller = new BannedChannelsController(
|
||||
dbChannels,
|
||||
messages
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBan();
|
||||
});
|
||||
|
||||
it('bans a channel', async () => {
|
||||
assert.strictEqual(await controller.getBannedChannel(testBan.name), null);
|
||||
|
||||
let received = null;
|
||||
messages.once('ChannelBanned', cb => {
|
||||
received = cb;
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
let info = await controller.getBannedChannel(testBan.name);
|
||||
for (let field of Object.keys(testBan)) {
|
||||
// Consider renaming parameter to avoid this branch
|
||||
if (field === 'name') {
|
||||
assert.strictEqual(info.channelName, testBan.name);
|
||||
} else {
|
||||
assert.strictEqual(info[field], testBan[field]);
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(received, null);
|
||||
assert.strictEqual(received.channel, testBan.name);
|
||||
assert.strictEqual(received.externalReason, testBan.externalReason);
|
||||
});
|
||||
|
||||
it('updates an existing ban', async () => {
|
||||
let received = [];
|
||||
messages.on('ChannelBanned', cb => {
|
||||
received.push(cb);
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
|
||||
let testBan2 = { ...testBan, externalReason: 'because of reasons' };
|
||||
await controller.banChannel(testBan2);
|
||||
|
||||
let info = await controller.getBannedChannel(testBan2.name);
|
||||
for (let field of Object.keys(testBan2)) {
|
||||
// Consider renaming parameter to avoid this branch
|
||||
if (field === 'name') {
|
||||
assert.strictEqual(info.channelName, testBan2.name);
|
||||
} else {
|
||||
assert.strictEqual(info[field], testBan2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(received, [
|
||||
{
|
||||
channel: testBan.name,
|
||||
externalReason: testBan.externalReason
|
||||
},
|
||||
{
|
||||
channel: testBan2.name,
|
||||
externalReason: testBan2.externalReason
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('unbans a channel', async () => {
|
||||
let received = null;
|
||||
messages.once('ChannelUnbanned', cb => {
|
||||
received = cb;
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
await controller.unbanChannel(testBan.name, testBan.bannedBy);
|
||||
|
||||
let info = await controller.getBannedChannel(testBan.name);
|
||||
assert.strictEqual(info, null);
|
||||
|
||||
assert.notEqual(received, null);
|
||||
assert.strictEqual(received.channel, testBan.name);
|
||||
});
|
||||
});
|
||||
88
integration_test/database/accounts.js
Normal file
88
integration_test/database/accounts.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
const assert = require('assert');
|
||||
const { testDB } = require('../testutil/db');
|
||||
const accounts = require('../../lib/database/accounts');
|
||||
|
||||
require('../../lib/database').init(testDB);
|
||||
|
||||
describe('AccountsDatabase', () => {
|
||||
describe('#verifyLogin', () => {
|
||||
let ip = '169.254.111.111';
|
||||
let user;
|
||||
let password;
|
||||
|
||||
beforeEach(async () => {
|
||||
return testDB.knex.table('users')
|
||||
.where({ ip })
|
||||
.delete();
|
||||
});
|
||||
|
||||
beforeEach(done => {
|
||||
user = `u${Math.random().toString(31).substring(2)}`;
|
||||
password = 'int!gration_Test';
|
||||
|
||||
accounts.register(
|
||||
user,
|
||||
password,
|
||||
'',
|
||||
ip,
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Created test user ${user}`);
|
||||
done();
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
it('verifies a correct login', done => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
password,
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.strictEqual(res.name, user);
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies a correct login with an older hash', done => {
|
||||
testDB.knex.table('users')
|
||||
.where({ name: user })
|
||||
.update({
|
||||
// 'test' hashed with old version of bcrypt module
|
||||
password: '$2b$10$2oCG7O9FFqie7T8O33yQDugFPS0NqkgbQjtThTs7Jr8E1QOzdRruK'
|
||||
})
|
||||
.then(() => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
'test',
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.strictEqual(res.name, user);
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an incorrect login', done => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
'not the right password',
|
||||
(error, res) => {
|
||||
assert.strictEqual(error, 'Invalid username/password combination');
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
integration_test/db/aliases.js
Normal file
76
integration_test/db/aliases.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const assert = require('assert');
|
||||
const AliasesDB = require('../../lib/db/aliases').AliasesDB;
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
|
||||
const aliasesDB = new AliasesDB(testDB);
|
||||
const testIPs = ['111.111.111.111', '111.111.111.222'];
|
||||
const testNames = ['itest1', 'itest2'];
|
||||
|
||||
function cleanup() {
|
||||
return testDB.knex.table('aliases')
|
||||
.where('ip', 'in', testIPs)
|
||||
.del()
|
||||
.then(() => {
|
||||
return testDB.knex.table('aliases')
|
||||
.where('name', 'in', testNames)
|
||||
.del();
|
||||
});
|
||||
}
|
||||
|
||||
function addSomeAliases() {
|
||||
return cleanup().then(() => {
|
||||
return testDB.knex.table('aliases')
|
||||
.insert([
|
||||
{ ip: testIPs[0], name: testNames[0], time: Date.now() },
|
||||
{ ip: testIPs[0], name: testNames[1], time: Date.now() },
|
||||
{ ip: testIPs[1], name: testNames[1], time: Date.now() }
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
describe('AliasesDB', () => {
|
||||
describe('#addAlias', () => {
|
||||
beforeEach(cleanup);
|
||||
afterEach(cleanup);
|
||||
|
||||
it('adds a new alias', () => {
|
||||
return aliasesDB.addAlias(testIPs[0], testNames[0])
|
||||
.then(() => {
|
||||
return testDB.knex.table('aliases')
|
||||
.where({ ip: testIPs[0], name: testNames[0] })
|
||||
.select()
|
||||
.then(rows => {
|
||||
assert.strictEqual(rows.length, 1, 'expected 1 row');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getAliasesByIP', () => {
|
||||
beforeEach(addSomeAliases);
|
||||
afterEach(cleanup);
|
||||
|
||||
it('retrieves aliases by IP', () => {
|
||||
return aliasesDB.getAliasesByIP(testIPs[0])
|
||||
.then(names => assert.deepStrictEqual(
|
||||
names.sort(), testNames.sort()));
|
||||
});
|
||||
|
||||
it('retrieves aliases by partial IP', () => {
|
||||
return aliasesDB.getAliasesByIP(testIPs[0].substring(4))
|
||||
.then(names => assert.deepStrictEqual(
|
||||
names.sort(), testNames.sort()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getIPsByName', () => {
|
||||
beforeEach(addSomeAliases);
|
||||
afterEach(cleanup);
|
||||
|
||||
it('retrieves IPs by name', () => {
|
||||
return aliasesDB.getIPsByName(testNames[1])
|
||||
.then(ips => assert.deepStrictEqual(
|
||||
ips.sort(), testIPs.sort()));
|
||||
});
|
||||
});
|
||||
});
|
||||
92
integration_test/db/globalban.js
Normal file
92
integration_test/db/globalban.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
const assert = require('assert');
|
||||
const GlobalBanDB = require('../../lib/db/globalban').GlobalBanDB;
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
const { o } = require('../testutil/o');
|
||||
|
||||
const globalBanDB = new GlobalBanDB(testDB);
|
||||
const testBan = { ip: '8.8.8.8', reason: 'test' };
|
||||
|
||||
function cleanupTestBan() {
|
||||
return testDB.knex.table('global_bans')
|
||||
.where({ ip: testBan.ip })
|
||||
.del();
|
||||
}
|
||||
|
||||
function setupTestBan() {
|
||||
return testDB.knex.table('global_bans')
|
||||
.insert(testBan)
|
||||
.catch(error => {
|
||||
if (error.code === 'ER_DUP_ENTRY') {
|
||||
return testDB.knex.table('global_bans')
|
||||
.where({ ip: testBan.ip })
|
||||
.update({ reason: testBan.reason });
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
describe('GlobalBanDB', () => {
|
||||
describe('#listGlobalBans', () => {
|
||||
beforeEach(setupTestBan);
|
||||
afterEach(cleanupTestBan);
|
||||
|
||||
it('lists existing IP bans', () => {
|
||||
return globalBanDB.listGlobalBans().then(bans => {
|
||||
assert.deepStrictEqual([{
|
||||
ip: '8.8.8.8',
|
||||
reason: 'test'
|
||||
}], bans.map(o));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#addGlobalIPBan', () => {
|
||||
beforeEach(cleanupTestBan);
|
||||
afterEach(cleanupTestBan);
|
||||
|
||||
it('adds a new ban', () => {
|
||||
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
|
||||
return testDB.knex.table('global_bans')
|
||||
.where({ ip: '8.8.8.8' })
|
||||
.select()
|
||||
.then(rows => {
|
||||
assert.strictEqual(rows.length, 1, 'Expected 1 row');
|
||||
assert.strictEqual(rows[0].ip, '8.8.8.8');
|
||||
assert.strictEqual(rows[0].reason, 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the reason on an existing ban', () => {
|
||||
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
|
||||
return globalBanDB.addGlobalIPBan('8.8.8.8', 'different').then(() => {
|
||||
return testDB.knex.table('global_bans')
|
||||
.where({ ip: '8.8.8.8' })
|
||||
.select()
|
||||
.then(rows => {
|
||||
assert.strictEqual(rows.length, 1, 'Expected 1 row');
|
||||
assert.strictEqual(rows[0].ip, '8.8.8.8');
|
||||
assert.strictEqual(rows[0].reason, 'different');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removeGlobalIPBan', () => {
|
||||
beforeEach(setupTestBan);
|
||||
afterEach(cleanupTestBan);
|
||||
|
||||
it('removes a ban', () => {
|
||||
return globalBanDB.removeGlobalIPBan('8.8.8.8').then(() => {
|
||||
return testDB.knex.table('global_bans')
|
||||
.where({ ip: '8.8.8.8' })
|
||||
.select()
|
||||
.then(rows => {
|
||||
assert.strictEqual(rows.length, 0, 'Expected 0 rows');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
144
integration_test/db/password-reset.js
Normal file
144
integration_test/db/password-reset.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
const assert = require('assert');
|
||||
const PasswordResetDB = require('../../lib/db/password-reset').PasswordResetDB;
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
const { o } = require('../testutil/o');
|
||||
|
||||
const passwordResetDB = new PasswordResetDB(testDB);
|
||||
|
||||
function cleanup() {
|
||||
return testDB.knex.table('password_reset').del();
|
||||
}
|
||||
|
||||
describe('PasswordResetDB', () => {
|
||||
describe('#insert', () => {
|
||||
beforeEach(cleanup);
|
||||
|
||||
const params = {
|
||||
ip: '1.2.3.4',
|
||||
name: 'testing',
|
||||
email: 'test@example.com',
|
||||
hash: 'abcdef',
|
||||
expire: 5678
|
||||
};
|
||||
|
||||
it('adds a new password reset', () => {
|
||||
return passwordResetDB.insert(params).then(() => {
|
||||
return testDB.knex.table('password_reset')
|
||||
.where({ name: 'testing' })
|
||||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), params);
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites an existing reset for the same name', () => {
|
||||
return passwordResetDB.insert(params).then(() => {
|
||||
params.ip = '5.6.7.8';
|
||||
params.email = 'somethingelse@example.com';
|
||||
params.hash = 'qwertyuiop';
|
||||
params.expire = 9999;
|
||||
|
||||
return passwordResetDB.insert(params);
|
||||
}).then(() => {
|
||||
return testDB.knex.table('password_reset')
|
||||
.where({ name: 'testing' })
|
||||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), params);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#get', () => {
|
||||
const reset = {
|
||||
ip: '1.2.3.4',
|
||||
name: 'testing',
|
||||
email: 'test@example.com',
|
||||
hash: 'abcdef',
|
||||
expire: 5678
|
||||
};
|
||||
|
||||
beforeEach(() => cleanup().then(() => {
|
||||
return testDB.knex.table('password_reset').insert(reset);
|
||||
}));
|
||||
|
||||
it('gets a password reset by hash', () => {
|
||||
return passwordResetDB.get(reset.hash).then(result => {
|
||||
assert.deepStrictEqual(o(result), reset);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when no reset exists for the input', () => {
|
||||
return passwordResetDB.get('lalala').then(() => {
|
||||
assert.fail('Expected not found error');
|
||||
}).catch(error => {
|
||||
assert.strictEqual(
|
||||
error.message,
|
||||
'No password reset found for hash lalala'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
const reset = {
|
||||
ip: '1.2.3.4',
|
||||
name: 'testing',
|
||||
email: 'test@example.com',
|
||||
hash: 'abcdef',
|
||||
expire: 5678
|
||||
};
|
||||
|
||||
beforeEach(() => cleanup().then(() => {
|
||||
return testDB.knex.table('password_reset').insert(reset);
|
||||
}));
|
||||
|
||||
it('deletes a password reset by hash', () => {
|
||||
return passwordResetDB.delete(reset.hash).then(() => {
|
||||
return testDB.knex.table('password_reset')
|
||||
.where({ name: 'testing' })
|
||||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#cleanup', () => {
|
||||
const now = Date.now();
|
||||
|
||||
const reset1 = {
|
||||
ip: '1.2.3.4',
|
||||
name: 'testing',
|
||||
email: 'test@example.com',
|
||||
hash: 'abcdef',
|
||||
expire: now - 25 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const reset2 = {
|
||||
ip: '5.6.7.8',
|
||||
name: 'testing2',
|
||||
email: 'test@example.com',
|
||||
hash: 'abcdef',
|
||||
expire: now
|
||||
};
|
||||
|
||||
beforeEach(() => cleanup().then(() => {
|
||||
return testDB.knex.table('password_reset')
|
||||
.insert([reset1, reset2]);
|
||||
}));
|
||||
|
||||
it('cleans up old password resets', () => {
|
||||
return passwordResetDB.cleanup().then(() => {
|
||||
return testDB.knex.table('password_reset')
|
||||
.whereIn('name', ['testing1', 'testing2'])
|
||||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), reset2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
136
integration_test/regressions/checkban-blank-name.js
Normal file
136
integration_test/regressions/checkban-blank-name.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
const assert = require('assert');
|
||||
const KickbanModule = require('../../lib/channel/kickban');
|
||||
const database = require('../../lib/database');
|
||||
const dbChannels = require('../../lib/database/channels');
|
||||
const Promise = require('bluebird');
|
||||
const ChannelModule = require('../../lib/channel/module');
|
||||
const Flags = require('../../lib/flags');
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
|
||||
function randomString(length) {
|
||||
const chars = 'abcdefgihkmnpqrstuvwxyz0123456789';
|
||||
let str = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
str += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
database.init(testDB);
|
||||
|
||||
describe('onPreUserJoin Ban Check', () => {
|
||||
const channelName = `test_${randomString(20)}`;
|
||||
const bannedIP = '1.1.1.1';
|
||||
const bannedName = 'troll';
|
||||
const mockChannel = {
|
||||
name: channelName,
|
||||
modules: {},
|
||||
is(flag) {
|
||||
return flag === Flags.C_REGISTERED;
|
||||
}
|
||||
};
|
||||
const module = new KickbanModule(mockChannel);
|
||||
before(done => {
|
||||
dbChannels.ban(channelName, bannedIP, bannedName, '', '', () => {
|
||||
dbChannels.ban(channelName, bannedIP, '', '', '', () => {
|
||||
dbChannels.ban(channelName, '*', bannedName, '', '', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
after(done => {
|
||||
dbChannels.deleteBans(channelName, null, () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a banned IP with a different name', done => {
|
||||
const user = {
|
||||
getName() {
|
||||
return 'anotherTroll';
|
||||
},
|
||||
|
||||
realip: bannedIP,
|
||||
|
||||
kick() {
|
||||
}
|
||||
};
|
||||
|
||||
module.onUserPreJoin(user, null, (error, res) => {
|
||||
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a banned name with a different IP', done => {
|
||||
const user = {
|
||||
getName() {
|
||||
return 'troll';
|
||||
},
|
||||
|
||||
realip: '5.5.5.5',
|
||||
|
||||
kick() {
|
||||
}
|
||||
};
|
||||
|
||||
module.onUserPreJoin(user, null, (error, res) => {
|
||||
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a banned IP with a blank name', done => {
|
||||
const user = {
|
||||
getName() {
|
||||
return '';
|
||||
},
|
||||
|
||||
realip: bannedIP,
|
||||
|
||||
kick() {
|
||||
}
|
||||
};
|
||||
|
||||
module.onUserPreJoin(user, null, (error, res) => {
|
||||
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a non-banned IP with a blank name', done => {
|
||||
const user = {
|
||||
getName() {
|
||||
return '';
|
||||
},
|
||||
|
||||
realip: '5.5.5.5'
|
||||
};
|
||||
|
||||
module.onUserPreJoin(user, null, (error, res) => {
|
||||
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a non-banned IP with a non-banned name', done => {
|
||||
const user = {
|
||||
getName() {
|
||||
return 'some_user';
|
||||
},
|
||||
|
||||
realip: '5.5.5.5'
|
||||
};
|
||||
|
||||
module.onUserPreJoin(user, null, (error, res) => {
|
||||
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
14
integration_test/testutil/config.js
Normal file
14
integration_test/testutil/config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const loadFromToml = require('../../lib/configuration/configloader').loadFromToml;
|
||||
const path = require('path');
|
||||
|
||||
class IntegrationTestConfig {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get knexConfig() {
|
||||
return this.config.database;
|
||||
}
|
||||
}
|
||||
|
||||
exports.testConfig = loadFromToml(IntegrationTestConfig, path.resolve(__dirname, '..', '..', 'conf', 'integration-test.toml'));
|
||||
4
integration_test/testutil/db.js
Normal file
4
integration_test/testutil/db.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
const testConfig = require('./config').testConfig;
|
||||
const Database = require('../../lib/database').Database;
|
||||
|
||||
exports.testDB = new Database(testConfig.knexConfig);
|
||||
4
integration_test/testutil/o.js
Normal file
4
integration_test/testutil/o.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
exports.o = function o(obj) {
|
||||
// Workaround for knex returning RowDataPacket and failing assertions
|
||||
return Object.assign({}, obj);
|
||||
}
|
||||
7481
package-lock.json
generated
Normal file
7481
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
99
package.json
99
package.json
|
|
@ -2,52 +2,81 @@
|
|||
"author": "Calvin Montgomery",
|
||||
"name": "CyTube",
|
||||
"description": "Online media synchronizer and chat",
|
||||
"version": "3.14.5",
|
||||
"version": "3.86.1",
|
||||
"repository": {
|
||||
"url": "http://github.com/calzoneman/sync"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel": "^5.8.23",
|
||||
"bcrypt": "^0.8.5",
|
||||
"bluebird": "^2.10.1",
|
||||
"body-parser": "^1.14.0",
|
||||
"cheerio": "^0.19.0",
|
||||
"clone": "^1.0.2",
|
||||
"compression": "^1.5.2",
|
||||
"cookie-parser": "^1.4.0",
|
||||
"@calzoneman/jsli": "^2.0.1",
|
||||
"@cytube/mediaquery": "github:CyTube/mediaquery#52a635d45f38785bdb26e842c9f2bb505bc37a1c",
|
||||
"bcrypt": "^5.0.1",
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.20.1",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"clone": "^2.1.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"create-error": "^0.3.1",
|
||||
"csrf": "^3.0.0",
|
||||
"cytube-mediaquery": "git://github.com/CyTube/mediaquery",
|
||||
"cytubefilters": "git://github.com/calzoneman/cytubefilters#67c7c69a",
|
||||
"express": "^4.13.3",
|
||||
"express-minify": "^0.1.6",
|
||||
"graceful-fs": "^4.1.2",
|
||||
"http-errors": "^1.3.1",
|
||||
"jade": "^1.11.0",
|
||||
"csrf": "^3.1.0",
|
||||
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
|
||||
"express": "^4.18.2",
|
||||
"express-minify": "^1.0.0",
|
||||
"json-typecheck": "^0.1.3",
|
||||
"morgan": "^1.6.1",
|
||||
"mysql": "^2.9.0",
|
||||
"nodemailer": "^1.4.0",
|
||||
"oauth": "^0.9.12",
|
||||
"q": "^1.4.1",
|
||||
"redis": "^2.4.2",
|
||||
"sanitize-html": "git://github.com/calzoneman/sanitize-html",
|
||||
"serve-static": "^1.10.0",
|
||||
"socket.io": "^1.4.0",
|
||||
"socket.io-redis": "^1.0.0",
|
||||
"source-map-support": "^0.4.0",
|
||||
"status-message-polyfill": "calzoneman/status-message-polyfill",
|
||||
"uuid": "^2.0.1",
|
||||
"yamljs": "^0.1.6"
|
||||
"knex": "^2.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.6.1",
|
||||
"prom-client": "^13.1.0",
|
||||
"proxy-addr": "^2.0.6",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^3.1.1",
|
||||
"sanitize-html": "^2.7.0",
|
||||
"serve-static": "^1.15.0",
|
||||
"socket.io": "^4.5.4",
|
||||
"source-map-support": "^0.5.19",
|
||||
"toml": "^3.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"yamljs": "^0.2.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build-player": "$npm_node_execpath build-player.js",
|
||||
"build-server": "babel --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/",
|
||||
"build-player": "./bin/build-player.js",
|
||||
"build-server": "babel -D --source-maps --out-dir lib/ src/",
|
||||
"flow": "flow",
|
||||
"lint": "eslint src",
|
||||
"pretest": "npm run lint",
|
||||
"postinstall": "./postinstall.sh",
|
||||
"server-dev": "babel --watch --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/"
|
||||
"server-dev": "babel -D --watch --source-maps --verbose --out-dir lib/ src/",
|
||||
"generate-userscript": "$npm_node_execpath gdrive-userscript/generate-userscript $@ > www/js/cytube-google-drive.user.js",
|
||||
"test": "mocha --recursive --exit test",
|
||||
"integration-test": "mocha --recursive --exit integration_test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"coffee-script": "^1.9.2"
|
||||
"@babel/cli": "^7.15.7",
|
||||
"@babel/core": "^7.15.8",
|
||||
"@babel/eslint-parser": "^7.15.8",
|
||||
"@babel/preset-env": "^7.15.8",
|
||||
"babel-plugin-add-module-exports": "^1.0.4",
|
||||
"coffeescript": "^1.9.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-no-jquery": "^2.7.0",
|
||||
"mocha": "^9.2.2",
|
||||
"sinon": "^10.0.0"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
[
|
||||
"@babel/env",
|
||||
{
|
||||
"targets": {
|
||||
"node": "12"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,24 @@ window.CustomEmbedPlayer = class CustomEmbedPlayer extends EmbedPlayer
|
|||
return
|
||||
|
||||
embedSrc = data.meta.embed.src
|
||||
link = "<a href=\"#{embedSrc}\" target=\"_blank\"><strong>#{embedSrc}</strong></a>"
|
||||
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link),
|
||||
|
||||
link = document.createElement('a')
|
||||
link.href = embedSrc
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferer'
|
||||
|
||||
strong = document.createElement('strong')
|
||||
strong.textContent = embedSrc
|
||||
link.appendChild(strong)
|
||||
|
||||
# TODO: Ideally makeAlert() would allow optionally providing a DOM
|
||||
# element instead of requiring HTML text
|
||||
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link.outerHTML),
|
||||
'alert-warning')
|
||||
.removeClass('col-md-12')
|
||||
$('<button/>').addClass('btn btn-default')
|
||||
.text('Embed')
|
||||
.click(=>
|
||||
.on('click', =>
|
||||
super(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
|
|
|
|||
|
|
@ -5,20 +5,25 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
|
||||
@setMediaProperties(data)
|
||||
@initialVolumeSet = false
|
||||
@playbackReadyCb = null
|
||||
|
||||
waitUntilDefined(window, 'DM', =>
|
||||
removeOld()
|
||||
|
||||
params =
|
||||
autoplay: 1
|
||||
wmode: if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||
logo: 0
|
||||
|
||||
quality = @mapQuality(USEROPTS.default_quality)
|
||||
if quality != 'auto'
|
||||
params.quality = quality
|
||||
|
||||
@dm = DM.player('ytapiplayer',
|
||||
@element = DM.$('ytapiplayer')
|
||||
if not @element or @element.nodeType != Node.ELEMENT_NODE
|
||||
throw new Error("Invalid player element in DailymotionPlayer(), requires an existing HTML element: " + @element)
|
||||
if DM.Player._INSTANCES[@element.id] != undefined
|
||||
@element = DM.Player.destroy(@element.id)
|
||||
@dm = DM.Player.create(@element,
|
||||
video: data.id
|
||||
width: parseInt(VWIDTH, 10)
|
||||
height: parseInt(VHEIGHT, 10)
|
||||
|
|
@ -26,7 +31,7 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
)
|
||||
|
||||
@dm.addEventListener('apiready', =>
|
||||
@dm.ready = true
|
||||
@dmReady = true
|
||||
@dm.addEventListener('ended', ->
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
|
|
@ -47,43 +52,64 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
@setVolume(VOLUME)
|
||||
@initialVolumeSet = true
|
||||
)
|
||||
|
||||
# Once the video stops, the internal state of the player
|
||||
# becomes unusable and attempting to load() will corrupt it and
|
||||
# crash the player with an error. As a short–medium term
|
||||
# workaround, mark the player as "not ready" until the next
|
||||
# playback_ready event
|
||||
@dm.addEventListener('video_end', =>
|
||||
@dmReady = false
|
||||
)
|
||||
@dm.addEventListener('playback_ready', =>
|
||||
@dmReady = true
|
||||
if @playbackReadyCb
|
||||
@playbackReadyCb()
|
||||
@playbackReadyCb = null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
@dm.load(data.id)
|
||||
@dm.seek(data.currentTime)
|
||||
else if @dm
|
||||
# TODO: Player::load() needs to be made asynchronous in the future
|
||||
console.log('Warning: load() called before DM is ready, queueing callback')
|
||||
@playbackReadyCb = () =>
|
||||
@dm.load(data.id)
|
||||
@dm.seek(data.currentTime)
|
||||
else
|
||||
console.error('WTF? DailymotionPlayer::load() called but dm is not ready')
|
||||
console.error('WTF? DailymotionPlayer::load() called but @dm is undefined')
|
||||
|
||||
pause: ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
@paused = true
|
||||
@dm.pause()
|
||||
|
||||
play: ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
@paused = false
|
||||
@dm.play()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
@dm.seek(time)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
@dm.setVolume(volume)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
cb(@dm.currentTime)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @dm and @dm.ready
|
||||
if @dm and @dmReady
|
||||
if @dm.muted
|
||||
cb(0)
|
||||
else
|
||||
|
|
@ -103,3 +129,7 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
when '360' then '380'
|
||||
when 'best' then '1080'
|
||||
else 'auto'
|
||||
|
||||
destroy: ->
|
||||
if @dm
|
||||
@dm.destroy('ytapiplayer')
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
DEFAULT_ERROR = 'You are currently connected via HTTPS but the embedded content
|
||||
uses non-secure plain HTTP. Your browser therefore blocks it from
|
||||
loading due to mixed content policy. To fix this, embed the video using a
|
||||
secure link if available (https://...), or load this page over plain HTTP by
|
||||
replacing "https://" with "http://" in the address bar (your websocket will
|
||||
still be secured using HTTPS, but this will permit non-secure content to load).'
|
||||
secure link if available (https://...), or find another source for the content.'
|
||||
|
||||
genParam = (name, value) ->
|
||||
$('<param/>').attr(
|
||||
|
|
@ -26,27 +24,10 @@ window.EmbedPlayer = class EmbedPlayer extends Player
|
|||
console.error('EmbedPlayer::load(): missing meta.embed')
|
||||
return
|
||||
|
||||
if embed.tag == 'object'
|
||||
@player = @loadObject(embed)
|
||||
else
|
||||
@player = @loadIframe(embed)
|
||||
|
||||
removeOld(@player)
|
||||
|
||||
loadObject: (embed) ->
|
||||
object = $('<object/>').attr(
|
||||
type: 'application/x-shockwave-flash'
|
||||
data: embed.src
|
||||
wmode: 'opaque'
|
||||
)
|
||||
genParam('allowfullscreen', 'true').appendTo(object)
|
||||
genParam('allowscriptaccess', 'always').appendTo(object)
|
||||
|
||||
for key, value of embed.params
|
||||
genParam(key, value).appendTo(object)
|
||||
|
||||
return object
|
||||
|
||||
loadIframe: (embed) ->
|
||||
if embed.src.indexOf('http:') == 0 and location.protocol == 'https:'
|
||||
if @__proto__.mixedContentError?
|
||||
|
|
@ -61,6 +42,7 @@ window.EmbedPlayer = class EmbedPlayer extends Player
|
|||
iframe = $('<iframe/>').attr(
|
||||
src: embed.src
|
||||
frameborder: '0'
|
||||
allow: 'autoplay'
|
||||
allowfullscreen: '1'
|
||||
)
|
||||
|
||||
|
|
|
|||
86
player/gdrive-player.coffee
Normal file
86
player/gdrive-player.coffee
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof GoogleDrivePlayer)
|
||||
return new GoogleDrivePlayer(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
if not window.hasDriveUserscript
|
||||
window.promptToInstallDriveUserscript()
|
||||
else if window.hasDriveUserscript
|
||||
window.maybePromptToUpgradeUserscript()
|
||||
if typeof window.getGoogleDriveMetadata is 'function'
|
||||
setTimeout(=>
|
||||
backoffRetry((cb) ->
|
||||
window.getGoogleDriveMetadata(data.id, cb)
|
||||
, (error, metadata) =>
|
||||
if error
|
||||
console.error(error)
|
||||
alertBox = window.document.createElement('div')
|
||||
alertBox.className = 'alert alert-danger'
|
||||
alertBox.textContent = error
|
||||
document.getElementById('ytapiplayer').appendChild(alertBox)
|
||||
else
|
||||
data.meta.direct = metadata.videoMap
|
||||
super(data)
|
||||
, {
|
||||
maxTries: 3
|
||||
delay: 1000
|
||||
factor: 1.2
|
||||
jitter: 500
|
||||
})
|
||||
, Math.random() * 1000)
|
||||
|
||||
window.promptToInstallDriveUserscript = ->
|
||||
if document.getElementById('prompt-install-drive-userscript')
|
||||
return
|
||||
alertBox = document.createElement('div')
|
||||
alertBox.id = 'prompt-install-drive-userscript'
|
||||
alertBox.className = 'alert alert-info'
|
||||
alertBox.innerHTML = """
|
||||
Due to continual breaking changes making it increasingly difficult to
|
||||
maintain Google Drive support, Google Drive now requires installing
|
||||
a userscript in order to play the video."""
|
||||
alertBox.appendChild(document.createElement('br'))
|
||||
infoLink = document.createElement('a')
|
||||
infoLink.className = 'btn btn-info'
|
||||
infoLink.href = '/google_drive_userscript'
|
||||
infoLink.textContent = 'Click here for details'
|
||||
infoLink.target = '_blank'
|
||||
alertBox.appendChild(infoLink)
|
||||
|
||||
closeButton = document.createElement('button')
|
||||
closeButton.className = 'close pull-right'
|
||||
closeButton.innerHTML = '×'
|
||||
closeButton.onclick = ->
|
||||
alertBox.parentNode.removeChild(alertBox)
|
||||
alertBox.insertBefore(closeButton, alertBox.firstChild)
|
||||
removeOld($('<div/>').append(alertBox))
|
||||
|
||||
window.tellUserNotToContactMeAboutThingsThatAreNotSupported = ->
|
||||
if document.getElementById('prompt-no-gdrive-support')
|
||||
return
|
||||
alertBox = document.createElement('div')
|
||||
alertBox.id = 'prompt-no-gdrive-support'
|
||||
alertBox.className = 'alert alert-danger'
|
||||
alertBox.innerHTML = """
|
||||
CyTube has detected an error in Google Drive playback. Please note that the
|
||||
staff in CyTube support channels DO NOT PROVIDE SUPPORT FOR GOOGLE DRIVE. It
|
||||
is left in the code as-is for existing users, but we will not assist in
|
||||
troubleshooting any errors that occur.<br>"""
|
||||
alertBox.appendChild(document.createElement('br'))
|
||||
infoLink = document.createElement('a')
|
||||
infoLink.className = 'btn btn-danger'
|
||||
infoLink.href = 'https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions#why-dont-you-support-google-drive-anymore'
|
||||
infoLink.textContent = 'Click here for details'
|
||||
infoLink.target = '_blank'
|
||||
alertBox.appendChild(infoLink)
|
||||
|
||||
closeButton = document.createElement('button')
|
||||
closeButton.className = 'close pull-right'
|
||||
closeButton.innerHTML = '×'
|
||||
closeButton.onclick = ->
|
||||
alertBox.parentNode.removeChild(alertBox)
|
||||
alertBox.insertBefore(closeButton, alertBox.firstChild)
|
||||
removeOld($('<div/>').append(alertBox))
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
window.GoogleDriveYouTubePlayer = class GoogleDriveYouTubePlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof GoogleDriveYouTubePlayer)
|
||||
return new GoogleDriveYouTubePlayer(data)
|
||||
|
||||
@setMediaProperties(data)
|
||||
@init(data)
|
||||
|
||||
init: (data) ->
|
||||
embed = $('<embed />').attr(
|
||||
type: 'application/x-shockwave-flash'
|
||||
src: "https://www.youtube.com/get_player?docid=#{data.id}&ps=docs\
|
||||
&partnerid=30&enablejsapi=1&cc_load_policy=1\
|
||||
&auth_timeout=86400000000"
|
||||
flashvars: 'autoplay=1&playerapiid=uniquePlayerId'
|
||||
wmode: 'opaque'
|
||||
allowscriptaccess: 'always'
|
||||
)
|
||||
removeOld(embed)
|
||||
|
||||
window.onYouTubePlayerReady = =>
|
||||
if PLAYER != this
|
||||
return
|
||||
|
||||
@yt = embed[0]
|
||||
window.gdriveStateChange = @onStateChange.bind(this)
|
||||
@yt.addEventListener('onStateChange', 'gdriveStateChange')
|
||||
@onReady()
|
||||
|
||||
load: (data) ->
|
||||
@yt = null
|
||||
@setMediaProperties(data)
|
||||
@init(data)
|
||||
|
||||
onReady: ->
|
||||
@yt.ready = true
|
||||
@setVolume(VOLUME)
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
|
||||
onStateChange: (ev) ->
|
||||
if PLAYER != this
|
||||
return
|
||||
|
||||
if (ev == YT.PlayerState.PAUSED and not @paused) or
|
||||
(ev == YT.PlayerState.PLAYING and @paused)
|
||||
@paused = (ev == YT.PlayerState.PAUSED)
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
|
||||
if ev == YT.PlayerState.ENDED and CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @yt and @yt.ready
|
||||
@yt.playVideo()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @yt and @yt.ready
|
||||
@yt.pauseVideo()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @yt and @yt.ready
|
||||
@yt.seekTo(time, true)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @yt and @yt.ready
|
||||
if volume > 0
|
||||
# If the player is muted, even if the volume is set,
|
||||
# the player remains muted
|
||||
@yt.unMute()
|
||||
@yt.setVolume(volume * 100)
|
||||
|
||||
setQuality: (quality) ->
|
||||
if not @yt or not @yt.ready
|
||||
return
|
||||
|
||||
ytQuality = switch String(quality)
|
||||
when '240' then 'small'
|
||||
when '360' then 'medium'
|
||||
when '480' then 'large'
|
||||
when '720' then 'hd720'
|
||||
when '1080' then 'hd1080'
|
||||
when 'best' then 'highres'
|
||||
else 'auto'
|
||||
|
||||
if ytQuality != 'auto'
|
||||
@yt.setPlaybackQuality(ytQuality)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
cb(@yt.getCurrentTime())
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
if @yt.isMuted()
|
||||
cb(0)
|
||||
else
|
||||
cb(@yt.getVolume() / 100)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
HITBOX_ERROR = 'Hitbox.tv only serves its content over plain HTTP, but you are
|
||||
viewing this page over secure HTTPS. Your browser therefore blocks the
|
||||
hitbox embed due to mixed content policy. In order to view hitbox, you must
|
||||
view this page over plain HTTP (change "https://" to "http://" in the address
|
||||
bar)-- your websocket will still be connected using secure HTTPS. This is
|
||||
something I have asked Hitbox to fix but they have not done so yet.'
|
||||
|
||||
window.HitboxPlayer = class HitboxPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof HitboxPlayer)
|
||||
return new HitboxPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
data.meta.embed =
|
||||
src: "http://hitbox.tv/embed/#{data.id}"
|
||||
tag: 'iframe'
|
||||
super(data)
|
||||
|
||||
mixedContentError: HITBOX_ERROR
|
||||
23
player/hls.coffee
Normal file
23
player/hls.coffee
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
window.HLSPlayer = class HLSPlayer extends VideoJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof HLSPlayer)
|
||||
return new HLSPlayer(data)
|
||||
|
||||
@setupMeta(data)
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@setupMeta(data)
|
||||
super(data)
|
||||
|
||||
setupMeta: (data) ->
|
||||
data.meta.direct =
|
||||
# Quality is required for data.meta.direct processing but doesn't
|
||||
# matter here because it's dictated by the stream. Arbitrarily
|
||||
# choose 480.
|
||||
480: [
|
||||
{
|
||||
link: data.id
|
||||
contentType: 'application/x-mpegURL'
|
||||
}
|
||||
]
|
||||
33
player/iframechild.coffee
Normal file
33
player/iframechild.coffee
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
window.IframeChild = class IframeChild extends PlayerJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof IframeChild)
|
||||
return new IframeChild(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
@ready = false
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: '/iframe'
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupFrame(iframe[0], data)
|
||||
@setupPlayer(iframe[0])
|
||||
)
|
||||
|
||||
setupFrame: (iframe, data) ->
|
||||
iframe.addEventListener('load', =>
|
||||
# TODO: ideally, communication with the child frame should use postMessage()
|
||||
iframe.contentWindow.VOLUME = VOLUME
|
||||
iframe.contentWindow.loadMediaPlayer(Object.assign({}, data, { type: 'cm' } ))
|
||||
iframe.contentWindow.document.querySelector('#ytapiplayer').classList.add('vjs-16-9')
|
||||
adapter = iframe.contentWindow.playerjs.VideoJSAdapter(iframe.contentWindow.PLAYER.player)
|
||||
adapter.ready()
|
||||
typeof data?.meta?.thumbnail == 'string' and iframe.contentWindow.PLAYER.player.poster(data.meta.thumbnail)
|
||||
)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
window.ImgurPlayer = class ImgurPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof ImgurPlayer)
|
||||
return new ImgurPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
data.meta.embed =
|
||||
tag: 'iframe'
|
||||
src: "https://imgur.com/a/#{data.id}/embed"
|
||||
super(data)
|
||||
|
|
@ -6,18 +6,12 @@ window.LivestreamPlayer = class LivestreamPlayer extends EmbedPlayer
|
|||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
if LIVESTREAM_CHROMELESS
|
||||
[ account, event ] = data.id.split(';')
|
||||
data.meta.embed =
|
||||
src: 'https://cdn.livestream.com/chromelessPlayer/v20/playerapi.swf'
|
||||
tag: 'object'
|
||||
params:
|
||||
flashvars: "channel=#{data.id}"
|
||||
else
|
||||
data.meta.embed =
|
||||
src: "https://cdn.livestream.com/embed/#{data.id}?\
|
||||
layout=4&\
|
||||
color=0x000000&\
|
||||
iconColorOver=0xe7e7e7&\
|
||||
iconColor=0xcccccc"
|
||||
src: "https://livestream.com/accounts/#{account}/events/#{event}/player?\
|
||||
enableInfoAndActivity=false&\
|
||||
defaultDrawer=&\
|
||||
autoPlay=true&\
|
||||
mute=false"
|
||||
tag: 'iframe'
|
||||
super(data)
|
||||
|
|
|
|||
66
player/niconico.coffee
Normal file
66
player/niconico.coffee
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
window.NicoPlayer = class NicoPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof NicoPlayer)
|
||||
return new NicoPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'NicovideoEmbed', =>
|
||||
@nico = new NicovideoEmbed({ playerId: 'ytapiplayer', videoId: data.id })
|
||||
removeOld($(@nico.iframe))
|
||||
|
||||
@nico.on('ended', =>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
|
||||
@nico.on('pause', =>
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@nico.on('play', =>
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@nico.on('ready', =>
|
||||
@play()
|
||||
@setVolume(VOLUME)
|
||||
)
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @nico
|
||||
@nico.play()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @nico
|
||||
@nico.pause()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @nico
|
||||
@nico.seek(time * 1000)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @nico
|
||||
@nico.volumeChange(volume)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @nico
|
||||
cb(parseFloat(@nico.state.currentTime / 1000))
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @nico
|
||||
cb(parseFloat(@nico.state.volume))
|
||||
else
|
||||
cb(VOLUME)
|
||||
21
player/odysee.coffee
Normal file
21
player/odysee.coffee
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
window.OdyseePlayer = class OdyseePlayer extends PlayerJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof OdyseePlayer)
|
||||
return new OdyseePlayer(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@ready = false
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: data.meta.embed.src
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0], data)
|
||||
)
|
||||
122
player/peertube.coffee
Normal file
122
player/peertube.coffee
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
PEERTUBE_EMBED_WARNING = 'This channel is embedding PeerTube content from %link%.
|
||||
PeerTube instances may use P2P technology that will expose your IP address to third parties, including but not
|
||||
limited to other users in this channel. It is also conceivable that if the content in question is in violation of
|
||||
copyright laws your IP address could be potentially be observed by legal authorities monitoring the tracker of
|
||||
this PeerTube instance. The operators of %site% are not responsible for the data sent by the embedded player to
|
||||
third parties on your behalf.<br><br> If you understand the risks, wish to assume all liability, and continue to
|
||||
the content, click "Embed" below to allow the content to be embedded.<hr>'
|
||||
|
||||
PEERTUBE_RISK = false
|
||||
|
||||
window.PeerPlayer = class PeerPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof PeerPlayer)
|
||||
return new PeerPlayer(data)
|
||||
|
||||
@warn(data)
|
||||
|
||||
warn: (data) ->
|
||||
if USEROPTS.peertube_risk or PEERTUBE_RISK
|
||||
return @load(data)
|
||||
|
||||
site = new URL(document.URL).hostname
|
||||
embedSrc = data.meta.embed.domain
|
||||
link = "<a href=\"http://#{embedSrc}\" target=\"_blank\" rel=\"noopener noreferer\"><strong>#{embedSrc}</strong></a>"
|
||||
alert = makeAlert('Privacy Advisory', PEERTUBE_EMBED_WARNING.replace('%link%', link).replace('%site%', site),
|
||||
'alert-warning')
|
||||
.removeClass('col-md-12')
|
||||
$('<button/>').addClass('btn btn-default')
|
||||
.text('Embed')
|
||||
.on('click', =>
|
||||
@load(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
$('<button/>').addClass('btn btn-default pull-right')
|
||||
.text('Embed and dont ask again for this session')
|
||||
.on('click', =>
|
||||
PEERTUBE_RISK = true
|
||||
@load(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
removeOld(alert)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'PeerTubePlayer', =>
|
||||
video = $('<iframe/>')
|
||||
removeOld(video)
|
||||
video.attr(
|
||||
src: "https://#{data.meta.embed.domain}/videos/embed/#{data.meta.embed.uuid}?api=1"
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
@peertube = new PeerTubePlayer(video[0])
|
||||
|
||||
@peertube.addEventListener('playbackStatusChange', (status) =>
|
||||
@paused = status == 'paused'
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@peertube.addEventListener('playbackStatusUpdate', (status) =>
|
||||
@peertube.currentTime = status.position
|
||||
|
||||
if status.playbackState == "ended" and CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
|
||||
@peertube.addEventListener('volumeChange', (volume) =>
|
||||
VOLUME = volume
|
||||
setOpt("volume", VOLUME)
|
||||
)
|
||||
|
||||
@play()
|
||||
@setVolume(VOLUME)
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.play().catch((error) ->
|
||||
console.error('PeerTube::play():', error)
|
||||
)
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.pause().catch((error) ->
|
||||
console.error('PeerTube::pause():', error)
|
||||
)
|
||||
|
||||
seekTo: (time) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.seek(time)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.getVolume().then((volume) ->
|
||||
cb(parseFloat(volume))
|
||||
).catch((error) ->
|
||||
console.error('PeerTube::getVolume():', error)
|
||||
)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.setVolume(volume).catch((error) ->
|
||||
console.error('PeerTube::setVolume():', error)
|
||||
)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @peertube and @peertube.ready
|
||||
cb(@peertube.currentTime)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
setQuality: (quality) ->
|
||||
# USEROPTS.default_quality
|
||||
# @peertube.getResolutions()
|
||||
# @peertube.setResolution(resolutionId : number)
|
||||
|
||||
85
player/playerjs.coffee
Normal file
85
player/playerjs.coffee
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
window.PlayerJSPlayer = class PlayerJSPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof PlayerJSPlayer)
|
||||
return new PlayerJSPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
@ready = false
|
||||
|
||||
if not data.meta.playerjs
|
||||
throw new Error('Invalid input: missing meta.playerjs')
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: data.meta.playerjs.src
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0])
|
||||
)
|
||||
|
||||
setupPlayer: (iframe) ->
|
||||
@player = new playerjs.Player(iframe)
|
||||
@player.on('ready', =>
|
||||
@player.on('error', (error) =>
|
||||
console.error('PlayerJS error', error.stack)
|
||||
)
|
||||
@player.on('ended', ->
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
@player.on('play', ->
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
@player.on('pause', ->
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@player.setVolume(VOLUME * 100)
|
||||
|
||||
if not @paused
|
||||
@player.play()
|
||||
|
||||
@ready = true
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @player and @ready
|
||||
@player.play()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @player and @ready
|
||||
@player.pause()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @player and @ready
|
||||
@player.setCurrentTime(time)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @player and @ready
|
||||
@player.setVolume(volume * 100)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @player and @ready
|
||||
@player.getCurrentTime(cb)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @player and @ready
|
||||
@player.getVolume((volume) ->
|
||||
cb(volume / 100)
|
||||
)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
codecToMimeType = (codec) ->
|
||||
switch codec
|
||||
when 'mov/h264' then 'video/mp4'
|
||||
when 'mov/h264', 'mov/av1' then 'video/mp4'
|
||||
when 'flv/h264' then 'video/flv'
|
||||
when 'matroska/vp8', 'matroska/vp9' then 'video/webm'
|
||||
when 'matroska/vp8', 'matroska/vp9', 'matroska/av1' then 'video/webm'
|
||||
when 'ogg/theora' then 'video/ogg'
|
||||
when 'mp3' then 'audio/mp3'
|
||||
when 'vorbis' then 'audio/ogg'
|
||||
when 'aac' then 'audio/aac'
|
||||
when 'opus' then 'audio/opus'
|
||||
else 'video/flv'
|
||||
|
||||
window.FilePlayer = class FilePlayer extends VideoJSPlayer
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ window.RTMPPlayer = class RTMPPlayer extends VideoJSPlayer
|
|||
480: [
|
||||
{
|
||||
link: data.id
|
||||
contentType: 'rtmp/flv'
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -91,13 +91,8 @@ window.SoundCloudPlayer = class SoundCloudPlayer extends Player
|
|||
@soundcloud.seekTo(time * 1000)
|
||||
|
||||
setVolume: (volume) ->
|
||||
# NOTE: SoundCloud's documentation claims that setVolume() accepts
|
||||
# volumes in the range [0, 100], however it *actually* accepts volumes
|
||||
# in the range [0, 1] (anything larger than 1 is treated as 1). I
|
||||
# emailed them about this 2 years ago and they still haven't fixed
|
||||
# their documentation.
|
||||
if @soundcloud and @soundcloud.ready
|
||||
@soundcloud.setVolume(volume)
|
||||
@soundcloud.setVolume(volume * 100)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @soundcloud and @soundcloud.ready
|
||||
|
|
@ -108,6 +103,6 @@ window.SoundCloudPlayer = class SoundCloudPlayer extends Player
|
|||
|
||||
getVolume: (cb) ->
|
||||
if @soundcloud and @soundcloud.ready
|
||||
@soundcloud.getVolume(cb)
|
||||
@soundcloud.getVolume((vol) -> cb(vol / 100))
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
|
|
|||
35
player/streamable.coffee
Normal file
35
player/streamable.coffee
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
window.StreamablePlayer = class StreamablePlayer extends PlayerJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof StreamablePlayer)
|
||||
return new StreamablePlayer(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@ready = false
|
||||
@finishing = false
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: "https://streamable.com/e/#{data.id}"
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0])
|
||||
@player.on('ready', =>
|
||||
# Streamable does not implement ended event since it loops
|
||||
# gotta use a timeupdate hack
|
||||
@player.on('timeupdate', (time) =>
|
||||
if time.duration - time.seconds < 1 and not @finishing
|
||||
setTimeout(=>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
@pause()
|
||||
, (time.duration - time.seconds) * 1000)
|
||||
@finishing = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -1,29 +1,128 @@
|
|||
window.twitchEventCallback = (events) ->
|
||||
if not (PLAYER instanceof TwitchPlayer)
|
||||
return false
|
||||
window.TWITCH_PARAMS_ERROR = 'The Twitch embed player now uses parameters which only
|
||||
work if the following requirements are met: (1) The embedding website uses
|
||||
HTTPS; (2) The embedding website uses the default port (443) and is accessed
|
||||
via https://example.com instead of https://example.com:port. I have no
|
||||
control over this -- see <a href="https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588" rel="noopener noreferrer" target="_blank">this Twitch post</a>
|
||||
for details'
|
||||
|
||||
events.forEach((event) ->
|
||||
if event.event == 'playerInit'
|
||||
PLAYER.twitch.unmute()
|
||||
PLAYER.twitch.ready = true
|
||||
)
|
||||
|
||||
window.TwitchPlayer = class TwitchPlayer extends EmbedPlayer
|
||||
window.TwitchPlayer = class TwitchPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof TwitchPlayer)
|
||||
return new TwitchPlayer(data)
|
||||
|
||||
@load(data)
|
||||
@setMediaProperties(data)
|
||||
waitUntilDefined(window, 'Twitch', =>
|
||||
waitUntilDefined(Twitch, 'Player', =>
|
||||
@init(data)
|
||||
)
|
||||
)
|
||||
|
||||
init: (data) ->
|
||||
removeOld()
|
||||
|
||||
if location.hostname != location.host or location.protocol != 'https:'
|
||||
alert = makeAlert(
|
||||
'Twitch API Parameters',
|
||||
window.TWITCH_PARAMS_ERROR,
|
||||
'alert-danger'
|
||||
).removeClass('col-md-12')
|
||||
removeOld(alert)
|
||||
@twitch = null
|
||||
return
|
||||
|
||||
options =
|
||||
parent: [location.hostname]
|
||||
width: $('#ytapiplayer').width()
|
||||
height: $('#ytapiplayer').height()
|
||||
|
||||
if data.type is 'tv'
|
||||
# VOD
|
||||
options.video = data.id
|
||||
else
|
||||
# Livestream
|
||||
options.channel = data.id
|
||||
|
||||
@twitch = new Twitch.Player('ytapiplayer', options)
|
||||
@twitch.addEventListener(Twitch.Player.READY, =>
|
||||
@setVolume(VOLUME)
|
||||
@twitch.setQuality(@mapQuality(USEROPTS.default_quality))
|
||||
@twitch.addEventListener(Twitch.Player.PLAY, =>
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
@twitch.addEventListener(Twitch.Player.PAUSE, =>
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
@twitch.addEventListener(Twitch.Player.ENDED, =>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
)
|
||||
|
||||
load: (data) ->
|
||||
data.meta.embed =
|
||||
src: '//www-cdn.jtvnw.net/swflibs/TwitchPlayer.swf'
|
||||
tag: 'object'
|
||||
params:
|
||||
flashvars: "embed=1&\
|
||||
hostname=localhost&\
|
||||
channel=#{data.id}&
|
||||
eventsCallback=twitchEventCallback&\
|
||||
auto_play=true&\
|
||||
start_volume=#{Math.floor(VOLUME * 100)}"
|
||||
super(data)
|
||||
@setMediaProperties(data)
|
||||
try
|
||||
if data.type is 'tv'
|
||||
# VOD
|
||||
@twitch.setVideo(data.id)
|
||||
else
|
||||
# Livestream
|
||||
@twitch.setChannel(data.id)
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
pause: ->
|
||||
try
|
||||
@twitch.pause()
|
||||
@paused = true
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
play: ->
|
||||
try
|
||||
@twitch.play()
|
||||
@paused = false
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
seekTo: (time) ->
|
||||
try
|
||||
@twitch.seek(time)
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
getTime: (cb) ->
|
||||
try
|
||||
cb(@twitch.getCurrentTime())
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
setVolume: (volume) ->
|
||||
try
|
||||
@twitch.setVolume(volume)
|
||||
if volume > 0
|
||||
@twitch.setMuted(false)
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
getVolume: (cb) ->
|
||||
try
|
||||
if @twitch.isPaused()
|
||||
cb(0)
|
||||
else
|
||||
cb(@twitch.getVolume())
|
||||
catch error
|
||||
console.error(error)
|
||||
|
||||
mapQuality: (quality) ->
|
||||
switch String(quality)
|
||||
when '1080' then 'chunked'
|
||||
when '720' then 'high'
|
||||
when '480' then 'medium'
|
||||
when '360' then 'low'
|
||||
when '240' then 'mobile'
|
||||
when 'best' then 'chunked'
|
||||
else ''
|
||||
|
|
|
|||
21
player/twitchclip.coffee
Normal file
21
player/twitchclip.coffee
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
window.TwitchClipPlayer = class TwitchClipPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof TwitchClipPlayer)
|
||||
return new TwitchClipPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
if location.hostname != location.host or location.protocol != 'https:'
|
||||
alert = makeAlert(
|
||||
'Twitch API Parameters',
|
||||
window.TWITCH_PARAMS_ERROR,
|
||||
'alert-danger'
|
||||
).removeClass('col-md-12')
|
||||
removeOld(alert)
|
||||
return
|
||||
|
||||
data.meta.embed =
|
||||
tag: 'iframe'
|
||||
src: "https://clips.twitch.tv/embed?clip=#{data.id}&parent=#{location.host}"
|
||||
super(data)
|
||||
|
|
@ -2,18 +2,24 @@ TYPE_MAP =
|
|||
yt: YouTubePlayer
|
||||
vi: VimeoPlayer
|
||||
dm: DailymotionPlayer
|
||||
gd: GoogleDriveYouTubePlayer
|
||||
gp: VideoJSPlayer
|
||||
gd: GoogleDrivePlayer
|
||||
fi: FilePlayer
|
||||
jw: FilePlayer
|
||||
sc: SoundCloudPlayer
|
||||
li: LivestreamPlayer
|
||||
tw: TwitchPlayer
|
||||
tv: TwitchPlayer
|
||||
cu: CustomEmbedPlayer
|
||||
rt: RTMPPlayer
|
||||
hb: HitboxPlayer
|
||||
us: UstreamPlayer
|
||||
im: ImgurPlayer
|
||||
hl: HLSPlayer
|
||||
sb: StreamablePlayer
|
||||
tc: TwitchClipPlayer
|
||||
cm: VideoJSPlayer
|
||||
pt: PeerPlayer
|
||||
bc: IframeChild
|
||||
bn: IframeChild
|
||||
od: OdyseePlayer
|
||||
wp: WhepPlayer
|
||||
nv: NicoPlayer
|
||||
|
||||
window.loadMediaPlayer = (data) ->
|
||||
try
|
||||
|
|
@ -22,7 +28,7 @@ window.loadMediaPlayer = (data) ->
|
|||
catch error
|
||||
console.error error
|
||||
|
||||
if data.meta.direct and data.type != 'gd'
|
||||
if data.meta.direct and data.type is 'vi'
|
||||
try
|
||||
window.PLAYER = new VideoJSPlayer(data)
|
||||
catch e
|
||||
|
|
@ -105,7 +111,8 @@ window.removeOld = (replace) ->
|
|||
$('#soundcloud-volume-holder').remove()
|
||||
replace ?= $('<div/>').addClass('embed-responsive-item')
|
||||
old = $('#ytapiplayer')
|
||||
old.attr('id', 'ytapiplayer-old')
|
||||
replace.attr('id', 'ytapiplayer')
|
||||
replace.insertBefore(old)
|
||||
old.remove()
|
||||
replace.attr('id', 'ytapiplayer')
|
||||
return replace
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
USTREAM_ERROR = 'Ustream.tv\'s embed player only works over plain HTTP, but you are
|
||||
viewing this page over secure HTTPS. Your browser therefore blocks the
|
||||
ustream embed due to mixed content policy. In order to view ustream, you must
|
||||
view this page over plain HTTP (change "https://" to "http://" in the address
|
||||
bar)-- your websocket will still be connecting using secure HTTPS. This is
|
||||
something that ustream needs to fix.'
|
||||
|
||||
window.UstreamPlayer = class UstreamPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof UstreamPlayer)
|
||||
return new UstreamPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
data.meta.embed =
|
||||
tag: 'iframe'
|
||||
src: "http://www.ustream.tv/embed/#{data.id}?v=3&wmode=direct&autoplay=1"
|
||||
super(data)
|
||||
|
||||
mixedContentError: USTREAM_ERROR
|
||||
|
|
@ -3,15 +3,16 @@ sortSources = (sources) ->
|
|||
console.error('sortSources() called with null source list')
|
||||
return []
|
||||
|
||||
qualities = ['1080', '720', '480', '360', '240']
|
||||
qualities = ['2160', '1440', '1080', '720', '540', '480', '360', '240']
|
||||
pref = String(USEROPTS.default_quality)
|
||||
if USEROPTS.default_quality == 'best'
|
||||
pref = '1080'
|
||||
pref = '2160'
|
||||
idx = qualities.indexOf(pref)
|
||||
if idx < 0
|
||||
idx = 2
|
||||
idx = 5 # 480p
|
||||
|
||||
qualityOrder = qualities.slice(idx).concat(qualities.slice(0, idx).reverse())
|
||||
qualityOrder.unshift('auto')
|
||||
sourceOrder = []
|
||||
flvOrder = []
|
||||
for quality in qualityOrder
|
||||
|
|
@ -31,43 +32,57 @@ sortSources = (sources) ->
|
|||
return sourceOrder.concat(flvOrder).map((source) ->
|
||||
type: source.contentType
|
||||
src: source.link
|
||||
quality: source.quality
|
||||
res: source.quality
|
||||
label: getSourceLabel(source)
|
||||
)
|
||||
|
||||
waitUntilDefined(window, 'videojs', =>
|
||||
videojs.options.flash.swf = '/video-js.swf'
|
||||
)
|
||||
getSourceLabel = (source) ->
|
||||
if source.res is 'auto'
|
||||
return 'auto'
|
||||
else
|
||||
return "#{source.quality}p #{source.contentType.split('/')[1]}"
|
||||
|
||||
hasAnyTextTracks = (data) ->
|
||||
ntracks = data?.meta?.textTracks?.length ? 0
|
||||
return ntracks > 0
|
||||
|
||||
hasAnyAudioTracks = (data) ->
|
||||
ntracks = data?.meta?.audioTracks?.length ? 0
|
||||
return ntracks > 0
|
||||
|
||||
window.VideoJSPlayer = class VideoJSPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof VideoJSPlayer)
|
||||
return new VideoJSPlayer(data)
|
||||
|
||||
@setMediaProperties(data)
|
||||
@loadPlayer(data)
|
||||
@load(data)
|
||||
|
||||
loadPlayer: (data) ->
|
||||
waitUntilDefined(window, 'videojs', =>
|
||||
attrs =
|
||||
width: '100%'
|
||||
height: '100%'
|
||||
|
||||
if @mediaType == 'cm' and hasAnyTextTracks(data)
|
||||
attrs.crossorigin = 'anonymous'
|
||||
|
||||
video = $('<video/>')
|
||||
.addClass('video-js vjs-default-skin embed-responsive-item')
|
||||
.attr(width: '100%', height: '100%')
|
||||
.attr(attrs)
|
||||
removeOld(video)
|
||||
|
||||
sources = sortSources(data.meta.direct)
|
||||
if sources.length == 0
|
||||
@sources = sortSources(data.meta.direct)
|
||||
if @sources.length == 0
|
||||
console.error('VideoJSPlayer::constructor(): data.meta.direct
|
||||
has no sources!')
|
||||
@mediaType = null
|
||||
return
|
||||
|
||||
sources.forEach((source) ->
|
||||
$('<source/>').attr(
|
||||
src: source.src
|
||||
type: source.type
|
||||
'data-quality': source.quality
|
||||
).appendTo(video)
|
||||
)
|
||||
@sourceIdx = 0
|
||||
|
||||
# TODO: Refactor VideoJSPlayer to use a preLoad()/load()/postLoad() pattern
|
||||
# VideoJSPlayer should provide the core functionality and logic for specific
|
||||
# dependent player types (gdrive) should be an extension
|
||||
if data.meta.gdrive_subtitles
|
||||
data.meta.gdrive_subtitles.available.forEach((subt) ->
|
||||
label = subt.lang_original
|
||||
|
|
@ -82,8 +97,58 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
|||
).appendTo(video)
|
||||
)
|
||||
|
||||
@player = videojs(video[0], autoplay: true, controls: true)
|
||||
if data.meta.textTracks
|
||||
data.meta.textTracks.forEach((track) ->
|
||||
label = track.name
|
||||
attrs =
|
||||
src: track.url
|
||||
kind: 'subtitles'
|
||||
type: track.type
|
||||
label: label
|
||||
|
||||
if track.default? and track.default
|
||||
attrs.default = ''
|
||||
|
||||
$('<track/>').attr(attrs).appendTo(video)
|
||||
)
|
||||
|
||||
pluginData =
|
||||
videoJsResolutionSwitcher:
|
||||
default: @sources[0].res
|
||||
|
||||
if hasAnyAudioTracks(data)
|
||||
pluginData.audioSwitch =
|
||||
audioTracks: data.meta.audioTracks,
|
||||
volume: VOLUME
|
||||
|
||||
@player = videojs(video[0],
|
||||
# https://github.com/Dash-Industry-Forum/dash.js/issues/2184
|
||||
autoplay: @sources[0].type != 'application/dash+xml',
|
||||
controls: true,
|
||||
plugins: pluginData
|
||||
|
||||
)
|
||||
@player.ready(=>
|
||||
# Have to use updateSrc instead of <source> tags
|
||||
# see: https://github.com/videojs/video.js/issues/3428
|
||||
@player.poster(data.meta.thumbnail)
|
||||
@player.updateSrc(@sources)
|
||||
@player.on('error', =>
|
||||
err = @player.error()
|
||||
if err and err.code == 4
|
||||
console.error('Caught error, trying next source')
|
||||
# Does this really need to be done manually?
|
||||
@sourceIdx++
|
||||
if @sourceIdx < @sources.length
|
||||
@player.src(@sources[@sourceIdx])
|
||||
else
|
||||
console.error('Out of sources, video will not play')
|
||||
if @mediaType is 'gd'
|
||||
if not window.hasDriveUserscript
|
||||
window.promptToInstallDriveUserscript()
|
||||
else
|
||||
window.tellUserNotToContactMeAboutThingsThatAreNotSupported()
|
||||
)
|
||||
@setVolume(VOLUME)
|
||||
@player.on('ended', ->
|
||||
if CLIENT.leader
|
||||
|
|
@ -114,12 +179,13 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
|||
# not to run until the ready() function returns.
|
||||
setTimeout(->
|
||||
$('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each((i, elem) ->
|
||||
if elem.textContent == localStorage.lastSubtitle
|
||||
textNode = elem.childNodes[0]
|
||||
if textNode.textContent == localStorage.lastSubtitle
|
||||
elem.click()
|
||||
|
||||
elem.onclick = ->
|
||||
if elem.attributes['aria-selected'].value == 'true'
|
||||
localStorage.lastSubtitle = elem.textContent
|
||||
if elem.attributes['aria-checked'].value == 'true'
|
||||
localStorage.lastSubtitle = textNode.textContent
|
||||
)
|
||||
, 1)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,73 +8,79 @@ window.VimeoPlayer = class VimeoPlayer extends Player
|
|||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, '$f', =>
|
||||
waitUntilDefined(window, 'Vimeo', =>
|
||||
video = $('<iframe/>')
|
||||
removeOld(video)
|
||||
video.attr(
|
||||
src: "https://player.vimeo.com/video/#{data.id}?api=1&player_id=ytapiplayer"
|
||||
webkitallowfullscreen: true
|
||||
mozallowfullscreen: true
|
||||
allowfullscreen: true
|
||||
src: "https://player.vimeo.com/video/#{data.id}"
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
if USEROPTS.wmode_transparent
|
||||
video.attr('wmode', 'transparent')
|
||||
@vimeo = new Vimeo.Player(video[0])
|
||||
|
||||
$f(video[0]).addEvent('ready', =>
|
||||
@vimeo = $f(video[0])
|
||||
@play()
|
||||
|
||||
@vimeo.addEvent('finish', =>
|
||||
@vimeo.on('ended', =>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
|
||||
@vimeo.addEvent('pause', =>
|
||||
@vimeo.on('pause', =>
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@vimeo.addEvent('play', =>
|
||||
@vimeo.on('play', =>
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@play()
|
||||
@setVolume(VOLUME)
|
||||
)
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @vimeo
|
||||
@vimeo.api('play')
|
||||
@vimeo.play().catch((error) ->
|
||||
console.error('vimeo::play():', error)
|
||||
)
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @vimeo
|
||||
@vimeo.api('pause')
|
||||
@vimeo.pause().catch((error) ->
|
||||
console.error('vimeo::pause():', error)
|
||||
)
|
||||
|
||||
seekTo: (time) ->
|
||||
if @vimeo
|
||||
@vimeo.api('seekTo', time)
|
||||
@vimeo.setCurrentTime(time).catch((error) ->
|
||||
console.error('vimeo::setCurrentTime():', error)
|
||||
)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @vimeo
|
||||
@vimeo.api('setVolume', volume)
|
||||
@vimeo.setVolume(volume).catch((error) ->
|
||||
console.error('vimeo::setVolume():', error)
|
||||
)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @vimeo
|
||||
@vimeo.api('getCurrentTime', (time) ->
|
||||
# I will never understand why Vimeo returns current time as a string
|
||||
@vimeo.getCurrentTime().then((time) ->
|
||||
cb(parseFloat(time))
|
||||
).catch((error) ->
|
||||
console.error('vimeo::getCurrentTime():', error)
|
||||
)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @vimeo
|
||||
@vimeo.api('getVolume', cb)
|
||||
@vimeo.getVolume().then((volume) ->
|
||||
cb(parseFloat(volume))
|
||||
).catch((error) ->
|
||||
console.error('vimeo::getVolume():', error)
|
||||
)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
|
|
|||
87
player/whepplayer.coffee
Normal file
87
player/whepplayer.coffee
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
window.WhepPlayer = class WhepPlayer extends Player
|
||||
constructor: (data) ->
|
||||
return new WhepPlayer(data) unless this instanceof WhepPlayer
|
||||
super(data)
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
@ready = false
|
||||
@paused = true
|
||||
@setMediaProperties(data)
|
||||
|
||||
unless data.meta?.whepURL
|
||||
u = new URL(data.id)
|
||||
streemId = u.pathname.split('/').filter(Boolean)[0]
|
||||
data.meta ?= {}
|
||||
# Adjust these to whatever your backend expects
|
||||
data.meta.whepURL = "#{u.origin}/api/whep"
|
||||
data.meta.streamKey = streemId
|
||||
|
||||
{ whepURL, streamKey } = data.meta
|
||||
|
||||
# Create video element
|
||||
videoEl = document.createElement 'video'
|
||||
videoEl.autoplay = true
|
||||
videoEl.muted = true
|
||||
videoEl.controls = true
|
||||
|
||||
# Use the standard container swapper
|
||||
holder = removeOld() # jQuery object
|
||||
holder.empty().append(videoEl)
|
||||
|
||||
@videoEl = videoEl
|
||||
|
||||
# ---- WebRTC setup ----
|
||||
pc = new RTCPeerConnection()
|
||||
pc.addTransceiver 'audio', direction: 'recvonly'
|
||||
pc.addTransceiver 'video', direction: 'recvonly'
|
||||
|
||||
pc.ontrack = (e) => @videoEl.srcObject = e.streams[0]
|
||||
|
||||
pc.onconnectionstatechange = =>
|
||||
if pc.connectionState is 'connected'
|
||||
@ready = true
|
||||
@fire? 'ready'
|
||||
|
||||
pc.createOffer()
|
||||
.then (offer) =>
|
||||
pc.setLocalDescription offer
|
||||
fetch whepURL,
|
||||
method: 'POST'
|
||||
headers:
|
||||
Authorization: "Bearer #{streamKey}"
|
||||
'Content-Type': 'application/sdp'
|
||||
body: offer.sdp
|
||||
.then (r) -> r.text()
|
||||
.then (answer) ->
|
||||
pc.setRemoteDescription type: 'answer', sdp: answer
|
||||
.catch (err) ->
|
||||
console.error 'WHEP negotiation failed:', err
|
||||
|
||||
@pc = pc
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
@videoEl?.play?()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
@videoEl?.pause?()
|
||||
|
||||
seekTo: (t) ->
|
||||
@videoEl?.currentTime = t if @ready
|
||||
|
||||
setVolume: (v) ->
|
||||
@videoEl?.volume = v if @ready
|
||||
|
||||
getTime: (cb) ->
|
||||
if @ready and @videoEl?
|
||||
cb @videoEl.currentTime
|
||||
else
|
||||
cb 0
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @ready and @videoEl?
|
||||
cb @videoEl.volume
|
||||
else
|
||||
cb VOLUME
|
||||
|
|
@ -4,7 +4,6 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
return new YouTubePlayer(data)
|
||||
|
||||
@setMediaProperties(data)
|
||||
@qualityRaceCondition = true
|
||||
@pauseSeekRaceCondition = false
|
||||
|
||||
waitUntilDefined(window, 'YT', =>
|
||||
|
|
@ -13,7 +12,6 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
waitUntilDefined(YT, 'Player', =>
|
||||
removeOld()
|
||||
|
||||
wmode = if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||
@yt = new YT.Player('ytapiplayer',
|
||||
videoId: data.id
|
||||
playerVars:
|
||||
|
|
@ -22,7 +20,6 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
controls: 1
|
||||
iv_load_policy: 3 # iv_load_policy 3 indicates no annotations
|
||||
rel: 0
|
||||
wmode: wmode
|
||||
events:
|
||||
onReady: @onReady.bind(this)
|
||||
onStateChange: @onStateChange.bind(this)
|
||||
|
|
@ -34,9 +31,6 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@setMediaProperties(data)
|
||||
if @yt and @yt.ready
|
||||
@yt.loadVideoById(data.id, data.currentTime)
|
||||
@qualityRaceCondition = true
|
||||
if USEROPTS.default_quality
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
else
|
||||
console.error('WTF? YouTubePlayer::load() called but yt is not ready')
|
||||
|
||||
|
|
@ -45,15 +39,9 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@setVolume(VOLUME)
|
||||
|
||||
onStateChange: (ev) ->
|
||||
# For some reason setting the quality doesn't work
|
||||
# until the first event has fired.
|
||||
if @qualityRaceCondition
|
||||
@qualityRaceCondition = false
|
||||
if USEROPTS.default_quality
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
|
||||
# Similar to above, if you pause the video before the first PLAYING
|
||||
# event is emitted, weird things happen.
|
||||
# If you pause the video before the first PLAYING
|
||||
# event is emitted, weird things happen (or at least that was true
|
||||
# whenever this comment was authored in 2015).
|
||||
if ev.data == YT.PlayerState.PLAYING and @pauseSeekRaceCondition
|
||||
@pause()
|
||||
@pauseSeekRaceCondition = false
|
||||
|
|
@ -90,20 +78,7 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@yt.setVolume(volume * 100)
|
||||
|
||||
setQuality: (quality) ->
|
||||
if not @yt or not @yt.ready
|
||||
return
|
||||
|
||||
ytQuality = switch String(quality)
|
||||
when '240' then 'small'
|
||||
when '360' then 'medium'
|
||||
when '480' then 'large'
|
||||
when '720' then 'hd720'
|
||||
when '1080' then 'hd1080'
|
||||
when 'best' then 'highres'
|
||||
else 'auto'
|
||||
|
||||
if ytQuality != 'auto'
|
||||
@yt.setPlaybackQuality(ytQuality)
|
||||
# https://github.com/calzoneman/sync/issues/726
|
||||
|
||||
getTime: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
#!/bin/sh
|
||||
|
||||
echo "In order to run the server, the source files in src/ must be transpiled to lib/. This will overwrite any changes you have made to the files in lib/."
|
||||
echo -n "Do you want to build now? [y/N]? "
|
||||
read answer
|
||||
echo
|
||||
set -e
|
||||
|
||||
if test "$answer" = "y" || test "$answer" = "Y"; then
|
||||
echo "Running $npm_package_scripts_build_server"
|
||||
$npm_package_scripts_build_server
|
||||
else
|
||||
echo "Skipping build step. You can build at a later time by running \`npm run build-server\`."
|
||||
if ! command -v npm >/dev/null; then
|
||||
echo "Could not find npm in \$PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building from src/ to lib/"
|
||||
npm run build-server
|
||||
echo "Building from player/ to www/js/player.js"
|
||||
npm run build-player
|
||||
echo "Done"
|
||||
|
|
|
|||
122
servcmd.sh.js
Executable file
122
servcmd.sh.js
Executable file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env node
|
||||
/*
|
||||
** CyTube Service Socket Commandline
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const spawn = require('child_process').spawn;
|
||||
const util = require('util');
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
|
||||
const COMPLETIONS = [
|
||||
"/delete_old_tables",
|
||||
"/gc",
|
||||
"/globalban",
|
||||
"/reload",
|
||||
"/reloadcert",
|
||||
"/reload-partitions",
|
||||
"/switch",
|
||||
"/unglobalban",
|
||||
"/unloadchan"
|
||||
];
|
||||
|
||||
var Config = require("./lib/config");
|
||||
Config.load("config.yaml");
|
||||
|
||||
if(!Config.get("service-socket.enabled")){
|
||||
console.error('The Service Socket is not enabled.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SOCKETFILE = Config.get("service-socket.socket");
|
||||
|
||||
// Wipe the TTY
|
||||
process.stdout.write('\x1Bc');
|
||||
|
||||
var commandline, eventlog, syslog, errorlog;
|
||||
var client = net.createConnection(SOCKETFILE).on('connect', () => {
|
||||
commandline = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
completer: tabcomplete
|
||||
});
|
||||
commandline.setPrompt("> ", 2);
|
||||
commandline.on("line", function(line) {
|
||||
if(line === 'exit'){ return cleanup(); }
|
||||
if(line === 'quit'){ return cleanup(); }
|
||||
if(line.match(/^\/globalban/) && line.split(/\s+/).length === 2){
|
||||
console.log('You must provide a reason')
|
||||
return commandline.prompt();
|
||||
}
|
||||
client.write(line);
|
||||
commandline.prompt();
|
||||
});
|
||||
commandline.on('close', function() {
|
||||
return cleanup();
|
||||
});
|
||||
commandline.on("SIGINT", function() {
|
||||
commandline.clearLine();
|
||||
commandline.question("Terminate connection? ", function(answer) {
|
||||
return answer.match(/^y(es)?$/i) ? cleanup() : commandline.output.write("> ");
|
||||
});
|
||||
});
|
||||
commandline.prompt();
|
||||
|
||||
console.log = function() { cmdouthndlr("log", arguments); }
|
||||
console.warn = function() { cmdouthndlr("warn", arguments); }
|
||||
console.error = function() { cmdouthndlr("error", arguments); }
|
||||
// console.info is reserved in this script for the exit message
|
||||
// this prevents an extraneous final prompt from readline on terminate
|
||||
|
||||
eventlog = spawn('tail', ['-f', 'events.log']);
|
||||
eventlog.stdout.on('data', function (data) {
|
||||
console.log(data.toString().replace(/^(.+)$/mg, 'events: $1'));
|
||||
});
|
||||
|
||||
syslog = spawn('tail', ['-f', 'sys.log']);
|
||||
syslog.stdout.on('data', function (data) {
|
||||
console.log(data.toString().replace(/^(.+)$/mg, 'sys: $1'));
|
||||
});
|
||||
|
||||
errorlog = spawn('tail', ['-f', 'error.log']);
|
||||
errorlog.stdout.on('data', function (data) {
|
||||
console.log(data.toString().replace(/^(.+)$/mg, 'error: $1'));
|
||||
});
|
||||
|
||||
}).on('data', (msg) => {
|
||||
msg = msg.toString();
|
||||
|
||||
if(msg === '__disconnect'){
|
||||
console.log('Server shutting down.');
|
||||
return cleanup();
|
||||
}
|
||||
|
||||
// Generic message handler
|
||||
console.log('server: ', data)
|
||||
|
||||
}).on('error', (data) => {
|
||||
console.error('Unable to connect to Service Socket.', data);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function cmdouthndlr(type, args) {
|
||||
var t = Math.ceil((commandline.line.length + 3) / process.stdout.columns);
|
||||
var text = util.format.apply(console, args);
|
||||
commandline.output.write("\n\x1B[" + t + "A\x1B[0J");
|
||||
commandline.output.write(text + "\n");
|
||||
commandline.output.write(Array(t).join("\n\x1B[E"));
|
||||
commandline._refreshLine();
|
||||
}
|
||||
|
||||
function cleanup(){
|
||||
console.info('\n',"Terminating.",'\n');
|
||||
eventlog.kill('SIGTERM');
|
||||
syslog.kill('SIGTERM');
|
||||
client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function tabcomplete(line) {
|
||||
return [COMPLETIONS.filter((cv)=>{ return cv.indexOf(line) == 0; }), line];
|
||||
}
|
||||
1
src/.eslintrc.json
Normal file
1
src/.eslintrc.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "env": { "node": true } }
|
||||
194
src/account.js
194
src/account.js
|
|
@ -1,155 +1,59 @@
|
|||
var db = require("./database");
|
||||
var Q = require("q");
|
||||
import db from './database';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
function Account(opts) {
|
||||
var defaults = {
|
||||
name: "",
|
||||
ip: "",
|
||||
aliases: [],
|
||||
globalRank: -1,
|
||||
channelRank: -1,
|
||||
guest: true,
|
||||
profile: {
|
||||
image: "",
|
||||
text: ""
|
||||
const dbGetGlobalRank = Promise.promisify(db.users.getGlobalRank);
|
||||
const dbMultiGetGlobalRank = Promise.promisify(db.users.getGlobalRanks);
|
||||
const dbGetChannelRank = Promise.promisify(db.channels.getRank);
|
||||
const dbMultiGetChannelRank = Promise.promisify(db.channels.getRanks);
|
||||
const dbGetAliases = Promise.promisify(db.getAliases);
|
||||
|
||||
const DEFAULT_PROFILE = Object.freeze({ image: '', text: '' });
|
||||
|
||||
class Account {
|
||||
constructor(ip, user, aliases) {
|
||||
this.ip = ip;
|
||||
this.user = user;
|
||||
this.aliases = aliases;
|
||||
this.channelRank = -1;
|
||||
this.guestName = null;
|
||||
|
||||
this.update();
|
||||
}
|
||||
};
|
||||
|
||||
this.name = opts.name || defaults.name;
|
||||
update() {
|
||||
if (this.user !== null) {
|
||||
this.name = this.user.name;
|
||||
this.globalRank = this.user.global_rank;
|
||||
} else if (this.guestName !== null) {
|
||||
this.name = this.guestName;
|
||||
this.globalRank = 0;
|
||||
} else {
|
||||
this.name = '';
|
||||
this.globalRank = -1;
|
||||
}
|
||||
this.lowername = this.name.toLowerCase();
|
||||
this.ip = opts.ip || defaults.ip;
|
||||
this.aliases = opts.aliases || defaults.aliases;
|
||||
this.globalRank = "globalRank" in opts ? opts.globalRank : defaults.globalRank;
|
||||
this.channelRank = "channelRank" in opts ? opts.channelRank : defaults.channelRank;
|
||||
this.effectiveRank = Math.max(this.globalRank, this.channelRank);
|
||||
this.guest = this.globalRank === 0;
|
||||
this.profile = opts.profile || defaults.profile;
|
||||
this.effectiveRank = Math.max(this.channelRank, this.globalRank);
|
||||
this.profile = (this.user === null) ? DEFAULT_PROFILE : this.user.profile;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.default = function (ip) {
|
||||
return new Account({ ip: ip });
|
||||
module.exports.Account = Account;
|
||||
|
||||
module.exports.rankForName = async function rankForNameAsync(name, channel) {
|
||||
const [globalRank, channelRank] = await Promise.all([
|
||||
dbGetGlobalRank(name),
|
||||
dbGetChannelRank(channel, name)
|
||||
]);
|
||||
|
||||
return Math.max(globalRank, channelRank);
|
||||
};
|
||||
|
||||
module.exports.getAccount = function (name, ip, opts, cb) {
|
||||
if (!cb) {
|
||||
cb = opts;
|
||||
opts = {};
|
||||
}
|
||||
opts.channel = opts.channel || false;
|
||||
module.exports.rankForIP = async function rankForIP(ip, channel) {
|
||||
const aliases = await dbGetAliases(ip);
|
||||
const [globalRanks, channelRanks] = await Promise.all([
|
||||
dbMultiGetGlobalRank(aliases),
|
||||
dbMultiGetChannelRank(channel, aliases)
|
||||
]);
|
||||
|
||||
var data = {};
|
||||
Q.nfcall(db.getAliases, ip)
|
||||
.then(function (aliases) {
|
||||
data.aliases = aliases;
|
||||
if (name && opts.registered) {
|
||||
return Q.nfcall(db.users.getGlobalRank, name);
|
||||
} else if (name) {
|
||||
return 0;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}).then(function (globalRank) {
|
||||
data.globalRank = globalRank;
|
||||
if (opts.channel && opts.registered) {
|
||||
return Q.nfcall(db.channels.getRank, opts.channel, name);
|
||||
} else {
|
||||
if (opts.registered) {
|
||||
return 1;
|
||||
} else if (name) {
|
||||
return 0;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}).then(function (chanRank) {
|
||||
data.channelRank = chanRank;
|
||||
/* Look up profile for registered user */
|
||||
if (data.globalRank >= 1) {
|
||||
return Q.nfcall(db.users.getProfile, name);
|
||||
} else {
|
||||
return { text: "", image: "" };
|
||||
}
|
||||
}).then(function (profile) {
|
||||
setImmediate(function () {
|
||||
cb(null, new Account({
|
||||
name: name,
|
||||
ip: ip,
|
||||
aliases: data.aliases,
|
||||
globalRank: data.globalRank,
|
||||
channelRank: data.channelRank,
|
||||
profile: profile
|
||||
}));
|
||||
});
|
||||
}).catch(function (err) {
|
||||
cb(err, null);
|
||||
}).done();
|
||||
};
|
||||
|
||||
module.exports.rankForName = function (name, opts, cb) {
|
||||
if (!cb) {
|
||||
cb = opts;
|
||||
opts = {};
|
||||
}
|
||||
|
||||
var rank = 0;
|
||||
Q.fcall(function () {
|
||||
return Q.nfcall(db.users.getGlobalRank, name);
|
||||
}).then(function (globalRank) {
|
||||
rank = globalRank;
|
||||
if (opts.channel) {
|
||||
return Q.nfcall(db.channels.getRank, opts.channel, name);
|
||||
} else {
|
||||
return globalRank > 0 ? 1 : 0;
|
||||
}
|
||||
}).then(function (chanRank) {
|
||||
setImmediate(function () {
|
||||
cb(null, Math.max(rank, chanRank));
|
||||
});
|
||||
}).catch(function (err) {
|
||||
cb(err, 0);
|
||||
}).done();
|
||||
};
|
||||
|
||||
module.exports.rankForIP = function (ip, opts, cb) {
|
||||
if (!cb) {
|
||||
cb = opts;
|
||||
opts = {};
|
||||
}
|
||||
|
||||
var globalRank, rank, names;
|
||||
|
||||
var promise = Q.nfcall(db.getAliases, ip)
|
||||
.then(function (_names) {
|
||||
names = _names;
|
||||
return Q.nfcall(db.users.getGlobalRanks, names);
|
||||
}).then(function (ranks) {
|
||||
ranks.push(0);
|
||||
globalRank = Math.max.apply(Math, ranks);
|
||||
rank = globalRank;
|
||||
});
|
||||
|
||||
if (!opts.channel) {
|
||||
promise.then(function () {
|
||||
setImmediate(function () {
|
||||
cb(null, globalRank);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
cb(err, null);
|
||||
}).done();
|
||||
} else {
|
||||
promise.then(function () {
|
||||
return Q.nfcall(db.channels.getRanks, opts.channel, names);
|
||||
}).then(function (ranks) {
|
||||
ranks.push(globalRank);
|
||||
rank = Math.max.apply(Math, ranks);
|
||||
}).then(function () {
|
||||
setImmediate(function () {
|
||||
cb(null, rank);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
setImmediate(function () {
|
||||
cb(err, null);
|
||||
});
|
||||
}).done();
|
||||
}
|
||||
return Math.max.apply(Math, globalRanks.concat(channelRanks));
|
||||
};
|
||||
|
|
|
|||
135
src/acp.js
135
src/acp.js
|
|
@ -2,8 +2,7 @@ var Logger = require("./logger");
|
|||
var Server = require("./server");
|
||||
var db = require("./database");
|
||||
var util = require("./utilities");
|
||||
var Config = require("./config");
|
||||
var Server = require("./server");
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
function eventUsername(user) {
|
||||
return user.getName() + "@" + user.realip;
|
||||
|
|
@ -13,6 +12,7 @@ function handleAnnounce(user, data) {
|
|||
var sv = Server.getServer();
|
||||
|
||||
sv.announce({
|
||||
id: uuidv4(),
|
||||
title: data.title,
|
||||
text: data.content,
|
||||
from: user.getName()
|
||||
|
|
@ -28,77 +28,60 @@ function handleAnnounceClear(user) {
|
|||
}
|
||||
|
||||
function handleGlobalBan(user, data) {
|
||||
db.globalBanIP(data.ip, data.note, function (err, res) {
|
||||
if (err) {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const globalBanDB = db.getGlobalBanDB();
|
||||
globalBanDB.addGlobalIPBan(data.ip, data.note).then(() => {
|
||||
Logger.eventlog.log("[acp] " + eventUsername(user) + " global banned " + data.ip);
|
||||
|
||||
db.listGlobalBans(function (err, bans) {
|
||||
if (err) {
|
||||
return globalBanDB.listGlobalBans().then(bans => {
|
||||
// Why is it called reason in the DB and note in the socket frame?
|
||||
// Who knows...
|
||||
const mappedBans = bans.map(ban => {
|
||||
return { ip: ban.ip, note: ban.reason };
|
||||
});
|
||||
user.socket.emit("acp-gbanlist", mappedBans);
|
||||
});
|
||||
}).catch(error => {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var flat = [];
|
||||
for (var ip in bans) {
|
||||
flat.push({
|
||||
ip: ip,
|
||||
note: bans[ip].reason
|
||||
});
|
||||
}
|
||||
user.socket.emit("acp-gbanlist", flat);
|
||||
msg: error.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleGlobalBanDelete(user, data) {
|
||||
db.globalUnbanIP(data.ip, function (err, res) {
|
||||
if (err) {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const globalBanDB = db.getGlobalBanDB();
|
||||
globalBanDB.removeGlobalIPBan(data.ip).then(() => {
|
||||
Logger.eventlog.log("[acp] " + eventUsername(user) + " un-global banned " +
|
||||
data.ip);
|
||||
|
||||
db.listGlobalBans(function (err, bans) {
|
||||
if (err) {
|
||||
return globalBanDB.listGlobalBans().then(bans => {
|
||||
// Why is it called reason in the DB and note in the socket frame?
|
||||
// Who knows...
|
||||
const mappedBans = bans.map(ban => {
|
||||
return { ip: ban.ip, note: ban.reason };
|
||||
});
|
||||
user.socket.emit("acp-gbanlist", mappedBans);
|
||||
});
|
||||
}).catch(error => {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var flat = [];
|
||||
for (var ip in bans) {
|
||||
flat.push({
|
||||
ip: ip,
|
||||
note: bans[ip].reason
|
||||
});
|
||||
}
|
||||
user.socket.emit("acp-gbanlist", flat);
|
||||
msg: error.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleListUsers(user, data) {
|
||||
var name = data.name;
|
||||
if (typeof name !== "string") {
|
||||
name = "";
|
||||
}
|
||||
var value = data.value;
|
||||
var field = data.field;
|
||||
value = (typeof value !== 'string') ? '' : value;
|
||||
field = (typeof field !== 'string') ? 'name' : field;
|
||||
|
||||
var fields = ["id", "name", "global_rank", "email", "ip", "time"];
|
||||
|
||||
db.users.search(name, fields, function (err, users) {
|
||||
if(!fields.includes(field)){
|
||||
user.socket.emit("errMessage", {
|
||||
msg: `The field "${field}" doesn't exist or isn't searchable.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
db.users.search(field, value, fields, function (err, users) {
|
||||
if (err) {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
|
|
@ -154,7 +137,7 @@ function handleSetRank(user, data) {
|
|||
});
|
||||
}
|
||||
|
||||
function handleResetPassword(user, data) {
|
||||
function handleResetPassword(user, data, ack) {
|
||||
var name = data.name;
|
||||
var email = data.email;
|
||||
if (typeof name !== "string" || typeof email !== "string") {
|
||||
|
|
@ -179,19 +162,14 @@ function handleResetPassword(user, data) {
|
|||
expire: expire
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
});
|
||||
ack && ack({ error: err });
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.eventlog.log("[acp] " + eventUsername(user) + " initialized a " +
|
||||
"password recovery for " + name);
|
||||
|
||||
user.socket.emit("errMessage", {
|
||||
msg: "Reset link: " + Config.get("http.domain") +
|
||||
"/account/passwordrecover/" + hash
|
||||
});
|
||||
ack && ack({ hash });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -274,12 +252,6 @@ function handleForceUnload(user, data) {
|
|||
Logger.eventlog.log("[acp] " + eventUsername(user) + " forced unload of " + name);
|
||||
}
|
||||
|
||||
function handleListStats(user) {
|
||||
db.listStats(function (err, rows) {
|
||||
user.socket.emit("acp-list-stats", rows);
|
||||
});
|
||||
}
|
||||
|
||||
function init(user) {
|
||||
var s = user.socket;
|
||||
s.on("acp-announce", handleAnnounce.bind(this, user));
|
||||
|
|
@ -293,24 +265,19 @@ function init(user) {
|
|||
s.on("acp-delete-channel", handleDeleteChannel.bind(this, user));
|
||||
s.on("acp-list-activechannels", handleListActiveChannels.bind(this, user));
|
||||
s.on("acp-force-unload", handleForceUnload.bind(this, user));
|
||||
s.on("acp-list-stats", handleListStats.bind(this, user));
|
||||
|
||||
db.listGlobalBans(function (err, bans) {
|
||||
if (err) {
|
||||
const globalBanDB = db.getGlobalBanDB();
|
||||
globalBanDB.listGlobalBans().then(bans => {
|
||||
// Why is it called reason in the DB and note in the socket frame?
|
||||
// Who knows...
|
||||
const mappedBans = bans.map(ban => {
|
||||
return { ip: ban.ip, note: ban.reason };
|
||||
});
|
||||
user.socket.emit("acp-gbanlist", mappedBans);
|
||||
}).catch(error => {
|
||||
user.socket.emit("errMessage", {
|
||||
msg: err
|
||||
msg: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var flat = [];
|
||||
for (var ip in bans) {
|
||||
flat.push({
|
||||
ip: ip,
|
||||
note: bans[ip].reason
|
||||
});
|
||||
}
|
||||
user.socket.emit("acp-gbanlist", flat);
|
||||
});
|
||||
Logger.eventlog.log("[acp] Initialized ACP for " + eventUsername(user));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ AsyncQueue.prototype.next = function () {
|
|||
if (!this.lock())
|
||||
return;
|
||||
var item = this._q.shift();
|
||||
var fn = item[0], tm = item[1];
|
||||
var fn = item[0];
|
||||
this._tm = Date.now() + item[1];
|
||||
fn(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
class BackendConfiguration {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getRedisConfig() {
|
||||
return this.config.redis;
|
||||
}
|
||||
|
||||
getListenerConfig() {
|
||||
return this.config.proxy.listeners.map(listener => ({
|
||||
getHost() {
|
||||
return listener.host;
|
||||
},
|
||||
|
||||
getPort() {
|
||||
return listener.port;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export { BackendConfiguration };
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { RedisClusterClient } from '../io/cluster/redisclusterclient';
|
||||
import { FrontendPool } from 'cytube-common/lib/redis/frontendpool';
|
||||
import RedisClientProvider from 'cytube-common/lib/redis/redisclientprovider';
|
||||
import { loadFromToml } from 'cytube-common/lib/configuration/configloader';
|
||||
import path from 'path';
|
||||
import { BackendConfiguration } from './backendconfiguration';
|
||||
import logger from 'cytube-common/lib/logger';
|
||||
import redisAdapter from 'socket.io-redis';
|
||||
|
||||
const BACKEND_CONFIG_PATH = path.resolve(__dirname, '..', '..', 'backend.toml');
|
||||
|
||||
class BackendModule {
|
||||
constructor() {
|
||||
this.initConfig();
|
||||
}
|
||||
|
||||
initConfig() {
|
||||
try {
|
||||
this.backendConfig = loadFromToml(BackendConfiguration, BACKEND_CONFIG_PATH);
|
||||
} catch (error) {
|
||||
if (typeof error.line !== 'undefined') {
|
||||
logger.error(`Error in configuration file: ${error} (line ${error.line})`);
|
||||
} else {
|
||||
logger.error(`Error loading configuration: ${error.stack}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
onReady() {
|
||||
const redisClientProvider = this.getRedisClientProvider();
|
||||
this.redisAdapter = redisAdapter({
|
||||
pubClient: redisClientProvider.get(),
|
||||
subClient: redisClientProvider.get()
|
||||
});
|
||||
this.sioEmitter = require('socket.io').instance;
|
||||
this.sioEmitter.adapter(this.redisAdapter);
|
||||
const IOBackend = require('./iobackend');
|
||||
this.ioBackend = new IOBackend(
|
||||
this.backendConfig.getListenerConfig()[0],
|
||||
this.sioEmitter,
|
||||
redisClientProvider.get()
|
||||
)
|
||||
}
|
||||
|
||||
getFrontendPool() {
|
||||
if (!this.frontendPool) {
|
||||
this.frontendPool = new FrontendPool(this.getRedisClientProvider().get());
|
||||
}
|
||||
|
||||
return this.frontendPool;
|
||||
}
|
||||
|
||||
getRedisClientProvider() {
|
||||
if (!this.redisClientProvider) {
|
||||
this.redisClientProvider = new RedisClientProvider(
|
||||
this.backendConfig.getRedisConfig()
|
||||
);
|
||||
}
|
||||
|
||||
return this.redisClientProvider;
|
||||
}
|
||||
|
||||
getClusterClient() {
|
||||
if (!this.redisClusterClient) {
|
||||
this.redisClusterClient = new RedisClusterClient(this.getFrontendPool());
|
||||
}
|
||||
|
||||
return this.redisClusterClient;
|
||||
}
|
||||
}
|
||||
|
||||
export { BackendModule }
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import Server from 'cytube-common/lib/proxy/server';
|
||||
import ProxyInterceptor from './proxyinterceptor';
|
||||
import uuid from 'uuid';
|
||||
import PoolEntryUpdater from 'cytube-common/lib/redis/poolentryupdater';
|
||||
import JSONProtocol from 'cytube-common/lib/proxy/protocol';
|
||||
import { formatProxyAddress } from 'cytube-common/lib/util/addressutil';
|
||||
|
||||
const BACKEND_POOL = 'backend-hosts';
|
||||
|
||||
export default class IOBackend {
|
||||
constructor(proxyListenerConfig, socketEmitter, poolRedisClient) {
|
||||
this.proxyListenerConfig = proxyListenerConfig;
|
||||
this.socketEmitter = socketEmitter;
|
||||
this.poolRedisClient = poolRedisClient;
|
||||
this.protocol = new JSONProtocol();
|
||||
this.initProxyInterceptor();
|
||||
this.initProxyListener();
|
||||
this.initBackendPoolUpdater();
|
||||
}
|
||||
|
||||
initProxyInterceptor() {
|
||||
this.proxyInterceptor = new ProxyInterceptor(this.socketEmitter);
|
||||
}
|
||||
|
||||
initProxyListener() {
|
||||
this.proxyListener = new Server(this.proxyListenerConfig, this.protocol);
|
||||
this.proxyListener.on('connection',
|
||||
this.proxyInterceptor.onConnection.bind(this.proxyInterceptor));
|
||||
}
|
||||
|
||||
initBackendPoolUpdater() {
|
||||
const hostname = this.proxyListenerConfig.getHost();
|
||||
const port = this.proxyListenerConfig.getPort();
|
||||
const entry = {
|
||||
address: formatProxyAddress(hostname, port)
|
||||
}
|
||||
this.poolEntryUpdater = new PoolEntryUpdater(
|
||||
this.poolRedisClient,
|
||||
BACKEND_POOL,
|
||||
uuid.v4(),
|
||||
entry
|
||||
);
|
||||
this.poolEntryUpdater.start();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import logger from 'cytube-common/lib/logger';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export default class ProxiedSocket extends EventEmitter {
|
||||
constructor(socketID, socketIP, socketUser, socketEmitter, frontendConnection) {
|
||||
super();
|
||||
this.id = socketID;
|
||||
this.ip = socketIP;
|
||||
this._realip = socketIP;
|
||||
if (socketUser) {
|
||||
this.user = {
|
||||
name: socketUser.name,
|
||||
global_rank: socketUser.globalRank
|
||||
};
|
||||
}
|
||||
this.socketEmitter = socketEmitter;
|
||||
this.frontendConnection = frontendConnection;
|
||||
}
|
||||
|
||||
emit() {
|
||||
const target = this.socketEmitter.to(this.id);
|
||||
target.emit.apply(target, arguments);
|
||||
}
|
||||
|
||||
onProxiedEventReceived() {
|
||||
try {
|
||||
EventEmitter.prototype.emit.apply(this, arguments);
|
||||
} catch (error) {
|
||||
logger.error(`Emit failed: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
join(channel) {
|
||||
this.frontendConnection.write(
|
||||
this.frontendConnection.protocol.newSocketJoinRoomsEvent(
|
||||
this.id, [channel]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.frontendConnection.write(
|
||||
this.frontendConnection.protocol.newSocketKickEvent(this.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import logger from 'cytube-common/lib/logger';
|
||||
import ioServer from '../io/ioserver';
|
||||
import ProxiedSocket from './proxiedsocket';
|
||||
|
||||
export default class ProxyInterceptor {
|
||||
constructor(socketEmitter) {
|
||||
this.socketEmitter = socketEmitter;
|
||||
this.frontendConnections = {};
|
||||
this.frontendProxiedSockets = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new frontend proxy connection.
|
||||
*
|
||||
* @param {Connection} socket frontend proxy connection
|
||||
*/
|
||||
onConnection(socket) {
|
||||
if (this.frontendConnections.hasOwnProperty(socket.endpoint)) {
|
||||
logger.error(`Duplicate frontend connection: ${socket.endpoint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.frontendConnections[socket.endpoint] = socket;
|
||||
socket.on('close', this.onFrontendDisconnect.bind(this, socket));
|
||||
socket.on('SocketConnectEvent', this.onSocketConnect.bind(this, socket));
|
||||
socket.on('SocketFrameEvent', this.onSocketFrame.bind(this, socket));
|
||||
socket.on('SocketDisconnectEvent', this.onSocketDisconnect.bind(this, socket));
|
||||
}
|
||||
|
||||
onFrontendDisconnect(socket) {
|
||||
const endpoint = socket.endpoint;
|
||||
if (this.frontendConnections.hasOwnProperty(endpoint)) {
|
||||
if (this.frontendProxiedSockets.hasOwnProperty(endpoint)) {
|
||||
logger.warn(`Frontend ${endpoint} disconnected`);
|
||||
for (const key in this.frontendProxiedSockets[endpoint]) {
|
||||
const proxySocket = this.frontendProxiedSockets[endpoint][key];
|
||||
proxySocket.onProxiedEventReceived('disconnect');
|
||||
}
|
||||
delete this.frontendProxiedSockets[endpoint];
|
||||
}
|
||||
delete this.frontendConnections[endpoint];
|
||||
}
|
||||
}
|
||||
|
||||
onSocketConnect(frontendConnection, socketID, socketIP, socketUser) {
|
||||
const mapKey = frontendConnection.endpoint;
|
||||
const proxiedSocket = new ProxiedSocket(
|
||||
socketID,
|
||||
socketIP,
|
||||
socketUser,
|
||||
this.socketEmitter,
|
||||
frontendConnection);
|
||||
|
||||
if (!this.frontendProxiedSockets.hasOwnProperty(mapKey)) {
|
||||
this.frontendProxiedSockets[mapKey] = {};
|
||||
} else if (this.frontendProxiedSockets[mapKey].hasOwnProperty(socketID)) {
|
||||
logger.error(`Duplicate SocketConnectEvent for ${socketID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.frontendProxiedSockets[mapKey][socketID] = proxiedSocket;
|
||||
ioServer.handleConnection(proxiedSocket);
|
||||
}
|
||||
|
||||
onSocketFrame(frontendConnection, socketID, event, args) {
|
||||
const mapKey = frontendConnection.endpoint;
|
||||
const socketMap = this.frontendProxiedSockets[mapKey];
|
||||
if (!socketMap || !socketMap.hasOwnProperty(socketID)) {
|
||||
logger.error(`Received SocketFrameEvent for nonexistent socket`,
|
||||
{ socketID, event });
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = socketMap[socketID];
|
||||
socket.onProxiedEventReceived.apply(socket, [event].concat(args));
|
||||
}
|
||||
|
||||
onSocketDisconnect(frontendConnection, socketID) {
|
||||
const mapKey = frontendConnection.endpoint;
|
||||
const socketMap = this.frontendProxiedSockets[mapKey];
|
||||
if (!socketMap || !socketMap.hasOwnProperty(socketID)) {
|
||||
logger.error(`Received SocketDisconnectEvent for nonexistent socket`,
|
||||
{ socketID });
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = socketMap[socketID];
|
||||
socket.onProxiedEventReceived.apply(socket, ['disconnect']);
|
||||
}
|
||||
}
|
||||
169
src/bgtask.js
169
src/bgtask.js
|
|
@ -5,82 +5,179 @@
|
|||
running.
|
||||
*/
|
||||
|
||||
var Logger = require("./logger");
|
||||
var Config = require("./config");
|
||||
var db = require("./database");
|
||||
var Promise = require("bluebird");
|
||||
const shows = require('./shows');
|
||||
const calendarDB = require('./database/calendar-integrations');
|
||||
const integrationsApi = require('./web/routes/api/integrations');
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('bgtask');
|
||||
|
||||
var init = null;
|
||||
|
||||
/* Stats */
|
||||
function initStats(Server) {
|
||||
var STAT_INTERVAL = parseInt(Config.get("stats.interval"));
|
||||
var STAT_EXPIRE = parseInt(Config.get("stats.max-age"));
|
||||
|
||||
setInterval(function () {
|
||||
var chancount = Server.channels.length;
|
||||
var usercount = 0;
|
||||
Server.channels.forEach(function (chan) {
|
||||
usercount += chan.users.length;
|
||||
});
|
||||
|
||||
var mem = process.memoryUsage().rss;
|
||||
|
||||
db.addStatPoint(Date.now(), usercount, chancount, mem, function () {
|
||||
db.pruneStats(Date.now() - STAT_EXPIRE);
|
||||
});
|
||||
}, STAT_INTERVAL);
|
||||
}
|
||||
|
||||
/* Alias cleanup */
|
||||
function initAliasCleanup(Server) {
|
||||
function initAliasCleanup() {
|
||||
var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval"));
|
||||
var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age"));
|
||||
|
||||
setInterval(function () {
|
||||
db.cleanOldAliases(CLEAN_EXPIRE, function (err) {
|
||||
Logger.syslog.log("Cleaned old aliases");
|
||||
LOGGER.info("Cleaned old aliases");
|
||||
if (err)
|
||||
Logger.errlog.log(err);
|
||||
LOGGER.error(err);
|
||||
});
|
||||
}, CLEAN_INTERVAL);
|
||||
}
|
||||
|
||||
/* Password reset cleanup */
|
||||
function initPasswordResetCleanup(Server) {
|
||||
function initPasswordResetCleanup() {
|
||||
var CLEAN_INTERVAL = 8*60*60*1000;
|
||||
|
||||
setInterval(function () {
|
||||
db.cleanOldPasswordResets(function (err) {
|
||||
if (err)
|
||||
Logger.errlog.log(err);
|
||||
LOGGER.error(err);
|
||||
});
|
||||
}, CLEAN_INTERVAL);
|
||||
}
|
||||
|
||||
function initChannelDumper(Server) {
|
||||
const chanPath = Config.get('channel-path');
|
||||
var CHANNEL_SAVE_INTERVAL = parseInt(Config.get("channel-save-interval"))
|
||||
* 60000;
|
||||
setInterval(function () {
|
||||
Promise.reduce(Server.channels, (_, chan) => {
|
||||
if (!chan.dead && chan.users && chan.users.length > 0) {
|
||||
return chan.saveState().catch(err => {
|
||||
Logger.errlog.log(`Failed to save /r/${chan.name}: ${err.stack}`);
|
||||
});
|
||||
if (Server.channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
var wait = CHANNEL_SAVE_INTERVAL / Server.channels.length;
|
||||
LOGGER.info(`Saving channels with delay ${wait}`);
|
||||
Promise.reduce(Server.channels, (_, chan) => {
|
||||
return Promise.delay(wait).then(async () => {
|
||||
if (!chan.dead && chan.users && chan.users.length > 0) {
|
||||
try {
|
||||
await chan.saveState();
|
||||
LOGGER.info(`Saved /${chanPath}/${chan.name}`);
|
||||
} catch (error) {
|
||||
LOGGER.error(
|
||||
'Failed to save /%s/%s: %s',
|
||||
chanPath,
|
||||
chan ? chan.name : '<undefined>',
|
||||
error.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
LOGGER.error(`Failed to save channel: ${error.stack}`);
|
||||
});
|
||||
}, 0).catch(error => {
|
||||
LOGGER.error(`Failed to save channels: ${error.stack}`);
|
||||
});
|
||||
}, CHANNEL_SAVE_INTERVAL);
|
||||
}
|
||||
|
||||
function initAccountCleanup() {
|
||||
setInterval(() => {
|
||||
(async () => {
|
||||
let rows = await db.users.findAccountsPendingDeletion();
|
||||
for (let row of rows) {
|
||||
try {
|
||||
await db.users.purgeAccount(row.id);
|
||||
LOGGER.info('Purged account from request %j', row);
|
||||
} catch (error) {
|
||||
LOGGER.error('Error purging account %j: %s', row, error.stack);
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
LOGGER.error('Error purging deleted accounts: %s', error.stack);
|
||||
});
|
||||
}, 3600 * 1000);
|
||||
}
|
||||
|
||||
function initShowScheduler() {
|
||||
var SCHEDULE_INTERVAL = 15 * 1000;
|
||||
var running = false;
|
||||
|
||||
setInterval(async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
await shows.pollAndRunDueShows();
|
||||
} catch (error) {
|
||||
LOGGER.error('Show scheduler failure: %s', error.stack || error);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, SCHEDULE_INTERVAL);
|
||||
}
|
||||
|
||||
function initCalendarAutoSyncScheduler() {
|
||||
const AUTO_SYNC_INTERVAL_MS = 60 * 1000;
|
||||
const AUTO_SYNC_PERIOD_MINUTES = 30;
|
||||
let running = false;
|
||||
|
||||
function inStaggerSlot(integrationId, now) {
|
||||
const slot = Number(integrationId || 0) % AUTO_SYNC_PERIOD_MINUTES;
|
||||
const minute = new Date(now).getUTCMinutes() % AUTO_SYNC_PERIOD_MINUTES;
|
||||
return slot === minute;
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const rows = await calendarDB.listConnectedByProvider('google');
|
||||
for (const integration of rows) {
|
||||
if (!inStaggerSlot(integration.id, now)) {
|
||||
continue;
|
||||
}
|
||||
if (integration.last_sync_at && now - integration.last_sync_at < AUTO_SYNC_PERIOD_MINUTES * 60 * 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await integrationsApi.syncIntegrationNow({
|
||||
provider: 'google',
|
||||
integration,
|
||||
channelRow: { id: integration.channel_id },
|
||||
enforceCooldown: false
|
||||
});
|
||||
} catch (err) {
|
||||
LOGGER.warn(
|
||||
'Auto calendar sync failed integration=%s channel=%s: %s',
|
||||
integration.id,
|
||||
integration.channel_id,
|
||||
err && (err.stack || err.message) || err
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
LOGGER.error('Calendar auto-sync scheduler failure: %s', err.stack || err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, AUTO_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
module.exports = function (Server) {
|
||||
if (init === Server) {
|
||||
Logger.errlog.log("WARNING: Attempted to re-init background tasks");
|
||||
LOGGER.warn("Attempted to re-init background tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
init = Server;
|
||||
initStats(Server);
|
||||
initAliasCleanup(Server);
|
||||
initAliasCleanup();
|
||||
initChannelDumper(Server);
|
||||
initPasswordResetCleanup(Server);
|
||||
initPasswordResetCleanup();
|
||||
initAccountCleanup();
|
||||
initShowScheduler();
|
||||
initCalendarAutoSyncScheduler();
|
||||
};
|
||||
|
|
|
|||
29
src/bot-socket-registry.js
Normal file
29
src/bot-socket-registry.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const registry = new Map(); // botId -> Set<socket>
|
||||
|
||||
function register(botId, socket) {
|
||||
if (!registry.has(botId)) {
|
||||
registry.set(botId, new Set());
|
||||
}
|
||||
registry.get(botId).add(socket);
|
||||
socket.once('disconnect', () => {
|
||||
const sockets = registry.get(botId);
|
||||
if (sockets) {
|
||||
sockets.delete(socket);
|
||||
if (sockets.size === 0) {
|
||||
registry.delete(botId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect(botId) {
|
||||
const sockets = registry.get(botId);
|
||||
if (sockets) {
|
||||
for (const socket of sockets) {
|
||||
socket.disconnect(true);
|
||||
}
|
||||
registry.delete(botId);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { register, disconnect };
|
||||
46
src/camo.js
Normal file
46
src/camo.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import crypto from 'crypto';
|
||||
import * as urlparse from 'url';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('camo');
|
||||
|
||||
function isWhitelisted(camoConfig, url) {
|
||||
const whitelistedDomains = camoConfig.getWhitelistedDomainsRegexp();
|
||||
const parsed = urlparse.parse(url);
|
||||
return whitelistedDomains.test('.' + parsed.hostname);
|
||||
}
|
||||
|
||||
export function camoify(camoConfig, url) {
|
||||
if (typeof url !== 'string') {
|
||||
throw new TypeError(`camoify expected a string, not [${url}]`);
|
||||
}
|
||||
|
||||
if (isWhitelisted(camoConfig, url)) {
|
||||
return url.replace(/^http:/, 'https:');
|
||||
}
|
||||
|
||||
const hmac = crypto.createHmac('sha1', camoConfig.getKey());
|
||||
hmac.update(url);
|
||||
const digest = hmac.digest('hex');
|
||||
// https://github.com/atmos/camo#url-formats
|
||||
if (camoConfig.getEncoding() === 'hex') {
|
||||
const hexUrl = Buffer.from(url, 'utf8').toString('hex');
|
||||
return `${camoConfig.getServer()}/${digest}/${hexUrl}`;
|
||||
} else {
|
||||
const encoded = encodeURIComponent(url);
|
||||
return `${camoConfig.getServer()}/${digest}?url=${encoded}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function transformImgTags(camoConfig, tagName, attribs) {
|
||||
if (typeof attribs.src === 'string') {
|
||||
try {
|
||||
const oldSrc = attribs.src;
|
||||
attribs.src = camoify(camoConfig, attribs.src);
|
||||
LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src);
|
||||
} catch (error) {
|
||||
LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { tagName, attribs };
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { FileStore } from './filestore';
|
||||
import { DatabaseStore } from './dbstore';
|
||||
import Config from '../config';
|
||||
import Promise from 'bluebird';
|
||||
|
|
@ -9,28 +8,29 @@ export function init() {
|
|||
CHANNEL_STORE = loadChannelStore();
|
||||
}
|
||||
|
||||
export function load(channelName) {
|
||||
export function load(id, channelName) {
|
||||
if (CHANNEL_STORE === null) {
|
||||
return Promise.reject(new Error('ChannelStore not initialized yet'));
|
||||
}
|
||||
|
||||
return CHANNEL_STORE.load(channelName);
|
||||
return CHANNEL_STORE.load(id, channelName);
|
||||
}
|
||||
|
||||
export function save(channelName, data) {
|
||||
export function save(id, channelName, data) {
|
||||
if (CHANNEL_STORE === null) {
|
||||
return Promise.reject(new Error('ChannelStore not initialized yet'));
|
||||
}
|
||||
|
||||
return CHANNEL_STORE.save(channelName, data);
|
||||
return CHANNEL_STORE.save(id, channelName, data);
|
||||
}
|
||||
|
||||
function loadChannelStore() {
|
||||
switch (Config.get('channel-storage.type')) {
|
||||
case 'database':
|
||||
if (Config.get('channel-storage.type') === 'file') {
|
||||
throw new Error(
|
||||
'channel-storage type "file" is no longer supported. Please see ' +
|
||||
'NEWS.md for instructions on upgrading.'
|
||||
);
|
||||
}
|
||||
|
||||
return new DatabaseStore();
|
||||
case 'file':
|
||||
default:
|
||||
return new FileStore();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import Promise from 'bluebird';
|
|||
import Config from '../config';
|
||||
import db from '../database';
|
||||
import { DatabaseStore } from './dbstore';
|
||||
import { syslog } from '../logger';
|
||||
syslog.log = () => undefined;
|
||||
|
||||
/* eslint no-console: off */
|
||||
function main() {
|
||||
Config.load('config.yaml');
|
||||
db.init();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
import Promise from 'bluebird';
|
||||
import { ChannelStateSizeError,
|
||||
ChannelNotFoundError } from '../errors';
|
||||
import { ChannelStateSizeError } from '../errors';
|
||||
import db from '../database';
|
||||
import Logger from '../logger';
|
||||
import { Counter } from 'prom-client';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('dbstore');
|
||||
const SIZE_LIMIT = 1048576;
|
||||
const QUERY_CHANNEL_ID_FOR_NAME = 'SELECT id FROM channels WHERE name = ?';
|
||||
const QUERY_CHANNEL_DATA = 'SELECT `key`, `value` FROM channel_data WHERE channel_id = ?';
|
||||
const loadRowcount = new Counter({
|
||||
name: 'cytube_channel_db_load_rows_total',
|
||||
help: 'Total rows loaded from the channel_data table'
|
||||
});
|
||||
const loadCharcount = new Counter({
|
||||
name: 'cytube_channel_db_load_chars_total',
|
||||
help: 'Total characters (JSON length) loaded from the channel_data table'
|
||||
});
|
||||
const saveRowcount = new Counter({
|
||||
name: 'cytube_channel_db_save_rows_total',
|
||||
help: 'Total rows saved in the channel_data table'
|
||||
});
|
||||
const saveCharcount = new Counter({
|
||||
name: 'cytube_channel_db_save_chars_total',
|
||||
help: 'Total characters (JSON length) saved in the channel_data table'
|
||||
});
|
||||
|
||||
function queryAsync(query, substitutions) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -34,20 +49,22 @@ function buildUpdateQuery(numEntries) {
|
|||
}
|
||||
|
||||
export class DatabaseStore {
|
||||
load(channelName) {
|
||||
return queryAsync(QUERY_CHANNEL_ID_FOR_NAME, [channelName]).then((rows) => {
|
||||
if (rows.length === 0) {
|
||||
throw new ChannelNotFoundError(`Channel does not exist: "${channelName}"`);
|
||||
load(id, channelName) {
|
||||
if (!id || id === 0) {
|
||||
return Promise.reject(new Error(`Cannot load state for [${channelName}]: ` +
|
||||
`id was passed as [${id}]`));
|
||||
}
|
||||
|
||||
return queryAsync(QUERY_CHANNEL_DATA, [rows[0].id]);
|
||||
}).then(rows => {
|
||||
return queryAsync(QUERY_CHANNEL_DATA, [id]).then(rows => {
|
||||
loadRowcount.inc(rows.length);
|
||||
|
||||
const data = {};
|
||||
rows.forEach(row => {
|
||||
try {
|
||||
data[row.key] = JSON.parse(row.value);
|
||||
loadCharcount.inc(row.value.length);
|
||||
} catch (e) {
|
||||
Logger.errlog.log(`Channel data for channel "${channelName}", ` +
|
||||
LOGGER.error(`Channel data for channel "${channelName}", ` +
|
||||
`key "${row.key}" is invalid: ${e}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -56,36 +73,50 @@ export class DatabaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
save(channelName, data) {
|
||||
return queryAsync(QUERY_CHANNEL_ID_FOR_NAME, [channelName]).then((rows) => {
|
||||
if (rows.length === 0) {
|
||||
throw new ChannelNotFoundError(`Channel does not exist: "${channelName}"`);
|
||||
async save(id, channelName, data) {
|
||||
if (!id || id === 0) {
|
||||
throw new Error(
|
||||
`Cannot save state for [${channelName}]: ` +
|
||||
`id was passed as [${id}]`
|
||||
);
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let rowCount = 0;
|
||||
const id = rows[0].id;
|
||||
const substitutions = [];
|
||||
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
|
||||
rowCount++;
|
||||
|
||||
const value = JSON.stringify(data[key]);
|
||||
totalSize += value.length;
|
||||
|
||||
substitutions.push(id);
|
||||
substitutions.push(key);
|
||||
substitutions.push(value);
|
||||
}
|
||||
|
||||
if (totalSize > SIZE_LIMIT) {
|
||||
throw new ChannelStateSizeError('Channel state size is too large', {
|
||||
limit: SIZE_LIMIT,
|
||||
actual: totalSize
|
||||
});
|
||||
if (rowCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return queryAsync(buildUpdateQuery(rowCount), substitutions);
|
||||
});
|
||||
if (totalSize > SIZE_LIMIT) {
|
||||
throw new ChannelStateSizeError(
|
||||
'Channel state size is too large',
|
||||
{
|
||||
limit: SIZE_LIMIT,
|
||||
actual: totalSize
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
saveRowcount.inc(rowCount);
|
||||
saveCharcount.inc(totalSize);
|
||||
|
||||
return await queryAsync(buildUpdateQuery(rowCount), substitutions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import * as Promise from 'bluebird';
|
||||
import { stat } from 'fs';
|
||||
import * as fs from 'graceful-fs';
|
||||
import path from 'path';
|
||||
import { ChannelStateSizeError } from '../errors';
|
||||
|
||||
const readFileAsync = Promise.promisify(fs.readFile);
|
||||
const writeFileAsync = Promise.promisify(fs.writeFile);
|
||||
const readdirAsync = Promise.promisify(fs.readdir);
|
||||
const statAsync = Promise.promisify(stat);
|
||||
const SIZE_LIMIT = 1048576;
|
||||
const CHANDUMP_DIR = path.resolve(__dirname, '..', '..', 'chandump');
|
||||
|
||||
export class FileStore {
|
||||
filenameForChannel(channelName) {
|
||||
return path.join(CHANDUMP_DIR, channelName);
|
||||
}
|
||||
|
||||
load(channelName) {
|
||||
const filename = this.filenameForChannel(channelName);
|
||||
return statAsync(filename).then(stats => {
|
||||
if (stats.size > SIZE_LIMIT) {
|
||||
throw new ChannelStateSizeError('Channel state file is too large', {
|
||||
limit: SIZE_LIMIT,
|
||||
actual: stats.size
|
||||
});
|
||||
} else {
|
||||
return readFileAsync(filename);
|
||||
}
|
||||
}).then(fileContents => {
|
||||
try {
|
||||
return JSON.parse(fileContents);
|
||||
} catch (e) {
|
||||
throw new Error('Channel state file is not valid JSON: ' + e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
save(channelName, data) {
|
||||
const filename = this.filenameForChannel(channelName);
|
||||
const fileContents = new Buffer(JSON.stringify(data), 'utf8');
|
||||
if (fileContents.length > SIZE_LIMIT) {
|
||||
return Promise.reject(new ChannelStateSizeError(
|
||||
'Channel state size is too large', {
|
||||
limit: SIZE_LIMIT,
|
||||
actual: fileContents.length
|
||||
}));
|
||||
}
|
||||
|
||||
return writeFileAsync(filename, fileContents);
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return readdirAsync(CHANDUMP_DIR);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
import Config from '../config';
|
||||
import Promise from 'bluebird';
|
||||
import db from '../database';
|
||||
import { FileStore } from './filestore';
|
||||
import { DatabaseStore } from './dbstore';
|
||||
import { sanitizeHTML } from '../xss';
|
||||
import { ChannelNotFoundError } from '../errors';
|
||||
|
||||
const QUERY_CHANNEL_NAMES = 'SELECT name FROM channels WHERE 1';
|
||||
const EXPECTED_KEYS = [
|
||||
'chatbuffer',
|
||||
'chatmuted',
|
||||
'css',
|
||||
'emotes',
|
||||
'filters',
|
||||
'js',
|
||||
'motd',
|
||||
'openPlaylist',
|
||||
'opts',
|
||||
'permissions',
|
||||
'playlist',
|
||||
'poll'
|
||||
];
|
||||
|
||||
function queryAsync(query, substitutions) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.query(query, substitutions, (err, res) => {
|
||||
if (err) {
|
||||
if (!(err instanceof Error)) {
|
||||
err = new Error(err);
|
||||
}
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fixOldChandump(data) {
|
||||
const converted = {};
|
||||
EXPECTED_KEYS.forEach(key => {
|
||||
converted[key] = data[key];
|
||||
});
|
||||
|
||||
if (data.queue) {
|
||||
converted.playlist = {
|
||||
pl: data.queue.map(item => {
|
||||
return {
|
||||
media: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
seconds: item.seconds,
|
||||
duration: item.duration,
|
||||
type: item.type,
|
||||
meta: {}
|
||||
},
|
||||
queueby: item.queueby,
|
||||
temp: item.temp
|
||||
};
|
||||
}),
|
||||
pos: data.position,
|
||||
time: data.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('openqueue')) {
|
||||
converted.openPlaylist = data.openqueue;
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('playlistLock')) {
|
||||
converted.openPlaylist = !data.playlistLock;
|
||||
}
|
||||
|
||||
if (data.chatbuffer) {
|
||||
converted.chatbuffer = data.chatbuffer.map(entry => {
|
||||
return {
|
||||
username: entry.username,
|
||||
msg: entry.msg,
|
||||
meta: entry.meta || {
|
||||
addClass: entry.msgclass ? entry.msgclass : undefined
|
||||
},
|
||||
time: entry.time
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (data.motd && data.motd.motd) {
|
||||
converted.motd = sanitizeHTML(data.motd.motd).replace(/\n/g, '<br>\n');
|
||||
}
|
||||
|
||||
if (data.opts && data.opts.customcss) {
|
||||
converted.opts.externalcss = data.opts.customcss;
|
||||
}
|
||||
|
||||
if (data.opts && data.opts.customjs) {
|
||||
converted.opts.externaljs = data.opts.customjs;
|
||||
}
|
||||
|
||||
if (data.filters && data.filters.length > 0 && Array.isArray(data.filters[0])) {
|
||||
converted.filters = data.filters.map(filter => {
|
||||
let [source, replace, active] = filter;
|
||||
return {
|
||||
source: source,
|
||||
replace: replace,
|
||||
flags: 'g',
|
||||
active: active,
|
||||
filterlinks: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
function migrate(src, dest, opts) {
|
||||
return src.listChannels().then(names => {
|
||||
return Promise.reduce(names, (_, name) => {
|
||||
// A long time ago there was a bug where CyTube would save a different
|
||||
// chandump depending on the capitalization of the channel name in the URL.
|
||||
// This was fixed, but there are still some really old chandumps with
|
||||
// uppercase letters in the name.
|
||||
//
|
||||
// If another chandump exists which is all lowercase, then that one is
|
||||
// canonical. Otherwise, it's safe to load the existing capitalization,
|
||||
// convert it, and save.
|
||||
if (name !== name.toLowerCase()) {
|
||||
if (names.indexOf(name.toLowerCase()) >= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
return src.load(name).then(data => {
|
||||
data = fixOldChandump(data);
|
||||
Object.keys(data).forEach(key => {
|
||||
if (opts.keyWhitelist.length > 0 &&
|
||||
opts.keyWhitelist.indexOf(key) < 0) {
|
||||
delete data[key];
|
||||
} else if (opts.keyBlacklist.length > 0 &&
|
||||
opts.keyBlacklist.indexOf(key) >= 0) {
|
||||
delete data[key];
|
||||
}
|
||||
});
|
||||
return dest.save(name, data);
|
||||
}).then(() => {
|
||||
console.log(`Migrated /r/${name}`);
|
||||
}).catch(ChannelNotFoundError, err => {
|
||||
console.log(`Skipping /r/${name} (not present in the database)`);
|
||||
}).catch(err => {
|
||||
console.error(`Failed to migrate /r/${name}: ${err.stack}`);
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function loadOpts(argv) {
|
||||
const opts = {
|
||||
keyWhitelist: [],
|
||||
keyBlacklist: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '-w') {
|
||||
opts.keyWhitelist = (argv[i+1] || '').split(',');
|
||||
i++;
|
||||
} else if (argv[i] === '-b') {
|
||||
opts.keyBlacklist = (argv[i+1] || '').split(',');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
Config.load('config.yaml');
|
||||
db.init();
|
||||
const src = new FileStore();
|
||||
const dest = new DatabaseStore();
|
||||
const opts = loadOpts(process.argv.slice(2));
|
||||
|
||||
Promise.delay(1000).then(() => {
|
||||
return migrate(src, dest, opts);
|
||||
}).then(() => {
|
||||
console.log('Migration complete');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error(`Migration failed: ${err.stack}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,18 +1,15 @@
|
|||
var Account = require("../account");
|
||||
var ChannelModule = require("./module");
|
||||
var Flags = require("../flags");
|
||||
|
||||
function AccessControlModule(channel) {
|
||||
function AccessControlModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
}
|
||||
|
||||
AccessControlModule.prototype = Object.create(ChannelModule.prototype);
|
||||
|
||||
var pending = 0;
|
||||
AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
|
||||
var chan = this.channel,
|
||||
opts = this.channel.modules.options;
|
||||
var self = this;
|
||||
if (user.socket.disconnected) {
|
||||
return cb("User disconnected", ChannelModule.DENY);
|
||||
}
|
||||
|
|
@ -30,20 +27,16 @@ AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
|
|||
} else {
|
||||
user.socket.emit("needPassword", typeof data.pw !== "undefined");
|
||||
/* Option 1: log in as a moderator */
|
||||
user.waitFlag(Flags.U_LOGGED_IN, function () {
|
||||
user.refreshAccount({ channel: self.channel.name }, function (err, account) {
|
||||
|
||||
/* Already joined the channel by some other condition */
|
||||
user.waitFlag(Flags.U_HAS_CHANNEL_RANK, function () {
|
||||
if (user.is(Flags.U_IN_CHANNEL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.effectiveRank >= 2) {
|
||||
if (user.account.effectiveRank >= 2) {
|
||||
cb(null, ChannelModule.PASSTHROUGH);
|
||||
user.socket.emit("cancelNeedPassword");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* Option 2: Enter correct password */
|
||||
var pwListener = function (pw) {
|
||||
|
|
|
|||
35
src/channel/anonymouscheck.js
Normal file
35
src/channel/anonymouscheck.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
var ChannelModule = require("./module");
|
||||
var Flags = require("../flags");
|
||||
|
||||
function AnonymousCheck(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
}
|
||||
|
||||
AnonymousCheck.prototype = Object.create(ChannelModule.prototype);
|
||||
|
||||
AnonymousCheck.prototype.onUserPreJoin = function (user, data, cb) {
|
||||
const opts = this.channel.modules.options;
|
||||
var anonymousBanned = opts.get("block_anonymous_users");
|
||||
|
||||
if (user.socket.disconnected) {
|
||||
return cb("User disconnected", ChannelModule.DENY);
|
||||
}
|
||||
|
||||
if(anonymousBanned && user.isAnonymous()) {
|
||||
user.socket.on("disconnect", function () {
|
||||
if (!user.is(Flags.U_IN_CHANNEL)) {
|
||||
cb("User disconnected", ChannelModule.DENY);
|
||||
}
|
||||
});
|
||||
|
||||
user.socket.emit("errorMsg", { msg : "This channel has blocked anonymous users. Please provide a user name to join."});
|
||||
user.waitFlag(Flags.U_LOGGED_IN, function () {
|
||||
cb(null, ChannelModule.PASSTHROUGH);
|
||||
});
|
||||
return;
|
||||
} else{
|
||||
cb(null, ChannelModule.PASSTHROUGH);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = AnonymousCheck;
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
var MakeEmitter = require("../emitter");
|
||||
var Logger = require("../logger");
|
||||
var ChannelModule = require("./module");
|
||||
var Flags = require("../flags");
|
||||
var Account = require("../account");
|
||||
var util = require("../utilities");
|
||||
var fs = require("graceful-fs");
|
||||
var fs = require("fs");
|
||||
var path = require("path");
|
||||
var sio = require("socket.io");
|
||||
var db = require("../database");
|
||||
import * as ChannelStore from '../channel-storage/channelstore';
|
||||
import { ChannelStateSizeError } from '../errors';
|
||||
import Promise from 'bluebird';
|
||||
import { EventEmitter } from 'events';
|
||||
import { throttle } from '../util/throttle';
|
||||
import Logger from '../logger';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('channel');
|
||||
|
||||
const USERCOUNT_THROTTLE = 10000;
|
||||
|
||||
class ReferenceCounter {
|
||||
constructor(channel) {
|
||||
|
|
@ -40,9 +42,10 @@ class ReferenceCounter {
|
|||
delete this.references[caller];
|
||||
}
|
||||
} else {
|
||||
Logger.errlog.log("ReferenceCounter::unref() called by caller [" +
|
||||
LOGGER.error("ReferenceCounter::unref() called by caller [" +
|
||||
caller + "] but this caller had no active references! " +
|
||||
`(channel: ${this.channelName})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,15 +56,15 @@ class ReferenceCounter {
|
|||
checkRefCount() {
|
||||
if (this.refCount === 0) {
|
||||
if (Object.keys(this.references).length > 0) {
|
||||
Logger.errlog.log("ReferenceCounter::refCount reached 0 but still had " +
|
||||
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
|
||||
"active references: " +
|
||||
JSON.stringify(Object.keys(this.references)) +
|
||||
` (channel: ${this.channelName})`);
|
||||
for (var caller in this.references) {
|
||||
this.refCount += this.references[caller];
|
||||
}
|
||||
} else if (this.channel.users.length > 0) {
|
||||
Logger.errlog.log("ReferenceCounter::refCount reached 0 but still had " +
|
||||
} else if (this.channel.users && this.channel.users.length > 0) {
|
||||
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
|
||||
this.channel.users.length + " active users" +
|
||||
` (channel: ${this.channelName})`);
|
||||
this.refCount = this.channel.users.length;
|
||||
|
|
@ -73,26 +76,40 @@ class ReferenceCounter {
|
|||
}
|
||||
|
||||
function Channel(name) {
|
||||
MakeEmitter(this);
|
||||
this.name = name;
|
||||
this.uniqueName = name.toLowerCase();
|
||||
this.modules = {};
|
||||
this.logger = new Logger.Logger(path.join(__dirname, "..", "..", "chanlogs",
|
||||
this.uniqueName + ".log"));
|
||||
this.logger = new Logger.Logger(
|
||||
path.join(
|
||||
__dirname, "..", "..", "chanlogs", this.uniqueName + ".log"
|
||||
)
|
||||
);
|
||||
this.users = [];
|
||||
this.refCounter = new ReferenceCounter(this);
|
||||
this.flags = 0;
|
||||
var self = this;
|
||||
this.id = 0;
|
||||
this.ownerName = null;
|
||||
this.broadcastUsercount = throttle(() => {
|
||||
this.broadcastAll("usercount", this.users.length);
|
||||
}, USERCOUNT_THROTTLE);
|
||||
const self = this;
|
||||
db.channels.load(this, function (err) {
|
||||
if (err && err !== "Channel is not registered") {
|
||||
return;
|
||||
if (err && err.code === 'EBANNED') {
|
||||
self.emit("loadFail", err.message);
|
||||
self.setFlag(Flags.C_ERROR);
|
||||
} else if (err && err !== "Channel is not registered") {
|
||||
self.emit("loadFail", "Failed to load channel data from the database. Please try again later.");
|
||||
self.setFlag(Flags.C_ERROR);
|
||||
} else {
|
||||
self.initModules();
|
||||
self.loadState();
|
||||
db.channels.updateLastLoaded(self.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Channel.prototype = Object.create(EventEmitter.prototype);
|
||||
|
||||
Channel.prototype.is = function (flag) {
|
||||
return Boolean(this.flags & flag);
|
||||
};
|
||||
|
|
@ -114,7 +131,7 @@ Channel.prototype.waitFlag = function (flag, cb) {
|
|||
} else {
|
||||
var wait = function (f) {
|
||||
if (f === flag) {
|
||||
self.unbind("setFlag", wait);
|
||||
self.removeListener("setFlag", wait);
|
||||
cb();
|
||||
}
|
||||
};
|
||||
|
|
@ -144,7 +161,8 @@ Channel.prototype.initModules = function () {
|
|||
"./poll" : "poll",
|
||||
"./kickban" : "kickban",
|
||||
"./ranks" : "rank",
|
||||
"./accesscontrol" : "password"
|
||||
"./accesscontrol" : "password",
|
||||
"./anonymouscheck": "anoncheck"
|
||||
};
|
||||
|
||||
var self = this;
|
||||
|
|
@ -159,23 +177,6 @@ Channel.prototype.initModules = function () {
|
|||
self.logger.log("[init] Loaded modules: " + inited.join(", "));
|
||||
};
|
||||
|
||||
Channel.prototype.getDiskSize = function (cb) {
|
||||
if (this._getDiskSizeTimeout > Date.now()) {
|
||||
return cb(null, this._cachedDiskSize);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
|
||||
fs.stat(file, function (err, stats) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
self._cachedDiskSize = stats.size;
|
||||
cb(null, self._cachedDiskSize);
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.loadState = function () {
|
||||
/* Don't load from disk if not registered */
|
||||
if (!this.is(Flags.C_REGISTERED)) {
|
||||
|
|
@ -185,22 +186,19 @@ Channel.prototype.loadState = function () {
|
|||
}
|
||||
|
||||
const self = this;
|
||||
function errorLoad(msg) {
|
||||
if (self.modules.customization) {
|
||||
self.modules.customization.load({
|
||||
motd: msg
|
||||
});
|
||||
function errorLoad(msg, suggestTryAgain = true) {
|
||||
const extra = suggestTryAgain ? " Please try again later." : "";
|
||||
self.emit("loadFail", "Failed to load channel data from the database: " +
|
||||
msg + extra);
|
||||
self.setFlag(Flags.C_ERROR);
|
||||
}
|
||||
|
||||
self.setFlag(Flags.C_READY | Flags.C_ERROR);
|
||||
}
|
||||
|
||||
ChannelStore.load(this.uniqueName).then(data => {
|
||||
ChannelStore.load(this.id, this.uniqueName).then(data => {
|
||||
Object.keys(this.modules).forEach(m => {
|
||||
try {
|
||||
this.modules[m].load(data);
|
||||
} catch (e) {
|
||||
Logger.errlog.log("Failed to load module " + m + " for channel " +
|
||||
LOGGER.error("Failed to load module " + m + " for channel " +
|
||||
this.uniqueName);
|
||||
}
|
||||
});
|
||||
|
|
@ -211,8 +209,8 @@ Channel.prototype.loadState = function () {
|
|||
"enforced by this server. Please contact an administrator " +
|
||||
"for assistance.";
|
||||
|
||||
Logger.errlog.log(err.stack);
|
||||
errorLoad(message);
|
||||
LOGGER.error(err.stack);
|
||||
errorLoad(message, false);
|
||||
}).catch(err => {
|
||||
if (err.code === 'ENOENT') {
|
||||
Object.keys(this.modules).forEach(m => {
|
||||
|
|
@ -223,50 +221,74 @@ Channel.prototype.loadState = function () {
|
|||
} else {
|
||||
const message = "An error occurred when loading this channel's data from " +
|
||||
"disk. Please contact an administrator for assistance. " +
|
||||
`The error was: ${err}`;
|
||||
`The error was: ${err}.`;
|
||||
|
||||
Logger.errlog.log(err.stack);
|
||||
LOGGER.error(err.stack);
|
||||
errorLoad(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.saveState = function () {
|
||||
Channel.prototype.saveState = async function () {
|
||||
if (!this.is(Flags.C_REGISTERED)) {
|
||||
return Promise.resolve();
|
||||
return;
|
||||
} else if (!this.is(Flags.C_READY)) {
|
||||
return Promise.reject(new Error(`Attempted to save channel ${this.name} ` +
|
||||
`but it wasn't finished loading yet!`));
|
||||
throw new Error(
|
||||
`Attempted to save channel ${this.name} ` +
|
||||
`but it wasn't finished loading yet!`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.is(Flags.C_ERROR)) {
|
||||
return Promise.reject(new Error(`Channel is in error state`));
|
||||
throw new Error(`Channel is in error state`);
|
||||
}
|
||||
|
||||
this.logger.log("[init] Saving channel state to disk");
|
||||
|
||||
const data = {};
|
||||
Object.keys(this.modules).forEach(m => {
|
||||
if (
|
||||
this.modules[m].dirty ||
|
||||
!this.modules[m].supportsDirtyCheck
|
||||
) {
|
||||
this.modules[m].save(data);
|
||||
});
|
||||
|
||||
return ChannelStore.save(this.uniqueName, data).catch(ChannelStateSizeError, err => {
|
||||
this.users.forEach(u => {
|
||||
if (u.account.effectiveRank >= 2) {
|
||||
u.socket.emit("warnLargeChandump", {
|
||||
limit: err.limit,
|
||||
actual: err.actual
|
||||
});
|
||||
} else {
|
||||
LOGGER.debug(
|
||||
"Skipping save for %s[%s]: not dirty",
|
||||
this.uniqueName,
|
||||
m
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
throw err;
|
||||
try {
|
||||
await ChannelStore.save(this.id, this.uniqueName, data);
|
||||
|
||||
Object.keys(this.modules).forEach(m => {
|
||||
this.modules[m].dirty = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChannelStateSizeError) {
|
||||
this.users.forEach(u => {
|
||||
if (u.account.effectiveRank >= 2) {
|
||||
u.socket.emit("warnLargeChandump", {
|
||||
limit: error.limit,
|
||||
actual: error.actual
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Channel.prototype.checkModules = function (fn, args, cb) {
|
||||
const self = this;
|
||||
const refCaller = `Channel::checkModules/${fn}`;
|
||||
this.waitFlag(Flags.C_READY, function () {
|
||||
if (self.dead) return;
|
||||
|
||||
self.refCounter.ref(refCaller);
|
||||
var keys = Object.keys(self.modules);
|
||||
var next = function (err, result) {
|
||||
|
|
@ -285,18 +307,31 @@ Channel.prototype.checkModules = function (fn, args, cb) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!self.modules) {
|
||||
LOGGER.warn(
|
||||
'checkModules(%s): self.modules is undefined; dead=%s,' +
|
||||
' current=%s, remaining=%s',
|
||||
fn,
|
||||
self.dead,
|
||||
m,
|
||||
keys
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var module = self.modules[m];
|
||||
module[fn].apply(module, args);
|
||||
};
|
||||
|
||||
args.push(next);
|
||||
next(null, ChannelModule.PASSTHROUGH);
|
||||
process.nextTick(next, null, ChannelModule.PASSTHROUGH);
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.notifyModules = function (fn, args) {
|
||||
var self = this;
|
||||
this.waitFlag(Flags.C_READY, function () {
|
||||
if (self.dead) return;
|
||||
var keys = Object.keys(self.modules);
|
||||
keys.forEach(function (k) {
|
||||
self.modules[k][fn].apply(self.modules[k], args);
|
||||
|
|
@ -309,28 +344,39 @@ Channel.prototype.joinUser = function (user, data) {
|
|||
|
||||
self.refCounter.ref("Channel::user");
|
||||
self.waitFlag(Flags.C_READY, function () {
|
||||
|
||||
/* User closed the connection before the channel finished loading */
|
||||
if (user.socket.disconnected) {
|
||||
self.refCounter.unref("Channel::user");
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.is(Flags.C_REGISTERED)) {
|
||||
user.refreshAccount({ channel: self.name }, function (err, account) {
|
||||
if (err) {
|
||||
Logger.errlog.log("user.refreshAccount failed at Channel.joinUser");
|
||||
Logger.errlog.log(err.stack);
|
||||
self.refCounter.unref("Channel::user");
|
||||
user.channel = self;
|
||||
user.waitFlag(Flags.U_LOGGED_IN, () => {
|
||||
if (self.dead) {
|
||||
LOGGER.warn(
|
||||
'Got U_LOGGED_IN for %s after channel already unloaded',
|
||||
user.getName()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
afterAccount();
|
||||
if (user.is(Flags.U_REGISTERED)) {
|
||||
db.channels.getRank(self.name, user.getName(), (error, rank) => {
|
||||
if (!error) {
|
||||
user.setChannelRank(rank);
|
||||
user.setFlag(Flags.U_HAS_CHANNEL_RANK);
|
||||
if (user.inChannel()) {
|
||||
self.broadcastAll("setUserRank", {
|
||||
name: user.getName(),
|
||||
rank: user.account.effectiveRank
|
||||
});
|
||||
} else {
|
||||
afterAccount();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function afterAccount() {
|
||||
if (user.socket.disconnected) {
|
||||
self.refCounter.unref("Channel::user");
|
||||
return;
|
||||
|
|
@ -340,29 +386,26 @@ Channel.prototype.joinUser = function (user, data) {
|
|||
|
||||
self.checkModules("onUserPreJoin", [user, data], function (err, result) {
|
||||
if (result === ChannelModule.PASSTHROUGH) {
|
||||
if (user.account.channelRank !== user.account.globalRank) {
|
||||
user.socket.emit("rank", user.account.effectiveRank);
|
||||
}
|
||||
user.channel = self;
|
||||
self.acceptUser(user);
|
||||
} else {
|
||||
user.channel = null;
|
||||
user.account.channelRank = 0;
|
||||
user.account.effectiveRank = user.account.globalRank;
|
||||
self.refCounter.unref("Channel::user");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.acceptUser = function (user) {
|
||||
user.channel = this;
|
||||
user.setFlag(Flags.U_IN_CHANNEL);
|
||||
user.socket.join(this.name);
|
||||
user.autoAFK();
|
||||
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
|
||||
|
||||
Logger.syslog.log(user.realip + " joined " + this.name);
|
||||
if (user.socket._isUsingTor) {
|
||||
LOGGER.info(user.realip + " joined " + this.name);
|
||||
if (user.socket.context.torConnection) {
|
||||
if (this.modules.options && this.modules.options.get("torbanned")) {
|
||||
user.kick("This channel has banned connections from Tor.");
|
||||
this.logger.log("[login] Blocked connection from Tor exit at " +
|
||||
|
|
@ -389,8 +432,10 @@ Channel.prototype.acceptUser = function (user) {
|
|||
if (user.account.globalRank === 0) loginStr += " (guest)";
|
||||
loginStr += " (aliases: " + user.account.aliases.join(",") + ")";
|
||||
self.logger.log(loginStr);
|
||||
|
||||
self.sendUserJoin(self.users, user);
|
||||
if (user.getName().toLowerCase() === self.ownerName) {
|
||||
db.channels.updateOwnerLastSeen(self.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.users.push(user);
|
||||
|
|
@ -402,15 +447,31 @@ Channel.prototype.acceptUser = function (user) {
|
|||
});
|
||||
|
||||
this.sendUserlist([user]);
|
||||
this.sendUsercount(this.users);
|
||||
|
||||
// Managing this from here is not great, but due to the sequencing involved
|
||||
// and the limitations of the existing design, it'll have to do.
|
||||
if (this.modules.playlist.leader !== null) {
|
||||
user.socket.emit("setLeader", this.modules.playlist.leader.getName());
|
||||
}
|
||||
|
||||
this.broadcastUsercount();
|
||||
if (!this.is(Flags.C_REGISTERED)) {
|
||||
user.socket.emit("channelNotRegistered");
|
||||
}
|
||||
|
||||
user.on('afk', function(afk){
|
||||
self.sendUserMeta(self.users, user);
|
||||
// TODO: Drop legacy setAFK frame after a few months
|
||||
self.broadcastAll("setAFK", { name: user.getName(), afk: afk });
|
||||
});
|
||||
user.on("effectiveRankChange", (newRank, oldRank) => {
|
||||
this.maybeResendUserlist(user, newRank, oldRank);
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.partUser = function (user) {
|
||||
if (!this.logger) {
|
||||
Logger.errlog.log("partUser called on dead channel");
|
||||
LOGGER.error("partUser called on dead channel");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -433,20 +494,32 @@ Channel.prototype.partUser = function (user) {
|
|||
Object.keys(this.modules).forEach(function (m) {
|
||||
self.modules[m].onUserPart(user);
|
||||
});
|
||||
this.sendUsercount(this.users);
|
||||
this.broadcastUsercount();
|
||||
|
||||
this.refCounter.unref("Channel::user");
|
||||
user.die();
|
||||
};
|
||||
|
||||
Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRank, oldRank) {
|
||||
if ((newRank >= 2 && oldRank < 2)
|
||||
|| (newRank < 2 && oldRank >= 2)
|
||||
|| (newRank >= 255 && oldRank < 255)
|
||||
|| (newRank < 255 && oldRank >= 255)) {
|
||||
this.sendUserlist([user]);
|
||||
}
|
||||
};
|
||||
|
||||
Channel.prototype.packUserData = function (user) {
|
||||
var isBot = Boolean(user.socket.context.user && user.socket.context.user.isBot);
|
||||
|
||||
var base = {
|
||||
name: user.getName(),
|
||||
rank: user.account.effectiveRank,
|
||||
profile: user.account.profile,
|
||||
meta: {
|
||||
afk: user.is(Flags.U_AFK),
|
||||
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED)
|
||||
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED),
|
||||
is_bot: isBot
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -459,7 +532,8 @@ Channel.prototype.packUserData = function (user) {
|
|||
muted: user.is(Flags.U_MUTED),
|
||||
smuted: user.is(Flags.U_SMUTED),
|
||||
aliases: user.account.aliases,
|
||||
ip: user.displayip
|
||||
ip: user.displayip,
|
||||
is_bot: isBot
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -472,7 +546,8 @@ Channel.prototype.packUserData = function (user) {
|
|||
muted: user.is(Flags.U_MUTED),
|
||||
smuted: user.is(Flags.U_SMUTED),
|
||||
aliases: user.account.aliases,
|
||||
ip: user.realip
|
||||
ip: user.realip,
|
||||
is_bot: isBot
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -487,7 +562,7 @@ Channel.prototype.sendUserMeta = function (users, user, minrank) {
|
|||
var self = this;
|
||||
var userdata = self.packUserData(user);
|
||||
users.filter(function (u) {
|
||||
return typeof minrank !== "number" || u.account.effectiveRank > minrank
|
||||
return typeof minrank !== "number" || u.account.effectiveRank >= minrank;
|
||||
}).forEach(function (u) {
|
||||
if (u.account.globalRank >= 255) {
|
||||
u.socket.emit("setUserMeta", {
|
||||
|
|
@ -629,7 +704,6 @@ Channel.prototype.handleReadLog = function (user) {
|
|||
return;
|
||||
}
|
||||
|
||||
var shouldMaskIP = user.account.globalRank < 255;
|
||||
this.readLog(function (err, data) {
|
||||
if (err) {
|
||||
user.socket.emit("readChanLog", {
|
||||
|
|
@ -645,12 +719,12 @@ Channel.prototype.handleReadLog = function (user) {
|
|||
});
|
||||
};
|
||||
|
||||
Channel.prototype._broadcast = function (msg, data, ns) {
|
||||
Channel.prototype.broadcastToRoom = function (msg, data, ns) {
|
||||
sio.instance.in(ns).emit(msg, data);
|
||||
};
|
||||
|
||||
Channel.prototype.broadcastAll = function (msg, data) {
|
||||
this._broadcast(msg, data, this.name);
|
||||
this.broadcastToRoom(msg, data, this.name);
|
||||
};
|
||||
|
||||
Channel.prototype.packInfo = function (isAdmin) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
var Config = require("../config");
|
||||
var User = require("../user");
|
||||
var XSS = require("../xss");
|
||||
var ChannelModule = require("./module");
|
||||
var util = require("../utilities");
|
||||
var Flags = require("../flags");
|
||||
var url = require("url");
|
||||
var counters = require("../counters");
|
||||
import { transformImgTags } from '../camo';
|
||||
import { Counter } from 'prom-client';
|
||||
|
||||
const SHADOW_TAG = "[shadow]";
|
||||
const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
|
||||
const LINK = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
|
||||
const LINK_PLACEHOLDER = '\ueeee';
|
||||
const LINK_PLACEHOLDER_RE = /\ueeee/g;
|
||||
|
||||
|
|
@ -29,11 +28,12 @@ const MIN_ANTIFLOOD = {
|
|||
sustained: 10
|
||||
};
|
||||
|
||||
function ChatModule(channel) {
|
||||
function ChatModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
this.buffer = [];
|
||||
this.muted = new util.Set();
|
||||
this.muted = new Set();
|
||||
this.commandHandlers = {};
|
||||
this.supportsDirtyCheck = true;
|
||||
|
||||
/* Default commands */
|
||||
this.registerCommand("/me", this.handleCmdMe.bind(this));
|
||||
|
|
@ -54,7 +54,7 @@ ChatModule.prototype = Object.create(ChannelModule.prototype);
|
|||
|
||||
ChatModule.prototype.load = function (data) {
|
||||
this.buffer = [];
|
||||
this.muted = new util.Set();
|
||||
this.muted = new Set();
|
||||
|
||||
if ("chatbuffer" in data) {
|
||||
for (var i = 0; i < data.chatbuffer.length; i++) {
|
||||
|
|
@ -63,18 +63,20 @@ ChatModule.prototype.load = function (data) {
|
|||
}
|
||||
|
||||
if ("chatmuted" in data) {
|
||||
for (var i = 0; i < data.chatmuted.length; i++) {
|
||||
for (i = 0; i < data.chatmuted.length; i++) {
|
||||
this.muted.add(data.chatmuted[i]);
|
||||
}
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
};
|
||||
|
||||
ChatModule.prototype.save = function (data) {
|
||||
data.chatbuffer = this.buffer;
|
||||
data.chatmuted = Array.prototype.slice.call(this.muted);
|
||||
data.chatmuted = Array.from(this.muted);
|
||||
};
|
||||
|
||||
ChatModule.prototype.packInfo = function (data, isAdmin) {
|
||||
ChatModule.prototype.packInfo = function (data, _isAdmin) {
|
||||
data.chat = Array.prototype.slice.call(this.buffer);
|
||||
};
|
||||
|
||||
|
|
@ -99,8 +101,8 @@ ChatModule.prototype.onUserPostJoin = function (user) {
|
|||
};
|
||||
|
||||
ChatModule.prototype.isMuted = function (name) {
|
||||
return this.muted.contains(name.toLowerCase()) ||
|
||||
this.muted.contains(SHADOW_TAG + name.toLowerCase());
|
||||
return this.muted.has(name.toLowerCase()) ||
|
||||
this.muted.has(SHADOW_TAG + name.toLowerCase());
|
||||
};
|
||||
|
||||
ChatModule.prototype.mutedUsers = function () {
|
||||
|
|
@ -111,7 +113,7 @@ ChatModule.prototype.mutedUsers = function () {
|
|||
};
|
||||
|
||||
ChatModule.prototype.isShadowMuted = function (name) {
|
||||
return this.muted.contains(SHADOW_TAG + name.toLowerCase());
|
||||
return this.muted.has(SHADOW_TAG + name.toLowerCase());
|
||||
};
|
||||
|
||||
ChatModule.prototype.shadowMutedUsers = function () {
|
||||
|
|
@ -121,16 +123,52 @@ ChatModule.prototype.shadowMutedUsers = function () {
|
|||
});
|
||||
};
|
||||
|
||||
ChatModule.prototype.anonymousUsers = function () {
|
||||
return this.channel.users.filter(function (u) {
|
||||
return u.getName() === "";
|
||||
});
|
||||
};
|
||||
|
||||
ChatModule.prototype.restrictNewAccount = function restrictNewAccount(user, data) {
|
||||
if (user.account.effectiveRank < 2 && this.channel.modules.options) {
|
||||
const firstSeen = user.getFirstSeenTime();
|
||||
const opts = this.channel.modules.options;
|
||||
if (firstSeen > Date.now() - opts.get("new_user_chat_delay")*1000) {
|
||||
user.socket.emit("spamFiltered", {
|
||||
reason: "NEW_USER_CHAT"
|
||||
});
|
||||
return true;
|
||||
} else if ((firstSeen > Date.now() - opts.get("new_user_chat_link_delay")*1000)
|
||||
&& data.msg.match(LINK)) {
|
||||
user.socket.emit("spamFiltered", {
|
||||
reason: "NEW_USER_CHAT_LINK"
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const chatIncomingCount = new Counter({
|
||||
name: 'cytube_chat_incoming_total',
|
||||
help: 'Number of incoming chatMsg frames'
|
||||
});
|
||||
ChatModule.prototype.handleChatMsg = function (user, data) {
|
||||
var self = this;
|
||||
counters.add("chat:incoming");
|
||||
chatIncomingCount.inc(1, new Date());
|
||||
|
||||
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to 240 characters
|
||||
data.msg = data.msg.substring(0, 240);
|
||||
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
|
||||
|
||||
// Restrict new accounts/IPs from chatting and posting links
|
||||
if (this.restrictNewAccount(user, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If channel doesn't permit them, strip ASCII control characters
|
||||
if (!this.channel.modules.options ||
|
||||
!this.channel.modules.options.get("allow_ascii_control")) {
|
||||
|
|
@ -174,6 +212,11 @@ ChatModule.prototype.handlePm = function (user, data) {
|
|||
});
|
||||
}
|
||||
|
||||
// Restrict new accounts/IPs from chatting and posting links
|
||||
if (this.restrictNewAccount(user, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
|
||||
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
|
||||
"blacklisted domain");
|
||||
|
|
@ -183,7 +226,6 @@ ChatModule.prototype.handlePm = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
var reallyTo = data.to;
|
||||
data.to = data.to.toLowerCase();
|
||||
|
||||
if (data.to === user.getLowerName()) {
|
||||
|
|
@ -206,7 +248,7 @@ ChatModule.prototype.handlePm = function (user, data) {
|
|||
}
|
||||
|
||||
|
||||
data.msg = data.msg.substring(0, 240);
|
||||
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
|
||||
var to = null;
|
||||
for (var i = 0; i < this.channel.users.length; i++) {
|
||||
if (this.channel.users[i].getLowerName() === data.to) {
|
||||
|
|
@ -242,6 +284,10 @@ ChatModule.prototype.handlePm = function (user, data) {
|
|||
user.socket.emit("pm", msgobj);
|
||||
};
|
||||
|
||||
const chatSentCount = new Counter({
|
||||
name: 'cytube_chat_sent_total',
|
||||
help: 'Number of broadcast chat messages'
|
||||
});
|
||||
ChatModule.prototype.processChatMsg = function (user, data) {
|
||||
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
|
||||
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
|
||||
|
|
@ -257,6 +303,9 @@ ChatModule.prototype.processChatMsg = function (user, data) {
|
|||
}
|
||||
|
||||
var msgobj = this.formatMessage(user.getName(), data);
|
||||
if (user.socket.context.user && user.socket.context.user.isBot) {
|
||||
msgobj.meta.is_bot = true;
|
||||
}
|
||||
var antiflood = MIN_ANTIFLOOD;
|
||||
if (this.channel.modules.options &&
|
||||
this.channel.modules.options.get("chat_antiflood") &&
|
||||
|
|
@ -293,6 +342,10 @@ ChatModule.prototype.processChatMsg = function (user, data) {
|
|||
this.shadowMutedUsers().forEach(function (u) {
|
||||
u.socket.emit("chatMsg", msgobj);
|
||||
});
|
||||
// This prevents shadowmuted users from easily detecting their state
|
||||
this.anonymousUsers().forEach(function (u) {
|
||||
u.socket.emit("chatMsg", msgobj);
|
||||
});
|
||||
msgobj.meta.shadow = true;
|
||||
this.channel.moderators().forEach(function (u) {
|
||||
u.socket.emit("chatMsg", msgobj);
|
||||
|
|
@ -306,7 +359,7 @@ ChatModule.prototype.processChatMsg = function (user, data) {
|
|||
return;
|
||||
}
|
||||
this.sendMessage(msgobj);
|
||||
counters.add("chat:sent");
|
||||
chatSentCount.inc(1, new Date());
|
||||
};
|
||||
|
||||
ChatModule.prototype.formatMessage = function (username, data) {
|
||||
|
|
@ -326,7 +379,6 @@ ChatModule.prototype.formatMessage = function (username, data) {
|
|||
|
||||
ChatModule.prototype.filterMessage = function (msg) {
|
||||
var filters = this.channel.modules.filters.filters;
|
||||
var chan = this.channel;
|
||||
var convertLinks = this.channel.modules.options.get("enable_link_regex");
|
||||
var links = msg.match(LINK);
|
||||
var intermediate = msg.replace(LINK, LINK_PLACEHOLDER);
|
||||
|
|
@ -342,13 +394,24 @@ ChatModule.prototype.filterMessage = function (msg) {
|
|||
if (filtered !== link) {
|
||||
return filtered;
|
||||
} else if (convertLinks) {
|
||||
return "<a href=\"" + link + "\" target=\"_blank\">" + link + "</a>";
|
||||
return "<a href=\"" + link + "\" target=\"_blank\" " +
|
||||
"rel=\"noopener noreferrer\">" + link + "</a>";
|
||||
} else {
|
||||
return link;
|
||||
}
|
||||
});
|
||||
|
||||
return XSS.sanitizeHTML(result);
|
||||
let settings = {};
|
||||
const camoConfig = Config.getCamoConfig();
|
||||
if (camoConfig.isEnabled()) {
|
||||
settings = {
|
||||
transformTags: {
|
||||
img: transformImgTags.bind(null, camoConfig)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return XSS.sanitizeHTML(result, settings);
|
||||
};
|
||||
|
||||
ChatModule.prototype.sendModMessage = function (msg, minrank) {
|
||||
|
|
@ -376,14 +439,17 @@ ChatModule.prototype.sendModMessage = function (msg, minrank) {
|
|||
ChatModule.prototype.sendMessage = function (msgobj) {
|
||||
this.channel.broadcastAll("chatMsg", msgobj);
|
||||
|
||||
this.dirty = true;
|
||||
this.buffer.push(msgobj);
|
||||
if (this.buffer.length > 15) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
|
||||
this.channel.logger.log("<" + msgobj.username + (msgobj.meta.addClass ?
|
||||
"." + msgobj.meta.addClass : "") +
|
||||
"> " + XSS.decodeText(msgobj.msg));
|
||||
this.channel.logger.log(
|
||||
"<" + msgobj.username +
|
||||
(msgobj.meta.addClass ? "." + msgobj.meta.addClass : "") +
|
||||
"> " + XSS.decodeText(msgobj.msg)
|
||||
);
|
||||
};
|
||||
|
||||
ChatModule.prototype.registerCommand = function (cmd, cb) {
|
||||
|
|
@ -422,13 +488,15 @@ ChatModule.prototype.handleCmdSay = function (user, msg, meta) {
|
|||
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
|
||||
};
|
||||
|
||||
ChatModule.prototype.handleCmdClear = function (user, msg, meta) {
|
||||
ChatModule.prototype.handleCmdClear = function (user, _msg, _meta) {
|
||||
if (!this.channel.modules.permissions.canClearChat(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
this.buffer = [];
|
||||
this.channel.broadcastAll("clearchat");
|
||||
this.channel.broadcastAll("clearchat", { clearedBy: user.getName() });
|
||||
this.sendModMessage(user.getName() + " cleared chat.", -1);
|
||||
this.channel.logger.log("[mod] " + user.getName() + " used /clear");
|
||||
};
|
||||
|
||||
|
|
@ -461,11 +529,11 @@ ChatModule.prototype.handleCmdAdminflair = function (user, msg, meta) {
|
|||
this.processChatMsg(user, { msg: cargs.join(" "), meta: meta });
|
||||
};
|
||||
|
||||
ChatModule.prototype.handleCmdAfk = function (user, msg, meta) {
|
||||
ChatModule.prototype.handleCmdAfk = function (user, _msg, _meta) {
|
||||
user.setAFK(!user.is(Flags.U_AFK));
|
||||
};
|
||||
|
||||
ChatModule.prototype.handleCmdMute = function (user, msg, meta) {
|
||||
ChatModule.prototype.handleCmdMute = function (user, msg, _meta) {
|
||||
if (!this.channel.modules.permissions.canMute(user)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -515,7 +583,7 @@ ChatModule.prototype.handleCmdMute = function (user, msg, meta) {
|
|||
this.sendModMessage(user.getName() + " muted " + target.getName(), muteperm);
|
||||
};
|
||||
|
||||
ChatModule.prototype.handleCmdSMute = function (user, msg, meta) {
|
||||
ChatModule.prototype.handleCmdSMute = function (user, msg, _meta) {
|
||||
if (!this.channel.modules.permissions.canMute(user)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -566,7 +634,7 @@ ChatModule.prototype.handleCmdSMute = function (user, msg, meta) {
|
|||
this.sendModMessage(user.getName() + " shadowmuted " + target.getName(), muteperm);
|
||||
};
|
||||
|
||||
ChatModule.prototype.handleCmdUnmute = function (user, msg, meta) {
|
||||
ChatModule.prototype.handleCmdUnmute = function (user, msg, _meta) {
|
||||
if (!this.channel.modules.permissions.canMute(user)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -598,8 +666,11 @@ ChatModule.prototype.handleCmdUnmute = function (user, msg, meta) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.muted.remove(name);
|
||||
this.muted.remove(SHADOW_TAG + name);
|
||||
this.muted.delete(name);
|
||||
this.muted.delete(SHADOW_TAG + name);
|
||||
|
||||
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + name);
|
||||
this.sendModMessage(user.getName() + " unmuted " + name, muteperm);
|
||||
|
||||
var target;
|
||||
for (var i = 0; i < this.channel.users.length; i++) {
|
||||
|
|
@ -615,8 +686,6 @@ ChatModule.prototype.handleCmdUnmute = function (user, msg, meta) {
|
|||
|
||||
target.clearFlag(Flags.U_MUTED | Flags.U_SMUTED);
|
||||
this.channel.sendUserMeta(this.channel.users, target, -1);
|
||||
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + target.getName());
|
||||
this.sendModMessage(user.getName() + " unmuted " + target.getName(), muteperm);
|
||||
};
|
||||
|
||||
module.exports = ChatModule;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
var ChannelModule = require("./module");
|
||||
var XSS = require("../xss");
|
||||
const ChannelModule = require("./module");
|
||||
const XSS = require("../xss");
|
||||
const { hash } = require('../util/hash');
|
||||
|
||||
const TYPE_SETCSS = {
|
||||
css: "string"
|
||||
|
|
@ -13,15 +14,38 @@ const TYPE_SETMOTD = {
|
|||
motd: "string"
|
||||
};
|
||||
|
||||
function CustomizationModule(channel) {
|
||||
function CustomizationModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
this.css = "";
|
||||
this.js = "";
|
||||
this.motd = "";
|
||||
this.supportsDirtyCheck = true;
|
||||
}
|
||||
|
||||
CustomizationModule.prototype = Object.create(ChannelModule.prototype);
|
||||
|
||||
Object.defineProperty(CustomizationModule.prototype, 'css', {
|
||||
get() {
|
||||
return this._css;
|
||||
},
|
||||
|
||||
set(val) {
|
||||
this._css = val;
|
||||
this.cssHash = hash('md5', val, 'base64');
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(CustomizationModule.prototype, 'js', {
|
||||
get() {
|
||||
return this._js;
|
||||
},
|
||||
|
||||
set(val) {
|
||||
this._js = val;
|
||||
this.jsHash = hash('md5', val, 'base64');
|
||||
}
|
||||
});
|
||||
|
||||
CustomizationModule.prototype.load = function (data) {
|
||||
if ("css" in data) {
|
||||
this.css = data.css;
|
||||
|
|
@ -42,6 +66,8 @@ CustomizationModule.prototype.load = function (data) {
|
|||
this.motd = XSS.sanitizeHTML(data.motd);
|
||||
}
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
};
|
||||
|
||||
CustomizationModule.prototype.save = function (data) {
|
||||
|
|
@ -66,7 +92,9 @@ CustomizationModule.prototype.onUserPostJoin = function (user) {
|
|||
CustomizationModule.prototype.sendCSSJS = function (users) {
|
||||
var data = {
|
||||
css: this.css,
|
||||
js: this.js
|
||||
cssHash: this.cssHash,
|
||||
js: this.js,
|
||||
jsHash: this.jsHash
|
||||
};
|
||||
users.forEach(function (u) {
|
||||
u.socket.emit("channelCSSJS", data);
|
||||
|
|
@ -86,10 +114,15 @@ CustomizationModule.prototype.handleSetCSS = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
let oldHash = this.cssHash;
|
||||
// TODO: consider sending back an error instead of silently truncating
|
||||
this.css = data.css.substring(0, 20000);
|
||||
this.sendCSSJS(this.channel.users);
|
||||
|
||||
if (oldHash !== this.cssHash) {
|
||||
this.dirty = true;
|
||||
this.sendCSSJS(this.channel.users);
|
||||
this.channel.logger.log("[mod] " + user.getName() + " updated the channel CSS");
|
||||
}
|
||||
};
|
||||
|
||||
CustomizationModule.prototype.handleSetJS = function (user, data) {
|
||||
|
|
@ -98,10 +131,14 @@ CustomizationModule.prototype.handleSetJS = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
let oldHash = this.jsHash;
|
||||
this.js = data.js.substring(0, 20000);
|
||||
this.sendCSSJS(this.channel.users);
|
||||
|
||||
if (oldHash !== this.jsHash) {
|
||||
this.dirty = true;
|
||||
this.sendCSSJS(this.channel.users);
|
||||
this.channel.logger.log("[mod] " + user.getName() + " updated the channel JS");
|
||||
}
|
||||
};
|
||||
|
||||
CustomizationModule.prototype.handleSetMotd = function (user, data) {
|
||||
|
|
@ -112,6 +149,7 @@ CustomizationModule.prototype.handleSetMotd = function (user, data) {
|
|||
|
||||
var motd = data.motd.substring(0, 20000);
|
||||
|
||||
this.dirty = true;
|
||||
this.setMotd(motd);
|
||||
this.channel.logger.log("[mod] " + user.getName() + " updated the MOTD");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// TODO: figure out what to do with this module
|
||||
// it serves a very niche use case and is only a core module because of
|
||||
// legacy reasons (early channels requested it before I had criteria
|
||||
// around what to include in core)
|
||||
var ChannelModule = require("./module");
|
||||
|
||||
function DrinkModule(channel) {
|
||||
function DrinkModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
this.drinks = 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,33 @@ EmoteList.prototype = {
|
|||
this.emotes = Array.prototype.slice.call(emotes);
|
||||
},
|
||||
|
||||
emoteExists: function (emote){
|
||||
for (let i = 0; i < this.emotes.length; i++) {
|
||||
if (this.emotes[i].name === emote.name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
renameEmote: function (emote) {
|
||||
var found = false;
|
||||
for (var i = 0; i < this.emotes.length; i++) {
|
||||
if (this.emotes[i].name === emote.old) {
|
||||
found = true;
|
||||
this.emotes[i] = emote;
|
||||
delete this.emotes[i].old;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(found){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
updateEmote: function (emote) {
|
||||
var found = false;
|
||||
for (var i = 0; i < this.emotes.length; i++) {
|
||||
|
|
@ -37,7 +64,6 @@ EmoteList.prototype = {
|
|||
},
|
||||
|
||||
removeEmote: function (emote) {
|
||||
var found = false;
|
||||
for (var i = 0; i < this.emotes.length; i++) {
|
||||
if (this.emotes[i].name === emote.name) {
|
||||
this.emotes.splice(i, 1);
|
||||
|
|
@ -75,7 +101,7 @@ function validateEmote(f) {
|
|||
f.image = f.image.substring(0, 1000);
|
||||
f.image = XSS.sanitizeText(f.image);
|
||||
|
||||
var s = XSS.sanitizeText(f.name).replace(/([\\\.\?\+\*\$\^\|\(\)\[\]\{\}])/g, "\\$1");
|
||||
var s = XSS.looseSanitizeText(f.name).replace(/([\\.?+*$^|()[\]{}])/g, "\\$1");
|
||||
s = "(^|\\s)" + s + "(?!\\S)";
|
||||
f.source = s;
|
||||
|
||||
|
|
@ -90,21 +116,22 @@ function validateEmote(f) {
|
|||
}
|
||||
|
||||
return f;
|
||||
};
|
||||
}
|
||||
|
||||
function EmoteModule(channel) {
|
||||
function EmoteModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
this.emotes = new EmoteList();
|
||||
this.supportsDirtyCheck = true;
|
||||
}
|
||||
|
||||
EmoteModule.prototype = Object.create(ChannelModule.prototype);
|
||||
|
||||
EmoteModule.prototype.load = function (data) {
|
||||
if ("emotes" in data) {
|
||||
for (var i = 0; i < data.emotes.length; i++) {
|
||||
this.emotes.updateEmote(data.emotes[i]);
|
||||
}
|
||||
this.emotes = new EmoteList(data.emotes);
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
};
|
||||
|
||||
EmoteModule.prototype.save = function (data) {
|
||||
|
|
@ -118,6 +145,7 @@ EmoteModule.prototype.packInfo = function (data, isAdmin) {
|
|||
};
|
||||
|
||||
EmoteModule.prototype.onUserPostJoin = function (user) {
|
||||
user.socket.on("renameEmote", this.handleRenameEmote.bind(this, user));
|
||||
user.socket.on("updateEmote", this.handleUpdateEmote.bind(this, user));
|
||||
user.socket.on("importEmotes", this.handleImportEmotes.bind(this, user));
|
||||
user.socket.on("moveEmote", this.handleMoveEmote.bind(this, user));
|
||||
|
|
@ -127,12 +155,58 @@ EmoteModule.prototype.onUserPostJoin = function (user) {
|
|||
|
||||
EmoteModule.prototype.sendEmotes = function (users) {
|
||||
var f = this.emotes.pack();
|
||||
var chan = this.channel;
|
||||
users.forEach(function (u) {
|
||||
u.socket.emit("emoteList", f);
|
||||
});
|
||||
};
|
||||
|
||||
EmoteModule.prototype.handleRenameEmote = function (user, data) {
|
||||
if (typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
** This shouldn't be able to happen,
|
||||
** but we have idiots that like to send handcrafted frames to fuck with shit
|
||||
*/
|
||||
if (typeof data.old !== "string"){
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.channel.modules.permissions.canEditEmotes(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var e = this.emotes.emoteExists(data);
|
||||
var f = validateEmote(data);
|
||||
if (!f || e) {
|
||||
var message = "Unable to rename emote '" + JSON.stringify(data) + "'. " +
|
||||
"Please contact an administrator for assistance.";
|
||||
if (!data.image || !data.name) {
|
||||
message = "Emote names and images must not be blank.";
|
||||
}
|
||||
if (e) {
|
||||
message = "Emote already exists.";
|
||||
}
|
||||
|
||||
user.socket.emit("errorMsg", {
|
||||
msg: message,
|
||||
alert: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// See comment above
|
||||
var success = this.emotes.renameEmote(Object.assign({}, f));
|
||||
if(!success){ return; }
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
var chan = this.channel;
|
||||
chan.broadcastAll("renameEmote", f);
|
||||
chan.logger.log(`[mod] ${user.getName()} renamed emote: ${f.old} -> ${f.name}`);
|
||||
};
|
||||
|
||||
EmoteModule.prototype.handleUpdateEmote = function (user, data) {
|
||||
if (typeof data !== "object") {
|
||||
return;
|
||||
|
|
@ -158,6 +232,9 @@ EmoteModule.prototype.handleUpdateEmote = function (user, data) {
|
|||
}
|
||||
|
||||
this.emotes.updateEmote(f);
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
var chan = this.channel;
|
||||
chan.broadcastAll("updateEmote", f);
|
||||
|
||||
|
|
@ -179,6 +256,9 @@ EmoteModule.prototype.handleImportEmotes = function (user, data) {
|
|||
this.emotes.importList(data.map(validateEmote).filter(function (f) {
|
||||
return f !== false;
|
||||
}));
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
this.sendEmotes(this.channel.users);
|
||||
};
|
||||
|
||||
|
|
@ -196,6 +276,9 @@ EmoteModule.prototype.handleRemoveEmote = function (user, data) {
|
|||
}
|
||||
|
||||
this.emotes.removeEmote(data);
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
this.channel.logger.log("[mod] " + user.getName() + " removed emote: " + data.name);
|
||||
this.channel.broadcastAll("removeEmote", data);
|
||||
};
|
||||
|
|
@ -214,6 +297,8 @@ EmoteModule.prototype.handleMoveEmote = function (user, data) {
|
|||
}
|
||||
|
||||
this.emotes.moveEmote(data.from, data.to);
|
||||
|
||||
this.dirty = true;
|
||||
};
|
||||
|
||||
module.exports = EmoteModule;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
var FilterList = require("cytubefilters");
|
||||
var ChannelModule = require("./module");
|
||||
var XSS = require("../xss");
|
||||
var Logger = require("../logger");
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('filters');
|
||||
|
||||
/*
|
||||
* Converts JavaScript-style replacements ($1, $2, etc.) with
|
||||
|
|
@ -22,7 +22,6 @@ function validateFilter(f) {
|
|||
}
|
||||
|
||||
f.replace = fixReplace(f.replace.substring(0, 1000));
|
||||
f.replace = XSS.sanitizeHTML(f.replace);
|
||||
f.flags = f.flags.substring(0, 4);
|
||||
|
||||
try {
|
||||
|
|
@ -63,9 +62,10 @@ const DEFAULT_FILTERS = [
|
|||
"<span class=\"spoiler\">\\1</span>")
|
||||
];
|
||||
|
||||
function ChatFilterModule(channel) {
|
||||
function ChatFilterModule(_channel) {
|
||||
ChannelModule.apply(this, arguments);
|
||||
this.filters = new FilterList();
|
||||
this.supportsDirtyCheck = true;
|
||||
}
|
||||
|
||||
ChatFilterModule.prototype = Object.create(ChannelModule.prototype);
|
||||
|
|
@ -78,13 +78,15 @@ ChatFilterModule.prototype.load = function (data) {
|
|||
try {
|
||||
this.filters = new FilterList(filters);
|
||||
} catch (e) {
|
||||
Logger.errlog.log("Filter load failed: " + e + " (channel:" +
|
||||
LOGGER.error("Filter load failed: " + e + " (channel:" +
|
||||
this.channel.name);
|
||||
this.channel.logger.log("Failed to load filters: " + e);
|
||||
}
|
||||
} else {
|
||||
this.filters = new FilterList(DEFAULT_FILTERS);
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
};
|
||||
|
||||
ChatFilterModule.prototype.save = function (data) {
|
||||
|
|
@ -150,6 +152,8 @@ ChatFilterModule.prototype.handleAddFilter = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
user.socket.emit("addFilterSuccess");
|
||||
|
||||
var chan = this.channel;
|
||||
|
|
@ -198,6 +202,8 @@ ChatFilterModule.prototype.handleUpdateFilter = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
var chan = this.channel;
|
||||
chan.users.forEach(function (u) {
|
||||
if (chan.modules.permissions.canEditFilters(u)) {
|
||||
|
|
@ -233,6 +239,8 @@ ChatFilterModule.prototype.handleImportFilters = function (user, data) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
this.channel.logger.log("[mod] " + user.getName() + " imported the filter list");
|
||||
this.sendChatFilters(this.channel.users);
|
||||
};
|
||||
|
|
@ -259,6 +267,9 @@ ChatFilterModule.prototype.handleRemoveFilter = function (user, data) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
var chan = this.channel;
|
||||
chan.users.forEach(function (u) {
|
||||
if (chan.modules.permissions.canEditFilters(u)) {
|
||||
|
|
@ -291,6 +302,8 @@ ChatFilterModule.prototype.handleMoveFilter = function (user, data) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
};
|
||||
|
||||
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue