Compare commits

...

108 commits

Author SHA1 Message Date
secretspecter db6253cd04 Blocked by FIXME 2024-01-04 19:54:13 -07:00
secretspecter 4846b22cd1 fix format and use database.client 2023-12-14 23:06:26 -07:00
secretspecter 0b806cec92 add support for PostgreSQL (and other databases)
* renamed the 'mysql' config.yaml key to 'database'
* added database.client which still defaults to 'mysql'
2023-12-14 22:11:17 -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
119 changed files with 126294 additions and 41488 deletions

6
.editorconfig Normal file
View file

@ -0,0 +1,6 @@
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_size = 4
indent_style = space

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',
],
}

View file

@ -1,35 +0,0 @@
env:
es6: true
node: true
extends: 'eslint:recommended'
parser: 'babel-eslint'
parserOptions:
sourceType: module
ecmaVersion: 2017 # For async/await
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

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ www/js/cytube-google-drive.user.js
www/js/cytube-google-drive.meta.js
www/js/player.js
tor-exit-list.json
*.patch

View file

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

67
NEWS.md
View file

@ -1,3 +1,70 @@
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
==========

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

View file

@ -6,26 +6,35 @@ var path = require('path');
var order = [
'base.coffee',
'dailymotion.coffee',
'niconico.coffee',
'peertube.coffee',
'soundcloud.coffee',
'twitch.coffee',
'vimeo.coffee',
'youtube.coffee',
'dailymotion.coffee',
'videojs.coffee',
// playerjs-based players
'playerjs.coffee',
'iframechild.coffee',
'odysee.coffee',
'streamable.coffee',
'gdrive-player.coffee',
'raw-file.coffee',
'soundcloud.coffee',
// iframe embed-based players
'embed.coffee',
'twitch.coffee',
'livestream.com.coffee',
'custom-embed.coffee',
'rtmp.coffee',
'smashcast.coffee',
'ustream.coffee',
'imgur.coffee',
'gdrive-youtube.coffee',
'hls.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'
];

View file

@ -107,6 +107,10 @@ io:
default-port: 1337
# limit the number of concurrent socket connections per IP address
ip-connection-limit: 10
cors:
# Additional origins to allow socket connections from (io.domain and
# https.domain are included implicitly).
allowed-origins: []
# YouTube v3 API key
# 1. Go to https://console.developers.google.com/, create a new "project" (or choose an existing one)
@ -124,6 +128,8 @@ max-channels-per-user: 5
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
# Allows you to customize the path divider. The /r/ in http://localhost/r/yourchannel
# Acceptable characters are a-z A-Z 0-9 _ and -

View file

@ -1,7 +1,7 @@
CyTube Custom Content Metadata
==============================
*Last updated: 2019-05-05*
*Last updated: 2022-02-12*
## Purpose ##
@ -61,6 +61,8 @@ To add custom content, the user provides a JSON object with the following keys:
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.
@ -99,19 +101,46 @@ The following MIME types are accepted for the `contentType` field:
RTMP streams are only supported through the existing `rt:` media
type.
* `audio/aac`
* `audio/ogg`
* `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`.
@ -177,3 +206,5 @@ non-exhaustive.
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

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

@ -110,6 +110,25 @@ describe('KickbanModule', () => {
);
});
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') {

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

10975
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,39 +2,40 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.76.3",
"version": "3.86.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
"license": "MIT",
"dependencies": {
"@calzoneman/jsli": "^2.0.1",
"@cytube/mediaquery": "0.0.25",
"@cytube/mediaquery": "github:CyTube/mediaquery#564d0c4615e80f72722b0f68ac81f837a4c5fc81",
"bcrypt": "^5.0.1",
"bluebird": "^3.7.2",
"body-parser": "^1.19.0",
"cheerio": "^1.0.0-rc.5",
"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.1.0",
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
"express": "^4.17.1",
"express": "^4.18.2",
"express-minify": "^1.0.0",
"json-typecheck": "^0.1.3",
"knex": "^0.95.2",
"knex": "^2.4.0",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"mysql": "^2.18.1",
"nodemailer": "^6.5.0",
"nodemailer": "^6.6.1",
"pg": "^8.11.3",
"pg-native": "^3.0.1",
"prom-client": "^13.1.0",
"proxy-addr": "^2.0.6",
"pug": "^3.0.2",
"redis": "^3.0.2",
"sanitize-html": "^2.3.3",
"serve-static": "^1.14.1",
"socket.io": "^2.0.3",
"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",
@ -53,15 +54,16 @@
"integration-test": "mocha --recursive --exit integration_test"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
"@babel/core": "^7.11.4",
"@babel/preset-env": "^7.11.0",
"babel-eslint": "^10.1.0",
"babel-plugin-add-module-exports": "^1.0.2",
"@babel/cli": "^7.15.7",
"@babel/core": "^7.15.8",
"@babel/eslint-parser": "^7.15.8",
"@babel/preset-env": "^7.15.8",
"babel-plugin-add-module-exports": "^1.0.4",
"coffeescript": "^1.9.2",
"eslint": "^7.7.0",
"mocha": "^8.1.1",
"sinon": "^9.0.3"
"eslint": "^7.32.0",
"eslint-plugin-no-jquery": "^2.7.0",
"mocha": "^9.2.2",
"sinon": "^10.0.0"
},
"babel": {
"presets": [

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

@ -12,7 +12,6 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
params =
autoplay: 1
wmode: if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
logo: 0
quality = @mapQuality(USEROPTS.default_quality)

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?

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

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)

View file

@ -8,55 +8,48 @@ window.PlayerJSPlayer = class PlayerJSPlayer extends Player
load: (data) ->
@setMediaProperties(data)
@ready = false
@finishing = 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)
.attr(
src: data.meta.playerjs.src
allow: 'autoplay; fullscreen'
)
removeOld(iframe)
@setupPlayer(iframe[0])
)
@player = new playerjs.Player(iframe[0])
@player.on('ready', =>
@player.on('error', (error) =>
console.error('PlayerJS error', error.stack)
)
@player.on('ended', ->
# Streamable seems to not implement this since it loops
# gotta use the timeupdate hack below
if CLIENT.leader
socket.emit('playNext')
)
@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
)
@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
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: ->

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

@ -1,12 +0,0 @@
window.SmashcastPlayer = class SmashcastPlayer extends EmbedPlayer
constructor: (data) ->
if not (this instanceof SmashcastPlayer)
return new SmashcastPlayer(data)
@load(data)
load: (data) ->
data.meta.embed =
src: "https://www.smashcast.tv/embed/#{data.id}"
tag: 'iframe'
super(data)

View file

@ -6,7 +6,30 @@ window.StreamablePlayer = class StreamablePlayer extends PlayerJSPlayer
super(data)
load: (data) ->
data.meta.playerjs =
src: "https://streamable.com/e/#{data.id}"
@ready = false
@finishing = false
@setMediaProperties(data)
super(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

@ -3,7 +3,6 @@ TYPE_MAP =
vi: VimeoPlayer
dm: DailymotionPlayer
gd: GoogleDrivePlayer
gp: VideoJSPlayer
fi: FilePlayer
sc: SoundCloudPlayer
li: LivestreamPlayer
@ -11,13 +10,15 @@ TYPE_MAP =
tv: TwitchPlayer
cu: CustomEmbedPlayer
rt: RTMPPlayer
hb: SmashcastPlayer
us: UstreamPlayer
im: ImgurPlayer
hl: HLSPlayer
sb: StreamablePlayer
tc: TwitchClipPlayer
cm: VideoJSPlayer
pt: PeerPlayer
bc: IframeChild
bn: IframeChild
od: OdyseePlayer
nv: NicoPlayer
window.loadMediaPlayer = (data) ->
try
@ -109,7 +110,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: "https://www.ustream.tv/embed/#{data.id}?html5ui"
super(data)

View file

@ -42,9 +42,13 @@ getSourceLabel = (source) ->
else
return "#{source.quality}p #{source.contentType.split('/')[1]}"
waitUntilDefined(window, 'videojs', =>
videojs.options.flash.swf = '/video-js.swf'
)
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) ->
@ -59,7 +63,7 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
width: '100%'
height: '100%'
if @mediaType == 'cm' and data.meta.textTracks
if @mediaType == 'cm' and hasAnyTextTracks(data)
attrs.crossorigin = 'anonymous'
video = $('<video/>')
@ -108,17 +112,26 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
$('<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:
videoJsResolutionSwitcher:
default: @sources[0].res
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()
@ -130,8 +143,11 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
@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

@ -13,14 +13,9 @@ window.VimeoPlayer = class VimeoPlayer extends Player
removeOld(video)
video.attr(
src: "https://player.vimeo.com/video/#{data.id}"
webkitallowfullscreen: true
mozallowfullscreen: true
allowfullscreen: true
allow: 'autoplay; fullscreen'
)
if USEROPTS.wmode_transparent
video.attr('wmode', 'transparent')
@vimeo = new Vimeo.Player(video[0])
@vimeo.on('ended', =>

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

1
src/.eslintrc.json Normal file
View file

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

View file

@ -94,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 {

View file

@ -3,7 +3,6 @@ var XSS = require("../xss");
var ChannelModule = require("./module");
var util = require("../utilities");
var Flags = require("../flags");
var counters = require("../counters");
import { transformImgTags } from '../camo';
import { Counter } from 'prom-client';
@ -157,14 +156,13 @@ const chatIncomingCount = new Counter({
});
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;
}
data.msg = data.msg.substring(0, 320);
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)) {
@ -250,7 +248,7 @@ ChatModule.prototype.handlePm = function (user, data) {
}
data.msg = data.msg.substring(0, 320);
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) {
@ -358,7 +356,6 @@ ChatModule.prototype.processChatMsg = function (user, data) {
return;
}
this.sendMessage(msgobj);
counters.add("chat:sent");
chatSentCount.inc(1, new Date());
};

View file

@ -4,6 +4,7 @@ var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
import Promise from 'bluebird';
const XSS = require("../xss");
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
@ -261,7 +262,6 @@ KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
this.banAll(user, name, range, reason).catch(error => {
//console.log('!!!', error.stack);
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
@ -276,6 +276,10 @@ KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
};
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;
@ -323,6 +327,9 @@ KickBanModule.prototype.banName = async function banName(actor, name, reason) {
};
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);
@ -404,7 +411,12 @@ KickBanModule.prototype.banAll = async function banAll(
);
if (!await dbIsNameBanned(chan.name, name)) {
promises.push(this.banName(actor, name, reason));
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);
@ -440,8 +452,9 @@ 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;
// TODO: quick fix, shouldn't trust name from unban frame.
self.channel.modules.chat.sendModMessage(
user.getName() + " unbanned " + data.name,
user.getName() + " unbanned " + XSS.sanitizeText(data.name),
banperm
);
}

View file

