Compare commits

...

524 commits
v3.36.1 ... 3.0

Author SHA1 Message Date
Speng Reb
20daaa780f Remove list of shows in shows tab - we can use the calendar from now on 2026-06-02 00:05:06 +02:00
Speng Reb
e917ea25eb Gcalendar can sync as background task 2026-06-01 23:51:27 +02:00
Speng Reb
efd5fe0465 Gcalendar now can batch jobs and queue create and delete. Keeps a local record of gcalendar records. 2026-06-01 23:43:30 +02:00
Speng Reb
6b40b9c500 After running a stress test a couple times i found some nasty edge cases and tried to patch them up as best i could 2026-06-01 21:36:54 +02:00
Speng Reb
03711e4726 Update bot API docs for new shows API 2026-06-01 14:42:08 +02:00
Speng Reb
dd1bf9d55b Add many UX improvements to channel schedule 2026-06-01 01:46:00 +02:00
Spengreb
4ec1e83337
Merge pull request #16 from Spengreb/google-calendar-sync
Google calendar sync
2026-06-01 00:24:24 +02:00
Spengreb
c102b90ef6
Merge pull request #15 from Spengreb/schedule
Calendar for showing scheduled events
2026-06-01 00:23:53 +02:00
Speng Reb
71b0a092ca Google calendar sync v1 2026-05-31 23:56:37 +02:00
Speng Reb
60c6a50d9e Add calendar for displaying scheduled shows per channel 2026-05-31 22:57:20 +02:00
Speng Reb
c977cbd754 Basic channel schedule 2026-05-31 22:24:43 +02:00
Speng Reb
7f8afe23cd Playlist items added from shows are always temp 2026-05-31 21:53:50 +02:00
Speng Reb
49623df29d Fix CSRF issues from previous commits 2026-05-31 15:06:06 +02:00
Spengreb
2788dae3c8
Merge pull request #14 from Spengreb/broadspectrum-code-analysis
Update to node 20 and jQuery 3
2026-05-21 21:18:27 +02:00
Speng Reb
36da4bdff1 Harden API and session security: enforce CSRF on cookie-auth /api/v1 writes, exempt bot bearer tokens, and set SameSite=Lax + conditional Secure on auth/CSRF/ip-session cookies 2026-05-21 16:25:34 +02:00
Speng Reb
6eeee342d7 Protect /api/v1 mutations with CSRF for cookie auth while exempting cbt_ bearer bot tokens and wiring UI X-CSRF-Token headers 2026-05-21 16:23:30 +02:00
Speng Reb
12696452aa Fix shows/bot API auth gaps, handle missing channels as 404, make recurrence DST-safe, and clear lint regressions 2026-05-21 16:13:56 +02:00
Speng Reb
e3dd961430 Add example python bot for making shows 2026-05-21 16:06:00 +02:00
Spengreb
03922e8484
Merge pull request #13 from Spengreb/emote-substring-search-ux
Emote substring search ux
2026-05-21 15:06:22 +02:00
Speng Reb
73c90d8802 Merge branch 'emote-substring-search' into emote-substring-search-ux 2026-05-21 15:04:26 +02:00
Speng Reb
341b91aad1 Improve UX for emote auto complete 2026-05-21 15:03:56 +02:00
Spengreb
b4e93cc63c
Merge pull request #12 from Spengreb/bugfix/show-force-start-issue
Fix bug where force start did not force starting the show
2026-05-21 14:15:21 +02:00
Speng Reb
c49ff4bac1 Fix bug where force start did not force starting the show 2026-05-21 14:14:47 +02:00
Spengreb
5209c1c10a
Merge pull request #9 from Spengreb/schedules-shows
Scheduled shows
2026-05-21 13:47:59 +02:00
Spengreb
8e9bd64e5b
Merge pull request #11 from Spengreb/emote-selector-better-modal
Emote selector can be resized
2026-05-21 13:47:29 +02:00
Speng Reb
ae037c7795 Emote selector can be resized 2026-05-21 13:46:51 +02:00
Spengreb
1050a15ef6
Merge pull request #10 from Spengreb/bugfix/tvmode
Fix bug where if Big Picture mode was set as default layout switching…
2026-05-21 13:01:15 +02:00
Speng Reb
25d4be7aae Fix bug where if Big Picture mode was set as default layout switching layouts would look weird 2026-05-21 13:00:36 +02:00
Speng Reb
c4ee655d15 Shows playlist editor now shows media title instead of ID 2026-05-20 21:10:49 +02:00
Speng Reb
56ab732f6b Better handling of TZ and Bot API added 2026-05-20 21:00:48 +02:00
Speng Reb
17f38874d1 Add a scheduled show concept to the project without bot API for now 2026-05-20 20:52:26 +02:00
Spengreb
4d61a68e8b
Merge pull request #8 from Spengreb/channel-api-bot-life
Channel API for Bots
2026-05-20 17:09:51 +02:00
Speng Reb
2bdd975c3c Add python example bot using lib 2026-05-05 01:34:00 +02:00
Speng Reb
0c15e06975 merge 3.0 2026-05-04 16:40:32 +02:00
Spengreb
8fb51e6dc3
Merge pull request #7 from Spengreb/ez-local-dev
Add docker compose setup
2026-05-04 16:37:24 +02:00
Speng Reb
aa5d4a1850 Add docker compose setup 2026-05-04 16:26:32 +02:00
Speng Reb
dc70e1236b Initial bot API v1 2026-05-04 16:07:59 +02:00
BigLargeExtraDelicious
914605f393 Improve emote autocomplete to match substrings 2026-05-03 20:10:00 +01:00
Spengreb
2a62e6df90
Merge pull request #5 from Spengreb/emote-suggest
Emote suggest
2026-04-21 17:52:48 +02:00
Speng Reb
6281b0ead3 Emote compact and infinite scroll emote list 2026-04-21 17:51:03 +02:00
Speng Reb
e7111689f1 Compact emote suggestions 2026-04-21 17:37:45 +02:00
Spengreb
9d313e1375
Merge pull request #4 from Spengreb/remove-bad-protoswitching
Remove bad protoswitching from previous commit
2026-04-21 00:27:51 +02:00
Speng Reb
6efb8902fa Remove bad protoswitching from previous commit 2026-04-21 00:27:20 +02:00
Spengreb
80cd107aa0
Merge pull request #3 from Spengreb/tv-layout
Add TV layout mode
2026-04-21 00:11:45 +02:00
Speng Reb
f3cfe74cfa Add TV layout mode 2026-04-21 00:08:25 +02:00
Spengreb
917b227ff5
Merge branch 'calzoneman:3.0' into 3.0 2026-03-18 23:17:04 +01:00
Xaekai
589f999a9c Fix bitchute queuing 2025-11-06 18:02:23 -08:00
Xaekai
eac1547aea Resolve #1011 2025-11-06 15:58:48 -08:00
dependabot[bot]
c1e050c26e
Bump semver from 5.7.1 to 5.7.2 (#972)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-09 01:11:39 -07:00
dependabot[bot]
1c3025ceee
Bump word-wrap from 1.2.3 to 1.2.4 (#974)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-09 01:11:27 -07:00
dependabot[bot]
e13d5b69c8
Bump postcss from 8.4.21 to 8.4.31 (#976)
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.21 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.21...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-09 01:11:13 -07:00
Spengreb
7de1894a0c
Merge pull request #1 from Spengreb/whep-streems
Whep streems
2025-07-22 18:36:11 +02:00
Speng Reb
2aabaf6b6d Update whepplayer to be a generic player instead of messing with playerJS 2025-07-22 18:33:14 +02:00
Speng Reb
f12115159e Accepts whep from streem.vereto.net 2025-07-22 01:02:34 +02:00
anonanonanon88
bb5173fd12 updated package-lock.json for node 22 2025-02-22 22:25:10 -08:00
anonanonanon88
6416b4a2b6 Uptated nan version in package-lock.json for node 22 compatibility 2025-02-22 22:25:10 -08:00
Zankaria
9738c3f8c8 Add support for unix socket connections to the mysql database 2024-09-19 19:25:10 -07:00
Honore Doktorr
adc0ea27a9 Add player integration code removed from the dailymotion js sdk
Restores https://github.com/dailymotion/dailymotion-sdk-js commit 75b4102
2024-05-26 17:30:11 -07:00
Calvin Montgomery
4c437efb5d Fix #981 2024-04-18 20:08:59 -07:00
dependabot[bot]
227244e2d0 Bump socket.io-parser from 4.2.1 to 4.2.3
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 4.2.1 to 4.2.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/4.2.1...4.2.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-28 21:12:55 -07:00
Calvin Montgomery
6f47ed42db Bump mediaquery 2023-05-28 21:12:36 -07:00
Kethsar
98bfb6736e Remove string template around maxlength property for chat input 2023-03-25 14:31:25 -07:00
Kethsar
2c541448a2 Set the cap for max-chat-message-length to 1000 2023-03-25 14:31:25 -07:00
Kethsar
21d7f16413 Fix missed expansion of the option 2023-03-25 14:31:25 -07:00
Kethsar
87198bd4e7 Expand chat message length option to be consistent with other options 2023-03-25 14:31:25 -07:00
Kethsar
986207b46b Add max chat message length config option 2023-03-25 14:31:25 -07:00
Kethsar
ed410fdebe Update mediaquery dependency hash 2023-03-25 14:29:56 -07:00
Calvin Montgomery
1a9d920884 Detect old browser JS engines 2023-01-28 19:41:39 -08:00
Calvin Montgomery
c78ef333da Fix a couple issues discussed on IRC 2023-01-11 17:57:02 -08:00
Calvin Montgomery
fad1da7ab4 deps: fix high sev warnings 2023-01-10 20:56:38 -08:00
Calvin Montgomery
d37e69e1a6 Update package-lock for nan so that node v19 builds successfully 2022-10-30 18:10:19 -07:00
Calvin Montgomery
1e2dcee4fa Update NEWS 2022-09-23 21:39:38 -07:00
Calvin Montgomery
6ec2f3d491 Fix todo 2022-09-23 21:39:38 -07:00
Calvin Montgomery
306e3adde8 Work around flaky test 2022-09-23 21:39:38 -07:00
Calvin Montgomery
99740a3673 Add cache, test 2022-09-23 21:39:38 -07:00
Calvin Montgomery
913348d46e Continue working on banned channels 2022-09-23 21:39:38 -07:00
Calvin Montgomery
ae5dbf5f48 Continue working on banned channels 2022-09-23 21:39:38 -07:00
Calvin Montgomery
8338fe2f25 Work on banned channels feature 2022-09-23 21:39:38 -07:00
Xaekai
7921f41174 Fix inadvertent code reversions 2022-09-18 20:04:42 -07:00
Calvin Montgomery
50e2692896 Fix update mediaquery git hash 2022-09-18 19:10:36 -07:00
Calvin Montgomery
9e0f7b8efa Tweaks 2022-09-18 19:10:36 -07:00
Xaekai
fd9586e0da Update custom manifest documentation regarding audioTracks 2022-09-18 19:10:36 -07:00
Xaekai
f185e6c3ea Add audioTracks support for custom manifests 2022-09-18 19:10:36 -07:00
Xaekai
2cf26cdc4c Add disposal to audio switcher 2022-09-18 19:10:36 -07:00
Xaekai
008c24f892 Add compiled JSO libraries 2022-09-18 19:10:36 -07:00
Xaekai
a398e3a6fa Track last chatMsg time, and ignore reconnect spam 2022-09-18 19:10:36 -07:00
Xaekai
aa04f0d034 Add vjs plugin for audio track switching 2022-09-18 19:10:36 -07:00
Xaekai
e7f0aa98be Move add to be first playlist control 2022-09-18 19:10:36 -07:00
Xaekai
0f9d778a27 Eliminate jQuery in index template microscript 2022-09-18 19:10:36 -07:00
Xaekai
119b6a62b8 Focus searchbox when emotelist modal is shown 2022-09-18 19:10:36 -07:00
Xaekai
9d00d9666d Fix Nicovideo methods 2022-09-18 19:10:36 -07:00
Xaekai
f6ba5b71e8 Update vjs components
Upgrade Video.js core to v7.18.0 from v5.10.7
Upgrade Dash.js to v4.2.8 from v2.6.3
Upgrade videojs-contrib-dash to v5.1.1 from v2.9.1
Modify videojs-resolution-switcher
2022-09-18 19:10:36 -07:00
Xaekai
9b05e2eb8c Move Video.js components to a subfolder 2022-09-18 19:10:36 -07:00
Xaekai
911558760f Remove all references to wmode
Usage of wmode was specific to Flash, which is long dead.
2022-09-18 19:10:36 -07:00
Xaekai
45217ccad8 Add Niconico support 2022-09-18 19:10:36 -07:00
Xaekai
aeb5de85b6 Update HLS support 2022-09-18 19:10:36 -07:00
Xaekai
53911ab9f0 Reorganize PlayerJSPlayer dependents 2022-09-18 19:10:36 -07:00
Xaekai
a2c4ea5036 Add Odysee support 2022-09-18 19:10:36 -07:00
Xaekai
517058bef3 Set videojs poster on player ready
Resolves Github issue #870
2022-09-18 19:10:36 -07:00
Xaekai
1790d5b569 Add BandCamp support 2022-09-18 19:10:36 -07:00
Xaekai
97b8d1b4b7 Enable caching BitChute metadata 2022-09-18 19:10:36 -07:00
Xaekai
25ddc336e0 Use child iframe for BitChute
By using an iframe we can take advantage of the referrer meta tag,
while still being able to scaffold everything relatively easily because it's same-origin
2022-09-18 19:10:36 -07:00
Xaekai
498272b128 Flash is long dead 2022-09-18 19:10:36 -07:00
Xaekai
26f6611ca8 Options to autoembed PeerTube 2022-09-18 19:10:36 -07:00
Xaekai
6b831bc367 Touch up data.js
Reorder useropts to match client
Remove long unused variable
2022-09-18 19:10:36 -07:00
Xaekai
ffd01fe30b Fix issue with queue progress
If the user queues a PeerTube link with a long uuid the progress bar would never go away. Now it will just check against the hostname.
2022-09-18 19:10:36 -07:00
Xaekai
8774dc89e7 Fixup Livestream.com 2022-09-18 19:10:36 -07:00
Xaekai
16f183c117 Add BitChute support 2022-09-18 19:10:36 -07:00
Xaekai
ba80c1591d Fixup various lint
Touched up callbacks and paginator
2022-09-18 19:10:36 -07:00
Xaekai
4fada9a8d2 Eliminate jQuery from inline js/css charlimit notice 2022-09-18 19:10:36 -07:00
Xaekai
7441892235 Eliminate jQuery event shorthands 2022-09-18 19:10:36 -07:00
Xaekai
f929758bfd Improve the ESLint situation 2022-09-18 19:10:36 -07:00
Xaekai
500f295506 Allow for the omission of particular frames in SOCKET_DEBUG
In particular, mediaUpdate spam.
2022-09-18 19:10:36 -07:00
Xaekai
de1f37735b EmoteList live reconfig support 2022-09-18 19:10:36 -07:00
Xaekai
9f9bbfa022 Update jQuery and jQuery UI 2022-09-18 19:10:36 -07:00
Xaekai
d516c5ebfc Add PeerTube support 2022-09-18 19:10:36 -07:00
Xaekai
3668c1b3da Refactor parseMediaLink 2022-09-18 19:10:36 -07:00
Xaekai
0e3307b9f4 Remove references to defunct services
Imgur discontinued support for albums
SmashCast/Hitbox disappeared
Ustream was sunset by IBM
Mixer is dead
Picasa is long dead
Vidme is long dead
IE11 is dead
2022-09-18 19:10:36 -07:00
Xaekai
3ea16944d2 Ignore patch files 2022-09-18 19:10:36 -07:00
Calvin Montgomery
dcfcee9a23 Accept #946 2022-05-17 21:13:50 -07:00
Calvin Montgomery
fd451fe9d2 Require at least one vote to skip 2022-05-09 20:25:34 -07:00
Calvin Montgomery
cc283c0be9 Switch mediaquery back to githash instead of npm 2022-04-23 19:41:00 -07:00
Calvin Montgomery
c9da64107f Upgrade a couple deps to shut up npm audit 2022-04-23 19:36:51 -07:00
dependabot[bot]
5b92ea0660 Bump node-fetch from 2.6.1 to 2.6.7
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-03 09:42:45 -07:00
dependabot[bot]
facc72b22d Bump nodemailer from 6.5.0 to 6.6.1
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.5.0 to 6.6.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.5.0...v6.6.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-03 09:42:22 -07:00
dependabot[bot]
578c0f0ddc Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-03 09:38:45 -07:00
Xaekai
e099781686 Revert 3dfa587
The issue was caused by Babel weirdness.
2022-03-06 19:41:30 -08:00
Calvin Montgomery
3dfa587739
Update google-drive-subtitles.md 2022-02-05 18:59:29 -08:00
Calvin Montgomery
0d9f4a5f03 Fix cookies on ACP for SIO4 upgrade 2021-11-06 19:53:16 -07:00
Techanon
ab8faf7c99 Fix chat width resizing when window is very thin
When the window resized to a small width, the chat header buttons would wrap to the next line, but would inline with the chat box itself making it resize to unreadable widths.
Changing the header to flex with some minor adjustments prevents the inline wrapping thus the chatbox retains it's intended width.
2021-11-05 16:14:15 -07:00
Calvin Montgomery
7c3f3070f9 Fix bug introduced by fixing #918 2021-10-17 16:37:57 -07:00
Calvin Montgomery
1bab65bb13 Bump some devdeps to shut up npm audit 2021-10-13 20:19:28 -07:00
dependabot[bot]
01063c2623 Bump nth-check from 2.0.0 to 2.0.1
Bumps [nth-check](https://github.com/fb55/nth-check) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/fb55/nth-check/releases)
- [Commits](https://github.com/fb55/nth-check/compare/v2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: nth-check
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-13 20:16:30 -07:00
Calvin Montgomery
bd63013524 Fix #925 2021-10-13 20:14:44 -07:00
Calvin Montgomery
af62fbaef4 Fix #924 2021-10-13 20:12:31 -07:00
Calvin Montgomery
f41e0bda82 Fix new messages indicator being hidden behind chat messages on chromium 2021-10-13 19:58:19 -07:00
dependabot[bot]
0d8dcc41b2 Bump tar from 6.1.5 to 6.1.11
Bumps [tar](https://github.com/npm/node-tar) from 6.1.5 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.5...v6.1.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-05 09:57:03 -07:00
Calvin Montgomery
d179cd896f Allow revoting without refreshing 2021-08-19 21:03:15 -07:00
Calvin Montgomery
1f10f0f09c Fix eslint error 2021-08-19 20:55:40 -07:00
Calvin Montgomery
edb5f94b7c Add a POST flow to password recovery (#871) 2021-08-19 20:55:02 -07:00
Calvin Montgomery
d563a85092 Use embed src as url in playlist for custom embed 2021-08-19 20:46:38 -07:00
Calvin Montgomery
394f03ee1c Remove some legacy cruft 2021-08-19 20:44:57 -07:00
Calvin Montgomery
7214b7c474 Upgrade to socket.io v4 2021-08-19 20:36:04 -07:00
Calvin Montgomery
1b7e7c74f5 Remove legacy counters 2021-08-19 20:36:04 -07:00
Calvin Montgomery
11a0cd79bb Fix test 2021-08-12 19:48:06 -07:00
Calvin Montgomery
5f799fe1a1 Disable soundcloud lookup due to #916 2021-08-12 19:46:47 -07:00
Calvin Montgomery
c717a55c2d Implement #884 2021-08-11 21:16:19 -07:00
Zero
9a008d4623 Add support for raw AV1/Opus 2021-08-10 21:14:03 -07:00
dependabot[bot]
47d268335e Bump path-parse from 1.0.6 to 1.0.7
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-10 21:05:05 -07:00
dependabot[bot]
f136a02240 Bump tar from 6.1.0 to 6.1.5
Bumps [tar](https://github.com/npm/node-tar) from 6.1.0 to 6.1.5.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.0...v6.1.5)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-10 21:03:42 -07:00
Calvin Montgomery
a33d1e12d2 Fix #918 2021-08-10 21:03:13 -07:00
Calvin Montgomery
337e8cd1d3 Add some big ol nags about no support for gdrive 2021-08-08 09:49:20 -07:00
Calvin Montgomery
adfe26aad1 Bump version 2021-08-02 19:24:40 -07:00
Calvin Montgomery
f84892dc6a Refactor polls 2021-08-02 19:23:53 -07:00
Calvin Montgomery
c290f9fcca deps: bump cheerio to rc10 to resolve dependabot alert 2021-07-25 20:49:37 -07:00
Calvin Montgomery
d85c4ec84b Remove old player that isn't used anymore 2021-07-25 20:46:32 -07:00
Calvin Montgomery
bce5d0d878 player/youtube: remove setQuality logic due to #726 2021-07-25 20:43:15 -07:00
Calvin Montgomery
a3c17ea8ea Fix #913 2021-07-22 21:55:23 -07:00
dependabot[bot]
982c6fbfab Bump ws from 7.4.4 to 7.4.6
Bumps [ws](https://github.com/websockets/ws) from 7.4.4 to 7.4.6.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.4.4...7.4.6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-30 21:20:19 -07:00
dependabot[bot]
709963fd81 Bump browserslist from 4.16.3 to 4.16.6
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.16.3 to 4.16.6.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.16.3...4.16.6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-30 21:19:53 -07:00
dependabot[bot]
1f4f9a9c3e Bump postcss from 8.2.8 to 8.2.15
Bumps [postcss](https://github.com/postcss/postcss) from 8.2.8 to 8.2.15.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.2.8...8.2.15)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-30 21:19:46 -07:00
dependabot[bot]
b621a1b327 Bump redis from 3.0.2 to 3.1.1
Bumps [redis](https://github.com/NodeRedis/node-redis) from 3.0.2 to 3.1.1.
- [Release notes](https://github.com/NodeRedis/node-redis/releases)
- [Changelog](https://github.com/NodeRedis/node-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NodeRedis/node-redis/compare/v3.0.2...v3.1.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-30 21:19:24 -07:00
Calvin Montgomery
d28be04416 Fix package-lock version 2021-04-04 14:27:43 -07:00
Calvin Montgomery
db08272416 deps: upgrade some devdeps 2021-04-04 14:27:04 -07:00
Calvin Montgomery
8562b2173e Add placeholder text for #877 2021-03-22 22:58:38 -07:00
Calvin Montgomery
da53decdd5 Fix #885 2021-03-22 22:53:03 -07:00
Calvin Montgomery
05107ce13f Remove ignore button from self (#904) 2021-03-22 22:49:11 -07:00
Calvin Montgomery
56b4ec8f3a bump version 2021-03-21 21:50:05 -07:00
Calvin Montgomery
a1c9ae3626 deps: check in package-lock.json 2021-03-21 21:50:05 -07:00
Calvin Montgomery
08c0cfcd58 deps: upgrade prom-client 2021-03-21 21:50:05 -07:00
Calvin Montgomery
5f3d0859fd deps: bump knex and mysql 2021-03-21 21:50:05 -07:00
Calvin Montgomery
988029e6c7 deps: bump uuid 2021-03-21 21:50:05 -07:00
Calvin Montgomery
0b57f528bf deps: bump sanitize-html 2021-03-21 21:50:05 -07:00
Calvin Montgomery
99559d8fda deps: remove graceful-fs
graceful-fs was added at a time when channel state was stored in
flatfiles that could become corrupted if enough concurrent saves
occurred to hit the ulimit for maxfds (EMFILE).  Saving channels this
way is no longer supported, so it shouldn't be an issue anymore.
2021-03-21 21:50:05 -07:00
Calvin Montgomery
811a7c4d48 deps: bump cheerio 2021-03-21 21:50:05 -07:00
Calvin Montgomery
182e6f0816 customembed: drop <object> and <embed> 2021-03-21 21:50:05 -07:00
Calvin Montgomery
9e5a63d880 dep upgrades part 1 2021-03-21 21:50:05 -07:00
Calvin Montgomery
bb165606d6 Add explicit integ test for old password hash verification 2021-03-21 21:50:05 -07:00
Calvin Montgomery
7b56f3f0e7 Bump copyright year in LICENSE 2021-03-21 21:50:05 -07:00
kr4ssi
e391a80d65 Allow alt-attribute on <img>-tags
https://www.w3.org/html/wg/wiki/IssueAltAttribute
2021-01-28 19:32:33 -09:00
Calvin Montgomery
a75917d4e4 fix: attempt to avoid socket leak in node >= 13.x
The default timeout was removed from the HTTP module in node 13.x:
https://github.com/nodejs/node/pull/27558.  I believe this is the most
likely cause of fd leaks when running under current node versions.
2021-01-16 14:19:22 -08:00
Calvin Montgomery
00e9acbe4d Revert "Remove channel reference counter"
This reverts commit d678fa56d1.  The
reference counter, flawed as it is, was masking far more issues than I
realized.  It would require a more significant rearchitecture of the
code to remove it.  Probably better to keep it and try to improve it for
now.
2021-01-09 13:03:38 -08:00
aleves64
3262f7822f Delete package-lock.json 2021-01-03 14:48:10 -09:00
aleves64
a8d9781821 Small changes 2021-01-03 14:48:10 -09:00
aleves64
18fd611c91 Links to wiki now 2021-01-03 14:48:10 -09:00
aleves64
7c3d2f74ed Made get-info save if yt video is age-restricted and made playlist refuse to add age-restricted videos 2021-01-03 14:48:10 -09:00
Calvin Montgomery
9e3c23c58a Refuse to start on invalid config 2020-12-02 18:09:49 -08:00
Calvin Montgomery
d678fa56d1 Remove channel reference counter
This was an old attempt at gracefully unloading channels that still had
pending callbacks.  Its implementation was always flawed, and the number
of places where it was used is small enough to replace with
straightforward checks for whether the channel has been unloaded after
an asynchronous operation.  Hopefully fixes the stuck 0 user channels
issue.
2020-11-11 22:05:05 -08:00
Calvin Montgomery
66fadab492 Handle some common error conditions in the ffprobe preflight path 2020-11-11 22:05:05 -08:00
animeavi
750509eaf1 Support enabling custom media subtitle by default
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track#Attributes
2020-11-09 18:37:45 -09:00
Calvin Montgomery
8fc951350e Bump gcc version in travis.yml 2020-10-23 13:46:26 -07:00
Calvin Montgomery
3f9a0aaf05 Fix npm jank 2020-10-23 11:51:59 -07:00
Calvin Montgomery
801e54afa2 Tweak urlencoded body size limit 2020-09-22 20:23:46 -07:00
deerfarce
6f8bde91e1 adjust sendUserMeta rank comparison
includes users with the same rank as the minimum rank for the action when sending the meta frame
2020-09-22 20:12:09 -07:00
Calvin Montgomery
df82d2d4f1 Add registration captcha support 2020-09-22 20:11:34 -07:00
Calvin Montgomery
f08cce5aed Update some devdeps, resolves some npm audit warnings 2020-08-21 20:47:01 -07:00
Calvin Montgomery
962998c543 deps: bump bcrypt to 5.0.0 2020-08-21 20:37:16 -07:00
Calvin Montgomery
80d3d14c85 Add integ test for verifyLogin 2020-08-21 20:31:54 -07:00
Calvin Montgomery
f081bc782a RIP Mixer 2020-07-26 10:24:36 -07:00
Lewis Crichton
99af92ed2c Make option on by default 2020-07-18 18:43:47 -07:00
Lewis Crichton
c148c991cd Relocate and rename option, make option restore after page refresh. 2020-07-18 18:43:47 -07:00
Lewis Crichton
d4f75146c7 option to disable IP on userlist hover 2020-07-18 18:43:47 -07:00
Calvin Montgomery
4598a6a58c Wrap chat input in fake form to appease chrome's heuristics (#807) 2020-07-03 11:55:17 -07:00
Calvin Montgomery
248c200a74 Implement twitch changes for #874 2020-06-22 19:39:00 -07:00
Calvin Montgomery
b70194c8f2 Add destroy cb for dailymotion (#873) 2020-06-19 18:31:25 -07:00
Calvin Montgomery
ffa10648e4 Update travis.yml: drop 10, 13, add 14 2020-06-18 21:25:54 -07:00
Calvin Montgomery
4f5cd7d741 Fix UI bug (#872) 2020-06-18 21:23:05 -07:00
Calvin Montgomery
a85b379f17
Fix old 6irc ref 2020-06-09 23:54:38 -07:00
Calvin Montgomery
9e5fcf4904 Fix #866 2020-05-17 10:42:21 -07:00
Calvin Montgomery
529a3561ca Set user-agent in ffprobe pre-flight check (#869) 2020-05-17 10:42:21 -07:00
Xaekai
88365612da
Replace userlist visibility check logic (#859)
* Replace visibility check logic

JQuery queries using getComputedStyle, which makes it impossible to change userlist behavior using CSS. This replaces the check with a direct style="" value check so the JS does not trip up if any CSS customizations to the list visibility were made.

Co-authored-by: Algoinde <algoinde@gmail.com>
2020-04-17 14:53:39 -07:00
Xaekai
f2adbe18da
Explicitly use UTF8 encoding for media metadata table (#863) 2020-04-11 14:23:36 -07:00
Xaekai
a53f65a1d5 Fix channel password prompt
Add zin param to allow explicit z-index in JS generated dialog boxes.
Give needpass dialog explicit z-index
2020-03-29 10:31:27 -07:00
Calvin Montgomery
47bb3e47a2 Add metric for yt cached result age 2020-03-20 19:54:34 -07:00
Calvin Montgomery
83fd8f11b2 Fix updated_at in media_metadata_cache 2020-03-20 19:44:11 -07:00
Calvin Montgomery
5a386d0f81 Remove fallback to YT from library search 2020-03-20 19:38:48 -07:00
kr4ssi@tuta.io
d2358924a4 Add tabcompletion for PMs 2020-03-16 20:08:04 -07:00
Calvin Montgomery
106065184f Remove old flatfile chandump storage 2020-02-15 16:17:49 -08:00
Calvin Montgomery
e3a9915b45 Clean up a few things that no longer work/are no longer used 2020-02-09 16:50:37 -08:00
Calvin Montgomery
b80a532f9e Add YouTube cache table 2020-02-09 15:49:38 -08:00
Calvin Montgomery
46311bd661 Add missed file 2020-01-26 20:20:37 -08:00
Calvin Montgomery
58e4e09840 Replace twitch clip player (#842) 2020-01-26 20:17:55 -08:00
Calvin Montgomery
c809b1994a Fix redirect logic for ffprobe pre-flight check 2020-01-11 11:24:34 -08:00
Olie440
842d0bb4be update button labels (#839)
Update video search button labels to use a consistent verb.
2019-12-05 20:32:23 -08:00
Calvin Montgomery
40b5a0fe83 Upgrade knex 2019-12-01 16:29:52 -08:00
Calvin Montgomery
4d3c90f5ee Sunset node v8, add node v13 to travis 2019-12-01 16:04:55 -08:00
Calvin Montgomery
9aa73bee7c Remove --loose babel parameter that throws errors now 2019-12-01 16:04:16 -08:00
Calvin Montgomery
b0b22a7579 Fix migrator (#831) 2019-10-27 13:09:22 -07:00
Calvin Montgomery
06b3916a6c Fix #822 2019-10-26 20:43:44 -07:00
Calvin Montgomery
c4a1d4b18c Add dummy ID to migrator to fix #831 2019-10-26 20:35:42 -07:00
Calvin Montgomery
08f9feef74 Match word boundaries for nick highlight (#819) 2019-08-01 20:02:37 -07:00
Calvin Montgomery
1ec3eab0dc Preserve current playing item when shuffling (#812) 2019-08-01 19:57:32 -07:00
Calvin Montgomery
27e8885285 Add kick logline (#821) 2019-08-01 19:49:22 -07:00
Calvin Montgomery
77b7af7fd9 deps: bump cytubefilters 2019-06-27 20:51:58 -07:00
Calvin Montgomery
5c51d73c4e Update nodemailer 2019-06-16 13:41:51 -07:00
mrx1983
959ef89c27 add autoplay attribute to custom embed iframe tag, so autoplay works as expected
calzone said i should make a pull request. so here it is.
it would be great if 'allow="autoplay"' attribute is added to the generated iframe for custom embeds.
so autoplay works as expected.
2019-06-09 10:33:25 -07:00
Calvin Montgomery
5a2494adcf Prevent uncaught exception if spawn() throws synchronously (e.g. ENOMEM) 2019-05-28 21:32:03 -07:00
Calvin Montgomery
6b2dfa483c Fix #813 2019-05-25 16:07:44 -07:00
Calvin Montgomery
df934f401c Add clarity to custom media doc about how the user must host the JSON file 2019-05-05 14:17:59 -07:00
Calvin Montgomery
60c348a905 Clamp timeouts to 1 day 2019-04-28 22:30:08 -07:00
Calvin Montgomery
2a1f1df17b Bump some crufty dependencies 2019-04-28 11:18:04 -07:00
Calvin Montgomery
97266b6dfc Better fix for jank dailymotion race conditions 2019-04-11 20:43:24 -07:00
Calvin Montgomery
a3a2daff4c Remove file extension check (#801) 2019-04-07 16:32:58 -07:00
Calvin Montgomery
5493a81611 Revert "Fix #799 and remove old unused quality selection"
This reverts commit 8c136c563a.
2019-03-30 12:52:17 -07:00
Calvin Montgomery
13c468c768 Fix test 2019-03-27 21:37:57 -07:00
Calvin Montgomery
12924b9b5a Implement #786 2019-03-27 21:33:16 -07:00
Calvin Montgomery
c5b122bcf8 Fix #790 2019-03-27 21:28:46 -07:00
Calvin Montgomery
96bf3df928 Fix #793 2019-03-27 21:26:06 -07:00
Calvin Montgomery
8c136c563a Fix #799 and remove old unused quality selection 2019-03-27 21:19:30 -07:00
Calvin Montgomery
b25560c4a9 Add error message for #798 2019-03-27 21:05:45 -07:00
Calvin Montgomery
cb95aaa4e8 Error on node < 8 2019-03-27 20:33:54 -07:00
Calvin Montgomery
367df3d70b Implement #797 2019-02-23 21:23:21 -08:00
Calvin Montgomery
c6f9b1611e Add some sanity checks for common first-startup issues 2019-02-10 10:22:16 -08:00
Calvin Montgomery
66d81ffb22 Update YouTube instructions (#792) 2019-02-02 15:59:02 -08:00
Calvin Montgomery
a81e4d1d16 Fix copyright year in LICENSE 2019-02-02 15:56:43 -08:00
Calvin Montgomery
dfb7177a6d Add workaround for Dailymotion issue 2019-02-02 15:56:20 -08:00
Calvin Montgomery
5c76eaf68a
Remove typo in custom-media.md 2018-12-29 14:42:06 -08:00
Calvin Montgomery
8d0c1a03d1 Add inactive column to base schema 2018-12-18 19:10:53 -08:00
Calvin Montgomery
c85be71f23 delet undefined 2018-12-07 21:11:40 -08:00
Calvin Montgomery
9c44488d8e Add sanity check to prevent null duration from corrupting playlist meta 2018-12-07 20:47:46 -08:00
Calvin Montgomery
693c0e8673 Update NEWS for account deletion 2018-12-07 20:36:53 -08:00
Calvin Montgomery
b68ed4d77a Set purge interval to 1 hour 2018-12-07 20:35:00 -08:00
Calvin Montgomery
aa2348656d Implement self-service account deletion 2018-12-07 20:35:00 -08:00
Calvin Montgomery
37c6fa3f79 Fix eslint complaint 2018-11-16 19:55:58 -08:00
Calvin Montgomery
fe4030a247 Fix ustream link parsing 2018-11-16 19:52:09 -08:00
Calvin Montgomery
4c9e85b293 Support IO token bucket capacity > refill rate 2018-11-15 23:04:03 -08:00
Calvin Montgomery
8456b6a125 Implement #767 for custom media only 2018-11-15 22:52:04 -08:00
Calvin Montgomery
027b27c1b0 Bump max message length (#782) 2018-11-15 22:48:30 -08:00
Calvin Montgomery
3620b07816 Add userlist-ignored strikethrough for ignored users 2018-11-15 22:44:21 -08:00
Calvin Montgomery
8c9622f1b2 Fix #783 2018-11-15 22:40:01 -08:00
Calvin Montgomery
4ccdca6dca Default channel-storage to database for new installs 2018-11-14 22:48:49 -08:00
Calvin Montgomery
bfff2900ca deps: replace cytubefilters short hash with full hash 2018-11-14 22:03:14 -08:00
Calvin Montgomery
b85406716b deps: bump cytubefilters git hash 2018-11-14 22:01:38 -08:00
Calvin Montgomery
f7cc00d16b Fix tabcomplete sort bug exposed by new v8 2018-11-12 21:01:43 -08:00
Calvin Montgomery
a9fac9d6d0 Add node.js 11.x to .travis.yml 2018-11-12 21:01:43 -08:00
Calvin Montgomery
cd94c8b83d Use page visibility API instead of buggy window.focus tracking 2018-11-11 20:24:19 -08:00
Calvin Montgomery
60a39890f0 Fix hostname comparison in /login 2018-11-11 16:11:51 -08:00
Calvin Montgomery
2d6af31c00 voteskip: add early exit for duplicate votes 2018-11-11 16:08:00 -08:00
Calvin Montgomery
f6a58d00b2 Adjust some socket.io settings (#780) 2018-11-07 21:23:00 -08:00
Calvin Montgomery
1f28c0b87d Add checks for kisscartoon/kissanime/mega to direct the user to the FAQ 2018-10-21 22:26:43 -07:00
Calvin Montgomery
801d3d9be1 Fix #775 2018-10-21 22:18:22 -07:00
Calvin Montgomery
5b86fb3187 Implement #779 2018-10-21 22:12:49 -07:00
really-need-an-api-key
0bc866dbfa Add desktop notifications 2018-10-12 20:19:45 -07:00
Calvin Montgomery
1923af16a9 Fix a few minor error conditions 2018-09-30 21:22:20 -07:00
Calvin Montgomery
ce44bfea9e Be stricter about ustream IDs 2018-09-30 21:05:04 -07:00
Calvin Montgomery
24a13c12cf Minor fixes, logging, metrics 2018-09-30 21:03:09 -07:00
Calvin Montgomery
13585a5e6a Replace raw DDL with knex table builder 2018-09-30 20:43:45 -07:00
Calvin Montgomery
0c100b1dec
Remove "experimental" note from custom-media.md 2018-09-24 21:20:50 -07:00
Calvin Montgomery
c7fcd11e53 Fix channel save error introduced by removing async-to-generator 2018-08-29 20:59:07 -07:00
Calvin Montgomery
d9e2a62f77 Add check for #766 2018-08-29 20:40:24 -07:00
Calvin Montgomery
7b0427afa2 Remove code that was never finished and likely won't be used 2018-08-27 22:07:42 -07:00
Calvin Montgomery
553052f901 Drop node.js 6.x 2018-08-27 21:59:48 -07:00
Calvin Montgomery
c90d9c0ddc Remove flow (not being used) 2018-08-27 21:48:52 -07:00
Calvin Montgomery
0bd11c3bba Add forgotten file 2018-08-26 22:08:59 -07:00
Calvin Montgomery
db48104b80 Initial mixer implementation 2018-08-26 22:04:14 -07:00
Calvin Montgomery
f19efdb859 Fix #762 2018-08-18 13:14:37 -07:00
Calvin Montgomery
c5c4fba7ce Fix unused import 2018-08-18 12:28:18 -07:00
Calvin Montgomery
a9a644460f Fix #760 2018-08-18 12:27:24 -07:00
Calvin Montgomery
cb687fc078 zalgo text hasn't been funny for years, guys 2018-08-06 19:56:55 -07:00
Calvin Montgomery
d54707c9c7 Wrap raw file HEAD check in try-catch in case of invalid URL 2018-07-25 21:38:09 -07:00
Calvin Montgomery
3d520ecf57 Add ffmpeg error handlers for 405 and 501 2018-07-25 21:34:02 -07:00
Calvin Montgomery
878b30bdb2 Fix undefined dereference in rank callback 2018-07-25 21:27:28 -07:00
Calvin Montgomery
67b1c97d89 Add io.throttle-in-rate-limit for socket event rate 2018-07-25 21:07:07 -07:00
Calvin Montgomery
db2361aee9 Misc fixes for password reset
* Remove messaging about asking an administrator for help if no email
    is associated with the account (no longer correct or relevant)
  * Compare user-provided email with registered email case-insensitively
    (#755)
  * Replace antiquated hash generator with cryptographically secure
    random byte string generator
2018-07-11 19:21:32 -07:00
Calvin Montgomery
3db751b65f Fix socket count metric leak 2018-07-09 20:24:53 -07:00
Ryan Huang
7acae30875 Add breaking spaces into footer 2018-07-09 17:15:20 -07:00
Calvin Montgomery
aca40dde0c Add note about unsupported filetypes 2018-06-15 20:33:55 -07:00
Calvin Montgomery
dd23564c15 link-domain-blacklist: fix blank blacklist matching empty string 2018-06-14 18:45:35 -07:00
Calvin Montgomery
fa49921866 Speed up join by avoiding quadratic userlist code
At some point the entire user presence logic needs to be refactored for
efficiency, but this at least gives a huge reduction in first page load
time for large channels.
2018-06-06 22:47:00 -07:00
Calvin Montgomery
3413c3bdaa Reject guest names matching the reserved usernames regex 2018-06-03 22:01:40 -07:00
Calvin Montgomery
90b5e5e09f deps: bump mocha and babel 2018-06-03 21:55:41 -07:00
Calvin Montgomery
125a781cc7 deps: updates to support node.js 10 2018-06-03 21:19:12 -07:00
Xaekai
a632a4cafa Show poll creator on hover of timestamp 2018-05-28 15:17:27 -07:00
Calvin Montgomery
fdab26b792 Hoist sortUserlist outside of userlist population inner loop 2018-05-26 13:28:26 -07:00
Calvin Montgomery
bfe0d75278 Add check for error condition exposed by misbehaving bot 2018-04-08 19:19:22 -07:00
Calvin Montgomery
976b0a2168 Fix error introduced by lint changes 2018-04-08 19:17:03 -07:00
Calvin Montgomery
e9a183bf9a Replace muted user set implementation with ES6 Set 2018-04-08 19:11:54 -07:00
Calvin Montgomery
62417f7fb8
Add eslint (#741) 2018-04-07 15:30:30 -07:00
Adam davis
953428cad5 Add Admin Setting - Block anonymous connections (#740) 2018-04-07 11:24:52 -07:00
Calvin Montgomery
ef7bf1a319 Use path.join(__dirname, ...) to potentially avoid www/js/player.js issue 2018-04-05 20:48:59 -07:00
Calvin Montgomery
c1e78fd4dc Kill process if www/js/player.js is not found at startup 2018-04-05 20:39:49 -07:00
Calvin Montgomery
2087921072 Clarify some wording 2018-03-17 17:47:27 -07:00
Calvin Montgomery
6070f7fc06 Update the README 2018-03-17 17:46:21 -07:00
Calvin Montgomery
304a6c9cfa Fix parseMediaLink stripping querystring from HLS manifest URLs 2018-03-17 10:49:08 -07:00
Calvin Montgomery
34eaca7b84 Fix version check in index.js to reject node <v6 2018-03-13 22:36:21 -07:00
Calvin Montgomery
fcfc45dd70 Save YouTube playlists to library in batch to avoid connection pool starvation 2018-03-05 22:19:51 -08:00
Calvin Montgomery
54bf7f1c5b Strip GDrive metadata from saved channel playlists 2018-03-05 21:56:08 -08:00
Calvin Montgomery
8340bf2c81 Add notice that quality preference doesn't work for YouTube anymore (#726) 2018-03-05 21:51:40 -08:00
Calvin Montgomery
81e1947656 Clear template cache on /reload (#734) 2018-03-05 21:46:58 -08:00
Calvin Montgomery
247cf770d0 Avoid O(N^2) loop when loading channel emotes on channel load 2018-03-05 21:35:56 -08:00
Calvin Montgomery
726a5bf7c4 Minor tweaks to specific error conditions 2018-02-24 19:51:28 -08:00
Calvin Montgomery
79556d9365
deps: remove "q" (#731)
Insert Star Trek joke here.

Also did significant refactoring of the surrounding logic for the things
that depended on Q.
2018-02-24 19:47:50 -08:00
Calvin Montgomery
d5f5c91b05 Minor fixes 2018-02-15 19:59:23 -08:00
Calvin Montgomery
49661a95ab Upgrade dependencies 2018-02-15 19:58:33 -08:00
Calvin Montgomery
03f30a82b9 Fix botched version bump in package.json 2018-02-01 17:41:06 -08:00
Calvin Montgomery
966da1ac58 Revert "Replace quadratic emote list impl with Map"
This reverts commit 0f9bc44925.

The original commit was not backwards compatible with use cases that
users were relying on, such as emotes being sorted in insertion order by
default.

I will develop a new patch which fixes the performance issue in a
backwards compatible way.
2018-02-01 17:39:45 -08:00
Calvin Montgomery
aeab31825e Fix a raw file error caused by facebook CDN violating RFC 2616 2018-01-21 18:53:16 -08:00
Calvin Montgomery
e7781b5c09 Remove accidentally committed script 2018-01-18 19:48:32 -08:00
Calvin Montgomery
8821de0e7d Try to reduce the extra crap logged when a db query fails 2018-01-18 19:47:55 -08:00
Calvin Montgomery
0f9bc44925 Replace quadratic emote list impl with Map 2018-01-18 19:34:57 -08:00
Calvin Montgomery
8399eab33f Fix error on invalid regex for /clean 2018-01-17 21:54:43 -08:00
Calvin Montgomery
326e67893c Minor bugfix 2018-01-14 15:43:12 -08:00
Calvin Montgomery
1797e11b43 Sanitize google drive IDs to remove URL hash etc. 2018-01-14 15:15:59 -08:00
Calvin Montgomery
46a738b7f4 Minor tweak to playlist dirty check 2018-01-14 15:08:55 -08:00
Calvin Montgomery
d706bf63b1 Fix ustream 2018-01-14 15:02:15 -08:00
Calvin Montgomery
fec1372e4e Annual bit flip 2018-01-07 15:45:18 -08:00
Calvin Montgomery
c07cf7c13a Remove confirmation prompt from postinstall 2018-01-07 15:42:08 -08:00
Calvin Montgomery
e350eb731b Fix #728 2018-01-07 15:14:20 -08:00
Calvin Montgomery
cf9b95a265 Add experimental support for dash streaming 2018-01-06 11:00:59 -08:00
Calvin Montgomery
67fbc8e267 Add more information to the voteskip passed log message 2018-01-06 10:31:59 -08:00
Calvin Montgomery
4b48966e1d Add ffprobe errors for ECONNRESET and CERT_HAS_EXPIRED 2018-01-06 10:13:07 -08:00
Calvin Montgomery
6d0498987a Add sanity check for one instance of error unload
Unfortunately I think this is just one of a whole class of race
conditions caused by errored channels being unloaded immediately without
waiting for the refcounter to reach 0.

However, this one is the only one that appears commonly in the logs so
adding this check should buy time to rethink the overall problem.
2018-01-06 10:09:22 -08:00
Calvin Montgomery
78bffad888 Fix errored channels getting stuck during unload 2018-01-06 09:59:18 -08:00
Zynjec
7b328b10c3 Removed Vidme provider from getInfo
Loading a channel fails due to it being removed.

`Uncaught exception: Error: Cannot find module 'cytube-mediaquery/lib/provider/vidme'`
2017-12-27 18:15:21 -08:00
Calvin Montgomery
8a8532fc84 Add node.js 9 to .travis.yml 2017-12-27 14:24:57 -08:00
Calvin Montgomery
95e147b5a0 Use socket.handshake instead of socket.client.request
Fixes a bug where sockets would be rejected if they connected directly
with the 'websocket' transport instead of doing an AJAX connection with
websocket upgrade (e.g. if `transports: ['websocket']` is passed to the
socket.io-client constructor).

See https://github.com/socketio/socket.io/blob/master/docs/API.md#sockethandshake
2017-12-27 14:24:33 -08:00
Calvin Montgomery
0b6106a89e Clarify gdrive userscript error when fmt_stream_map is missing 2017-12-26 20:40:12 -08:00
Calvin Montgomery
fa74ee0538 Add NEWS.md update I forgot to commit 2017-12-26 11:04:40 -08:00
Calvin Montgomery
24322d3b52 Remove config option that is no longer used 2017-12-26 11:00:18 -08:00
Calvin Montgomery
b7bc93f194 Disable vid.me (RIP) 2017-12-24 11:19:30 -08:00
Calvin Montgomery
0c330a82ce Add dirty check to playlist for efficiency of channel saving 2017-12-16 10:34:04 -08:00
Calvin Montgomery
a4e72a002a Fix #719 2017-12-16 00:05:28 -08:00
Calvin Montgomery
7fbd62142e Minor tweaks/fixes 2017-12-15 19:10:32 -08:00
Calvin Montgomery
29be9233e9 Add check for weird setAFK edge case 2017-12-11 22:46:41 -08:00
Calvin Montgomery
1e969117c4 Fix #722 2017-12-10 19:28:05 -08:00
Calvin Montgomery
fbee6d2ab7 Fix a few common causes of error logs (incl. better ffprobe error messages) 2017-12-10 16:39:06 -08:00
Calvin Montgomery
c4cc22dd05 Add experimental feature to reduce database writes for channel data 2017-12-10 10:36:28 -08:00
Calvin Montgomery
a9062159ed Fix partial saving for flatfile channel data 2017-12-10 09:48:40 -08:00
Calvin Montgomery
64350cc492 Disable test for middleware commented out due to #724 2017-12-06 22:17:37 -08:00
Calvin Montgomery
4e8c97bfb5 Fix deprecation warning about no callback to fs.unlink 2017-12-06 22:16:25 -08:00
Calvin Montgomery
39587a8448 Add DB query error count metric 2017-12-06 22:13:07 -08:00
Calvin Montgomery
9886f648f2 Workaround for #724 2017-12-06 22:10:06 -08:00
Calvin Montgomery
60f77d4eb9
Merge pull request #720 from calzoneman/servicelogin
This resolves an issue where Google returns HTTP200 but provides an H…
2017-11-28 21:37:28 -08:00
Xaekai
6a0608bf7e Explicit type conversion in version number comparison. 2017-11-27 23:56:21 -08:00
Xaekai
aa5066762b This resolves an issue where Google returns HTTP200 but provides an HTML redirect to a login portal instead of video data.
Closes #718
2017-11-27 23:37:41 -08:00
Calvin Montgomery
342e5d406a Drive userscript: support violentmonkey (#713) 2017-11-27 22:42:50 -08:00
Calvin Montgomery
a260f79c7d Replace gm4 polyfill 2017-11-20 18:00:45 -08:00
Calvin Montgomery
85169fbb56 Update drive userscript (#714) 2017-11-15 22:27:31 -08:00
Calvin Montgomery
875337d9a6 web/account: add referrer check 2017-11-05 16:17:37 -08:00
Calvin Montgomery
b876c8907a ffmpeg: preserve cookies when following redirects in pre-flight req 2017-11-05 16:01:39 -08:00
Calvin Montgomery
b453aecee5 Replace froogaloop
Froogaloop no longer appears to work.

Followed migration guide: https://github.com/vimeo/player.js/blob/master/docs/migrate-from-froogaloop.md
2017-10-28 23:10:15 -07:00
Calvin Montgomery
3cd8bfa8c7 Remove /sioconfig for real 2017-09-30 15:26:47 -07:00
Calvin Montgomery
a2be65aead Reset prometheus summaries for more accurate percentiles per 5 minutes 2017-09-27 21:55:42 -07:00
Calvin Montgomery
014f3f008e Remove config key that is no longer used 2017-09-27 21:50:51 -07:00
Calvin Montgomery
c4ad9099c2 Merge pull request #707 from calzoneman/nodemailer-upgrade
Upgrade nodemailer to 4.x
2017-09-27 21:46:54 -07:00
Calvin Montgomery
f975f7ef85 Update password reset to use new nodemailer impl 2017-09-26 21:22:15 -07:00
Calvin Montgomery
9cfe71d4c4 Start working on nodemailer upgrade 2017-09-25 22:31:45 -07:00
Calvin Montgomery
071def0838 Fix streamable autoplay 2017-09-25 19:25:31 -07:00
Calvin Montgomery
8db22ad924 Implement playerjs for streamable (#706) 2017-09-25 19:18:46 -07:00
Calvin Montgomery
bfc7cfc193 Remove old /useragreement 2017-09-19 22:07:00 -07:00
Calvin Montgomery
9868a97dbd Remove a couple config keys that are no longer used 2017-09-19 22:03:34 -07:00
Calvin Montgomery
c159fa8060 Remove old HTTPS redirect kludges 2017-09-19 20:49:33 -07:00
Calvin Montgomery
4e1bce6a24 Remove flaky (in CI) test 2017-09-19 19:11:18 -07:00
Calvin Montgomery
de5cc3352a Fix another bug with prometheus socket.io emtrics 2017-09-19 19:03:43 -07:00
Calvin Montgomery
9a1d50dcd3 Add support for v8-profiler (optional dep) 2017-09-18 21:54:36 -07:00
Calvin Montgomery
4db78deda3 Support updating profile via /account/data 2017-09-06 22:53:34 -07:00
Calvin Montgomery
9e3426633d Support updating email via /account/data 2017-09-05 23:11:28 -07:00
Calvin Montgomery
5b6f86668a Refactoring 2017-09-05 22:47:29 -07:00
Calvin Montgomery
3eb97bab6a Fix bug in cytube_sockets_num_connected metric 2017-09-04 10:04:33 -07:00
Calvin Montgomery
97231e515c player: support HLS vod for vidme (fixes #703)
- Upgrade videojs-contrib-hls to latest version
  - Update VideoJSPlayer to support "auto" quality tag to delegate to
    the HLS plugin for automatic quality selection
  - mediaquery change:
    9f5122e031
2017-09-04 09:44:30 -07:00
Calvin Montgomery
45d0e0b4c3 Guard unfinished web route with env variable 2017-09-03 17:22:57 -07:00
Calvin Montgomery
b76869e2d2 Add some basic tests for implemented /account/data handlers 2017-09-01 21:20:07 -07:00
Calvin Montgomery
8b1b501bbd Start working on /account/data controller 2017-08-30 22:45:48 -07:00
Calvin Montgomery
33b2bc2d30 Add basic knex methods for channel data needed for /account/* 2017-08-29 21:23:04 -07:00
Calvin Montgomery
269aa6bfe6 Add basic knex methods to be used for /account/* pages 2017-08-28 23:37:32 -07:00
Calvin Montgomery
162f8fd9b5 Fix index page JS submit 2017-08-24 21:01:10 -07:00
Calvin Montgomery
3d50b8f52e Fix getSafeReferrer when referrer is null 2017-08-24 20:55:18 -07:00
Calvin Montgomery
cc69b3c225 Revert "Remove legacy /sioconfig and user agreement link"
ACP has a dependency on `/sioconfig`.  Reverting until that can be
fixed.

This reverts commit a48cab81b9.
2017-08-23 23:15:30 -07:00
Calvin Montgomery
cacde7f72d Fix unhandled rejections in webserver 2017-08-23 23:02:08 -07:00
Calvin Montgomery
712a8c228b Refactor most pug templates to share a common layout template 2017-08-22 22:09:48 -07:00
Calvin Montgomery
0810591fe3 Remove unnecessary template mixin vars 2017-08-22 17:33:29 -07:00
Calvin Montgomery
7e6312f9d1 Remove ?dest= redirect logic for /login and use referrer instead 2017-08-22 17:25:18 -07:00
Calvin Montgomery
a48cab81b9 Remove legacy /sioconfig and user agreement link
- `/sioconfig` has been deprecated for ages in favor of
    `/socketconfig/${channel}.json`
  - Each website administrator should be responsible for determining the
    appropriate terms of service for their website instead of CyTube
    providing a default one.
2017-08-21 23:19:19 -07:00
Calvin Montgomery
7c897d91db Add crossorigin attribute for custom media with text tracks
Mitigates #702
2017-08-21 20:06:07 -07:00
Calvin Montgomery
0885a619b9 Generate .meta.js for gdrive userscript for update checks
Tampermonkey automatically requests www/js/cytube-google-drive.meta.js
to check for updates.  Changed the userscript generator to write an
additional .meta.js file so that works instead of 404ing.
2017-08-19 16:31:02 -07:00
Calvin Montgomery
9f0444a962 Fix jquery 404 on /register 2017-08-19 16:13:15 -07:00
Calvin Montgomery
ae7098085c Work on knexifying password resets 2017-08-16 23:28:29 -07:00
Calvin Montgomery
791a712a68 Move channel register/delete reload logic to message bus 2017-08-15 18:55:36 -07:00
Calvin Montgomery
d16cfb7328 Add message bus for #677 2017-08-15 18:23:03 -07:00
Calvin Montgomery
9ee650461f Change unhandledRejection from fatal log level to error 2017-08-14 20:35:30 -07:00
Calvin Montgomery
2990d83c02 ffmpeg: add ETIMEDOUT error message 2017-08-14 20:33:09 -07:00
Calvin Montgomery
99076412b6 Fix unhandled rejection 2017-08-14 20:31:45 -07:00
Calvin Montgomery
c6c3bafca2 database: include legacy query() in prometheus metrics 2017-08-14 18:24:53 -07:00
Calvin Montgomery
82004aab73 ioserver: change on disconnect to once to avoid double-counting 2017-08-14 18:23:07 -07:00
Calvin Montgomery
82bd645781 Minor cleanup of some no longer used client stuff 2017-08-13 22:33:54 -07:00
Calvin Montgomery
70b875c0e9 Remove some ancient db upgrade stuff
If anyone is still running a database from 2014 they want to upgrade,
sorry.
2017-08-13 22:19:47 -07:00
Calvin Montgomery
4102d6eaf2 Refactor index.js logic into src/main 2017-08-13 22:16:42 -07:00
Calvin Montgomery
ba8088b678 videojs: default quality to 480 instead of 1080 2017-08-13 21:48:50 -07:00
Calvin Montgomery
a90d88ad65 Fix race condition that might be causing errors 2017-08-12 13:30:24 -07:00
Calvin Montgomery
8a8ed0a932 ffmpeg: better error messages for ECONNREFUSED and ENOTFOUND 2017-08-12 13:20:44 -07:00
Calvin Montgomery
d0c1e8cbd9 Change metric names to follow prometheus naming guide 2017-08-12 13:12:58 -07:00
Calvin Montgomery
92f0a956b9 custom-media: import spec and fix a minor missed validation 2017-08-08 20:46:10 -07:00
Calvin Montgomery
04c9d48779 custom-media: implement queueing and playback changes 2017-08-08 20:35:17 -07:00
Calvin Montgomery
a6de8731b3 custom-media: add metadata downloader 2017-08-07 22:37:56 -07:00
Calvin Montgomery
f4ce2fe69d custom-media: add converter to CyTube Media object 2017-08-07 21:44:55 -07:00
Calvin Montgomery
8b7cdfd4c3 soundcloud: fix getVolume to match setVolume 2017-08-07 21:08:04 -07:00
Calvin Montgomery
c7f7dcfed3 custom-media: use url.parse, not whatwg URL (node v6 compat) 2017-08-06 21:59:14 -07:00
Calvin Montgomery
ea6e3f921f custom-media: add validator
Initial work for #655
2017-08-06 21:50:27 -07:00
Calvin Montgomery
331a4626a0 Fix borrow-rank 2017-08-06 20:42:33 -07:00
Calvin Montgomery
0b560f15a9 Add prometheus counter for changeMedia 2017-08-05 18:50:27 -07:00
Calvin Montgomery
dac2e41488 Fix and enable efficient emotes by default 2017-08-05 12:22:58 -07:00
Calvin Montgomery
cb6cfc8455 Instrument some more metrics with prometheus 2017-08-02 21:24:44 -07:00
Calvin Montgomery
6043647cb7 Skip full user auth for most page renders
Previously, the user's session cookie was being checked against the
database for all non-static requests.  However, this is not really
needed and wastes resources (and is slow).

For most page views (e.g. index, channel page), just parsing the value
of the cookie is sufficient:

  * The cookies are already HMAC signed, so tampering with them ought to
    be for all reasonable purposes, impossible.
  * Assuming the worst case, all a nefarious user could manage to do is
    change the text of the "Welcome, {user}" and cause a (non-functional)
    ACP link to appear clientside, both of which are already possible by
    using the Inspect Element tool.

For authenticated pages (currently, the ACP, and anything under
/account/), the full database check is still performed (for now).
2017-08-01 21:40:26 -07:00
Calvin Montgomery
0118a6fb15 Refactor socket.io controller 2017-08-01 19:29:11 -07:00
Calvin Montgomery
107155a661 Stop knex from thrashing idle connections 2017-07-27 18:01:40 -07:00
Calvin Montgomery
7bd9934e58 Minor cleanup of no longer used things 2017-07-26 20:32:51 -07:00
Calvin Montgomery
f593f7283c Replace alert() with modal for ACP password reset
Some browsers (e.g. Chrome) don't allow copying text out of alert()
dialogs.
2017-07-24 22:35:15 -07:00
Calvin Montgomery
5a78056c91 Some small refactoring 2017-07-24 22:08:26 -07:00
Calvin Montgomery
e80613c7ec Fix rtmp again because chrome is picky about mime types 2017-07-23 17:55:25 -07:00
Calvin Montgomery
9dd0ee4446 Fix logger misreference in copied-over lualoader 2017-07-22 11:44:33 -07:00
Calvin Montgomery
08a42f6739 ffmpeg: add specific error for invalid SSL cert 2017-07-22 11:32:43 -07:00
Calvin Montgomery
282ad986b6 Deprecate legacy vimeo-oauth lookup 2017-07-22 11:14:29 -07:00
Calvin Montgomery
52030506b5 deps: remove status-message-polyfill
This hasn't been necessary since node v0.10, and CyTube only supports
node v6.x+ by this point.
2017-07-22 10:45:36 -07:00
Calvin Montgomery
a8f1e48157 ffmpeg: remove bitrate and codec warning
Browsers which don't support CyTube's limited subset of
generally-supported codecs probably aren't worth warning about.

1Mbps is way too low of a threshold to warn about bandwidth, but even if
the threshold for warning were raised, it's probably still not that
useful.
2017-07-22 10:43:18 -07:00
Calvin Montgomery
ffde151ebd Make redis announcement channel configurable
Finally fix the bug where announcements bleed across beta & live due to
sharing a redis pubsub channel.
2017-07-22 10:41:22 -07:00
Calvin Montgomery
964feb7243 Add id field to announcements and hide previously closed announcements 2017-07-22 10:35:45 -07:00
Calvin Montgomery
ff3ececc36 Copy utils from cytube-common and remove dep
The `cytube-common` module was created as part of a now-defunct
experiment and since then has just remained a crufty container for a few
utils.  Moved the utils to the main repo and removed the dependency.
2017-07-19 20:47:02 -07:00
Calvin Montgomery
e780e7dadb Deprecate stats table in favor of prometheus integration 2017-07-17 21:58:58 -07:00
Calvin Montgomery
c7bec6251e Begin prometheus integration
Add a dependency on `prom-client` and emit a basic latency metric for
testing purposes.  Add a new configuration file for enabling/disabling
prometheus exporter and configuring the listen address.
2017-07-16 22:35:33 -07:00
Calvin Montgomery
dd770137e5 Fix error for rtmp player 2017-07-15 20:17:13 -07:00
Calvin Montgomery
7efa3d4704 deps: upgrade to socket.io 2.0 2017-07-15 14:56:36 -07:00
Calvin Montgomery
d9813e6244 Remove legacy tab complete (no longer used) 2017-07-15 14:48:53 -07:00
Calvin Montgomery
c152a19624 Ignore library cached metadata when queueing
The use of the channel library as a cache for metadata to avoid
re-requesting metadata for known media is an optimization that dates
back to 1.0.  However, it doesn't have any TTL, is prone to bugs, and is
of dubious value.

This commit ignores the results of the library check when queueing a new
video, opting to always re-request the metadata.  This fixes a few bugs:

  * Google Drive metadata being lost when storing in library
  * Streamable metadata being lost when storing in library
  * Videos in the channel library that are now unavailable on their
    source website being queueable and then failing to play (e.g. deleted
    YouTube videos).

In its place, a small fail-open check is left behind to emit metric
counters on how many queues would have been cache-hits, to provide
insight into whether a proper caching solution (i.e. one not tacked on
top of the library) would be worth pursuing or not.  This will be
removed eventually.
2017-07-15 14:41:37 -07:00
Calvin Montgomery
b7ceee8ef4 Fix video sources being lost when playlist is saved 2017-07-15 14:12:32 -07:00
Calvin Montgomery
30a5657d62 soundcloud: fix volume issue
It took them 4 years, but they finally did actually make the player
accept volume in the range 0-100 like their documentation suggests.

*slow clap*
2017-07-10 21:38:27 -07:00
Calvin Montgomery
fc66e758ac Minor fix 2017-07-09 22:40:09 -07:00
Calvin Montgomery
637bcad816 camo: include subdomains of whitelisted domains in whitelist 2017-07-08 20:46:42 -07:00
Calvin Montgomery
07179d6c83 Upgrade to jsli 2.0 2017-07-08 20:11:54 -07:00
Calvin Montgomery
486ce04a3e camo: support URL encoding option 2017-07-08 19:21:14 -07:00
Calvin Montgomery
54045766f2 Replace instances of cytube-common logger with jsli 2017-07-02 22:38:54 -07:00
Calvin Montgomery
00901f9cdb Remove junk from an old abandoned project 2017-07-02 22:35:12 -07:00
Calvin Montgomery
860775a90b Remove html5hack (legacy google drive setting) 2017-07-02 22:30:19 -07:00
Calvin Montgomery
5500054b84 Add resolution switcher plugin for video.js
Allows switching resolutions via the video.js UI.  Also added support on
the player side for 540p, 1440p, and 2160p videos, although the metadata
extractors have not been updated to provide these sources yet.
2017-07-01 16:54:19 -07:00
Calvin Montgomery
d36bc160ca Merge pull request #693 from Xaekai/damnit
Minor fixes to afk stuff.
2017-06-29 21:09:54 -07:00
Xaekai
18bf1b946b Minor fixes to afk stuff. 2017-06-29 19:04:49 -07:00
Calvin Montgomery
7ebf3c18ab Add knex AliasesDB 2017-06-28 22:58:40 -07:00
Calvin Montgomery
76e0d1b7ec Use proxy-addr for parsing x-forwarded-for
Closes #683 by providing functionality to trust proxies other than
localhost.
2017-06-27 23:37:18 -07:00
Calvin Montgomery
9cffd7dde8 Merge pull request #691 from calzoneman/upgrade-babel-nodejs-6
Upgrade babel preset for node 6, add async transform
2017-06-21 22:34:46 -07:00
Calvin Montgomery
2427b3ef4b Merge pull request #690 from Xaekai/shadow.anons
Send shadowmuted messages to anons
2017-06-20 23:21:27 -07:00
Calvin Montgomery
9fc399c200 Upgrade babel preset for node 6, add async transform 2017-06-20 23:16:33 -07:00
Xaekai
5f71c4d368 Send shadowmuted messages to anons
Resolves #689
2017-06-20 22:29:27 -07:00
Calvin Montgomery
a96f7976d8 Change Tor exit list
Use the endpoint suggested in #688 to avoid unnecessarilly banning
relays.
2017-06-17 10:12:15 -07:00
Calvin Montgomery
6161f4ad44 Add ffmpeg error log for request failure case 2017-06-17 09:47:22 -07:00
Calvin Montgomery
6633e23aa3 Add characterization test for sanitize-html
At various times in the past, upgrades in the sanitize-html library that
changed behavior of HTML filtering have caused things like emotes to
break unexpectedly.  This commit adds a basic test to sanitize
non-alphanumeric characters found in channels' emote codes so that if
the library changes, the test will break and give a heads up that
something changed.
2017-06-17 09:47:22 -07:00
Calvin Montgomery
53cee986c6 Resend userlist if rank changes meta visibility
Fixes #681.  Technically, resending the entire userlist is not
necessary; it would be sufficient to resent setUserMeta, but there's not
currently a bulk frame for that so sending the userlist is probably more
efficient.
2017-06-17 09:47:22 -07:00
Calvin Montgomery
efae9c4774 Merge pull request #686 from Xaekai/silly.bug
Fix a typo.
2017-06-16 22:00:02 -07:00
Xaekai
be8318f014 Fix a typo. 2017-06-16 21:50:17 -07:00
Calvin Montgomery
33f632036e Merge pull request #684 from Xaekai/afk.meta
Single source of truth for AFK
2017-06-16 21:42:23 -07:00
Xaekai
2dc6504a77 Use a consistent pattern. 2017-06-16 21:37:30 -07:00
Calvin Montgomery
0f5193c700 Merge pull request #685 from Xaekai/custom.path
Customize channel path
2017-06-16 21:22:11 -07:00
Xaekai
6d4558c978 Allow channel path to be customizable
We now allow server operators to customize the /r/ part of the channel links
The new config option in the template is commented and the config module validates and will terminate with status 78 if an improper value is used.
We've also dropped some old cruft and uses a more elegant method to assign CHANNEL.name

Resolves #668
2017-06-16 20:09:36 -07:00
Xaekai
f89832a6d1 Gracefully allow script authors time to update their code 2017-06-15 22:09:09 -07:00
Xaekai
df0fc769d9 Single source of truth for AFK
Resolves #678
2017-06-15 21:48:17 -07:00
Calvin Montgomery
00a65a1584 Deprecate legacy global ban junk 2017-06-05 23:18:20 -07:00
Calvin Montgomery
b23a858a8c Integrate socket.io ban check with GlobalBanDB 2017-06-05 23:14:45 -07:00
Calvin Montgomery
ed811db6ec Integrate ACP with GlobalBanDB class 2017-06-05 22:53:35 -07:00
Calvin Montgomery
b80a87ba01 Add integration test for global bans 2017-06-05 22:45:14 -07:00
Calvin Montgomery
8ad9b4e543 Remove redundant template local 2017-06-05 18:57:04 -07:00
Calvin Montgomery
58c65a5bac Use host header instead of req.host which is actually req.hostname 2017-06-05 18:53:36 -07:00
Calvin Montgomery
830486bc4f Fix channel registration error due to extra knex query param 2017-06-05 18:46:41 -07:00
Calvin Montgomery
07c801a12d Merge pull request #674 from Xaekai/channel.registration
Touch up validations on account pages
2017-06-05 18:40:50 -07:00
Xaekai
699aa2abe1 Finish validation touchup 2017-06-05 00:06:15 -07:00
Xaekai
d42de93d74 Round 2. 2017-06-04 22:16:40 -07:00
Xaekai
668477d711 Nice is subjective. 2017-06-04 22:04:39 -07:00
Xaekai
8769ca1dd9 Basic validation of channel IDs on the registration page 2017-06-04 19:03:42 -07:00
Calvin Montgomery
d0712d007e Work on refactoring global IP ban database calls 2017-05-31 22:46:15 -07:00
Calvin Montgomery
7fcf31dec6 Merge pull request #671 from calzoneman/knex
The knexening: part 1
2017-05-29 13:16:35 -07:00
Calvin Montgomery
290f802b7c Merge pull request #670 from calzoneman/camo-proxy-chat-images
Support proxying chat images via camo
2017-05-29 10:32:01 -07:00
Calvin Montgomery
e02bc46ed2 Add camo example config 2017-05-29 10:24:49 -07:00
Calvin Montgomery
2a694e73af The knexening: part 1 2017-05-28 22:39:27 -07:00
Calvin Montgomery
22a9acfc90 Support proxying chat images via camo
Camo: https://github.com/atmos/camo.  This has a couple advantages over
just allowing images to be dumped as-is:

  - Prevents mixed-content warnings by allowing the server to proxy HTTP
    images to an HTTPS camo instance
  - Protects users' privacy by not exposing their browser directly to
    the image host
  - Allows the camo proxy to intercept and reject bad image sources
    (URLs that are not actually images, gigapixel-sized images likely to
    DoS users' browsers, etc.)

Whitelisting specific domains is supported for cases where the source is
known to be trustworthy.
2017-05-28 19:38:43 -07:00
Calvin Montgomery
f968521936 Remove google drive refresh logic
No longer relevant since the video links are retrieved by the
userscript.
2017-05-28 18:35:13 -07:00
Calvin Montgomery
e9c519c6e2 Add twitch clip support (#659) 2017-05-27 11:49:43 -07:00
Calvin Montgomery
995ab142e3 Merge pull request #669 from Xaekai/hail.satan
Glory to the dark lord
2017-05-25 21:42:05 -07:00
Xaekai
5163c2acb1 Glory to the dark lord 2017-05-25 08:04:19 -07:00
Calvin Montgomery
97f94dd3ac Merge pull request #667 from Xaekai/acp.users
[ACP] Allow searching users by email.
2017-05-24 19:50:05 -07:00
Xaekai
1d65eb036e Use a more salient variable name.
Use a style thats readable with brevity.
2017-05-24 19:18:47 -07:00
Xaekai
93ef067b8c [ACP] Allow searching users by email. 2017-05-24 04:44:55 -07:00
Calvin Montgomery
d23b5278b1 Rename Hitbox -> Smashcast 2017-05-20 16:50:00 -07:00
Calvin Montgomery
55b03d51d7 Fix setOptions for playlist_max_duration_per_user 2017-05-20 16:31:52 -07:00
Calvin Montgomery
02587dbb5c Merge pull request #664 from Xaekai/emote.rename
Emote renaming
2017-05-18 22:02:50 -07:00
Xaekai
9cfd97088e Some validation 2017-05-18 20:12:00 -07:00
Xaekai
8434d20826 Fix minor issues with emote rename 2017-05-17 09:50:47 -07:00
Xaekai
8e3ce4e1c3 Emote renaming
This allow emotes to be renamed in the same fashion the image URLs can be changed.
2017-05-16 10:08:53 -07:00
292 changed files with 147072 additions and 43663 deletions

45
.eslintrc.js Normal file
View 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',
],
}

9
.gitignore vendored
View file

@ -13,3 +13,12 @@ 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

View file

@ -4,10 +4,11 @@ addons:
sources:
- ubuntu-toolchain-r-test
packages:
- gcc-4.8
- g++-4.8
- gcc-9
- g++-9
env:
- CXX="g++-4.8"
- CXX="g++-9"
node_js:
- "7"
- "6"
- "15"
- "14"
- "12"

18
Dockerfile Normal file
View 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"]

View file

@ -1,6 +1,6 @@
/*
The MIT License (MIT)
Copyright (c) 2013-2017 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:

327
NEWS.md
View file

@ -1,3 +1,330 @@
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 `&quot;` when not inside an HTML attribute value. This
potentially breaks any chat filters matching quotes as `&quot;` (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
==========

109
README.md
View file

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

View file

@ -1,32 +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',
'gdrive-player.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',
'hls.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
View 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'

View 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
View 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"

View 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'

View file

@ -1,14 +1,14 @@
# 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
@ -48,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'
@ -70,6 +69,9 @@ http:
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:
@ -84,10 +86,6 @@ 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
@ -109,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:
@ -164,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.
@ -208,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
@ -231,16 +203,6 @@ 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:
@ -253,3 +215,14 @@ 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
View 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
View 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 (120 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
View 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.

View file

@ -21,4 +21,4 @@ example, for cytu.be I use:
npm run generate-userscript CyTube http://cytu.be/r/* https://cytu.be/r/*
```
This will generate `www/js/cytube-google-drive.user.js`.
This will generate `www/js/cytube-google-drive.user.js`. If you've changed the channel path, be sure to take that into account.

View file

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

View file

@ -29,7 +29,9 @@ natively. Accordingly, CyTube only supports a few codecs:
**Video**
* MP4 (AV1)
* MP4 (H.264)
* WebM (AV1)
* WebM (VP8)
* WebM (VP9)
* Ogg/Theora

View file

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

View 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
View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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
View 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=="
}
}
}

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

View 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

View 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())

View 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

View 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()

View 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

View file

@ -5,24 +5,32 @@
// {INCLUDE_BLOCK}
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect docs.google.com
// @run-at document-end
// @version 1.3.0
// @version 1.7.0
// ==/UserScript==
try {
function debug(message) {
if (!unsafeWindow.enableCyTubeGoogleDriveUserscriptDebug) {
return;
}
try {
unsafeWindow.console.log(message);
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,
@ -56,28 +64,47 @@ try {
+ '&hl=en';
debug('Fetching ' + url);
GM_xmlhttpRequest({
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') {
var error = 'Google Docs request failed: ' +
error = 'Google Drive request failed: ' +
unescape(data.reason).replace(/\+/g, ' ');
return cb(error);
}
if (!data.fmt_stream_map) {
var error = 'Google Docs request failed: ' +
'metadata lookup returned no valid links';
error = (
'Google has removed the video streams associated' +
' with this item. It can no longer be played.'
);
return cb(error);
}
@ -95,8 +122,8 @@ try {
},
onerror: function () {
var error = 'Google Docs request failed: ' +
'metadata lookup HTTP request failed';
var error = 'Google Drive request failed: ' +
'metadata lookup HTTP request failed';
error.reason = 'HTTP_ONERROR';
return cb(error);
}
@ -185,15 +212,20 @@ try {
}, 1000);
}
function isRunningTampermonkey() {
var TM_COMPATIBLES = [
'Tampermonkey',
'Violentmonkey' // https://github.com/calzoneman/sync/issues/713
];
function isTampermonkeyCompatible() {
try {
return GM_info.scriptHandler === 'Tampermonkey';
return TM_COMPATIBLES.indexOf(GM_info.scriptHandler) >= 0;
} catch (error) {
return false;
}
}
if (isRunningTampermonkey()) {
if (isTampermonkeyCompatible()) {
unsafeWindow.getGoogleDriveMetadata = getVideoInfo;
} else {
debug('Using non-TM polling workaround');
@ -204,7 +236,8 @@ try {
unsafeWindow.console.log('Initialized userscript Google Drive player');
unsafeWindow.hasDriveUserscript = true;
unsafeWindow.driveUserscriptVersion = '1.3';
// Checked against GS_VERSION from data.js
unsafeWindow.driveUserscriptVersion = '1.7';
} catch (error) {
unsafeWindow.console.error(error);
}

View file

@ -8,12 +8,30 @@ var includes = process.argv.slice(3).map(function (include) {
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\}/)) {
console.log(includes);
userscriptOutput += includes + '\n';
} else if (line.match(/\{SITENAME\}/)) {
console.log(line.replace(/\{SITENAME\}/, sitename));
line = line.replace(/\{SITENAME\}/, sitename) + '\n';
userscriptOutput += line;
metaOutput += line;
} else {
console.log(line);
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
);

193
index.js Normal file → Executable file
View file

@ -1,18 +1,56 @@
if (/^v0/.test(process.version)) {
console.error('node.js ' + process.version + ' is not supported. ' +
'For more information, visit ' +
'https://github.com/calzoneman/sync/wiki/CyTube-3.0-Installation-Guide#nodejs');
#!/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);
}
try {
require("./lib/logger");
var Server = require("./lib/server");
} catch (err) {
console.error('FATAL: Failed to require() lib/server.js');
checkPlayerExists();
const args = parseArgs();
if (args.has('--daemonize')) {
fork();
} else {
try {
require('./lib/main');
} catch (err) {
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 ' +
@ -22,120 +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");
const Switches = require("./lib/switches");
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);
}
}
return args;
}
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);
}
});
function checkPlayerExists() {
const fs = require('fs');
const path = require('path');
var validIP = require('net').isIP;
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");
}
});
} else if (line.indexOf("/switch") === 0) {
var args = line.split(" ");
args.shift();
if (args.length === 1) {
Logger.syslog.log("Switch " + args[0] + " is " +
(Switches.isActive(args[0]) ? "ON" : "OFF"));
} else if (args.length === 2) {
Switches.setActive(args[0], args[1].toLowerCase() === "on" ? true : false);
Logger.syslog.log("Switch " + args[0] + " is now " +
(Switches.isActive(args[0]) ? "ON" : "OFF"));
}
} else if (line.indexOf("/reload-partitions") === 0) {
sv.reloadPartitionMap();
} else if (line.indexOf("/globalban") === 0) {
var args = line.split(/\s+/); args.shift();
if (args.length >= 2 && validIP(args[0]) !== 0) {
var ip = args.shift();
var comment = args.join(' ');
require("./lib/database").globalBanIP(ip, comment, function (err, res) {
if (!err) {
Logger.eventlog.log("[acp] " + "SYSTEM" + " global banned " + ip);
}
})
}
} else if (line.indexOf("/unglobalban") === 0) {
var args = line.split(/\s+/); args.shift();
if (args.length >= 1 && validIP(args[0]) !== 0) {
var ip = args.shift();
require("./lib/database").globalUnbanIP(ip, function (err, res) {
if (!err) {
Logger.eventlog.log("[acp] " + "SYSTEM" + " un-global banned " + ip);
}
})
}
} else if (line.indexOf("/unloadchan") === 0) {
var args = line.split(/\s+/); args.shift();
if(args.length){
var name = args.shift();
var chan = sv.getChannel(name);
var users = Array.prototype.slice.call(chan.users);
chan.emit("empty");
users.forEach(function (u) {
u.kick("Channel shutting down");
});
Logger.eventlog.log("[acp] " + "SYSTEM" + " forced unload of " + name);
}
} else if (line.indexOf("/reloadcert") === 0) {
sv.reloadCertificateData();
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);
}
}
// Go Go Gadget Service Socket
if (Config.get("service-socket.enabled")) {
Logger.syslog.log("Opening service socket");
var ServiceSocket = require('./lib/servsock');
var server = new ServiceSocket;
server.init(handleLine, Config.get("service-socket.socket"));
}
// Hi I'm Mr POSIX! Look at me!
process.on('SIGUSR2', () => {
sv.reloadCertificateData();
});
require("bluebird");
process.on("unhandledRejection", function (reason, promise) {
Logger.errlog.log("[SEVERE] Unhandled rejection: " + reason.stack);
});

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

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

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

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

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

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

View file

@ -1,13 +1,11 @@
const assert = require('assert');
const KickbanModule = require('../../lib/channel/kickban');
const db = require('../../lib/database');
const database = require('../../lib/database');
const dbChannels = require('../../lib/database/channels');
const Promise = require('bluebird');
const Config = require('../../lib/config');
const ChannelModule = require('../../lib/channel/module');
const Flags = require('../../lib/flags');
const TestConfig = require('../../integration-test-config.json');
require('../../lib/counters');
const testDB = require('../testutil/db').testDB;
function randomString(length) {
const chars = 'abcdefgihkmnpqrstuvwxyz0123456789';
@ -18,8 +16,7 @@ function randomString(length) {
return str;
}
Config.set('mysql.password', TestConfig.mysql.password);
db.init();
database.init(testDB);
describe('onPreUserJoin Ban Check', () => {
const channelName = `test_${randomString(20)}`;
@ -54,7 +51,10 @@ describe('onPreUserJoin Ban Check', () => {
return 'anotherTroll';
},
realip: bannedIP
realip: bannedIP,
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
@ -70,7 +70,10 @@ describe('onPreUserJoin Ban Check', () => {
return 'troll';
},
realip: '5.5.5.5'
realip: '5.5.5.5',
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
@ -86,7 +89,10 @@ describe('onPreUserJoin Ban Check', () => {
return '';
},
realip: bannedIP
realip: bannedIP,
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
@ -127,4 +133,4 @@ describe('onPreUserJoin Ban Check', () => {
done();
});
});
});
});

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

View file

@ -0,0 +1,4 @@
const testConfig = require('./config').testConfig;
const Database = require('../../lib/database').Database;
exports.testDB = new Database(testConfig.knexConfig);

View 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

File diff suppressed because it is too large Load diff

View file

@ -2,83 +2,81 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.36.1",
"version": "3.86.1",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
"license": "MIT",
"dependencies": {
"@calzoneman/jsli": "^1.0.1",
"babel-cli": "^6.1.4",
"babel-core": "^6.1.4",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-es2015-destructuring": "^6.3.15",
"babel-preset-es2015": "^6.1.4",
"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-common": "git://github.com/CyTube/cytube-common",
"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",
"csrf": "^3.1.0",
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
"express": "^4.18.2",
"express-minify": "^1.0.0",
"json-typecheck": "^0.1.3",
"lodash": "^4.13.1",
"morgan": "^1.6.1",
"mysql": "^2.9.0",
"nodemailer": "^1.4.0",
"oauth": "^0.9.12",
"pug": "^2.0.0-beta3",
"q": "^1.4.1",
"redis": "^2.4.2",
"sanitize-html": "^1.14.1",
"serve-static": "^1.10.0",
"socket.io": "^1.4.0",
"socket.io-redis": "^1.0.0",
"source-map-support": "^0.4.0",
"sprintf-js": "^1.0.3",
"status-message-polyfill": "git://github.com/calzoneman/status-message-polyfill",
"toml": "^2.3.0",
"uuid": "^2.0.1",
"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 -D --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 -D --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 test",
"integration-test": "mocha --recursive integration_test"
"test": "mocha --recursive --exit test",
"integration-test": "mocha --recursive --exit integration_test"
},
"devDependencies": {
"babel-plugin-transform-flow-strip-types": "^6.22.0",
"coffee-script": "^1.9.2",
"flow-bin": "^0.43.0",
"mocha": "^3.2.0"
"@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": [
"es2015"
[
"@babel/env",
{
"targets": {
"node": "12"
}
}
]
],
"plugins": [
[
"transform-es2015-destructuring",
{
"loose": true
}
],
"add-module-exports",
"transform-flow-strip-types"
"add-module-exports"
]
}
}

View file

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

View file

@ -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 shortmedium 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')

View file

@ -24,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)
@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?
@ -59,6 +42,7 @@ window.EmbedPlayer = class EmbedPlayer extends Player
iframe = $('<iframe/>').attr(
src: embed.src
frameborder: '0'
allow: 'autoplay'
allowfullscreen: '1'
)

View file

@ -31,3 +31,56 @@ window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
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 = '&times;'
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 = '&times;'
closeButton.onclick = ->
alertBox.parentNode.removeChild(alertBox)
alertBox.insertBefore(closeButton, alertBox.firstChild)
removeOld($('<div/>').append(alertBox))

View file

@ -1,131 +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) ->
window.promptToInstallDriveUserscript()
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)
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 = '&times;'
closeButton.onclick = ->
alertBox.parentNode.removeChild(alertBox)
alertBox.insertBefore(closeButton, alertBox.firstChild)
removeOld($('<div/>').append(alertBox))

View file

@ -1,12 +0,0 @@
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: "https://www.hitbox.tv/embed/#{data.id}"
tag: 'iframe'
super(data)

33
player/iframechild.coffee Normal file
View 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)
)

View file

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

View file

@ -6,18 +6,12 @@ window.LivestreamPlayer = class LivestreamPlayer extends EmbedPlayer
@load(data)
load: (data) ->
if LIVESTREAM_CHROMELESS
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"
tag: 'iframe'
[ account, event ] = data.id.split(';')
data.meta.embed =
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
View 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
View 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
View 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
View 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)

View file

@ -1,12 +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

View file

@ -18,5 +18,6 @@ window.RTMPPlayer = class RTMPPlayer extends VideoJSPlayer
480: [
{
link: data.id
contentType: 'rtmp/flv'
}
]

View file

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

View file

@ -1,3 +1,10 @@
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'
window.TwitchPlayer = class TwitchPlayer extends Player
constructor: (data) ->
if not (this instanceof TwitchPlayer)
@ -12,14 +19,29 @@ window.TwitchPlayer = class TwitchPlayer extends Player
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
options.video = data.id
else
# Livestream
options =
channel: data.id
options.channel = data.id
@twitch = new Twitch.Player('ytapiplayer', options)
@twitch.addEventListener(Twitch.Player.READY, =>
@setVolume(VOLUME)

21
player/twitchclip.coffee Normal file
View 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)

View file

@ -3,21 +3,23 @@ TYPE_MAP =
vi: VimeoPlayer
dm: DailymotionPlayer
gd: GoogleDrivePlayer
gp: VideoJSPlayer
fi: FilePlayer
jw: FilePlayer
sc: SoundCloudPlayer
li: LivestreamPlayer
tw: TwitchPlayer
tv: TwitchPlayer
cu: CustomEmbedPlayer
rt: RTMPPlayer
hb: HitboxPlayer
us: UstreamPlayer
im: ImgurPlayer
vm: VideoJSPlayer
hl: HLSPlayer
sb: VideoJSPlayer
sb: StreamablePlayer
tc: TwitchClipPlayer
cm: VideoJSPlayer
pt: PeerPlayer
bc: IframeChild
bn: IframeChild
od: OdyseePlayer
wp: WhepPlayer
nv: NicoPlayer
window.loadMediaPlayer = (data) ->
try
@ -26,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
@ -109,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

View file

@ -1,12 +0,0 @@
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: "/ustream_bypass/embed/#{data.id}?html5ui&autoplay=1"
super(data)

View file

@ -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,12 +32,23 @@ 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) ->
@ -47,9 +59,16 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
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)
@ -60,14 +79,10 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
return
@sourceIdx = 0
@sources.forEach((source) ->
$('<source/>').attr(
src: source.src
type: source.type
'data-quality': source.quality
).appendTo(video)
)
# 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,19 +97,57 @@ 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' and not window.hasDriveUserscript
window.promptToInstallDriveUserscript()
if @mediaType is 'gd'
if not window.hasDriveUserscript
window.promptToInstallDriveUserscript()
else
window.tellUserNotToContactMeAboutThingsThatAreNotSupported()
)
@setVolume(VOLUME)
@player.on('ended', ->

View file

@ -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', =>
if CLIENT.leader
socket.emit('playNext')
)
@vimeo.addEvent('pause', =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@vimeo.addEvent('play', =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
@setVolume(VOLUME)
@vimeo.on('ended', =>
if CLIENT.leader
socket.emit('playNext')
)
@vimeo.on('pause', =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@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
View 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

View file

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

View file

@ -1,19 +1,14 @@
#!/bin/sh
if test "$BUILD_OVERRIDE" = "Y"; then
echo "Running $npm_package_scripts_build_server"
$npm_package_scripts_build_server
exit $?
set -e
if ! command -v npm >/dev/null; then
echo "Could not find npm in \$PATH"
exit 1
fi
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
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\`."
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"

View file

@ -34,7 +34,7 @@ const SOCKETFILE = Config.get("service-socket.socket");
// Wipe the TTY
process.stdout.write('\x1Bc');
var commandline, eventlog, syslog;
var commandline, eventlog, syslog, errorlog;
var client = net.createConnection(SOCKETFILE).on('connect', () => {
commandline = readline.createInterface({
input: process.stdin,
@ -79,6 +79,11 @@ var client = net.createConnection(SOCKETFILE).on('connect', () => {
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();

1
src/.eslintrc.json Normal file
View file

@ -0,0 +1 @@
{ "env": { "node": true } }

View file

@ -1,5 +1,11 @@
var db = require("./database");
var Q = require("q");
import db from './database';
import Promise from 'bluebird';
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: '' });
@ -33,71 +39,21 @@ class Account {
module.exports.Account = Account;
module.exports.rankForName = function (name, opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
module.exports.rankForName = async function rankForNameAsync(name, channel) {
const [globalRank, channelRank] = await Promise.all([
dbGetGlobalRank(name),
dbGetChannelRank(channel, name)
]);
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();
return Math.max(globalRank, channelRank);
};
module.exports.rankForIP = function (ip, opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
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 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));
};

View file

@ -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) {
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);
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: 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) {
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);
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: 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) {
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);
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: error.message
});
});
Logger.eventlog.log("[acp] Initialized ACP for " + eventUsername(user));
}

View file

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

View file

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

View file

@ -1,93 +0,0 @@
import { RedisClusterClient } from '../io/cluster/redisclusterclient';
import { DualClusterClient } from '../io/cluster/dualclusterclient';
import NullClusterClient from '../io/cluster/nullclusterclient';
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';
import LegacyConfig from '../config';
import IOConfiguration from '../configuration/ioconfig';
import * as Switches from '../switches';
const BACKEND_CONFIG_PATH = path.resolve(__dirname, '..', '..', 'backend.toml');
class BackendModule {
constructor() {
this.initConfig();
}
initConfig() {
logger.initialize(null, null, LegacyConfig.get('debug'));
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(),
// return_buffers is needed for msgpack-js to function correctly
subClient: redisClientProvider.get({ return_buffers: true })
});
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());
}
if (Switches.isActive(Switches.DUAL_BACKEND) && !this.nullClusterClient) {
this.nullClusterClient = new NullClusterClient(
IOConfiguration.fromOldConfig(LegacyConfig));
}
if (Switches.isActive(Switches.DUAL_BACKEND)) {
this.clusterClient = new DualClusterClient(this.nullClusterClient,
this.redisClusterClient);
} else {
this.clusterClient = this.redisClusterClient;
}
return this.clusterClient;
}
}
export { BackendModule }

View file

@ -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();
}
}

View file

@ -1,54 +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]
)
);
}
leave(room) {
this.frontendConnection.write(
this.frontendConnection.protocol.newSocketLeaveRoomsEvent(
this.id, [room]
)
);
}
disconnect() {
this.frontendConnection.write(
this.frontendConnection.protocol.newSocketKickEvent(this.id)
);
}
}

View file

@ -1,91 +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;
}
logger.info(`Got proxy connection from ${socket.endpoint}`);
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']);
}
}

View file

@ -8,34 +8,16 @@
var Config = require("./config");
var db = require("./database");
var Promise = require("bluebird");
import { LoggerFactory } from '@calzoneman/jsli';
const shows = require('./shows');
const calendarDB = require('./database/calendar-integrations');
const integrationsApi = require('./web/routes/api/integrations');
const LOGGER = LoggerFactory.getLogger('bgtask');
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"));
@ -49,7 +31,7 @@ function initAliasCleanup(Server) {
}
/* Password reset cleanup */
function initPasswordResetCleanup(Server) {
function initPasswordResetCleanup() {
var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () {
@ -61,19 +43,30 @@ function initPasswordResetCleanup(Server) {
}
function initChannelDumper(Server) {
const chanPath = Config.get('channel-path');
var CHANNEL_SAVE_INTERVAL = parseInt(Config.get("channel-save-interval"))
* 60000;
setInterval(function () {
if (Server.channels.length === 0) {
return;
}
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(() => {
return Promise.delay(wait).then(async () => {
if (!chan.dead && chan.users && chan.users.length > 0) {
return chan.saveState().tap(() => {
LOGGER.info(`Saved /r/${chan.name}`);
}).catch(err => {
LOGGER.error(`Failed to save /r/${chan.name}: ${err.stack}`);
});
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}`);
@ -84,6 +77,96 @@ function initChannelDumper(Server) {
}, 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.warn("Attempted to re-init background tasks");
@ -91,8 +174,10 @@ module.exports = function (Server) {
}
init = Server;
initStats(Server);
initAliasCleanup(Server);
initAliasCleanup();
initChannelDumper(Server);
initPasswordResetCleanup(Server);
initPasswordResetCleanup();
initAccountCleanup();
initShowScheduler();
initCalendarAutoSyncScheduler();
};

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

View file

@ -1,4 +1,3 @@
import { FileStore } from './filestore';
import { DatabaseStore } from './dbstore';
import Config from '../config';
import Promise from 'bluebird';
@ -26,11 +25,12 @@ export function save(id, channelName, data) {
}
function loadChannelStore() {
switch (Config.get('channel-storage.type')) {
case 'database':
return new DatabaseStore();
case 'file':
default:
return new FileStore();
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();
}

View file

@ -4,6 +4,7 @@ import Config from '../config';
import db from '../database';
import { DatabaseStore } from './dbstore';
/* eslint no-console: off */
function main() {
Config.load('config.yaml');
db.init();

View file

@ -1,12 +1,27 @@
import Promise from 'bluebird';
import { ChannelStateSizeError } from '../errors';
import db from '../database';
import { LoggerFactory } from '@calzoneman/jsli';
const LOGGER = LoggerFactory.getLogger('dbstore');
import { Counter } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('dbstore');
const SIZE_LIMIT = 1048576;
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) => {
@ -41,10 +56,13 @@ export class DatabaseStore {
}
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.error(`Channel data for channel "${channelName}", ` +
`key "${row.key}" is invalid: ${e}`);
@ -55,35 +73,50 @@ export class DatabaseStore {
});
}
save(id, channelName, data) {
async save(id, channelName, data) {
if (!id || id === 0) {
return Promise.reject(new Error(`Cannot save state for [${channelName}]: ` +
`id was passed as [${id}]`));
throw new Error(
`Cannot save state for [${channelName}]: ` +
`id was passed as [${id}]`
);
}
let totalSize = 0;
let rowCount = 0;
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) {
return Promise.reject(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);
}
}

View file

@ -1,57 +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(id, channelName) {
const filename = this.filenameForChannel(channelName);
return statAsync(filename).then(stats => {
if (stats.size > SIZE_LIMIT) {
return Promise.reject(
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) {
return Promise.reject(new Error('Channel state file is not valid JSON: ' + e));
}
});
}
save(id, 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);
}
}

View file

@ -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();

View file

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

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

View file

@ -1,18 +1,16 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
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';
import { LoggerFactory } from '@calzoneman/jsli';
const LOGGER = LoggerFactory.getLogger('channel');
const LOGGER = require('@calzoneman/jsli')('channel');
const USERCOUNT_THROTTLE = 10000;
@ -47,6 +45,7 @@ class ReferenceCounter {
LOGGER.error("ReferenceCounter::unref() called by caller [" +
caller + "] but this caller had no active references! " +
`(channel: ${this.channelName})`);
return;
}
}
@ -64,7 +63,7 @@ class ReferenceCounter {
for (var caller in this.references) {
this.refCount += this.references[caller];
}
} else if (this.channel.users.length > 0) {
} 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})`);
@ -80,8 +79,11 @@ function Channel(name) {
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;
@ -92,7 +94,10 @@ function Channel(name) {
}, USERCOUNT_THROTTLE);
const self = this;
db.channels.load(this, function (err) {
if (err && err !== "Channel is not registered") {
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 {
@ -156,7 +161,8 @@ Channel.prototype.initModules = function () {
"./poll" : "poll",
"./kickban" : "kickban",
"./ranks" : "rank",
"./accesscontrol" : "password"
"./accesscontrol" : "password",
"./anonymouscheck": "anoncheck"
};
var self = this;
@ -171,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)) {
@ -240,43 +229,66 @@ Channel.prototype.loadState = function () {
});
};
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 => {
this.modules[m].save(data);
if (
this.modules[m].dirty ||
!this.modules[m].supportsDirtyCheck
) {
this.modules[m].save(data);
} else {
LOGGER.debug(
"Skipping save for %s[%s]: not dirty",
this.uniqueName,
m
);
}
});
return ChannelStore.save(this.id, 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
});
}
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 err;
});
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) {
@ -295,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);
@ -319,6 +344,7 @@ 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");
@ -327,6 +353,14 @@ Channel.prototype.joinUser = function (user, data) {
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;
}
if (user.is(Flags.U_REGISTERED)) {
db.channels.getRank(self.name, user.getName(), (error, rank) => {
if (!error) {
@ -371,7 +405,7 @@ Channel.prototype.acceptUser = function (user) {
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
LOGGER.info(user.realip + " joined " + this.name);
if (user.socket._isUsingTor) {
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 " +
@ -413,10 +447,26 @@ Channel.prototype.acceptUser = function (user) {
});
this.sendUserlist([user]);
// 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) {
@ -450,14 +500,26 @@ Channel.prototype.partUser = function (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
}
};
@ -470,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
}
};
@ -483,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
}
};
@ -498,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", {
@ -640,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", {

View file

@ -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,6 +123,12 @@ 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();
@ -142,16 +150,19 @@ ChatModule.prototype.restrictNewAccount = function restrictNewAccount(user, data
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)) {
@ -215,7 +226,6 @@ ChatModule.prototype.handlePm = function (user, data) {
return;
}
var reallyTo = data.to;
data.to = data.to.toLowerCase();
if (data.to === user.getLowerName()) {
@ -238,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) {
@ -274,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 " +
@ -289,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") &&
@ -325,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);
@ -338,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) {
@ -358,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);
@ -381,7 +401,17 @@ ChatModule.prototype.filterMessage = function (msg) {
}
});
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) {
@ -409,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) {
@ -455,11 +488,12 @@ 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", { clearedBy: user.getName() });
this.sendModMessage(user.getName() + " cleared chat.", -1);
@ -495,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;
}
@ -549,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;
}
@ -600,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;
}
@ -632,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++) {
@ -649,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;

View file

@ -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);
this.channel.logger.log("[mod] " + user.getName() + " updated the channel CSS");
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);
this.channel.logger.log("[mod] " + user.getName() + " updated the channel JS");
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");
};

View file

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

View file

@ -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.looseSanitizeText(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;

View file

@ -1,8 +1,7 @@
var FilterList = require("cytubefilters");
var ChannelModule = require("./module");
import { LoggerFactory } from '@calzoneman/jsli';
const LOGGER = LoggerFactory.getLogger('filters');
const LOGGER = require('@calzoneman/jsli')('filters');
/*
* Converts JavaScript-style replacements ($1, $2, etc.) with
@ -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);
@ -85,6 +85,8 @@ ChatFilterModule.prototype.load = function (data) {
} 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) {

View file

@ -3,14 +3,20 @@ var db = require("../database");
var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
var Q = require("q");
import Promise from 'bluebird';
const XSS = require("../xss");
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
const dbAddBan = Promise.promisify(db.channels.ban);
const dbGetIPs = Promise.promisify(db.getIPs);
const TYPE_UNBAN = {
id: "number",
name: "string"
};
function KickBanModule(channel) {
function KickBanModule(_channel) {
ChannelModule.apply(this, arguments);
if (this.channel.modules.chat) {
@ -34,16 +40,6 @@ function checkIPBan(cname, ip, cb) {
});
}
function checkNameBan(cname, name, cb) {
db.channels.isNameBanned(cname, name, function (err, banned) {
if (err) {
cb(false);
} else {
cb(banned);
}
});
}
function checkBan(cname, ip, name, cb) {
db.channels.isBanned(cname, ip, name, function (err, banned) {
if (err) {
@ -60,7 +56,6 @@ KickBanModule.prototype.onUserPreJoin = function (user, data, cb) {
}
const cname = this.channel.name;
const check = (user.getName() !== '') ? checkBan : checkIPBan;
function callback(banned) {
if (banned) {
cb(null, ChannelModule.DENY);
@ -157,7 +152,7 @@ KickBanModule.prototype.sendUnban = function (users, data) {
});
};
KickBanModule.prototype.handleCmdKick = function (user, msg, meta) {
KickBanModule.prototype.handleCmdKick = function (user, msg, _meta) {
if (!this.channel.modules.permissions.canKick(user)) {
return;
}
@ -201,7 +196,7 @@ KickBanModule.prototype.handleCmdKick = function (user, msg, meta) {
}
};
KickBanModule.prototype.handleCmdKickAnons = function (user, msg, meta) {
KickBanModule.prototype.handleCmdKickAnons = function (user, _msg, _meta) {
if (!this.channel.modules.permissions.canKick(user)) {
return;
}
@ -221,7 +216,7 @@ KickBanModule.prototype.handleCmdKickAnons = function (user, msg, meta) {
};
/* /ban - name bans */
KickBanModule.prototype.handleCmdBan = function (user, msg, meta) {
KickBanModule.prototype.handleCmdBan = function (user, msg, _meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ban */
if (args.length === 0 || args[0].trim() === "") {
@ -234,13 +229,17 @@ KickBanModule.prototype.handleCmdBan = function (user, msg, meta) {
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdBan");
this.banName(user, name, reason, function (err) {
this.banName(user, name, reason).catch(error => {
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdBan");
});
};
/* /ipban - bans name and IP addresses associated with it */
KickBanModule.prototype.handleCmdIPBan = function (user, msg, meta) {
KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ipban */
if (args.length === 0 || args[0].trim() === "") {
@ -261,23 +260,32 @@ KickBanModule.prototype.handleCmdIPBan = function (user, msg, meta) {
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
this.banAll(user, name, range, reason, function (err) {
this.banAll(user, name, range, reason).catch(error => {
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdIPBan");
});
};
KickBanModule.prototype.banName = function (actor, name, reason, cb) {
var self = this;
KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
if (!this.channel || this.channel.dead) {
throw new Error("Channel not live");
}
};
KickBanModule.prototype.banName = async function banName(actor, name, reason) {
if (!util.isValidUserName(name)) {
throw new Error("Invalid username");
}
reason = reason.substring(0, 255);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
throw new Error("You do not have ban permissions on this channel");
}
name = name.toLowerCase();
@ -285,129 +293,134 @@ KickBanModule.prototype.banName = function (actor, name, reason, cb) {
actor.socket.emit("costanza", {
msg: "You can't ban yourself"
});
return cb("Attempted to ban self");
throw new Error("You cannot ban yourself");
}
Q.nfcall(Account.rankForName, name, { channel: chan.name })
.then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban " + name;
}
const rank = await Account.rankForName(name, chan.name);
this.checkChannelAlive();
return Q.nfcall(db.channels.isNameBanned, chan.name, name);
}).then(function (banned) {
if (banned) {
throw name + " is already banned";
}
if (rank >= actor.account.effectiveRank) {
throw new Error("You don't have permission to ban " + name);
}
if (chan.dead) { throw null; }
const isBanned = await dbIsNameBanned(chan.name, name);
this.checkChannelAlive();
return Q.nfcall(db.channels.ban, chan.name, "*", name, reason, actor.getName());
}).then(function () {
chan.logger.log("[mod] " + actor.getName() + " namebanned " + name);
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(actor.getName() + " namebanned " + name,
chan.modules.permissions.permissions.ban);
}
return true;
}).then(function () {
self.kickBanTarget(name, null);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
if (isBanned) {
throw new Error(name + " is already banned");
}
await dbAddBan(chan.name, "*", name, reason, actor.getName());
this.checkChannelAlive();
chan.logger.log("[mod] " + actor.getName() + " namebanned " + name);
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(
actor.getName() + " namebanned " + name,
chan.modules.permissions.permissions.ban
);
}
this.kickBanTarget(name, null);
};
KickBanModule.prototype.banIP = function (actor, ip, name, reason, cb) {
var self = this;
KickBanModule.prototype.banIP = async function banIP(actor, ip, name, reason) {
if (!util.isValidUserName(name)) {
throw new Error("Invalid username");
}
reason = reason.substring(0, 255);
var masked = util.cloakIP(ip);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
throw new Error("You do not have ban permissions on this channel");
}
Q.nfcall(Account.rankForIP, ip, { channel: chan.name }).then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban IP " + masked;
}
const rank = await Account.rankForIP(ip, chan.name);
this.checkChannelAlive();
return Q.nfcall(db.channels.isIPBanned, chan.name, ip);
}).then(function (banned) {
if (banned) {
throw masked + " is already banned";
}
if (rank >= actor.account.effectiveRank) {
// TODO: this message should be made friendlier
throw new Error("You don't have permission to ban IP " + masked);
}
if (chan.dead) { throw null; }
const isBanned = await dbIsIPBanned(chan.name, ip);
this.checkChannelAlive();
return Q.nfcall(db.channels.ban, chan.name, ip, name, reason, actor.getName());
}).then(function () {
var cloaked = util.cloakIP(ip);
chan.logger.log("[mod] " + actor.getName() + " banned " + cloaked + " (" + name + ")");
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(actor.getName() + " banned " +
cloaked + " (" + name + ")",
chan.modules.permissions.permissions.ban);
}
}).then(function () {
self.kickBanTarget(name, ip);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
if (isBanned) {
// TODO: this message should be made friendlier
throw new Error(masked + " is already banned");
}
await dbAddBan(chan.name, ip, name, reason, actor.getName());
this.checkChannelAlive();
var cloaked = util.cloakIP(ip);
chan.logger.log(
"[mod] " + actor.getName() + " banned " + cloaked +
" (" + name + ")"
);
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(
actor.getName() + " banned " + cloaked + " (" + name + ")",
chan.modules.permissions.permissions.ban
);
}
this.kickBanTarget(name, ip);
};
KickBanModule.prototype.banAll = function (actor, name, range, reason, cb) {
var self = this;
KickBanModule.prototype.banAll = async function banAll(
actor,
name,
range,
reason
) {
reason = reason.substring(0, 255);
var chan = self.channel;
var error = function (what) {
cb(what);
};
var chan = this.channel;
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
throw new Error("You do not have ban permissions on this channel");
}
self.banName(actor, name, reason, function (err) {
if (err && err.indexOf("is already banned") === -1) {
cb(err);
} else {
db.getIPs(name, function (err, ips) {
if (err) {
return error(err);
}
const ips = await dbGetIPs(name);
this.checkChannelAlive();
var seenIPs = {};
var all = ips.map(function (ip) {
if (range === "range") {
ip = util.getIPRange(ip);
} else if (range === "wrange") {
ip = util.getWideIPRange(ip);
}
if (seenIPs.hasOwnProperty(ip)) {
return;
} else {
seenIPs[ip] = true;
}
return Q.nfcall(self.banIP.bind(self), actor, ip, name, reason);
});
Q.all(all).then(function () {
setImmediate(cb);
}).catch(error).done();
});
const toBan = new Set();
for (let ip of ips) {
switch (range) {
case "range":
toBan.add(util.getIPRange(ip));
break;
case "wrange":
toBan.add(util.getWideIPRange(ip));
break;
default:
toBan.add(ip);
break;
}
});
}
const promises = Array.from(toBan).map(ip =>
this.banIP(actor, ip, name, reason)
);
if (!await dbIsNameBanned(chan.name, name)) {
promises.push(this.banName(actor, name, reason).catch(error => {
// TODO: banning should be made idempotent, not throw an error
if (!/already banned/.test(error.message)) {
throw error;
}
}));
}
await Promise.all(promises);
this.checkChannelAlive();
};
KickBanModule.prototype.kickBanTarget = function (name, ip) {
@ -439,8 +452,11 @@ KickBanModule.prototype.handleUnban = function (user, data) {
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
if (self.channel.modules.chat) {
var banperm = self.channel.modules.permissions.permissions.ban;
self.channel.modules.chat.sendModMessage(user.getName() + " unbanned " +
data.name, banperm);
// TODO: quick fix, shouldn't trust name from unban frame.
self.channel.modules.chat.sendModMessage(
user.getName() + " unbanned " + XSS.sanitizeText(data.name),
banperm
);
}
self.channel.refCounter.unref("KickBanModule::handleUnban");
});

View file

@ -3,7 +3,8 @@ var Flags = require("../flags");
var util = require("../utilities");
var InfoGetter = require("../get-info");
var db = require("../database");
var Media = require("../media");
import { Counter, Summary } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('channel/library');
const TYPE_UNCACHE = {
id: "string"
@ -14,7 +15,7 @@ const TYPE_SEARCH_MEDIA = {
query: "string"
};
function LibraryModule(channel) {
function LibraryModule(_channel) {
ChannelModule.apply(this, arguments);
}
@ -31,15 +32,17 @@ LibraryModule.prototype.cacheMedia = function (media) {
}
};
LibraryModule.prototype.getItem = function (id, cb) {
db.channels.getLibraryItem(this.channel.name, id, function (err, row) {
if (err) {
cb(err, null);
} else {
var meta = JSON.parse(row.meta || "{}");
cb(null, new Media(row.id, row.title, row.seconds, row.type, meta));
}
});
LibraryModule.prototype.cacheMediaList = function (list) {
if (this.channel.is(Flags.C_REGISTERED)) {
LOGGER.info(
'Saving %d items to library for %s',
list.length,
this.channel.name
);
db.channels.addListToLibrary(this.channel.name, list).catch(error => {
LOGGER.error('Failed to add list to library: %s', error.stack);
});
}
};
LibraryModule.prototype.handleUncache = function (user, data) {
@ -53,7 +56,7 @@ LibraryModule.prototype.handleUncache = function (user, data) {
const chan = this.channel;
chan.refCounter.ref("LibraryModule::handleUncache");
db.channels.deleteFromLibrary(chan.name, data.id, function (err, res) {
db.channels.deleteFromLibrary(chan.name, data.id, function (err, _res) {
if (chan.dead) {
return;
} else if (err) {
@ -67,11 +70,24 @@ LibraryModule.prototype.handleUncache = function (user, data) {
});
};
const librarySearchQueryCount = new Counter({
name: 'cytube_library_search_query_count',
help: 'Counter for number of channel library searches',
labelNames: ['source']
});
const librarySearchResultSize = new Summary({
name: 'cytube_library_search_results_size',
help: 'Summary for number of channel library results returned',
labelNames: ['source']
});
LibraryModule.prototype.handleSearchMedia = function (user, data) {
var query = data.query.substring(0, 100);
var searchYT = function () {
librarySearchQueryCount.labels('yt').inc(1, new Date());
InfoGetter.Getters.ytSearch(query, function (e, vids) {
if (!e) {
librarySearchResultSize.labels('yt')
.observe(vids.length, new Date());
user.socket.emit("searchResults", {
source: "yt",
results: vids
@ -84,14 +100,15 @@ LibraryModule.prototype.handleSearchMedia = function (user, data) {
!this.channel.modules.permissions.canSeePlaylist(user)) {
searchYT();
} else {
librarySearchQueryCount.labels('library').inc(1, new Date());
db.channels.searchLibrary(this.channel.name, query, function (err, res) {
if (err) {
res = [];
}
if (res.length === 0) {
return searchYT();
}
librarySearchResultSize.labels('library')
.observe(res.length, new Date());
res.sort(function (a, b) {
var x = a.title.toLowerCase();

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