@ -8,7 +8,6 @@ var Flags = require("../flags");
var db = require("../database");
var CustomEmbedFilter = require("../customembed").filter;
var XSS = require("../xss");
import counters from '../counters';
import { Counter } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('playlist');
@ -159,15 +158,22 @@ PlaylistModule.prototype.load = function (data) {
}
} else if (item.media.type === "gd") {
delete item.media.meta.gpdirect;
} else if (["vm", "jw", "mx"].includes(item.media.type)) {
} else if (["vm", "jw", "mx", "im", "gp", "us", "hb"].includes(item.media.type)) {
// JW has been deprecated for a long time
// VM shut down in December 2017
// Mixer shut down in July 2020
// Imgur replaced albums with a feature called galleries in 2019
// Picasa shut down in 2016
// Ustream was sunset by IBM in September 2018
// SmashCast (Hitbox) seemed to just vanish November 2020
LOGGER.warn(
"Dropping playlist item with deprecated type %s",
item.media.type
);
return;
} else if (item.media.meta.embed && item.media.meta.embed.tag !== 'iframe') {
LOGGER.warn("Dropping playlist item with flash embed");
return;
}
var m = new Media(item.media.id, item.media.title, item.media.seconds,
@ -512,7 +518,6 @@ PlaylistModule.prototype.queueStandard = function (user, data) {
const self = this;
this.channel.refCounter.ref("PlaylistModule::queueStandard");
counters.add("playlist:queue:count", 1);
this.semaphore.queue(function (lock) {
InfoGetter.getMedia(data.id, data.type, function (err, media) {
if (err) {

View file

@ -8,6 +8,7 @@ const TYPE_NEW_POLL = {
title: "string",
timeout: "number,optional",
obscured: "boolean",
retainVotes: "boolean,optional",
opts: "array"
};
@ -42,12 +43,7 @@ PollModule.prototype.unload = function () {
PollModule.prototype.load = function (data) {
if ("poll" in data) {
if (data.poll !== null) {
this.poll = new Poll(data.poll.initiator, "", [], data.poll.obscured);
this.poll.title = data.poll.title;
this.poll.options = data.poll.options;
this.poll.counts = data.poll.counts;
this.poll.votes = data.poll.votes;
this.poll.timestamp = data.poll.timestamp;
this.poll = Poll.fromChannelData(data.poll);
}
}
@ -60,15 +56,7 @@ PollModule.prototype.save = function (data) {
return;
}
data.poll = {
title: this.poll.title,
initiator: this.poll.initiator,
options: this.poll.options,
counts: this.poll.counts,
votes: this.poll.votes,
obscured: this.poll.obscured,
timestamp: this.poll.timestamp
};
data.poll = this.poll.toChannelData();
};
PollModule.prototype.onUserPostJoin = function (user) {
@ -97,8 +85,7 @@ PollModule.prototype.addUserToPollRoom = function (user) {
};
PollModule.prototype.onUserPart = function(user) {
if (this.poll) {
this.poll.unvote(user.realip);
if (this.poll && !this.poll.retainVotes && this.poll.uncountVote(user.realip)) {
this.broadcastPoll(false);
}
};
@ -110,12 +97,11 @@ PollModule.prototype.sendPoll = function (user) {
var perms = this.channel.modules.permissions;
user.socket.emit("closePoll");
if (perms.canViewHiddenPoll(user)) {
var unobscured = this.poll.packUpdate(true);
var unobscured = this.poll.toUpdateFrame(true);
user.socket.emit("newPoll", unobscured);
} else {
var obscured = this.poll.packUpdate(false);
var obscured = this.poll.toUpdateFrame(false);
user.socket.emit("newPoll", obscured);
}
};
@ -125,13 +111,10 @@ PollModule.prototype.broadcastPoll = function (isNewPoll) {
return;
}
var obscured = this.poll.packUpdate(false);
var unobscured = this.poll.packUpdate(true);
var obscured = this.poll.toUpdateFrame(false);
var unobscured = this.poll.toUpdateFrame(true);
const event = isNewPoll ? "newPoll" : "updatePoll";
if (isNewPoll) {
this.channel.broadcastAll("closePoll");
}
this.channel.broadcastToRoom(event, unobscured, this.roomViewHidden);
this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden);
@ -165,6 +148,9 @@ PollModule.prototype.handleNewPoll = function (user, data, ack) {
return;
}
// Ensure any existing poll is closed
this.handleClosePoll(user);
ack = ackOrErrorMsg(ack, user);
if (typeof data !== 'object' || data === null) {
@ -197,7 +183,15 @@ PollModule.prototype.handleNewPoll = function (user, data, ack) {
return;
}
var poll = new Poll(user.getName(), data.title, data.opts, data.obscured);
var poll = Poll.create(
user.getName(),
data.title,
data.opts,
{
hideVotes: data.obscured,
retainVotes: data.retainVotes === undefined ? false : data.retainVotes
}
);
var self = this;
if (data.hasOwnProperty("timeout")) {
poll.timer = setTimeout(function () {
@ -223,9 +217,10 @@ PollModule.prototype.handleVote = function (user, data) {
}
if (this.poll) {
this.poll.vote(user.realip, data.option);
this.dirty = true;
this.broadcastPoll(false);
if (this.poll.countVote(user.realip, data.option)) {
this.dirty = true;
this.broadcastPoll(false);
}
}
};
@ -235,9 +230,9 @@ PollModule.prototype.handleClosePoll = function (user) {
}
if (this.poll) {
if (this.poll.obscured) {
this.poll.obscured = false;
this.channel.broadcastAll("updatePoll", this.poll.packUpdate(true));
if (this.poll.hideVotes) {
this.poll.hideVotes = false;
this.channel.broadcastAll("updatePoll", this.poll.toUpdateFrame(true));
}
if (this.poll.timer) {
@ -256,6 +251,9 @@ PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
return;
}
// Ensure any existing poll is closed
this.handleClosePoll(user);
msg = msg.replace(/^\/h?poll/, "");
var args = msg.split(",");
@ -270,7 +268,7 @@ PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
return;
}
var poll = new Poll(user.getName(), title, args, obscured);
var poll = Poll.create(user.getName(), title, args, { hideVotes: obscured });
this.poll = poll;
this.dirty = true;
this.broadcastPoll(true);

View file

@ -37,10 +37,10 @@ VoteskipModule.prototype.handleVoteskip = function (user) {
}
if (!this.poll) {
this.poll = new Poll("[server]", "voteskip", ["skip"], false);
this.poll = Poll.create("[server]", "voteskip", ["skip"]);
}
if (!this.poll.vote(user.realip, 0)) {
if (!this.poll.countVote(user.realip, 0)) {
// Vote was already recorded for this IP, no update needed
return;
}
@ -62,7 +62,7 @@ VoteskipModule.prototype.unvote = function(ip) {
return;
}
this.poll.unvote(ip);
this.poll.uncountVote(ip);
};
VoteskipModule.prototype.update = function () {
@ -78,10 +78,14 @@ VoteskipModule.prototype.update = function () {
return;
}
const { counts } = this.poll.toUpdateFrame(false);
const { total, eligible, noPermission, afk } = this.calcUsercounts();
const need = Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"));
if (this.poll.counts[0] >= need) {
const info = `${this.poll.counts[0]}/${eligible} skipped; ` +
const need = Math.max(
1, // Require at least one vote, see #944
Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
);
if (counts[0] >= need) {
const info = `${counts[0]}/${eligible} skipped; ` +
`eligible voters: ${eligible} = total (${total}) - AFK (${afk}) ` +
`- no permission (${noPermission}); ` +
`ratio = ${this.channel.modules.options.get("voteskip_ratio")}`;
@ -107,11 +111,20 @@ VoteskipModule.prototype.update = function () {
VoteskipModule.prototype.sendVoteskipData = function (users) {
const { eligible } = this.calcUsercounts();
var data = {
count: this.poll ? this.poll.counts[0] : 0,
need: this.poll ? Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
: 0
};
let data;
if (this.poll) {
const { counts } = this.poll.toUpdateFrame(false);
data = {
count: counts[0],
need: Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
};
} else {
data = {
count: 0,
need: 0
};
}
var perms = this.channel.modules.permissions;

View file

@ -0,0 +1,24 @@
import Server from '../server';
export async function handleBanChannel({ name, externalReason, internalReason }) {
await Server.getServer().bannedChannelsController.banChannel({
name,
externalReason,
internalReason,
bannedBy: '[console]'
});
return { status: 'success' };
}
export async function handleUnbanChannel({ name }) {
await Server.getServer().bannedChannelsController.unbanChannel(name, '[console]');
return { status: 'success' };
}
export async function handleShowBannedChannel({ name }) {
let banInfo = await Server.getServer().bannedChannelsController.getBannedChannel(name);
return { status: 'success', ban: banInfo };
}

View file

@ -11,7 +11,8 @@ import { CaptchaConfig } from './configuration/captchaconfig';
const LOGGER = require('@calzoneman/jsli')('config');
var defaults = {
mysql: {
database: {
client: "mysql",
server: "localhost",
port: 3306,
database: "cytube3",
@ -60,15 +61,18 @@ var defaults = {
io: {
domain: "http://localhost",
"default-port": 1337,
"ip-connection-limit": 10
"ip-connection-limit": 10,
cors: {
"allowed-origins": []
}
},
"youtube-v3-key": "",
"channel-blacklist": [],
"channel-path": "r",
"channel-save-interval": 5,
"max-channels-per-user": 5,
"max-accounts-per-ip": 5,
"guest-login-delay": 60,
"max-chat-message-length": 320,
aliases: {
"purge-interval": 3600000,
"max-age": 2592000000
@ -369,13 +373,6 @@ function preprocessConfig(cfg) {
}
}
/* Convert channel blacklist to a hashtable */
var tbl = {};
cfg["channel-blacklist"].forEach(function (c) {
tbl[c.toLowerCase()] = true;
});
cfg["channel-blacklist"] = tbl;
/* Check channel path */
if(!/^[-\w]+$/.test(cfg["channel-path"])){
LOGGER.error("Channel paths may only use the same characters as usernames and channel names.");
@ -432,6 +429,11 @@ function preprocessConfig(cfg) {
cfg['channel-storage'] = { type: undefined };
}
if (cfg["max-chat-message-length"] > 1000) {
LOGGER.warn("Max chat message length was greater than 1000. Setting to 1000.");
cfg["max-chat-message-length"] = 1000;
}
return cfg;
}

View file

@ -0,0 +1,67 @@
import { eventlog } from '../logger';
import { SimpleCache } from '../util/simple-cache';
const LOGGER = require('@calzoneman/jsli')('BannedChannelsController');
export class BannedChannelsController {
constructor(dbChannels, globalMessageBus) {
this.dbChannels = dbChannels;
this.globalMessageBus = globalMessageBus;
this.cache = new SimpleCache({
maxElem: 1000,
maxAge: 5 * 60_000
});
}
/*
* TODO: add an audit log to the database
*/
async banChannel({ name, externalReason, internalReason, bannedBy }) {
LOGGER.info(`Banning channel ${name} (banned by ${bannedBy})`);
eventlog.log(`[acp] ${bannedBy} banned channel ${name}`);
let banInfo = await this.dbChannels.getBannedChannel(name);
if (banInfo !== null) {
LOGGER.warn(`Channel ${name} is already banned, updating ban reason`);
}
this.cache.delete(name);
await this.dbChannels.putBannedChannel({
name,
externalReason,
internalReason,
bannedBy
});
this.globalMessageBus.emit(
'ChannelBanned',
{ channel: name, externalReason }
);
}
async unbanChannel(name, unbannedBy) {
LOGGER.info(`Unbanning channel ${name}`);
eventlog.log(`[acp] ${unbannedBy} unbanned channel ${name}`);
this.cache.delete(name);
this.globalMessageBus.emit(
'ChannelUnbanned',
{ channel: name }
);
await this.dbChannels.removeBannedChannel(name);
}
async getBannedChannel(name) {
name = name.toLowerCase();
let info = this.cache.get(name);
if (info === null) {
info = await this.dbChannels.getBannedChannel(name);
this.cache.put(name, info);
}
return info;
}
}

View file

@ -1,55 +0,0 @@
import io from 'socket.io';
import Socket from 'socket.io/lib/socket';
import * as Metrics from './metrics/metrics';
import { JSONFileMetricsReporter } from './metrics/jsonfilemetricsreporter';
const LOGGER = require('@calzoneman/jsli')('counters');
var server = null;
exports.add = Metrics.incCounter;
Socket.prototype._packet = Socket.prototype.packet;
Socket.prototype.packet = function () {
this._packet.apply(this, arguments);
exports.add('socket.io:packet');
};
function getConnectedSockets() {
var sockets = io.instance.sockets.sockets;
if (typeof sockets.length === 'number') {
return sockets.length;
} else {
return Object.keys(sockets).length;
}
}
function setChannelCounts(metrics) {
if (server === null) {
server = require('./server').getServer();
}
try {
var publicCount = 0;
var allCount = 0;
server.channels.forEach(function (c) {
allCount++;
if (c.modules.options && c.modules.options.get("show_public")) {
publicCount++;
}
});
metrics.addProperty('channelCount:all', allCount);
metrics.addProperty('channelCount:public', publicCount);
} catch (error) {
LOGGER.error(error.stack);
}
}
const reporter = new JSONFileMetricsReporter('counters.log');
Metrics.setReporter(reporter);
Metrics.setReportInterval(60000);
Metrics.addReportHook((metrics) => {
metrics.addProperty('socket.io:count', getConnectedSockets());
setChannelCounts(metrics);
});

View file

@ -22,15 +22,21 @@ const SOURCE_CONTENT_TYPES = new Set([
'application/dash+xml',
'application/x-mpegURL',
'audio/aac',
'audio/ogg',
'audio/mp4',
'audio/mpeg',
'audio/ogg',
'audio/opus',
'video/mp4',
'video/ogg',
'video/webm'
]);
const LIVE_ONLY_CONTENT_TYPES = new Set([
'application/dash+xml'
const AUDIO_ONLY_CONTENT_TYPES = new Set([
'audio/aac',
'audio/mp4',
'audio/mpeg',
'audio/ogg',
'audio/opus'
]);
export function lookup(url, opts) {
@ -134,6 +140,7 @@ export function convert(id, data) {
const meta = {
direct: sources,
audioTracks: data.audioTracks,
textTracks: data.textTracks,
thumbnail: data.thumbnail, // Currently ignored by Media
live: !!data.live // Currently ignored by Media
@ -162,11 +169,20 @@ export function validate(data) {
validateURL(data.thumbnail);
}
validateSources(data.sources, data);
validateSources(data.sources);
validateAudioTracks(data.audioTracks);
validateTextTracks(data.textTracks);
/*
* TODO: Xaekai's Octopus subtitle support uses a separate subTracks array
* in a slightly different format than textTracks. That currently requires
* a channel script to use, but if that is integrated in core then it needs
* to be validated here (and ideally merged with textTracks so there is only
* one array).
*/
validateFonts(data.fonts);
}
function validateSources(sources, data) {
function validateSources(sources) {
if (!Array.isArray(sources))
throw new ValidationError('sources must be a list');
if (sources.length === 0)
@ -182,12 +198,8 @@ function validateSources(sources, data) {
`unacceptable source contentType "${source.contentType}"`
);
if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live)
throw new ValidationError(
`contentType "${source.contentType}" requires live: true`
);
if (!SOURCE_QUALITIES.has(source.quality))
// TODO (Xaekai): This should be allowed
if (/*!AUDIO_ONLY_CONTENT_TYPES.has(source.contentType) && */!SOURCE_QUALITIES.has(source.quality))
throw new ValidationError(`unacceptable source quality "${source.quality}"`);
if (source.hasOwnProperty('bitrate')) {
@ -201,6 +213,45 @@ function validateSources(sources, data) {
}
}
function validateAudioTracks(audioTracks) {
if (typeof audioTracks === 'undefined') {
return;
}
if (!Array.isArray(audioTracks)){
throw new ValidationError('audioTracks must be a list');
}
for (let track of audioTracks) {
if (typeof track.url !== 'string'){
throw new ValidationError('audio track URL must be a string');
}
validateURL(track.url);
if (!AUDIO_ONLY_CONTENT_TYPES.has(track.contentType)){
throw new ValidationError(
`unacceptable audio track contentType "${track.contentType}"`
);
}
if (typeof track.label !== 'string'){
throw new ValidationError('audio track label must be a string');
}
if (!track.label){
throw new ValidationError('audio track label must be nonempty');
}
if (typeof track.language !== 'string'){
throw new ValidationError('audio track language must be a string');
}
if (!track.language){
throw new ValidationError('audio track language must be nonempty');
}
if (!/^[a-z]{2,3}$/.test(track.language)){
throw new ValidationError('audio track language must be a two or three letter IETF BCP 47 subtag');
}
}
}
function validateTextTracks(textTracks) {
if (typeof textTracks === 'undefined') {
return;
@ -236,6 +287,20 @@ function validateTextTracks(textTracks) {
}
}
function validateFonts(fonts) {
if (typeof textTracks === 'undefined') {
return;
}
if (!Array.isArray(fonts))
throw new ValidationError('fonts must be a list of URLs');
for (let f of fonts) {
if (typeof f !== 'string')
throw new ValidationError('fonts must be a list of URLs');
}
}
function parseURL(urlstring) {
const url = urlParse(urlstring);

View file

@ -1,6 +1,5 @@
var Config = require("./config");
var tables = require("./database/tables");
import * as Metrics from './metrics/metrics';
import knex from 'knex';
import { GlobalBanDB } from './db/globalban';
import { MetadataCacheDB } from './database/metadata_cache';
@ -31,19 +30,19 @@ class Database {
constructor(knexConfig = null) {
if (knexConfig === null) {
knexConfig = {
client: 'mysql',
client: Config.get('database.client'),
connection: {
host: Config.get('mysql.server'),
port: Config.get('mysql.port'),
user: Config.get('mysql.user'),
password: Config.get('mysql.password'),
database: Config.get('mysql.database'),
host: Config.get('database.server'),
port: Config.get('database.port'),
user: Config.get('database.user'),
password: Config.get('database.password'),
database: Config.get('database.database'),
multipleStatements: true, // Legacy thing
charset: 'utf8mb4'
},
pool: {
min: Config.get('mysql.pool-size'),
max: Config.get('mysql.pool-size')
min: Config.get('database.pool-size'),
max: Config.get('database.pool-size')
},
debug: !!process.env.KNEX_DEBUG
};
@ -53,14 +52,12 @@ class Database {
}
runTransaction(fn) {
const timer = Metrics.startTimer('db:queryTime');
const end = queryLatency.startTimer();
return this.knex.transaction(fn).catch(error => {
queryErrorCount.inc(1);
throw error;
}).finally(() => {
end();
Metrics.stopTimer(timer);
queryCount.inc(1);
});
}
@ -76,6 +73,8 @@ module.exports.init = function (newDB) {
} else {
db = new Database();
}
// FIXME Initial database connection failed: error: select 1 from dual
// relation "dual" does not exist
db.knex.raw('select 1 from dual')
.catch(error => {
LOGGER.error('Initial database connection failed: %s', error.stack);
@ -88,6 +87,9 @@ module.exports.init = function (newDB) {
require('@cytube/mediaquery/lib/provider/youtube').setCache(
new MetadataCacheDB(db)
);
require('@cytube/mediaquery/lib/provider/bitchute').setCache(
new MetadataCacheDB(db)
);
}).catch(error => {
LOGGER.error(error.stack);
process.exit(1);
@ -110,7 +112,6 @@ module.exports.getGlobalBanDB = function getGlobalBanDB() {
* Execute a database query
*/
module.exports.query = function (query, sub, callback) {
const timer = Metrics.startTimer('db:queryTime');
// 2nd argument is optional
if (typeof sub === "function") {
callback = sub;
@ -157,7 +158,6 @@ module.exports.query = function (query, sub, callback) {
process.nextTick(callback, 'Database failure', null);
}).finally(() => {
end();
Metrics.stopTimer(timer);
queryCount.inc(1);
});
};

View file

@ -2,6 +2,7 @@ var db = require("../database");
var valid = require("../utilities").isValidChannelName;
var Flags = require("../flags");
var util = require("../utilities");
// TODO: I think newer knex has native support for this
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
import Config from '../config';
@ -209,7 +210,9 @@ module.exports = {
return;
}
db.query("SELECT * FROM `channels` WHERE owner=?", [owner],
db.query("SELECT c.*, bc.external_reason as banReason " +
"FROM channels c LEFT OUTER JOIN banned_channels bc " +
"ON bc.channel_name = c.name WHERE c.owner=?", [owner],
function (err, res) {
if (err) {
callback(err, []);
@ -245,13 +248,28 @@ module.exports = {
return;
}
db.query("SELECT * FROM `channels` WHERE name=?", chan.name, function (err, res) {
db.query("SELECT c.*, bc.external_reason as banReason " +
"FROM channels c LEFT OUTER JOIN banned_channels bc " +
"ON bc.channel_name = c.name WHERE c.name=? " +
"UNION " +
"SELECT c.*, bc.external_reason as banReason " +
"FROM channels c RIGHT OUTER JOIN banned_channels bc " +
"ON bc.channel_name = c.name WHERE bc.channel_name=? ",
[chan.name, chan.name],
function (err, res) {
if (err) {
callback(err, null);
return;
}
if (res.length === 0) {
if (res.length > 0 && res[0].banReason !== null) {
let banError = new Error(`Channel is banned: ${res[0].banReason}`);
banError.code = 'EBANNED';
callback(banError, null);
return;
}
if (res.length === 0 || res[0].id === null) {
callback("Channel is not registered", null);
return;
}
@ -704,5 +722,63 @@ module.exports = {
LOGGER.error(`Failed to update owner_last_seen column for channel ID ${channelId}: ${error}`);
}
});
},
getBannedChannel: async function getBannedChannel(name) {
if (!valid(name)) {
throw new Error("Invalid channel name");
}
return await db.getDB().runTransaction(async tx => {
let rows = await tx.table('banned_channels')
.where({ channel_name: name })
.select();
if (rows.length === 0) {
return null;
}
return {
channelName: rows[0].channel_name,
externalReason: rows[0].external_reason,
internalReason: rows[0].internal_reason,
bannedBy: rows[0].banned_by,
createdAt: rows[0].created_at,
updatedAt: rows[0].updated_at
};
});
},
putBannedChannel: async function putBannedChannel({ name, externalReason, internalReason, bannedBy }) {
if (!valid(name)) {
throw new Error("Invalid channel name");
}
return await db.getDB().runTransaction(async tx => {
let insert = tx.table('banned_channels')
.insert({
channel_name: name,
external_reason: externalReason,
internal_reason: internalReason,
banned_by: bannedBy
});
let update = tx.raw(createMySQLDuplicateKeyUpdate(
['external_reason', 'internal_reason', 'banned_by']
));
return tx.raw(insert.toString() + update.toString());
});
},
removeBannedChannel: async function removeBannedChannel(name) {
if (!valid(name)) {
throw new Error("Invalid channel name");
}
return await db.getDB().runTransaction(async tx => {
await tx.table('banned_channels')
.where({ channel_name: name })
.delete();
});
}
};

View file

@ -9,6 +9,8 @@ function mediaquery2cytube(type) {
switch (type) {
case 'youtube':
return 'yt';
case 'bitchute':
return 'bc';
default:
throw new Error(`mediaquery2cytube: no mapping for ${type}`);
}
@ -18,14 +20,17 @@ function cytube2mediaquery(type) {
switch (type) {
case 'yt':
return 'youtube';
case 'bc':
return 'bitchute';
default:
throw new Error(`cytube2mediaquery: no mapping for ${type}`);
}
}
const cachedResultAge = new Summary({
name: 'cytube_yt_cache_result_age_seconds',
help: 'Age (in seconds) of cached record'
name: 'cytube_media_cache_result_age_seconds',
help: 'Age (in seconds) of cached record',
labelNames: ['source']
});
class MetadataCacheDB {
@ -62,10 +67,11 @@ class MetadataCacheDB {
return null;
}
let age = 0;
try {
let age = (Date.now() - row.updated_at.getTime())/1000;
age = (Date.now() - row.updated_at.getTime())/1000;
if (age > 0) {
cachedResultAge.observe(age);
cachedResultAge.labels(type).observe(age);
}
} catch (error) {
LOGGER.error('Failed to record cachedResultAge metric: %s', error.stack);
@ -73,6 +79,7 @@ class MetadataCacheDB {
let metadata = JSON.parse(row.metadata);
metadata.type = cytube2mediaquery(metadata.type);
metadata.meta.cacheAge = age;
return new Media(metadata);
});
}

View file

@ -156,4 +156,15 @@ export async function initTables() {
t.primary(['type', 'id']);
t.index('updated_at');
});
await ensureTable('banned_channels', t => {
t.charset('utf8mb4');
t.string('channel_name', 30)
.notNullable()
.unique();
t.text('external_reason').notNullable();
t.text('internal_reason').notNullable();
t.string('banned_by', 20).notNullable();
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
});
}

View file

@ -80,13 +80,16 @@ var acceptedCodecs = {
"flv/h264": true,
"matroska/vp8": true,
"matroska/vp9": true,
"ogg/theora": true
"ogg/theora": true,
"mov/av1": true,
"matroska/av1": true
};
var acceptedAudioCodecs = {
"mp3": true,
"vorbis": true,
"aac": true
"aac": true,
"opus": true
};
var audioOnlyContainers = {

View file

@ -6,6 +6,11 @@ const ffmpeg = require("./ffmpeg");
const mediaquery = require("@cytube/mediaquery");
const YouTube = require("@cytube/mediaquery/lib/provider/youtube");
const Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
const Odysee = require("@cytube/mediaquery/lib/provider/odysee");
const PeerTube = require("@cytube/mediaquery/lib/provider/peertube");
const BitChute = require("@cytube/mediaquery/lib/provider/bitchute");
const BandCamp = require("@cytube/mediaquery/lib/provider/bandcamp");
const Nicovideo = require("@cytube/mediaquery/lib/provider/nicovideo");
const Streamable = require("@cytube/mediaquery/lib/provider/streamable");
const TwitchVOD = require("@cytube/mediaquery/lib/provider/twitch-vod");
const TwitchClip = require("@cytube/mediaquery/lib/provider/twitch-clip");
@ -204,114 +209,22 @@ var Getters = {
});
},
/* soundcloud.com */
/* soundcloud.com - see https://github.com/calzoneman/sync/issues/916 */
sc: function (id, callback) {
/* TODO: require server owners to register their own API key, put in config */
const SC_CLIENT = "2e0c82ab5a020f3a7509318146128abd";
var m = id.match(/([\w-/.:]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "api.soundcloud.com",
port: 443,
path: "/resolve.json?url=" + id + "&client_id=" + SC_CLIENT,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
switch (status) {
case 200:
case 302:
break; /* Request is OK, skip to handling data */
case 400:
return callback("Invalid request", null);
case 403:
return callback("Private sound", null);
case 404:
return callback("Sound not found", null);
case 500:
case 503:
return callback("Service unavailable", null);
default:
return callback("HTTP " + status, null);
}
var track = null;
try {
data = JSON.parse(data);
track = data.location;
} catch(e) {
callback(e, null);
return;
}
var options2 = {
host: "api.soundcloud.com",
port: 443,
path: track,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
/**
* There has got to be a way to directly get the data I want without
* making two requests to Soundcloud...right?
* ...right?
*/
urlRetrieve(https, options2, function (status, data) {
switch (status) {
case 200:
break; /* Request is OK, skip to handling data */
case 400:
return callback("Invalid request", null);
case 403:
return callback("Private sound", null);
case 404:
return callback("Sound not found", null);
case 500:
case 503:
return callback("Service unavailable", null);
default:
return callback("HTTP " + status, null);
}
try {
data = JSON.parse(data);
var seconds = data.duration / 1000;
var title = data.title;
var meta = {};
if (data.sharing === "private" && data.embeddable_by === "all") {
meta.scuri = data.uri;
}
var media = new Media(id, title, seconds, "sc", meta);
callback(false, media);
} catch(e) {
callback(e, null);
}
});
});
callback(
"Soundcloud is not supported anymore due to requiring OAuth but not " +
"accepting new API key registrations."
);
},
/* livestream.com */
li: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
if (!id.match(/^\d+;\d+$/)) {
callback("Invalid ID", null);
return;
}
var title = "Livestream.com - " + id;
var title = "Livestream.com";
var media = new Media(id, title, "--:--", "li");
callback(false, media);
},
@ -368,47 +281,6 @@ var Getters = {
});
},
/* ustream.tv */
us: function (id, callback) {
var m = id.match(/(channel\/[^?&#]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "www.ustream.tv",
port: 443,
path: "/" + id,
method: "GET",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
if(status !== 200) {
callback("Ustream HTTP " + status, null);
return;
}
/*
* Yes, regexing this information out of the HTML sucks.
* No, there is not a better solution -- it seems IBM
* deprecated the old API (or at least replaced with an
* enterprise API marked "Contact sales") so fuck it.
*/
var m = data.match(/https:\/\/www\.ustream\.tv\/embed\/(\d+)/);
if (m) {
var title = "Ustream.tv - " + id;
var media = new Media(m[1], title, "--:--", "us");
callback(false, media);
} else {
callback("Channel ID not found", null);
}
});
},
/* rtmp stream */
rt: function (id, callback) {
var title = "Livestream";
@ -430,23 +302,6 @@ var Getters = {
callback(false, media);
},
/* imgur.com albums */
im: function (id, callback) {
/**
* TODO: Consider deprecating this in favor of custom embeds
*/
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Imgur Album - " + id;
var media = new Media(id, title, "--:--", "im");
callback(false, media);
},
/* custom embed */
cu: function (id, callback) {
var media;
@ -498,28 +353,6 @@ var Getters = {
});
},
/* hitbox.tv / smashcast.tv */
hb: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Smashcast - " + id;
var media = new Media(id, title, "--:--", "hb");
callback(false, media);
},
/* vid.me */
vm: function (id, callback) {
process.nextTick(
callback,
"As of December 2017, vid.me is no longer in service."
);
},
/* streamable */
sb: function (id, callback) {
if (!/^[\w-]+$/.test(id)) {
@ -536,6 +369,16 @@ var Getters = {
});
},
/* PeerTube network */
pt: function (id, callback) {
PeerTube.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "pt", video.meta);
callback(null, video);
}).catch(error => {
callback(error.message || error);
});
},
/* custom media - https://github.com/calzoneman/sync/issues/655 */
cm: async function (id, callback) {
try {
@ -546,12 +389,44 @@ var Getters = {
}
},
/* mixer.com */
mx: function (id, callback) {
process.nextTick(
callback,
"As of July 2020, Mixer is no longer in service."
);
/* BitChute */
bc: function (id, callback) {
BitChute.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "bc", video.meta);
callback(null, video);
}).catch(error => {
callback(error.message || error);
});
},
/* Odysee */
od: function (id, callback) {
Odysee.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "od", video.meta);
callback(null, video);
}).catch(error => {
callback(error.message || error);
});
},
/* BandCamp */
bn: function (id, callback) {
BandCamp.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "bn", video.meta);
callback(null, video);
}).catch(error => {
callback(error.message || error);
});
},
/* Niconico */
nv: function (id, callback) {
Nicovideo.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "nv", video.meta);
callback(null, video);
}).catch(error => {
callback(error.message || error);
});
}
};

View file

@ -7,7 +7,6 @@ const cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
import typecheck from 'json-typecheck';
import { isTorExit } from '../tor';
import session from '../session';
import counters from '../counters';
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
import Promise from 'bluebird';
const verifySession = Promise.promisify(session.verifySession);
@ -15,7 +14,6 @@ const getAliases = Promise.promisify(db.getAliases);
import { CachingGlobalBanlist } from './globalban';
import proxyaddr from 'proxy-addr';
import { Counter, Gauge } from 'prom-client';
import Socket from 'socket.io/lib/socket';
import { TokenBucket } from '../util/token-bucket';
import http from 'http';
@ -109,28 +107,6 @@ class IOServer {
next();
}
/*
TODO: see https://github.com/calzoneman/sync/issues/724
ipConnectionLimitMiddleware(socket, next) {
const ip = socket.context.ipAddress;
const count = this.ipCount.get(ip) || 0;
if (count >= Config.get('io.ip-connection-limit')) {
// TODO: better error message would be nice
next(new Error('Too many connections from your IP address'));
return;
}
this.ipCount.set(ip, count + 1);
console.log(ip, this.ipCount.get(ip));
socket.once('disconnect', () => {
console.log('Disconnect event has fired for', socket.id);
this.ipCount.set(ip, this.ipCount.get(ip) - 1);
});
next();
}
*/
checkIPLimit(socket) {
const ip = socket.context.ipAddress;
const count = this.ipCount.get(ip) || 0;
@ -223,12 +199,14 @@ class IOServer {
return;
}
patchTypecheckedFunctions(socket);
patchSocketMetrics(socket);
this.setRateLimiter(socket);
emitMetrics(socket);
LOGGER.info('Accepted socket from %s', socket.context.ipAddress);
counters.add('socket.io:accept', 1);
socket.once('disconnect', (reason, reasonDetail) => {
LOGGER.info(
'%s disconnected (%s%s)',
@ -236,7 +214,6 @@ class IOServer {
reason,
reasonDetail ? ` - ${reasonDetail}` : ''
);
counters.add('socket.io:disconnect', 1);
});
const user = new User(socket, socket.context.ipAddress, socket.context.user);
@ -271,14 +248,10 @@ class IOServer {
}
initSocketIO() {
patchSocketMetrics();
patchTypecheckedFunctions();
const io = this.io = sio.instance = sio();
io.use(this.ipProxyMiddleware.bind(this));
io.use(this.ipBanMiddleware.bind(this));
io.use(this.ipThrottleMiddleware.bind(this));
//io.use(this.ipConnectionLimitMiddleware.bind(this));
io.use(this.cookieParsingMiddleware.bind(this));
io.use(this.ipSessionCookieMiddleware.bind(this));
io.use(this.authUserMiddleware.bind(this));
@ -293,7 +266,7 @@ class IOServer {
const engineOpts = {
/*
* Set ping timeout to 2 minutes to avoid spurious reconnects
* during transient network issues. The default of 5 minutes
* during transient network issues. The default of 20 seconds
* is too aggressive.
*
* https://github.com/calzoneman/sync/issues/780
@ -312,11 +285,17 @@ class IOServer {
perMessageDeflate: false,
httpCompression: false,
maxHttpBufferSize: 1 << 20,
/*
* Default is 10MB.
* Even 1MiB seems like a generous limit...
* Enable legacy support for socket.io v2 clients (e.g., bots)
*/
maxHttpBufferSize: 1 << 20
allowEIO3: true,
cors: {
origin: getCorsAllowCallback(),
credentials: true // enable cookies for auth
}
};
servers.forEach(server => {
@ -333,26 +312,25 @@ const outgoingPacketCount = new Counter({
name: 'cytube_socketio_outgoing_packets_total',
help: 'Number of outgoing socket.io packets to clients'
});
function patchSocketMetrics() {
const onevent = Socket.prototype.onevent;
const packet = Socket.prototype.packet;
function patchSocketMetrics(sock) {
const emit = require('events').EventEmitter.prototype.emit;
Socket.prototype.onevent = function patchedOnevent() {
onevent.apply(this, arguments);
sock.onAny(() => {
incomingEventCount.inc(1);
emit.call(this, 'cytube:count-event');
};
emit.call(sock, 'cytube:count-event');
});
Socket.prototype.packet = function patchedPacket() {
let packet = sock.packet;
sock.packet = function patchedPacket() {
packet.apply(this, arguments);
outgoingPacketCount.inc(1);
};
}.bind(sock);
}
/* TODO: remove this crap */
function patchTypecheckedFunctions() {
Socket.prototype.typecheckedOn = function typecheckedOn(msg, template, cb) {
/* Addendum 2021-08-14: socket.io v4 supports middleware, maybe move type validation to that */
function patchTypecheckedFunctions(sock) {
sock.typecheckedOn = function typecheckedOn(msg, template, cb) {
this.on(msg, (data, ack) => {
typecheck(data, template, (err, data) => {
if (err) {
@ -364,9 +342,9 @@ function patchTypecheckedFunctions() {
}
});
});
};
}.bind(sock);
Socket.prototype.typecheckedOnce = function typecheckedOnce(msg, template, cb) {
sock.typecheckedOnce = function typecheckedOnce(msg, template, cb) {
this.once(msg, data => {
typecheck(data, template, (err, data) => {
if (err) {
@ -378,7 +356,7 @@ function patchTypecheckedFunctions() {
}
});
});
};
}.bind(sock);
}
let globalIPBanlist = null;
@ -412,16 +390,17 @@ const promSocketReconnect = new Counter({
function emitMetrics(sock) {
try {
let closed = false;
let transportName = sock.client.conn.transport.name;
let transportName = sock.conn.transport.name;
promSocketCount.inc({ transport: transportName });
promSocketAccept.inc(1);
sock.client.conn.on('upgrade', newTransport => {
sock.conn.on('upgrade', () => {
try {
let newTransport = sock.conn.transport.name;
// Sanity check
if (!closed && newTransport.name !== transportName) {
if (!closed && newTransport !== transportName) {
promSocketCount.dec({ transport: transportName });
transportName = newTransport.name;
transportName = newTransport;
promSocketCount.inc({ transport: transportName });
}
} catch (error) {
@ -529,3 +508,30 @@ setInterval(function () {
LOGGER.info('Cleaned up %d stale IP throttle token buckets', cleaned);
}
}, 5 * 60 * 1000);
function getCorsAllowCallback() {
let origins = Array.prototype.slice.call(Config.get('io.cors.allowed-origins'));
origins = origins.concat([
Config.get('io.domain'),
Config.get('https.domain')
]);
return function corsOriginAllowed(origin, callback) {
if (!origin) {
// Non-browser clients might not care about Origin, allow these.
callback(null, true);
return;
}
// Different ports are technically cross-origin; a distinction that does not matter to CyTube.
origin = origin.replace(/:\d+$/, '');
if (origins.includes(origin)) {
callback(null, true);
} else {
LOGGER.warn('Rejecting origin "%s"; allowed origins are %j', origin, origins);
callback(new Error('Invalid origin'));
}
};
}

View file

@ -2,6 +2,7 @@ import Config from './config';
import * as Switches from './switches';
import { eventlog } from './logger';
require('source-map-support').install();
import * as bannedChannels from './cli/banned-channels';
const LOGGER = require('@calzoneman/jsli')('main');
@ -28,10 +29,39 @@ if (!Config.get('debug')) {
});
}
async function handleCliCmd(cmd) {
try {
switch (cmd.command) {
case 'ban-channel':
return bannedChannels.handleBanChannel(cmd);
case 'unban-channel':
return bannedChannels.handleUnbanChannel(cmd);
case 'show-banned-channel':
return bannedChannels.handleShowBannedChannel(cmd);
default:
throw new Error(`Unrecognized command "${cmd.command}"`);
}
} catch (error) {
return { status: 'error', error: String(error) };
}
}
// TODO: this can probably just be part of servsock.js
// servsock should also be refactored to send replies instead of
// relying solely on tailing logs
function handleLine(line) {
function handleLine(line, client) {
try {
let cmd = JSON.parse(line);
handleCliCmd(cmd).then(res => {
client.write(JSON.stringify(res) + '\n');
}).catch(error => {
LOGGER.error(`Unexpected error in handleCliCmd: ${error.stack}`);
client.write('{"status":"error","error":"internal error"}\n');
});
} catch (_error) {
// eslint no-empty: off
}
if (line === '/reload') {
LOGGER.info('Reloading config');
try {
@ -81,9 +111,9 @@ if (Config.get('service-socket.enabled')) {
const ServiceSocket = require('./servsock');
const sock = new ServiceSocket();
sock.init(
line => {
(line, client) => {
try {
handleLine(line);
handleLine(line, client);
} catch (error) {
LOGGER.error(
'Error in UNIX socket command handler: %s',

View file

@ -39,7 +39,7 @@ Media.prototype = {
embed: this.meta.embed,
gdrive_subtitles: this.meta.gdrive_subtitles,
textTracks: this.meta.textTracks,
mixer: this.meta.mixer
audioTracks: this.meta.audioTracks
}
};
@ -54,6 +54,11 @@ Media.prototype = {
result.meta.direct = this.meta.direct;
}
// Only save thumbnails for items which can be audio track only
if (['bn','cm'].includes(this.type)) {
result.meta.thumbnail = this.meta.thumbnail;
}
return result;
},

View file

@ -1,73 +0,0 @@
import fs from 'fs';
/** MetricsReporter that records metrics as JSON objects in a file, one per line */
class JSONFileMetricsReporter {
/**
* Create a new JSONFileMetricsReporter that writes to the given file path.
*
* @param {string} filename file path to write to
*/
constructor(filename) {
this.writeStream = fs.createWriteStream(filename, { flags: 'a' });
this.metrics = {};
this.timers = {};
}
/**
* @see {@link module:cytube-common/metrics/metrics.incCounter}
*/
incCounter(counter, value) {
if (!this.metrics.hasOwnProperty(counter)) {
this.metrics[counter] = 0;
}
this.metrics[counter] += value;
}
/**
* Add a time metric
*
* @param {string} timer name of the timer
* @param {number} ms milliseconds to record
*/
addTime(timer, ms) {
if (!this.timers.hasOwnProperty(timer)) {
this.timers[timer] = {
totalTime: 0,
count: 0,
p100: 0
};
}
this.timers[timer].totalTime += ms;
this.timers[timer].count++;
if (ms > this.timers[timer].p100) {
this.timers[timer].p100 = ms;
}
}
/**
* @see {@link module:cytube-common/metrics/metrics.addProperty}
*/
addProperty(property, value) {
this.metrics[property] = value;
}
report() {
for (const timer in this.timers) {
this.metrics[timer+':avg'] = this.timers[timer].totalTime / this.timers[timer].count;
this.metrics[timer+':count'] = this.timers[timer].count;
this.metrics[timer+':p100'] = this.timers[timer].p100;
}
const line = JSON.stringify(this.metrics) + '\n';
try {
this.writeStream.write(line);
} finally {
this.metrics = {};
this.timers = {};
}
}
}
export { JSONFileMetricsReporter };

View file

@ -1,136 +0,0 @@
import os from 'os';
/** @module cytube-common/metrics/metrics */
const MEM_RSS = 'memory:rss';
const LOAD_1MIN = 'load:1min';
const TIMESTAMP = 'time';
const logger = require('@calzoneman/jsli')('metrics');
var delegate = null;
var reportInterval = null;
var reportHooks = [];
let warnedNoReporter = false;
function warnNoReporter() {
if (!warnedNoReporter) {
warnedNoReporter = true;
logger.warn('No metrics reporter configured. Metrics will not be recorded.');
}
}
/**
* Increment a metrics counter by the specified amount.
*
* @param {string} counter name of the counter to increment
* @param {number} value optional value to increment by (default 1)
*/
export function incCounter(counter, amount = 1) {
if (delegate === null) {
warnNoReporter();
} else {
delegate.incCounter(counter, amount);
}
}
/**
* Start a timer. Returns a handle to use to end the timer.
*
* @param {string} timer name
* @return {object} timer handle
*/
export function startTimer(timer) {
return {
timer: timer,
hrtime: process.hrtime()
};
}
/**
* Stop a timer and record the time (as an average)
*
* @param {object} handle timer handle to Stop
*/
export function stopTimer(handle) {
if (delegate === null) {
warnNoReporter();
return;
}
const [seconds, ns] = process.hrtime(handle.hrtime);
delegate.addTime(handle.timer, seconds*1e3 + ns/1e6);
}
/**
* Add a property to the current metrics period.
*
* @param {string} property property name to add
* @param {any} property value
*/
export function addProperty(property, value) {
if (delegate === null) {
warnNoReporter();
} else {
delegate.addProperty(property, value);
}
}
/**
* Set the metrics reporter to record to.
*
* @param {MetricsReporter} reporter reporter to record metrics to
*/
export function setReporter(reporter) {
delegate = reporter;
}
/**
* Set the interval at which to report metrics.
*
* @param {number} interval time in milliseconds between successive reports
*/
export function setReportInterval(interval) {
clearInterval(reportInterval);
if (!isNaN(interval) && interval >= 0) {
reportInterval = setInterval(reportLoop, interval);
}
}
/**
* Add a callback to add additional metrics before reporting.
*
* @param {function(metricsReporter)} hook callback to be invoked before reporting
*/
export function addReportHook(hook) {
reportHooks.push(hook);
}
export function clearReportHooks() {
reportHooks = [];
}
/**
* Force metrics to be reported right now.
*/
export function flush() {
reportLoop();
}
function addDefaults() {
addProperty(MEM_RSS, process.memoryUsage().rss / 1048576);
addProperty(LOAD_1MIN, os.loadavg()[0]);
addProperty(TIMESTAMP, new Date());
}
function reportLoop() {
if (delegate !== null) {
try {
addDefaults();
reportHooks.forEach(hook => {
hook(delegate);
});
delegate.report();
} catch (error) {
logger.error(error.stack);
}
}
}

29
src/peertubelist.js Normal file
View file

@ -0,0 +1,29 @@
import { fetchPeertubeDomains, setDomains } from '@cytube/mediaquery/lib/provider/peertube';
import { stat, readFile, writeFile } from 'node:fs/promises';
import path from 'path';
const LOGGER = require('@calzoneman/jsli')('peertubelist');
const ONE_DAY = 24 * 3600 * 1000;
const FILENAME = path.join(__dirname, '..', 'peertube-hosts.json');
export async function setupPeertubeDomains() {
try {
let mtime;
try {
mtime = (await stat(FILENAME)).mtime;
} catch (_error) {
mtime = 0;
}
if (Date.now() - mtime > ONE_DAY) {
LOGGER.info('Updating peertube host list');
const hosts = await fetchPeertubeDomains();
await writeFile(FILENAME, JSON.stringify(hosts));
}
const hosts = JSON.parse(await readFile(FILENAME));
setDomains(hosts);
} catch (error) {
LOGGER.error('Failed to initialize peertube host list: %s', error.stack);
}
}

View file

@ -1,58 +1,103 @@
const link = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
var XSS = require("./xss");
const XSS = require('./xss');
var Poll = function(initiator, title, options, obscured) {
this.initiator = initiator;
title = XSS.sanitizeText(title);
this.title = title.replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
this.options = options;
for (let i = 0; i < this.options.length; i++) {
this.options[i] = XSS.sanitizeText(this.options[i]);
this.options[i] = this.options[i].replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
function sanitizedWithLinksReplaced(text) {
return XSS.sanitizeText(text)
.replace(link, '<a href="$1" target="_blank" rel="noopener noreferer">$1</a>');
}
class Poll {
static create(createdBy, title, choices, options = { hideVotes: false, retainVotes: false }) {
let poll = new Poll();
poll.createdAt = new Date();
poll.createdBy = createdBy;
poll.title = sanitizedWithLinksReplaced(title);
poll.choices = choices.map(choice => sanitizedWithLinksReplaced(choice));
poll.hideVotes = options.hideVotes;
poll.retainVotes = options.retainVotes;
poll.votes = new Map();
return poll;
}
this.obscured = obscured || false;
this.counts = new Array(options.length);
for(let i = 0; i < this.counts.length; i++) {
this.counts[i] = 0;
}
this.votes = {};
this.timestamp = Date.now();
};
Poll.prototype.vote = function(ip, option) {
if(!(ip in this.votes) || this.votes[ip] == null) {
this.votes[ip] = option;
this.counts[option]++;
return true;
static fromChannelData({ initiator, title, options, _counts, votes, timestamp, obscured, retainVotes }) {
let poll = new Poll();
if (timestamp === undefined) // Very old polls still in the database lack timestamps
timestamp = Date.now();
poll.createdAt = new Date(timestamp);
poll.createdBy = initiator;
poll.title = title;
poll.choices = options;
poll.votes = new Map();
Object.keys(votes).forEach(key => {
if (votes[key] !== null)
poll.votes.set(key, votes[key]);
});
poll.hideVotes = obscured;
poll.retainVotes = retainVotes || false;
return poll;
}
return false;
};
Poll.prototype.unvote = function(ip) {
if(ip in this.votes && this.votes[ip] != null) {
this.counts[this.votes[ip]]--;
this.votes[ip] = null;
toChannelData() {
let counts = new Array(this.choices.length);
counts.fill(0);
// TODO: it would be desirable one day to move away from using an Object here.
// This is just for backwards-compatibility with the existing format.
let votes = {};
this.votes.forEach((index, key) => {
votes[key] = index;
counts[index]++;
});
return {
title: this.title,
initiator: this.createdBy,
options: this.choices,
counts,
votes,
obscured: this.hideVotes,
retainVotes: this.retainVotes,
timestamp: this.createdAt.getTime()
};
}
};
Poll.prototype.packUpdate = function (showhidden) {
var counts = Array.prototype.slice.call(this.counts);
if (this.obscured) {
for(var i = 0; i < counts.length; i++) {
if (!showhidden)
counts[i] = "";
counts[i] += "?";
countVote(key, choiceId) {
if (choiceId < 0 || choiceId >= this.choices.length)
return false;
let changed = !this.votes.has(key) || this.votes.get(key) !== choiceId;
this.votes.set(key, choiceId);
return changed;
}
uncountVote(key) {
let changed = this.votes.has(key);
this.votes.delete(key);
return changed;
}
toUpdateFrame(showHiddenVotes) {
let counts = new Array(this.choices.length);
counts.fill(0);
this.votes.forEach(index => counts[index]++);
if (this.hideVotes) {
counts = counts.map(c => {
if (showHiddenVotes) return `${c}?`;
else return '?';
});
}
return {
title: this.title,
options: this.choices,
counts: counts,
initiator: this.createdBy,
timestamp: this.createdAt.getTime()
};
}
var packed = {
title: this.title,
options: this.options,
counts: counts,
initiator: this.initiator,
timestamp: this.timestamp
};
return packed;
};
}
exports.Poll = Poll;

View file

@ -48,6 +48,7 @@ import { PartitionModule } from './partition/partitionmodule';
import { Gauge } from 'prom-client';
import { EmailController } from './controller/email';
import { CaptchaController } from './controller/captcha';
import { BannedChannelsController } from './controller/banned-channels';
var Server = function () {
var self = this;
@ -71,6 +72,7 @@ var Server = function () {
const globalMessageBus = this.initModule.getGlobalMessageBus();
globalMessageBus.on('UserProfileChanged', this.handleUserProfileChange.bind(this));
globalMessageBus.on('ChannelDeleted', this.handleChannelDelete.bind(this));
globalMessageBus.on('ChannelBanned', this.handleChannelBanned.bind(this));
globalMessageBus.on('ChannelRegistered', this.handleChannelRegister.bind(this));
// database init ------------------------------------------------------
@ -108,6 +110,11 @@ var Server = function () {
Config.getCaptchaConfig()
);
self.bannedChannelsController = new BannedChannelsController(
self.db.channels,
globalMessageBus
);
// webserver init -----------------------------------------------------
const ioConfig = IOConfiguration.fromOldConfig(Config);
const webConfig = WebConfiguration.fromOldConfig(Config);
@ -134,7 +141,8 @@ var Server = function () {
Config.getEmailConfig(),
emailController,
Config.getCaptchaConfig(),
captchaController
captchaController,
self.bannedChannelsController
);
// http/https/sio server init -----------------------------------------
@ -204,6 +212,8 @@ var Server = function () {
// background tasks init ----------------------------------------------
require("./bgtask")(self);
require("./peertubelist").setupPeertubeDomains().then(() => {});
// prometheus server
const prometheusConfig = Config.getPrometheusConfig();
if (prometheusConfig.isEnabled()) {
@ -547,6 +557,34 @@ Server.prototype.handleChannelDelete = function (event) {
}
};
Server.prototype.handleChannelBanned = function (event) {
try {
const lname = event.channel.toLowerCase();
const reason = event.externalReason;
this.channels.forEach(channel => {
if (channel.dead) return;
if (channel.uniqueName === lname) {
channel.clearFlag(Flags.C_REGISTERED);
const users = Array.prototype.slice.call(channel.users);
users.forEach(u => {
u.kick(`Channel was banned: ${reason}`);
});
if (!channel.dead && !channel.dying) {
channel.emit('empty');
}
LOGGER.info('Processed banned channel %s', lname);
}
});
} catch (error) {
LOGGER.error('handleChannelBanned failed: %s', error);
}
};
Server.prototype.handleChannelRegister = function (event) {
try {
const lname = event.channel.toLowerCase();

View file

@ -34,7 +34,7 @@ export default class ServiceSocket {
delete this.connections[id];
});
stream.on('data', (msg) => {
this.handler(msg.toString());
this.handler(msg.toString(), stream);
});
}).listen(this.socket);
process.on('exit', this.closeServiceSocket.bind(this));

View file

@ -76,10 +76,6 @@ User.prototype.handleJoinChannel = function handleJoinChannel(data) {
}
data.name = data.name.toLowerCase();
if (data.name in Config.get("channel-blacklist")) {
this.kick("This channel is blacklisted.");
return;
}
this.waitFlag(Flags.U_READY, () => {
var chan;
@ -102,10 +98,6 @@ User.prototype.handleJoinChannel = function handleJoinChannel(data) {
if (!chan.is(Flags.C_READY)) {
chan.once("loadFail", reason => {
this.socket.emit("errorMsg", {
msg: reason,
alert: true
});
this.kick(`Channel could not be loaded: ${reason}`);
});
}

45
src/util/simple-cache.js Normal file
View file

@ -0,0 +1,45 @@
class SimpleCache {
constructor({ maxElem, maxAge }) {
this.maxElem = maxElem;
this.maxAge = maxAge;
this.cache = new Map();
setInterval(() => {
this.cleanup();
}, maxAge).unref();
}
put(key, value) {
this.cache.set(key, { value: value, at: Date.now() });
if (this.cache.size > this.maxElem) {
this.cache.delete(this.cache.keys().next().value);
}
}
get(key) {
let val = this.cache.get(key);
if (val != null && Date.now() < val.at + this.maxAge) {
return val.value;
} else {
return null;
}
}
delete(key) {
this.cache.delete(key);
}
cleanup() {
let now = Date.now();
for (let [key, value] of this.cache) {
if (value.at < now - this.maxAge) {
this.cache.delete(key);
}
}
}
}
export { SimpleCache };

View file

@ -177,7 +177,7 @@
};
},
root.formatLink = function (id, type, meta) {
root.formatLink = function (id, type, _meta) {
switch (type) {
case "yt":
return "https://youtu.be/" + id;
@ -193,16 +193,10 @@
return "https://twitch.tv/" + id;
case "rt":
return id;
case "im":
return "https://imgur.com/a/" + id;
case "us":
return "https://ustream.tv/channel/" + id;
case "gd":
return "https://docs.google.com/file/d/" + id;
case "fi":
return id;
case "hb":
return "https://www.smashcast.tv/" + id;
case "hl":
return id;
case "sb":
@ -211,12 +205,22 @@
return "https://clips.twitch.tv/" + id;
case "cm":
return id;
case "mx":
if (meta !== null) {
return `https://mixer.com/${meta.mixer.channelToken}`;
} else {
return `https://mixer.com/${id}`;
}
case "pt": {
const [domain,uuid] = id.split(';');
return `https://${domain}/videos/watch/${uuid}`;
}
case "bc":
return `https://www.bitchute.com/video/${id}/`;
case "bn": {
const [artist,track] = id.split(';');
return `https://${artist}.bandcamp.com/track/${track}`;
}
case "od": {
const [user,video] = id.split(';');
return `https://odysee.com/@${user}/${video}`;
}
case "nv":
return `https://www.nicovideo.jp/watch/${id}`;
default:
return "";
}
@ -226,13 +230,9 @@
switch (type) {
case "li":
case "tw":
case "us":
case "rt":
case "cu":
case "im":
case "hb":
case "hl":
case "mx":
return true;
default:
return false;

View file

@ -265,6 +265,8 @@ async function handleNewChannel(req, res) {
});
}
let banInfo = await db.channels.getBannedChannel(name);
db.channels.listUserChannels(user.name, function (err, channels) {
if (err) {
sendPug(res, "account-channels", {
@ -274,6 +276,14 @@ async function handleNewChannel(req, res) {
return;
}
if (banInfo !== null) {
sendPug(res, "account-channels", {
channels: channels,
newChannelError: `Cannot register "${name}": this channel is banned.`
});
return;
}
if (name.match(Config.get("reserved-names.channels"))) {
sendPug(res, "account-channels", {
channels: channels,
@ -631,7 +641,43 @@ function handlePasswordReset(req, res) {
/**
* Handles a request for /account/passwordrecover/<hash>
*/
function handlePasswordRecover(req, res) {
function handleGetPasswordRecover(req, res) {
var hash = req.params.hash;
if (typeof hash !== "string") {
res.send(400);
return;
}
db.lookupPasswordReset(hash, function (err, row) {
if (err) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: err
});
return;
}
if (Date.now() >= row.expire) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: "This password recovery link has expired. Password " +
"recovery links are valid only for 24 hours after " +
"submission."
});
return;
}
sendPug(res, "account-passwordrecover", {
confirm: true,
recovered: false
});
});
}
/**
* Handles a POST request for /account/passwordrecover/<hash>
*/
function handlePostPasswordRecover(req, res) {
var hash = req.params.hash;
if (typeof hash !== "string") {
res.send(400);
@ -703,7 +749,8 @@ module.exports = {
app.post("/account/profile", handleAccountProfile);
app.get("/account/passwordreset", handlePasswordResetPage);
app.post("/account/passwordreset", handlePasswordReset);
app.get("/account/passwordrecover/:hash", handlePasswordRecover);
app.get("/account/passwordrecover/:hash", handleGetPasswordRecover);
app.post("/account/passwordrecover/:hash", handlePostPasswordRecover);
app.get("/account", function (req, res) {
res.redirect("/login");
});

View file

@ -1,16 +1,25 @@
import CyTubeUtil from '../../utilities';
import Config from '../../config';
import { sanitizeText } from '../../xss';
import { sendPug } from '../pug';
import * as HTTPStatus from '../httpstatus';
import { HTTPError } from '../../errors';
export default function initialize(app, ioConfig, chanPath) {
app.get(`/${chanPath}/:channel`, (req, res) => {
export default function initialize(app, ioConfig, chanPath, getBannedChannel) {
app.get(`/${chanPath}/:channel`, async (req, res) => {
if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) {
throw new HTTPError(`"${sanitizeText(req.params.channel)}" is not a valid ` +
'channel name.', { status: HTTPStatus.NOT_FOUND });
}
let banInfo = await getBannedChannel(req.params.channel);
if (banInfo !== null) {
sendPug(res, 'banned_channel', {
externalReason: banInfo.externalReason
});
return;
}
const endpoints = ioConfig.getSocketEndpoints();
if (endpoints.length === 0) {
throw new HTTPError('No socket.io endpoints configured');
@ -19,7 +28,8 @@ export default function initialize(app, ioConfig, chanPath) {
sendPug(res, 'channel', {
channelName: req.params.channel,
sioSource: `${socketBaseURL}/socket.io/socket.io.js`
sioSource: `${socketBaseURL}/socket.io/socket.io.js`,
maxMsgLen: Config.get("max-chat-message-length")
});
});
}

7
src/web/routes/iframe.js Normal file
View file

@ -0,0 +1,7 @@
import { sendPug } from '../pug';
export default function initialize(app) {
app.get('/iframe', (req, res) => {
return sendPug(res, 'iframe');
});
}

View file

@ -9,7 +9,6 @@ import morgan from 'morgan';
import csrf from './csrf';
import * as HTTPStatus from './httpstatus';
import { CSRFError, HTTPError } from '../errors';
import counters from '../counters';
import { Summary, Counter } from 'prom-client';
import session from '../session';
const verifySessionAsync = require('bluebird').promisify(session.verifySession);
@ -144,16 +143,13 @@ module.exports = {
emailConfig,
emailController,
captchaConfig,
captchaController
captchaController,
bannedChannelsController
) {
patchExpressToHandleAsync();
const chanPath = Config.get('channel-path');
initPrometheus(app);
app.use((req, res, next) => {
counters.add("http:request", 1);
next();
});
require('./middleware/x-forwarded-for').initialize(app, webConfig);
app.use(bodyParser.urlencoded({
extended: false,
@ -199,7 +195,12 @@ module.exports = {
LOGGER.info('Enabled express-minify for CSS and JS');
}
require('./routes/channel')(app, ioConfig, chanPath);
require('./routes/channel')(
app,
ioConfig,
chanPath,
async name => bannedChannelsController.getBannedChannel(name)
);
require('./routes/index')(app, channelIndex, webConfig.getMaxIndexEntries());
require('./routes/socketconfig')(app, clusterClient);
require('./routes/contact')(app, webConfig);
@ -217,6 +218,7 @@ module.exports = {
require('./acp').init(app, ioConfig);
require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app);
require('./routes/iframe')(app);
app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), {
maxAge: webConfig.getCacheTTL()

View file

@ -24,7 +24,7 @@ block content
tbody
for c in channels
tr
th
td
form.form-inline.pull-right(action="/account/channels", method="post", onsubmit="return confirm('Are you sure you want to delete " +c.name+ "? This cannot be undone');")
input(type="hidden", name="_csrf", value=csrfToken)
input(type="hidden", name="action", value="delete_channel")
@ -32,6 +32,9 @@ block content
button.btn.btn-xs.btn-danger(type="submit") Delete
span.glyphicon.glyphicon-trash
a(href=`/${channelPath}/${c.name}`, style="margin-left: 5px")= c.name
if c.banReason != null
| &nbsp;
span.label.label-danger Banned
.col-lg-6.col-md-6
h3 Register a new channel
if newChannelError

View file

@ -7,6 +7,9 @@ block content
.alert.alert-success.center.messagebox
strong Your password has been changed
p Your account has been assigned the temporary password <code>#{recoverPw}</code>. You may now use this password to log in and choose a new password by visiting the <a href="/account/edit">change password/email</a> page.
else if confirm
form(role="form", method="POST")
button.btn.btn-primary.btn-block(type="submit") Click here to reset password
else
.alert.alert-danger.center.messagebox
strong Password recovery failed

View file

@ -0,0 +1,9 @@
extends layout.pug
block content
.col-md-12
.alert.alert-danger
h1 Banned Channel
strong This channel is banned:
p
= externalReason

View file

@ -46,7 +46,7 @@ html(lang="en")
#userlist
#messagebuffer.linewrap
form(action="javascript:void(0)")
input#chatline.form-control(type="text", maxlength="320", style="display: none")
input#chatline.form-control(type="text", maxlength=maxMsgLen, style="display: none")
#guestlogin.input-group
span.input-group-addon Guest login
input#guestname.form-control(type="text", placeholder="Name")
@ -63,10 +63,10 @@ html(lang="en")
button#emotelistbtn.btn.btn-sm.btn-default Emote List
#rightcontrols.col-lg-7.col-md-7
#plcontrol.btn-group
button#showsearch.btn.btn-sm.btn-default(title="Search for a video", data-toggle="collapse", data-target="#searchcontrol")
span.glyphicon.glyphicon-search
button#showmediaurl.btn.btn-sm.btn-default(title="Add video from URL", data-toggle="collapse", data-target="#addfromurl")
span.glyphicon.glyphicon-plus
button#showsearch.btn.btn-sm.btn-default(title="Search for a video", data-toggle="collapse", data-target="#searchcontrol")
span.glyphicon.glyphicon-search
button#showcustomembed.btn.btn-sm.btn-default(title="Embed a custom frame", data-toggle="collapse", data-target="#customembed")
span.glyphicon.glyphicon-th-large
button#showplaylistmanager.btn.btn-sm.btn-default(title="Manage playlists", data-toggle="collapse", data-target="#playlistmanager")
@ -249,14 +249,18 @@ html(lang="en")
script(src="/js/paginator.js")
script(src="/js/ui.js")
script(src="/js/callbacks.js")
script(defer, src="/js/vjs/dash.all.min.js")
script(defer, src="/js/vjs/video.js")
script(defer, src="/js/vjs/videojs-dash.js")
script(defer, src="/js/vjs/videojs-hlsjs-plugin.js")
script(defer, src="/js/vjs/videojs-resolution-switcher.js")
script(defer, src="/js/vjs/videojs-audio-switcher.js")
script(defer, src="/js/octopus/subtitles-octopus.js")
script(defer, src="/js/playerjs-0.0.12.js")
script(defer, src="/js/niconico.js")
script(defer, src="/js/peertube.js")
script(defer, src="/js/sc.js")
script(defer, src="https://www.youtube.com/iframe_api")
script(defer, src="https://api.dmcdn.net/all.js")
script(defer, src="https://player.vimeo.com/api/player.js")
script(defer, src="/js/sc.js")
script(defer, src="/js/video.js")
script(defer, src="/js/videojs-contrib-hls.min.js")
script(defer, src="/js/videojs-resolution-switcher.js")
script(defer, src="/js/playerjs-0.0.12.js")
script(defer, src="/js/dash.all.min.js")
script(defer, src="/js/videojs-dash.js")
script(defer, src="https://player.twitch.tv/js/embed/v1.js")

View file

@ -3,7 +3,7 @@ mixin footer
.container
p.text-muted.credit.
Powered by CyTube, available on <a href="https://github.com/calzoneman/sync" target="_blank" rel="noreferrer noopener">GitHub</a>&nbsp;&middot; <a href="/contact" target="_blank">Contact</a>&nbsp;&middot; <a href="https://github.com/calzoneman/sync/wiki" target="_blank" rel="noopener noreferrer">Wiki</a>
script(src="/js/jquery-1.11.0.min.js")
script(src="/js/jquery-1.12.4.min.js")
// Must be included before jQuery-UI since jQuery-UI overrides jQuery.fn.button
// I should really abandon this crap one day
script(src="/js/jquery-ui.js")

View file

@ -3,18 +3,21 @@ extends layout.pug
block content
.col-md-8.col-md-offset-2
h1 Google Drive Userscript
h2 Why?
p.
Since Google Drive support was launched in early 2014, it has broken
at least 4-5 times, requiring increasing effort to get it working again
and disrupting many channels. This is because there is no official API
for it like there is for YouTube videos, which means support for it
relies on undocumented tricks. In August 2016, the decision was made
to phase out the native support for Google Drive and instead require
users to install a userscript, which allows to bypass certain browser
restrictions and make the code easier, simpler, and less prone to failure
(it could still break due to future Google Drive changes, but is less
likely to be difficult to fix).
h2 Disclaimer
.alert.alert-danger.messagebox
strong Unsupported
p.
This functionality is provided <strong>as-is</strong> for backwards
compatibility for existing users for whom it already is known to work.
There are many reasons, known and unknown, for which it may
<strong>not</strong> work for you; please note the staff in CyTube
support channels cannot provide any troubleshooting assistance and you
will be asked to simply use a different video provider.
p.
This functionality was originally added so that users could share their
own personal videos stored in their Drive. No support whatsoever will
be provided to users attempting to use it to circumvent copyright
restrictions on third-party video hosts.
h2 How It Works
p.
The userscript is a short script that you can install using a browser

36
templates/iframe.pug Normal file
View file

@ -0,0 +1,36 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(name="referrer", content="same-origin")
link(rel="stylesheet", href="/css/video-js.css")
link(rel="stylesheet", href="/css/videojs-resolution-switcher.css")
style.
body { overflow-y: hidden }
body
#wrap
#videowrap
#ytapiplayer
script.
const USEROPTS = {
default_quality: 'auto'
}
const CLIENT = {
leader: false
}
let VOLUME = 0;
function waitUntilDefined(obj, key, fn) {
if(typeof obj[key] === "undefined") {
setTimeout(function () {
waitUntilDefined(obj, key, fn);
}, 100);
return;
}
fn();
}
script(src="/js/jquery-1.12.4.min.js")
script(src="/js/vjs/video.js")
script(src="/js/vjs/videojs-resolution-switcher.js")
script(src="/js/playerjs-0.0.12.js")
script(src="/js/player.js")

View file

@ -21,8 +21,15 @@ block content
append footer
script(type="text/javascript").
$("#channelname").keydown(function (ev) {
const entrance = document.querySelector('#channelname');
entrance.addEventListener('keydown', function (ev) {
if (ev.keyCode === 13) {
location.href = "/#{channelPath}/" + $("#channelname").val();
const channel = `/${CHANNELPATH}/${entrance.value}`;
if (ev.shiftKey || ev.ctrlKey) {
window.open(channel, '_blank');
entrance.value = '';
} else {
location.href = channel;
}
}
});

View file

@ -68,11 +68,6 @@ mixin us-playback
form.form-horizontal(action="javascript:void(0)")
+rcheckbox("us-synch", "Synchronize video playback")
+textbox("us-synch-accuracy", "Synch threshold (seconds)", "2")
+rcheckbox("us-wmode-transparent", "Set wmode=transparent")
.form-group
.col-sm-4
.col-sm-8
p.text-info Setting <code>wmode=transparent</code> allows objects to be displayed above the video player, but may cause performance issues on some systems.
+rcheckbox("us-hidevideo", "Remove the video player")
+rcheckbox("us-playlistbuttons", "Hide playlist buttons by default")
+rcheckbox("us-oldbtns", "Old style playlist buttons")
@ -91,6 +86,7 @@ mixin us-playback
.col-sm-4
.col-sm-8
p.text-info Due to technical changes on YouTube's side, the CyTube quality preference can no longer be automatically applied on YouTube videos. See <a href="https://github.com/calzoneman/sync/issues/726" rel="noopener noreferer" target="_blank">this GitHub issue</a> for details.
+rcheckbox("us-peertube", "Accept PeerTube embeds automatically")
mixin us-chat
#us-chat.tab-pane

View file

@ -97,7 +97,10 @@ describe('PollModule', () => {
}
}
};
let pollModule = new PollModule(fakeChannel);
let pollModule;
beforeEach(() => {
pollModule = new PollModule(fakeChannel);
});
it('creates a valid poll', () => {
let sentNewPoll = false;
@ -122,10 +125,54 @@ describe('PollModule', () => {
}, (ackResult) => {
assert(!ackResult.error, `Unexpected error: ${ackResult.error}`);
});
assert(sentClosePoll, 'Expected broadcast of closePoll event');
assert(!sentClosePoll, 'Unexpected broadcast of closePoll event');
assert(sentNewPoll, 'Expected broadcast of newPoll event');
});
it('closes an existing poll when a new one is created', () => {
let sentNewPoll = 0;
let sentClosePoll = 0;
let sentUpdatePoll = 0;
fakeChannel.broadcastToRoom = (event, data, room) => {
if (room === 'testChannel:viewHidden' && event === 'newPoll') {
sentNewPoll++;
}
};
fakeChannel.broadcastAll = (event, data) => {
if (event === 'closePoll') {
sentClosePoll++;
} else if (event === 'updatePoll') {
sentUpdatePoll++;
assert.deepStrictEqual(data.counts, [0, 0]);
}
};
pollModule.handleNewPoll(fakeUser, {
title: 'test poll',
opts: [
'option 1',
'option 2'
],
obscured: true
}, (ackResult) => {
assert(!ackResult.error, `Unexpected error: ${ackResult.error}`);
});
pollModule.handleNewPoll(fakeUser, {
title: 'poll 2',
opts: [
'option 3',
'option 4'
],
obscured: false
}, (ackResult) => {
assert(!ackResult.error, `Unexpected error: ${ackResult.error}`);
});
assert.strictEqual(sentClosePoll, 1, 'Expected 1 broadcast of closePoll event');
assert.strictEqual(sentUpdatePoll, 1, 'Expected 1 broadcast of updatePoll event');
assert.strictEqual(sentNewPoll, 2, 'Expected 2 broadcasts of newPoll event');
});
it('rejects an invalid poll', () => {
fakeChannel.broadcastToRoom = (event, data, room) => {
assert(false, 'Expected no events to be sent');
@ -171,4 +218,4 @@ describe('PollModule', () => {
assert(sentErrorMsg, 'Expected to send errorMsg since ack was missing');
});
})
});
});

View file

@ -77,7 +77,9 @@ describe('VoteskipModule', () => {
};
voteskipModule.poll = {
counts: [1]
toUpdateFrame() {
return { counts: [1] };
}
};
voteskipModule.update();
assert.equal(voteskipModule.poll, false, 'Expected voteskip poll to be reset to false');
@ -93,11 +95,30 @@ describe('VoteskipModule', () => {
sentMessage = true;
};
voteskipModule.poll = {
counts: [1]
toUpdateFrame() {
return { counts: [1] };
}
};
voteskipModule.update();
assert(sentMessage, 'Expected voteskip passed message');
});
it('requires at least one vote to pass', () => {
let sentMessage = false;
fakeChannel.broadcastAll = (frame, data) => {
assert.strictEqual(frame, 'chatMsg');
assert(/voteskip passed/i.test(data.msg), 'Expected voteskip passed message')
sentMessage = true;
};
fakeUser.is = flag => (flag == Flags.U_AFK);
voteskipModule.poll = {
toUpdateFrame() {
return { counts: [0] };
}
};
voteskipModule.update();
assert(!sentMessage, 'Expected voteskip not to pass');
});
});
describe('#calcUsercounts', () => {

View file

@ -90,15 +90,6 @@ describe('custom-media', () => {
assert.throws(() => validate(invalid), /URL protocol must be HTTPS/);
});
it('rejects non-live DASH', () => {
invalid.live = false;
invalid.sources[0].contentType = 'application/dash+xml';
assert.throws(
() => validate(invalid),
/contentType "application\/dash\+xml" requires live: true/
);
});
});
describe('#validateSources', () => {
@ -242,7 +233,8 @@ describe('custom-media', () => {
contentType: 'text/vtt',
name: 'English Subtitles'
}
]
],
thumbnail: 'https://example.com/thumb.jpg',
}
};
});
@ -330,7 +322,8 @@ describe('custom-media', () => {
contentType: 'text/vtt',
name: 'English Subtitles'
}
]
],
thumbnail: 'https://example.com/thumb.jpg',
}
};

262
test/poll.js Normal file
View file

@ -0,0 +1,262 @@
const assert = require('assert');
const { Poll } = require('../lib/poll');
describe('Poll', () => {
describe('constructor', () => {
it('constructs a poll', () => {
let poll = Poll.create(
'pollster',
'Which is better?',
[
'Coke',
'Pepsi'
]
/* default opts */
);
assert.strictEqual(poll.createdBy, 'pollster');
assert.strictEqual(poll.title, 'Which is better?');
assert.deepStrictEqual(poll.choices, ['Coke', 'Pepsi']);
assert.strictEqual(poll.hideVotes, false);
});
it('constructs a poll with hidden vote setting', () => {
let poll = Poll.create(
'pollster',
'Which is better?',
[
'Coke',
'Pepsi'
],
{ hideVotes: true }
);
assert.strictEqual(poll.hideVotes, true);
});
it('sanitizes title and choices', () => {
let poll = Poll.create(
'pollster',
'Which is better? <script></script>',
[
'<strong>Coke</strong>',
'Pepsi'
]
/* default opts */
);
assert.strictEqual(poll.title, 'Which is better? &lt;script&gt;&lt;/script&gt;');
assert.deepStrictEqual(poll.choices, ['&lt;strong&gt;Coke&lt;/strong&gt;', 'Pepsi']);
});
it('replaces URLs in title and choices', () => {
let poll = Poll.create(
'pollster',
'Which is better? https://example.com',
[
'Coke https://example.com',
'Pepsi'
]
/* default opts */
);
assert.strictEqual(
poll.title,
'Which is better? <a href="https://example.com" target="_blank" rel="noopener noreferer">https://example.com</a>'
);
assert.deepStrictEqual(
poll.choices,
[
'Coke <a href="https://example.com" target="_blank" rel="noopener noreferer">https://example.com</a>',
'Pepsi'
]
);
});
});
describe('#countVote', () => {
let poll;
beforeEach(() => {
poll = Poll.create(
'pollster',
'Which is better?',
[
'Coke',
'Pepsi'
]
/* default opts */
);
});
it('counts a new vote', () => {
assert.strictEqual(poll.countVote('userA', 0), true);
assert.strictEqual(poll.countVote('userB', 1), true);
assert.strictEqual(poll.countVote('userC', 0), true);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [2, 1]);
});
it('does not count a revote for the same choice', () => {
assert.strictEqual(poll.countVote('userA', 0), true);
assert.strictEqual(poll.countVote('userA', 0), false);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [1, 0]);
});
it('changes a vote to a different choice', () => {
assert.strictEqual(poll.countVote('userA', 0), true);
assert.strictEqual(poll.countVote('userA', 1), true);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [0, 1]);
});
it('ignores out of range votes', () => {
assert.strictEqual(poll.countVote('userA', 1000), false);
assert.strictEqual(poll.countVote('userA', -10), false);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [0, 0]);
});
});
describe('#uncountVote', () => {
let poll;
beforeEach(() => {
poll = Poll.create(
'pollster',
'Which is better?',
[
'Coke',
'Pepsi'
]
/* default opts */
);
});
it('uncounts an existing vote', () => {
assert.strictEqual(poll.countVote('userA', 0), true);
assert.strictEqual(poll.uncountVote('userA', 0), true);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [0, 0]);
});
it('does not uncount if there is no existing vote', () => {
assert.strictEqual(poll.uncountVote('userA', 0), false);
let { counts } = poll.toUpdateFrame();
assert.deepStrictEqual(counts, [0, 0]);
});
});
describe('#toUpdateFrame', () => {
let poll;
beforeEach(() => {
poll = Poll.create(
'pollster',
'Which is better?',
[
'Coke',
'Pepsi'
]
/* default opts */
);
poll.countVote('userA', 0);
poll.countVote('userB', 1);
poll.countVote('userC', 0);
});
it('generates an update frame', () => {
assert.deepStrictEqual(
poll.toUpdateFrame(),
{
title: 'Which is better?',
options: ['Coke', 'Pepsi'],
counts: [2, 1],
initiator: 'pollster',
timestamp: poll.createdAt.getTime()
}
);
});
it('hides votes when poll is hidden', () => {
poll.hideVotes = true;
assert.deepStrictEqual(
poll.toUpdateFrame(),
{
title: 'Which is better?',
options: ['Coke', 'Pepsi'],
counts: ['?', '?'],
initiator: 'pollster',
timestamp: poll.createdAt.getTime()
}
);
});
it('displays hidden votes when requested', () => {
poll.hideVotes = true;
assert.deepStrictEqual(
poll.toUpdateFrame(true),
{
title: 'Which is better?',
options: ['Coke', 'Pepsi'],
counts: ['2?', '1?'],
initiator: 'pollster',
timestamp: poll.createdAt.getTime()
}
);
});
});
describe('#toChannelData/fromChannelData', () => {
it('round trips a poll', () => {
let data = {
title: '&lt;strong&gt;ready?&lt;/strong&gt;',
initiator: 'aUser',
options: ['yes', 'no'],
counts: [0, 1],
votes:{
'1.2.3.4': null, // Previous poll code would set removed votes to null
'5.6.7.8': 1
},
obscured: false,
timestamp: 1483414981110
};
let poll = Poll.fromChannelData(data);
// New code does not store null votes
data.votes = { '5.6.7.8': 1 };
data.retainVotes = false;
assert.deepStrictEqual(poll.toChannelData(), data);
});
it('coerces a missing timestamp to the current time', () => {
let data = {
title: '&lt;strong&gt;ready?&lt;/strong&gt;',
initiator: 'aUser',
options: ['yes', 'no'],
counts: [0, 1],
votes:{
'1.2.3.4': null,
'5.6.7.8': 1
},
obscured: false
};
let now = Date.now();
let poll = Poll.fromChannelData(data);
const { timestamp } = poll.toChannelData();
if (typeof timestamp !== 'number' || isNaN(timestamp))
assert.fail(`Unexpected timestamp: ${timestamp}`);
if (Math.abs(timestamp - now) > 1000)
assert.fail(`Unexpected timestamp: ${timestamp}`);
});
});
});

52
test/util/simple-cache.js Normal file
View file

@ -0,0 +1,52 @@
const { SimpleCache } = require('../../lib/util/simple-cache');
const assert = require('assert');
describe('SimpleCache', () => {
const CACHE_MAX_ELEM = 5;
const CACHE_MAX_AGE = 5;
let cache;
beforeEach(() => {
cache = new SimpleCache({
maxElem: CACHE_MAX_ELEM,
maxAge: CACHE_MAX_AGE
});
});
it('sets, gets, and deletes a value', () => {
assert.strictEqual(cache.get('foo'), null);
cache.put('foo', 'bar');
assert.strictEqual(cache.get('foo'), 'bar');
cache.delete('foo');
assert.strictEqual(cache.get('foo'), null);
});
it('does not return an expired value', done => {
cache.put('foo', 'bar');
setTimeout(() => {
assert.strictEqual(cache.get('foo'), null);
done();
}, CACHE_MAX_AGE + 1);
});
it('cleans up old values', done => {
cache.put('foo', 'bar');
setTimeout(() => {
assert.strictEqual(cache.get('foo'), null);
done();
}, CACHE_MAX_AGE * 2);
});
it('removes the oldest entry if max elem is reached', () => {
for (let i = 0; i < CACHE_MAX_ELEM + 1; i++) {
cache.put(`foo${i}`, 'bar');
}
assert.strictEqual(cache.get('foo0'), null);
assert.strictEqual(cache.get('foo1'), 'bar');
});
});

59
www/.eslintrc.json Normal file
View file

@ -0,0 +1,59 @@
{
"env": { "browser": true, "jquery": true },
"globals": {
"CHANNEL": "writable",
"CHANNELNAME": "writable",
"CHATHIST": "writable",
"CHATHISTIDX": "writable",
"CHATSOUND": "writable",
"CHATTHROTTLE": "writable",
"CLIENT": "writable",
"CSEMOTELIST": "writable",
"DEFAULT_THEME": "writable",
"EMOTELIST": "writable",
"EMOTELISTMODAL": "writable",
"FILTER_FROM": "writable",
"FILTER_TO": "writable",
"FOCUSED": "writable",
"GS_VERSION": "writable",
"HAS_CONNECTED_BEFORE": "writable",
"IGNORE_SCROLL_EVENT": "writable",
"IGNORED": "writable",
"IMAGE_MATCH": "writable",
"JSPREF": "writable",
"KICKED": "writable",
"LASTCHAT": "writable",
"LEADTMR": "writable",
"PAGETITLE": "writable",
"PL_ACTION_QUEUE": "writable",
"PL_AFTER": "writable",
"PL_CURRENT": "writable",
"PL_FROM": "writable",
"PL_QUEUED_ACTIONS": "writable",
"PL_WAIT_SCROLL": "writable",
"PLAYER": "writable",
"REBUILDING": "writable",
"SCROLLCHAT": "writable",
"SOCKETIO_CONNECT_ERROR_COUNT": "writable",
"SUPERADMIN": "writable",
"TITLE_BLINK": "writable",
"USEROPTS": "writable",
"VHEIGHT": "writable",
"VOLUME": "writable",
"VWIDTH": "writable",
"CyTube": "writable",
"Rank": "writable",
"getOpt": "writable",
"setOpt": "writable",
"socket": "writable"
},
"plugins": [
"no-jquery"
],
"extends": [
"plugin:no-jquery/deprecated"
],
"rules": {
"no-jquery/no-event-shorthand": "error"
}
}

Binary file not shown.

View file

@ -28,6 +28,11 @@
padding-right: 5px;
}
#usercount {
white-space: nowrap;
flex-grow: 2;
}
#userlist {
width: 120px;
float: left;
@ -90,6 +95,12 @@
font-weight: bold;
}
#chatheader {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#chatheader > p, #videowrap-header {
margin: 0;
}
@ -582,7 +593,7 @@ table td {
}
#userlisttoggle {
padding-top: 2px;
padding-bottom: 2px;
}
.queue_entry {
@ -651,6 +662,7 @@ input#logout[type="submit"]:hover {
}
#newmessages-indicator {
position: relative;
margin-top: -30px;
line-height: 30px;
height: 30px;

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
.vjs-resolution-button .vjs-menu-icon:before {
.vjs-resolution-button .vjs-icon-placeholder:before {
content: '\f110';
font-family: VideoJS;
font-weight: normal;

View file

@ -2,7 +2,8 @@
var chosenServer = IO_SERVERS[0]; // Is the array even necessary for the ACP?
var opts = {
secure: chosenServer.secure
secure: chosenServer.secure,
withCredentials: true // needed for sio cookie to work
};
window.socket = io.connect(chosenServer.url, opts);

View file

@ -1,4 +1,4 @@
Callbacks = {
const Callbacks = {
/* fired when socket connection completes */
connect: function() {
HAS_CONNECTED_BEFORE = true;
@ -82,7 +82,7 @@ Callbacks = {
var announcement = makeAlert(data.title, data.text + signature)
.appendTo($("#announcements"));
if (data.id) {
announcement.find(".close").click(function suppressThisAnnouncement() {
announcement.find(".close").on('click', function suppressThisAnnouncement() {
CyTube.ui.suppressedAnnouncementId = data.id;
setOpt("suppressed_announcement_id", data.id);
});
@ -179,7 +179,7 @@ Callbacks = {
$("<button/>").addClass("close pull-right")
.appendTo(div)
.click(function () {
.on('click', function () {
div.parent().remove();
})
.html("&times;");
@ -444,7 +444,7 @@ Callbacks = {
var li = $("<li/>").appendTo(menu);
$("<a/>").attr("href", "javascript:void(0)")
.html(disp)
.click(function() {
.on('click', function() {
socket.emit("borrow-rank", r);
})
.appendTo(li);
@ -658,8 +658,7 @@ Callbacks = {
}
$("#drinkcount").text(text);
$("#drinkbar").show();
}
else {
} else {
$("#drinkbar").hide();
}
},
@ -752,8 +751,7 @@ Callbacks = {
if(data.temp) {
btn.html(btn.html().replace("Make Temporary",
"Make Permanent"));
}
else {
} else {
btn.html(btn.html().replace("Make Permanent",
"Make Temporary"));
}
@ -867,8 +865,7 @@ Callbacks = {
$("#qlockbtn").find("span")
.removeClass("glyphicon-lock")
.addClass("glyphicon-ok");
}
else {
} else {
$("#qlockbtn").removeClass("btn-success")
.addClass("btn-danger")
.attr("title", "Playlist Locked");
@ -886,7 +883,7 @@ Callbacks = {
.css("margin-left", "0")
.attr("id", "search_clear")
.text("Clear Results")
.click(function() {
.on('click', function() {
clearSearchResults();
})
.insertBefore($("#library"));
@ -927,12 +924,12 @@ Callbacks = {
var poll = $("<div/>").addClass("well active").prependTo($("#pollwrap"));
$("<button/>").addClass("close pull-right").html("&times;")
.appendTo(poll)
.click(function() { poll.remove(); });
.on('click', function() { poll.remove(); });
if(hasPermission("pollctl")) {
$("<button/>").addClass("btn btn-danger btn-sm pull-right").text("End Poll")
.appendTo(poll)
.click(function() {
socket.emit("closePoll")
.on('click', function() {
socket.emit("closePoll");
});
}
@ -944,14 +941,16 @@ Callbacks = {
option: i
});
poll.find(".option button").each(function() {
$(this).attr("disabled", "disabled");
$(this).removeClass("active");
$(this).parent().removeClass("option-selected");
});
$(this).addClass("active");
$(this).parent().addClass("option-selected");
}
};
$("<button/>").addClass("btn btn-default btn-sm").text(data.counts[i])
.prependTo($("<div/>").addClass("option").html(data.options[i])
.appendTo(poll))
.click(callback);
.on('click', callback);
})(i);
}
@ -979,7 +978,7 @@ Callbacks = {
$(this).attr("disabled", true);
});
poll.find(".btn-danger").each(function() {
$(this).remove()
$(this).remove();
});
}
},
@ -998,14 +997,14 @@ Callbacks = {
updateEmote: function (data) {
data.regex = new RegExp(data.source, "gi");
var found = false;
for (var i = 0; i < CHANNEL.emotes.length; i++) {
for (let i = 0; i < CHANNEL.emotes.length; i++) {
if (CHANNEL.emotes[i].name === data.name) {
found = true;
CHANNEL.emotes[i] = data;
break;
}
}
for (var i = 0; i < CHANNEL.badEmotes.length; i++) {
for (let i = 0; i < CHANNEL.badEmotes.length; i++) {
if (CHANNEL.badEmotes[i].name === data.name) {
CHANNEL.badEmotes[i] = data;
break;
@ -1048,22 +1047,20 @@ Callbacks = {
if(!badBefore){
CHANNEL.badEmotes.push(data);
delete CHANNEL.emoteMap[oldName];
}
// Was bad before too: Update
else {
for (var i = 0; i < CHANNEL.badEmotes.length; i++) {
} else {
for (let i = 0; i < CHANNEL.badEmotes.length; i++) {
if (CHANNEL.badEmotes[i].name === oldName) {
CHANNEL.badEmotes[i] = data;
break;
}
}
}
}
// Not bad now
else {
} else {
// But was bad before: Drop from list
if(badBefore){
for (var i = 0; i < CHANNEL.badEmotes.length; i++) {
for (let i = 0; i < CHANNEL.badEmotes.length; i++) {
if (CHANNEL.badEmotes[i].name === oldName) {
CHANNEL.badEmotes.splice(i, 1);
break;
@ -1081,7 +1078,7 @@ Callbacks = {
removeEmote: function (data) {
var found = -1;
for (var i = 0; i < CHANNEL.emotes.length; i++) {
for (let i = 0; i < CHANNEL.emotes.length; i++) {
if (CHANNEL.emotes[i].name === data.name) {
found = i;
break;
@ -1091,9 +1088,9 @@ Callbacks = {
if (found !== -1) {
var row = $("code:contains('" + data.name + "')").parent().parent();
row.hide("fade", row.remove.bind(row));
CHANNEL.emotes.splice(i, 1);
CHANNEL.emotes.splice(found, 1);
delete CHANNEL.emoteMap[data.name];
for (var i = 0; i < CHANNEL.badEmotes.length; i++) {
for (let i = 0; i < CHANNEL.badEmotes.length; i++) {
if (CHANNEL.badEmotes[i].name === data.name) {
CHANNEL.badEmotes.splice(i, 1);
break;
@ -1169,20 +1166,31 @@ Callbacks = {
$("#voteskip").attr("disabled", false);
}
}
}
};
var SOCKET_DEBUG = localStorage.getItem('cytube_socket_debug') === 'true';
setupCallbacks = function() {
window.Callbacks = Callbacks;
// For sanity, do this
// localStorage.setItem('cytube_socket_omissions', '["mediaUpdate"]')
var SOCKET_DEBUG = {
enabled: (localStorage.getItem('cytube_socket_debug') === 'true'),
omit: (((data)=>{
const frames = data === null ? [] : JSON.parse(data);
return frames;
})(localStorage.getItem('cytube_socket_omissions')))
};
function setupCallbacks() {
for(var key in Callbacks) {
(function(key) {
socket.on(key, function(data) {
if (SOCKET_DEBUG) {
if (SOCKET_DEBUG.enabled && !SOCKET_DEBUG.omit.includes(key)) {
console.log(key, data);
}
try {
Callbacks[key](data);
} catch (e) {
if (SOCKET_DEBUG) {
if (SOCKET_DEBUG.enabled) {
console.log("EXCEPTION: " + e + "\n" + e.stack);
}
}
@ -1209,7 +1217,7 @@ setupCallbacks = function() {
.appendTo($("#announcements"));
}
});
};
}
function ioServerConnect(socketConfig) {
if (socketConfig.error) {
@ -1249,7 +1257,8 @@ function ioServerConnect(socketConfig) {
}
var opts = {
secure: chosenServer.secure
secure: chosenServer.secure,
withCredentials: true // enable cookies for auth
};
window.socket = io(chosenServer.url, opts);
@ -1290,7 +1299,7 @@ function initSocketIO(socketConfig) {
function checkLetsEncrypt(socketConfig, nonLetsEncryptError) {
var servers = socketConfig.servers.filter(function (server) {
return !server.secure && !server.ipv6Only
return !server.secure && !server.ipv6Only;
});
if (servers.length === 0) {

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,55 @@
/*eslint no-unused-vars: "off"*/
(function() {
/**
* Test whether the browser supports nullish-coalescing operator.
*
* Users with old browsers will probably fail to load the client correctly
* because parsing this operator in older browsers results in a SyntaxError
* that aborts compilation of the entire script (not just an exception where
* it is used). In particular, as of 2023-01-28, Utherverse ships with
* a rather old browser version (Chrome 76) and several users have reported
* it not working.
*/
try {
try {
new Function('x?.y');
} catch (e) {
if (e.name === 'SyntaxError') {
/**
* If we're at this point, we can't be sure what scripts have
* actually loaded, so construct the error alert the old
* fashioned way.
*/
var wrap = document.createElement('div');
wrap.className = 'col-md-12';
var al = document.createElement('div');
al.className = 'alert alert-danger';
var title = document.createElement('strong');
title.textContent = 'Unsupported Browser';
var msg = document.createElement('p');
msg.textContent = 'It looks like your browser does not support ' +
'the required JavaScript features to run ' +
'CyTube. This is usually caused by ' +
'using an outdated browser version. Please '+
'check if an update is available. Your ' +
'browser version is reported as:';
var version = document.createElement('tt');
version.textContent = navigator.userAgent;
wrap.appendChild(al);
al.appendChild(title);
al.appendChild(msg);
al.appendChild(document.createElement('br'));
al.appendChild(version);
document.getElementById('motdrow').appendChild(wrap);
}
}
} catch (e) {
console.error('Error probing for feature support:', e.stack);
}
})();
var CL_VERSION = 3.0;
var GS_VERSION = 1.7; // Google Drive Userscript
@ -48,7 +100,8 @@ var CHATMAXSIZE = 100;
var SCROLLCHAT = true;
var IGNORE_SCROLL_EVENT = false;
var LASTCHAT = {
name: ""
name: "",
time: 0
};
var FOCUSED = true;
var PAGETITLE = "CyTube";
@ -112,33 +165,37 @@ function getOrDefault(k, def) {
var IGNORED = getOrDefault("ignorelist", []);
var USEROPTS = {
// General tab
theme : getOrDefault("theme", DEFAULT_THEME), // Set in head template
layout : getOrDefault("layout", "fluid"),
synch : getOrDefault("synch", true),
hidevid : getOrDefault("hidevid", false),
show_timestamps : getOrDefault("show_timestamps", true),
modhat : getOrDefault("modhat", false),
blink_title : getOrDefault("blink_title", "onlyping"),
sync_accuracy : getOrDefault("sync_accuracy", 2),
wmode_transparent : getOrDefault("wmode_transparent", true),
chatbtn : getOrDefault("chatbtn", false),
altsocket : getOrDefault("altsocket", false),
qbtn_hide : getOrDefault("qbtn_hide", false),
qbtn_idontlikechange : getOrDefault("qbtn_idontlikechange", false),
first_visit : getOrDefault("first_visit", true),
ignore_channelcss : getOrDefault("ignore_channelcss", false),
ignore_channeljs : getOrDefault("ignore_channeljs", false),
// Playback tab
synch : getOrDefault("synch", true),
sync_accuracy : getOrDefault("sync_accuracy", 2),
hidevid : getOrDefault("hidevid", false),
default_quality : getOrDefault("default_quality", "auto"),
qbtn_hide : getOrDefault("qbtn_hide", false),
qbtn_idontlikechange : getOrDefault("qbtn_idontlikechange", false),
peertube_risk : getOrDefault("peertube_risk", false),
// Chat tab
show_timestamps : getOrDefault("show_timestamps", true),
sort_rank : getOrDefault("sort_rank", true),
sort_afk : getOrDefault("sort_afk", false),
default_quality : getOrDefault("default_quality", "auto"),
blink_title : getOrDefault("blink_title", "onlyping"),
boop : getOrDefault("boop", "never"),
show_shadowchat : getOrDefault("show_shadowchat", false),
emotelist_sort : getOrDefault("emotelist_sort", true),
notifications : getOrDefault("notifications", "never"),
chatbtn : getOrDefault("chatbtn", false),
no_emotes : getOrDefault("no_emotes", false),
strip_image : getOrDefault("strip_image", false),
chat_tab_method : getOrDefault("chat_tab_method", "Cycle options"),
notifications : getOrDefault("notifications", "never"),
show_ip_in_tooltip : getOrDefault("show_ip_in_tooltip", true)
// Moderator tab
modhat : getOrDefault("modhat", false),
show_shadowchat : getOrDefault("show_shadowchat", false),
show_ip_in_tooltip : getOrDefault("show_ip_in_tooltip", true),
// Elsewhere
first_visit : getOrDefault("first_visit", true),
emotelist_sort : getOrDefault("emotelist_sort", true),
};
/* Backwards compatibility check */
@ -179,7 +236,6 @@ if (["never", "onlyping", "always"].indexOf(USEROPTS.boop) === -1) {
var VOLUME = parseFloat(getOrDefault("volume", 1));
var NO_WEBSOCKETS = USEROPTS.altsocket;
var NO_VIMEO = Boolean(location.host.match("cytu.be"));
var JSPREF = getOpt("channel_js_pref") || {};

File diff suppressed because one or more lines are too long

5
www/js/jquery-1.12.4.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22523
www/js/jquery-ui.js vendored

File diff suppressed because it is too large Load diff

237
www/js/niconico.js Normal file
View file

@ -0,0 +1,237 @@
/*
* Niconico iframe embed api
* Written by Xaekai
* Copyright (c) 2022 Radiant Feather; Licensed AGPLv3
*
* Dual-licensed MIT when distributed with CyTube/sync.
*
*/
class NicovideoEmbed {
static origin = 'https://embed.nicovideo.jp';
static methods = [
'loadComplete',
'mute',
'pause',
'play',
'seek',
'volumeChange',
];
static frames = [
'error',
'loadComplete',
'playerMetadataChange',
'playerStatusChange',
'seekStatusChange',
'statusChange',
//'player-error:video:play',
//'player-error:video:seek',
];
static events = [
'ended',
'error',
'muted',
'pause',
'play',
'progress',
'ready',
'timeupdate',
'unmuted',
'volumechange',
];
constructor(options) {
this.handlers = Object.fromEntries(NicovideoEmbed.frames.map(key => [key,[]]));
this.listeners = Object.fromEntries(NicovideoEmbed.events.map(key => [key,[]]));
this.state = ({
ready: false,
playerStatus: 1,
currentTime: 0.0, // ms
muted: false,
volume: 0.99,
maximumBuffered: 0,
});
this.setupHandlers();
this.scaffold(options);
}
scaffold({ iframe = null, playerId = 1, videoId = null }){
this.playerId = playerId;
this.messageListener();
if(iframe === null){
if(videoId === null){
throw new Error('You must provide either an existing iframe or a videoId');
}
const iframe = this.iframe = document.createElement('iframe');
const source = new URL(`${NicovideoEmbed.origin}/watch/${videoId}`);
source.search = new URLSearchParams({
jsapi: 1,
autoplay: 1,
playerId
});
iframe.setAttribute('src', source);
iframe.setAttribute('id', playerId);
iframe.setAttribute('allow', 'autoplay; fullscreen');
iframe.addEventListener('load', ()=>{
this.observe();
})
} else {
this.iframe = iframe;
this.observe();
}
}
setupHandlers() {
this.handlers.loadComplete.push((data) => {
this.emit('ready');
this.state.ready = true;
Object.assign(this, data);
});
this.handlers.error.push((data) => {
this.emit('error', data);
});
this.handlers.playerStatusChange.push((data) => {
let event;
switch (data.playerStatus) {
case 1: /* Buffering */ return;
case 2: event = 'play'; break;
case 3: event = 'pause'; break;
case 4: event = 'ended'; break;
}
this.state.playerStatus = data.playerStatus;
this.emit(event);
});
this.handlers.playerMetadataChange.push(({ currentTime, volume, muted, maximumBuffered }) => {
const self = this.state;
if (currentTime !== self.currentTime) {
self.currentTime = currentTime;
this.emit('timeupdate', currentTime);
}
if (muted !== self.muted) {
self.muted = muted;
this.emit(muted ? 'muted' : 'unmuted');
}
if (volume !== self.volume) {
self.volume = volume;
this.emit('volumechange', volume);
}
if (maximumBuffered !== self.maximumBuffered) {
self.maximumBuffered = maximumBuffered;
this.emit('progress', maximumBuffered);
}
});
this.handlers.seekStatusChange.push((data) => {
//
});
this.handlers.statusChange.push((data) => {
//
});
}
messageListener() {
const dispatcher = (event) => {
if (event.origin === NicovideoEmbed.origin && event.data.playerId === this.playerId) {
const { data } = event.data;
this.dispatch(event.data.eventName, data);
}
}
window.addEventListener('message', dispatcher);
/* Clean up */
this.observer = new MutationObserver((alterations) => {
alterations.forEach((change) => {
change.removedNodes.forEach((deletion) => {
if(deletion.nodeName === 'IFRAME') {
window.removeEventListener('message', dispatcher)
this.observer.disconnect();
}
});
});
});
}
observe(){
this.state.receptive = true;
this.observer.observe(this.iframe.parentElement, { subtree: true, childList: true });
}
dispatch(frame, data = null){
if(!NicovideoEmbed.frames.includes(frame)){
console.error(JSON.stringify(data, undefined, 4));
throw new Error(`NicovideoEmbed ${frame}`);
}
[...this.handlers[frame]].forEach(handler => {
handler.call(this, data);
});
}
emit(event, data = null){
[...this.listeners[event]].forEach(listener => {
listener.call(this, data);
});
if(event === 'ready'){
this.listeners.ready.length = 0;
}
}
postMessage(request) {
if(!this.state.receptive){
setTimeout(() => { this.postMessage(request) }, 1000 / 24);
return;
}
const message = Object.assign({
sourceConnectorType: 1,
playerId: this.playerId
}, request);
this.iframe.contentWindow.postMessage(message, NicovideoEmbed.origin);
}
on(event, listener){
if(!NicovideoEmbed.events.includes(event)){
throw new Error('Unrecognized event name');
}
if(event === 'ready'){
if(this.state.ready){
listener();
return this;
} else {
setTimeout(() => { this.loadComplete() }, 1000 / 60);
}
}
this.listeners[event].push(listener);
return this;
}
mute(state){
this.postMessage({ eventName: 'mute', data: { mute: state } });
}
pause(){
this.postMessage({ eventName: 'pause' });
}
play(){
this.postMessage({ eventName: 'play' });
}
loadComplete(){
this.postMessage({ eventName: 'loadComplete' });
}
seek(ms){
this.postMessage({ eventName: 'seek', data: { time: ms } });
}
volumeChange(volume){
this.postMessage({ eventName: 'volumeChange', data: { volume } });
}
}
window.NicovideoEmbed = NicovideoEmbed;

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

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