Compare commits

..

35 commits

Author SHA1 Message Date
calzoneman 7ab1202617 Fix google drive links with \xkk in them 2014-02-15 17:32:22 -06:00
calzoneman 0abc71c8e9 Fix vimeo and hopefully make it more fault tolerant 2014-02-04 16:04:22 -06:00
calzoneman 2558a87e96 Fix vimeo 2014-01-27 18:04:04 -06:00
calzoneman f7932166b2 A few small fixes 2014-01-20 11:59:53 -06:00
calzoneman ed12dd76fd Fix chrome being dumb w.r.t. deleting <video> elements 2014-01-11 12:59:03 -06:00
calzoneman 22a6a4617c Fix race condition 2014-01-08 16:44:31 -06:00
Calvin Montgomery 97fd06f7a6 Merge pull request #323 from calzoneman/vimeoisadouchecopter
Workaround for Vimeo refusing to unblock my domain
2014-01-08 14:31:45 -08:00
calzoneman 00dd092fef A few minor fixes to the vimeo workaround 2014-01-08 16:26:52 -06:00
calzoneman 73235e5fbb Fix some volume sync issues and another small issue 2014-01-07 19:30:30 -06:00
calzoneman 1efcca6be9 Add option to use JWPlayer for h.264 2014-01-07 19:24:03 -06:00
calzoneman c6d214b981 Use native HTML5 controls; remove pile of shit known as mediaelementjs 2014-01-07 17:14:16 -06:00
calzoneman 090ae06b69 Fix a few bugs with the mediaelement player 2014-01-07 13:51:58 -06:00
calzoneman d14ee4f0db Add mediaelement player 2014-01-07 11:32:48 -06:00
calzoneman eaf01f4d8a Vimeo workaround 2014-01-07 00:46:30 -06:00
calzoneman 6d6bf69828 Fix custom embed titles 2014-01-05 16:39:12 -06:00
calzoneman 4131b227e9 Fix some volume normalization issues 2014-01-04 19:30:39 -06:00
calzoneman 234b15831e Fix a potential error issue with get-info's domain 2014-01-02 22:38:12 -05:00
calzoneman 8b383e0bdb Merge branch 'master' of github.com:calzoneman/sync 2014-01-02 18:39:51 -05:00
calzoneman 2c66de146e Increment version number 2014-01-02 18:39:36 -05:00
Calvin Montgomery 38f144f5f9 Merge pull request #322 from calzoneman/flatvolume
Synchronize volume across player types
2014-01-02 15:38:21 -08:00
calzoneman 22e1253422 Fix typo in previous commit 2014-01-02 18:34:40 -05:00
calzoneman 3460b0544c Fix corner case causing TypeError in userJoin
I believe this issue was caused by a single user in the channel being kicked for a duplicate login, thereby unloading the channel while the for loop was iterating, so this.users became undefined before the loop exited and thus there was a TypeError for reading this.users.length
2014-01-02 18:32:49 -05:00
calzoneman b243f8bc7e Fix soundcloud/soundcloud transition 2014-01-02 10:04:16 -05:00
calzoneman afe14c2128 Save volume in localStorage 2014-01-02 00:13:45 -05:00
Calvin Montgomery c136d566fe Fix some volume sync issues with youtube and soundcloud 2014-01-01 23:59:53 -05:00
calzoneman d7011029b8 Continue working on flat volumes 2013-12-31 11:09:30 -05:00
Calvin Montgomery 7ecbddfca1 Start working on flat volumes 2013-12-31 00:14:03 -05:00
calzoneman df16458f98 Start working on terms of service template 2013-12-19 23:07:47 -05:00
calzoneman 10de5df3c6 Minor bugfixes 2013-12-17 23:07:01 -05:00
Calvin Montgomery 31368cfe7f Merge pull request #320 from nuclearace/patch-2
fix allow_voteskip
2013-12-17 19:37:16 -08:00
nuclearace c0431eb3e7 fix allow_voteskip 2013-12-17 22:35:28 -05:00
calzoneman 57fe5eea4d Fix a typo 2013-12-15 18:55:15 -06:00
calzoneman 46baa5532f Modify /api/channels and /api/allchannels not to include videourl 2013-12-13 00:35:15 -06:00
calzoneman dd5c0e5b82 Merge branch 'master' of github.com:calzoneman/sync 2013-12-11 19:54:04 -06:00
calzoneman 5610b6f0b4 Fix stupid chrome behavior on index page 2013-12-11 19:53:53 -06:00
345 changed files with 42519 additions and 168081 deletions

View file

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

View file

@ -1,45 +0,0 @@
/* 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',
],
}

12
.gitignore vendored
View file

@ -1,6 +1,5 @@
*.swp
cfg.json
config.yaml
chandump
chanlogs
*.log
@ -8,14 +7,3 @@ node_modules
*.crt
*.cert
*.key
torlist
www/cache
google-drive-subtitles
lib/
integration-test-config.json
conf/*.toml
www/js/cytube-google-drive.user.js
www/js/cytube-google-drive.meta.js
www/js/player.js
tor-exit-list.json
*.patch

View file

@ -1,14 +0,0 @@
language: node_js
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- gcc-9
- g++-9
env:
- CXX="g++-9"
node_js:
- "15"
- "14"
- "12"

View file

@ -1,47 +0,0 @@
Please fill out the templates below to the best of your ability, based on whether your problem is with using the website or with running your own server.
## Website Problem ##
**Please confirm whether you've tried the following debugging steps:**
- [ ] Clearing cache and refreshing the page (On Firefox, press Ctrl+F5. On Chrome, press F12, then right click the refresh button and click "Empty Cache and Hard Reload")
- [ ] Disabling all browser extensions
- [ ] Using a clean channel with no customizations
### Description of the Problem ###
- What triggers the problem?
- What happens?
- What do you expect to happen instead?
### System Information ###
- **Operating System (Windows / Mac / Linux / Android / iOS):**
- **Web Browser (Firefox / Chrome / Other):**
- **Error Messages Displayed:**
- **Screenshot of JavaScript Console:**
_On Firefox, press `Ctrl+Shift+K` to open the JavaScript console. On Chrome, press `Ctrl+Shift+J`._
## Server Problem ##
_If your issue is related to using the website and not about running a server, you can remove this section._
**Please confirm whether you've tried the following debugging steps:**
- [ ] Run `npm run build-server` to regenerate `lib/` from `src/`
- [ ] Run `rm -rf node_modules && npm install` to get a fresh install of dependencies
- [ ] Restarted the server
### Description of the Problem ###
- What triggers the problem?
- What happens?
- What do you expect to happen instead?
### System Information ###
- **Operating System:**
- **Node Version:** _(run `node -v`)_
- **CyTube Version:** _(displayed at startup)_
- **Error Messages Displayed:**

View file

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

501
NEWS.md
View file

@ -1,501 +0,0 @@
2022-09-21
==========
**Upgrade intervention required**
This release adds a feature to ban channels, replacing the earlier (hastily
added) configuration-based `channel-blacklist`. If you have any entries in
`channel-blacklist` in your `config.yaml`, you will need to migrate them to the
new bans table by using a command after upgrading (the ACP web interface hasn't
been updated for this feature):
./bin/admin.js ban-channel <channel-name> <external-reason> <internal-reason>
The external reason will be displayed when users attempt to join the banned
channel, while the internal reason is only displayed when using the
`show-channel-ban` command.
You can later use `unban-channel` to remove a ban. The owner of the banned
channel can still delete it, but the banned state will persist, so the channel
cannot be re-registered later.
2022-08-28
==========
This release integrates Xaekai's added support for Bandcamp, BitChute, Odysee,
and Nicovideo playback support into the main repository. The updated support
for custom fonts and audio tracks in custom media manifests is also included,
but does not work out of the box -- it requires a separate channel script; this
may be addressed in the future.
2021-08-14
==========
CyTube has been upgraded to socket.io v4 (from v2).
**Breaking change:** Newer versions of socket.io require CORS to validate the
origin initiating the socket connection. CyTube allows the origins specified in
the `io.domain` and `https.domain` configuration keys by default, which should
work for many use cases, however, if you host your website on a different domain
than the socket connection, you will need to configure the allowed origins (see
config.template.yaml under `io.cors`).
CyTube enables the `allowEIO3` configuration in socket.io by default, which
means that existing clients and bots using socket.io-client v2 should continue
to work.
2021-08-12
==========
The legacy metrics recorder (`counters.log` file) has been removed. For over 4
years now, CyTube has integrated with [Prometheus](https://prometheus.io/),
which provides a superior way to monitor the application. Copy
`conf/example/prometheus.toml` to `conf/prometheus.toml` and edit it to
configure CyTube's Prometheus support.
2021-08-12
==========
Due to changes in Soundcloud's authorization scheme, support has been dropped
from core due to requiring each server owner to register an API key (which is
currently impossible as they have not accepted new API key registrations for
*years*).
If you happen to already have an API key registered, or if Soundcloud reopens
registration at some point in the future, feel free to reach out to me for
patches to reintroduce support for it.
2020-08-21
==========
Some of CyTube's dependencies depends on features in newer versions of node.js.
Accordingly, node 10 is no longer supported. Administrators are recommended to
use node 12 (the active LTS), or node 14 (the current version).
2020-06-22
==========
Twitch has [updated their embed
player](https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588),
which adds new requirements for embedding Twitch:
1. The origin website must be served over HTTPS
2. The origin website must be served over the default port (i.e., the hostname
cannot include a port; https://example.com:8443 won't work)
Additionally, third-party cookies must be enabled for whatever internal
subdomains Twitch is using.
CyTube now sets the parameters expected by Twitch, and displays an error message
if it detects (1) or (2) above are not met.
2020-02-15
==========
Old versions of CyTube defaulted to storing channel state in flatfiles located
in the `chandump` directory. The default was changed a while ago, and the
flatfile storage mechanism has now been removed.
Admins who have not already migrated their installation to the "database"
channel storage type can do so by following these instructions:
1. Run `git checkout e3a9915b454b32e49d3871c94c839899f809520a` to temporarily
switch to temporarily revert to the previous version of the code that
supports the "file" channel storage type
2. Run `npm run build-server` to build the old version
3. Run `node lib/channel-storage/migrator.js |& tee migration.log` to migrate
channel state from files to the database
4. Inspect the output of the migration tool for errors
5. Set `channel-storage`/`type` to `"database"` in `config.yaml` and start the
server. Load a channel to verify the migration worked as expected
6. Upgrade back to the latest version with `git checkout 3.0` and `npm run
build-server`
7. Remove the `channel-storage` block from `config.yaml` and remove the
`chandump` directory since it is no longer needed (you may wish to archive
it somewhere in case you later discover the migration didn't work as
expected).
If you encounter any errors during the process, please file an issue on GitHub
and attach the output of the migration tool (which if you use the above commands
will be written to `migration.log`).
2019-12-01
==========
In accordance with node v8 LTS becoming end-of-life on 2019-12-31, CyTube no
longer supports v8.
Please upgrade to v10 or v12 (active LTS); refer to
https://nodejs.org/en/about/releases/ for the node.js support timelines.
2018-12-07
==========
Users can now self-service request their account to be deleted, and it will be
automatically purged after 7 days. In order to send a notification email to
the user about the request, copy the [email
configuration](https://github.com/calzoneman/sync/blob/3.0/conf/example/email.toml#L43)
to `conf/email.toml` (the same file used for password reset emails).
2018-10-21
==========
The `sanitize-html` dependency has made a change that results in `"` no longer
being replaced by `&quot;` when not inside an HTML attribute value. This
potentially breaks any chat filters matching quotes as `&quot;` (on my
particular instance, this seems to be quite rare). These filters will need to
be updated in order to continue matching quotes.
2018-08-27
==========
Support for node.js 6.x has been dropped, in order to bump the babel preset to
generate more efficient code (8.x supports async-await and other ES6+ features
natively and is the current node.js LTS).
If you are unable to upgrade to node.js 8.x, you can revert the changes to
package.json in this commit, however, be warned that I no longer test on 6.x.
2018-06-03
==========
## Dependency upgrades
In order to support node.js 10, the `bcrypt` dependency has been upgraded to
version 2. `bcrypt` version 2 defaults to the `$2b$` algorithm, whereas version
1 defaults to the `$2a$` algorithm. Existing password hashes will continue to
be readable, however hashes created with version 2 will not be readable by
version 1. See https://github.com/kelektiv/node.bcrypt.js for details.
In addition, the optional dependency on `v8-profiler` has been removed, since
this is not compatible with newer versions of v8.
## Supported node.js versions
In accordance with the node.js release schedule, node.js 4.x, 5.x, 7.x, and 9.x
are end-of-life and are no longer maintained upstream. Accordingly, these
versions are no longer supported by CyTube.
Please upgrade to 8.x (LTS) or 10.x (current). 6.x is still supported, but is
in the "maintenance" phase upstream, and should be phased out.
2018-01-07
==========
**Build changes:** When the `babel` dependency was first added to transpile ES6
code to ES5, an interactive prompt was added to the `postinstall` script before
transpilation, in case the user had made local modifications to the files in
`lib` which previously would have been detected as a git conflict when pulling.
It has now been sufficiently long that this is no longer needed, so I've removed
it. As always, users wishing to make local modifications (or forks) should edit
the code in `src/` and run `npm run build-server` to regenerate `lib/`.
This commit also removes the bundled `www/js/player.js` file in favor of having
`postinstall` generate it from the sources in `player/`.
2017-12-24
==========
As of December 2017, Vid.me is no longer in service. Accordingly, Vid.me
support in CyTube has been deprecated.
2017-11-27
==========
The Google Drive userscript has been updated once again. Violentmonkey is
now explicitly supported. Google login redirects are caught and handled.
See directly below on how to regenerate the user script again.
2017-11-15
==========
The Google Drive userscript has been updated due to breaking changes in
Greasemonkey 4.0. Remember to generate the script by running:
$ npm run generate-userscript "Your Site Name" http://your-site.example.com/r/*
2017-11-05
==========
The latest commit introduces a referrer check in the account page handlers.
This is added as a short-term mitigation for a recent report that account
management functions (such as deleting channels) can be executed without the
user's consent if placed in channel JS.
Longer term options are being considered, such as moving account management to a
separate subdomain to take advantage of cross-origin checks in browsers, and
requiring the user to re-enter their password to demonstrate intent. As always,
I recommend admins take extreme caution when accepting channel JS.
2017-09-26
==========
**Breaking change:** the `nodemailer` dependency has been upgraded to version
4.x. I also took this opportunity to make some modifications to the email
configuration and move it out of `config.yaml` to `conf/email.toml`.
To upgrade:
* Run `npm upgrade` (or `rm -rf node_modules; npm install`)
* Copy `conf/example/email.toml` to `conf/email.toml`
* Edit `conf/email.toml` to your liking
* Remove the `mail:` block from `config.yaml`
This feature only supports sending via SMTP for now. If there is demand for
other transports, feel free to open an issue or submit a pull request.
2017-09-19
==========
The `/useragreement` default page has been removed. Server administrators can
substitute their own terms of service page by editing `templates/footer.pug`
2017-09-19
==========
This commit removes an old kludge that redirected users to HTTPS (when enabled)
specifically for the account authorization pages (e.g., `/login`). The code for
doing this was to work around limitations that no longer exist, and does not
represent current security best practices.
The recommended solution to ensure that users are logged in securely (assuming
you've configured support for HTTPS) is to use
[Strict-Transport-Security](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security)
to direct browsers to access the HTTPS version of the website at all times. You
can enable this by configuring a reverse proxy (e.g. nginx) in front of CyTube
to intercept HTTP traffic and redirect it to HTTPS, and add the
`Strict-Transport-Security` header when returning the response from CyTube.
2017-07-22
==========
Support for the old version of Vimeo's OAuth API (the `vimeo-oauth`
configuration block) has been dropped. It's unlikely anyone was using this,
since you haven't been able to register new API keys for it in years (it was
superseded by a newer OAuth API, which CyTube does not support), and in fact I
lost my credentials for this API and no longer have a way to test it.
Vimeo videos can still be added -- the metadata will be queried from the
anonymous API which has been the default since the beginning.
2017-07-17
==========
The `stats` database table and associated ACP subpage have been removed in favor
of integration with [Prometheus](https://prometheus.io/). You can enable
Prometheus reporting by copying `conf/example/prometheus.toml` to
`conf/prometheus.toml` and editing it to your liking. I recommend integrating
Prometheus with [Grafana](https://grafana.com/) for dashboarding needs.
The particular metrics that were saved in the `stats` table are reported by the
following Prometheus metrics:
* Channel count: `cytube_channels_num_active` gauge.
* User count: `cytube_sockets_num_connected` gauge (labeled by socket.io
transport).
* CPU/Memory: default metrics emitted by the
[`prom-client`](https://github.com/siimon/prom-client) module.
More Prometheus metrics will be added in the future to make CyTube easier to
monitor :)
2017-07-15
==========
The latest commit upgrades `socket.io` to version 2.0, a major version change
from 1.4. This release improves performance by switching to `uws` for the
websocket transport, and fixes several bugs; you can read about it
[here](https://github.com/socketio/socket.io/releases/tag/2.0.0).
For browser clients, the upgrade should basically just work with no
intervention. For node.js clients, all that is needed is to upgrade
`socket.io-client` to 2.0. For other clients, work required may vary depending
on whether the implementation has compatibility problems with 2.0.
2017-06-20
==========
The latest commit drops support for node.js versions below 6 (the [current
LTS](https://github.com/nodejs/LTS#lts-schedule1)). This is to allow the babel
preset to avoid generating inefficient code to polyfill ES2015+ features that
are now implemented in the node.js core.
New versions of node.js can be downloaded from the [node.js
website](https://nodejs.org/en/download/), if they are not already available in
your distribution's package manager.
2017-03-20
==========
Polls are now more strictly validated, including the number of options. The
default limit is 50 options, which you can configure via `poll.max-options`.
2017-03-11
==========
Commit f8183bea1b37154d79db741ac2845adf282e7514 modifes the schema of the
`users` table to include a new column (`name_dedupe`) which has a `UNIQUE`
constraint. This column is populated with a modified version of the user's name
to prevent the registration of usernames which are bitwise distinct but visually
similar. 'l', 'L', and '1' are all mapped to '1'; 'o', 'O', and '0' are all
mapped to '0'; '\_' and '-' are mapped to '\_'. On first startup after
upgrading, the new column will be added and populated.
This replaces the earlier solution which was put in place to mitigate PR#489 but
was overly-restrictive since it wildcarded these characters against *any*
character, not just characters in the same group.
2017-03-03
==========
The dependency on `sanitize-html`, which previously pointed to a fork, has now
been switched back to the upstream module. XSS filtering has been turned off
for the chat filter replacement itself (since this provides no additional
security), and is now only run on the final chat message after filtering.
Certain chat filters and MOTDs which relied on syntactically incorrect HTML,
such as unclosed tags, may have different behavior now, since `sanitize-html`
fixes these.
2016-11-02
==========
After upgrading the dependency on `yamljs`, you may see this error if you didn't
notice and correct a typo in the config.yaml template:
Error loading config file config.yaml:
{ [Error: Unexpected characters near ",".]
message: 'Unexpected characters near ",".',
parsedLine: 88,
snippet: 'title: \'CyTube\',' }
The fix is to edit config.yaml and remove the trailing comma for the `title:`
property under `html-template`. If there are other syntax errors that the old
version didn't detect, you will need to correct those as well.
Longer term, I am looking to move away from using `yamljs` to parse
configuration because it's a little buggy and the current configuration system
is confusing.
2016-10-20
==========
Google Drive changed the URL schema for retrieving video metadata, which broke
CyTube's Google Drive support, even with the userscript. I have updated the
userscript source with the new URL, so server administrators will have to
regenerate the userscript for their site and users will be prompted to install
the newer version.
Additionally, fixing Drive lookups required an update to the `mediaquery`
module, so you will have to do an `npm install` to pull that fix in.
2016-08-23
==========
A few weeks ago, the previous Google Drive player stopped working. This is
nothing new; Google Drive has consistently broken a few times a year ever since
support for it was added. However, it's becoming increasingly difficult and
complicated to provide good support for Google Drive, so I've made the decision
to phase out the native player and require a userscript for it, in order to
bypass CORS and allow each browser to request the video stream itself.
See [the updated documentation](docs/gdrive-userscript-serveradmins.md) for
details on how to enable this for your users.
2016-04-27
==========
A new dependency has been added on `cytube-common`, a module that will hold
common code shared between the current version of CyTube and the upcoming work
around splitting it into multiple services. You will need to be sure to run
`npm install` after pulling in this change to pull in the new dependency.
2016-01-06
==========
This release updates socket.io to version 1.4.0. The updates to socket.io
include a few security-related fixes, so please be sure to run `npm install`
to ensure the updated version is installed before restarting your CyTube server.
* https://nodesecurity.io/advisories/67
* https://github.com/socketio/engine.io/commit/391ce0dc8b88a6609d88db83ea064040a05ab803
2015-10-25
==========
In order to support future clustering support, the legacy `/sioconfig`
endpoint is being deprecated. Instead, you should make a request to
`/socketconfig/<channel name>.json`. See [the
documentation](docs/socketconfig.md) for more information.
2015-10-04
==========
* The channel data storage system has been refactored a bit. For
compatibility, the default remains to store JSON objects for each channel in
the `chandump` folder, however there is now also the option of storing
channel data in the database. You can take advantage of this by setting
`channel-storage: type: 'database'` in your `config.yaml`.
- In order to migrate existing channel data from the `chandump` files to the
database, run `node lib/channel-storage/migrate.js`.
* The database storage method uses foreign keys to associate the channel data
with the corresponding row in the `channels` table. This requires that the
tables be stored using the InnoDB engine rather than MyISAM. If your CyTube
tables defaulted to MyISAM, you can fix them by running
```sql
ALTER TABLE `channels` ENGINE = InnoDB;
```
2015-09-21
==========
* CyTube is now transpiled with [babel] to allow the use of ES6/ES2015
features. All source files have been moved from `lib` to `src`.
* Running `npm install` or `npm run postinstall` will prompt you to
build from `src` to `lib`.
* Running `npm run build-server` will run the build script without any
prompts.
* After updating with `git pull`, you should run `npm install` or `npm run
build-server` in order to rebuild after the changes.
[babel]: https://babeljs.io/
2015-07-25
==========
* CyTube now supports subtitles for Google Drive videos. In order to take
advantage of this, you must upgrade mediaquery by running `npm install
cytube/mediaquery`. Subtitles are cached in the google-drive-subtitles
folder.
2015-07-07
==========
* CyTube and CyTube/mediaquery have both been updated to use
calzoneman/status-message-polyfill to polyfill res.statusMessage on older
versions of node (e.g., v0.10). After pulling, run `npm install` to update
this dependency. This fixes an issue where HTTP status messages from
mediaquery were reported as `undefined`, and removes the need for manually
looking up status messages in `lib/ffmpeg.js`.
2015-07-06
==========
* As part of the video player rewrite, Google Drive and Google+ metadata
lookups are now offloaded to CyTube/mediaquery. After pulling the new
changes, run `npm install` or `npm update` to update the mediaquery
dependency.
* `www/js/player.js` is now built from the CoffeeScript source files in the
`player/` directory. Instead of modifying it directly, modify the relevant
player implementations in `player/` and run `npm run build-player` (or `node
build-player.js`) to generate `www/js/player.js`.
* Also as part of the video player rewrite, the schema for custom embeds
changed so any custom embeds stored in the `channel_libraries` table need to
be updated. The automatic upgrade script will convert any custom embeds
that are parseable (i.e., not truncated by the width of the `id` field using
the old format) and will delete the rest (you may see a lot of WARNING:
unable to convert xxx messages-- this is normal). Custom embeds in channel
playlists in the chandumps will be converted when the channel is loaded.

117
README.md
View file

@ -1,46 +1,99 @@
CyTube
======
Read before submitting an issue: https://github.com/calzoneman/sync/wiki/Reporting-an-Issue
===========================================================================================
CyTube is a project I started in early 2013 as a hobby project to build my own
clone of synchtube.com (which shut down in March 2013).
calzoneman/sync
===============
The basic concept is that users register channels where connected viewers can
watch videos from different video hosts (e.g., YouTube, Twitch) and the playback
is synchronized for all the viewers in the channel.
About
-----
Each channel has a playlist where users can queue up videos to play, as well as
an integrated chatroom for discussion.
CyTube is a web application providing media synchronization, chat, and more for an arbitrary number of channels.
I began developing this as a hobby project, and when synchtube.com announced their closure, I
began polishing it and readying it for the public.
The official server is located at https://cytu.be, but there are other public
servers hosted for various communities.
I am hosting a CyTube server at http://cytu.be
## Installation
The serverside is written in JavaScript and runs on Node.JS. It makes use
of a MySQL database to store user registrations, cached media metadata, and
data about each channel.
The installation guide for server administrators is located [on the
wiki](https://github.com/calzoneman/sync/wiki/CyTube-3.0-Installation-Guide).
The clientside is written in JavaScript and makes use of Socket.IO and
jQuery as well as the APIs for various media providers.
The web interface uses Bootstrap for layout and styling.
## Contact
Features
--------
- Standalone web/socket.io server
- Optional SSL support for socket.io and the account API
- Synchronized playback from the following sources:
- YouTube (individual videos + playlists)
- Google Docs videos
- Vimeo
- Dailymotion
- Soundcloud
- Raw video/audio files (via JWPlayer)
- Embedding of the following sources:
- livestream.com
- twitch.tv
- justin.tv
- ustream.tv
- RTMP streams
- Icecast (via JWPlayer)
- Custom `<iframe>` and `<object>` embeds
- Channel customization
- HTML Message of the Day
- CSS
- JavaScript
- Permissions
- Tiered ranks (Site admin > Channel admin > Moderator > Leader > Member > Guest > Anonymous)
- Chat filters (based on regular expressions)
- Lock/unlock playlist to allow additions by non-moderators (configurable with permissions)
- Searchable library of videos
- Integrated YouTube search
- Save/load playlists per user account
- Polls
- Voteskip (can be disabled by a channel moderator)
- Auto-AFK status (can be configured per-channel)
- Leader
- Grants control of playback to a user (can pause/seek)
- Can also be used to grant temporary mod-like powers to a user
- Not necessary for synchronization as the server has an internal timer
- Channel state saves/loads on restart
- Account management
- Password change
- Password reset (via email)
- Profile avatar and text
- Moderation
- Mute users
- Kick users
- Ban users by name
- Ban users by IP address (and by /24 range)
- Administration
- Log viewer
- Global bans
- Search registered channels and users
- Currently loaded channels
- Stats (usercount, channelcount, RAM usage)
**Please check if the
[FAQ](https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions)
answers your question already.**
Installing
----------
For bug reports and feature requests, please open a GitHub issue. To report a
security vulnerability, or to discuss an issue with https://cytu.be itself
(unrelated to the code), please send me an email: cyzon@cytu.be
Installation instructions are available here: https://github.com/calzoneman/sync/wiki/Installing
Please be courteous and search through [the open and closed
issues](https://github.com/calzoneman/sync/issues?utf8=%E2%9C%93&q=is%3Aissue)
for your request before submitting a new one.
Running
-------
General help with the software and the website is also available on the IRC
channel at [irc.esper.net#cytube](http://webchat.esper.net/?channels=cytube)
during US daytime hours.
Start the server: `node index.js`
You should now be able to connect via `yourhostname:port` where `port` is
the port you defined in config.js
## License
Feedback
--------
Original source code in this repository is provided under the MIT license
(see the LICENSE file for the full text).
Please open a GitHub Issue.
Bundled source code, such as third-party CSS and JavaScript libraries, are
provided under their respective licenses.
License
-------
Licensed under MIT
See LICENSE for the full license text

View file

@ -1,176 +0,0 @@
#!/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

@ -1,51 +0,0 @@
#!/usr/bin/env node
var coffee = require('coffeescript');
var fs = require('fs');
var path = require('path');
var order = [
'base.coffee',
'dailymotion.coffee',
'niconico.coffee',
'peertube.coffee',
'soundcloud.coffee',
'twitch.coffee',
'vimeo.coffee',
'youtube.coffee',
// playerjs-based players
'playerjs.coffee',
'iframechild.coffee',
'odysee.coffee',
'streamable.coffee',
// iframe embed-based players
'embed.coffee',
'custom-embed.coffee',
'livestream.com.coffee',
'twitchclip.coffee',
// video.js-based players
'videojs.coffee',
'gdrive-player.coffee',
'hls.coffee',
'raw-file.coffee',
'rtmp.coffee',
// mediaUpdate handler
'update.coffee'
];
var buffer = '';
order.forEach(function (file) {
buffer += fs.readFileSync(
path.join(__dirname, '..', 'player', file)
) + '\n';
});
fs.writeFileSync(
path.join(__dirname, '..', 'www', 'js', 'player.js'),
coffee.compile(buffer)
);

452
changelog Normal file
View file

@ -0,0 +1,452 @@
Thu Nov 14 22:22 2013 CDT
* www/assets/js/callbacks.js: Fix being kicked when leader is removed
Thu Nov 14 19:49 2013 CDT
* www/assets/js/callbacks.js: Fix AFK users on join and profile
updates.
Wed Nov 13 22:35 2013 CDT
* www/assets/js/util.js, www/assets/js/ui.js,
www/assets/js/callbacks.js, www/channel.html:
Add an in-place MOTD editor
Sun Nov 10 22:24 2013 CDT
* www/assets/js/util.js: Add queueMessage function for displaying
warnings/errors
* lib/get-info.js: Add warning for videos blocked in certain
countries (ytv2)
* lib/user.js: If channel search turns up no results, youtube search
instead
Fri Nov 08 20:44 2013 CDT
* lib/channel.js, lib/chatcommand.js: Implement basic shadow mute
command
Thu Nov 07 17:18 2013 CDT
* lib/channel.js, lib/get-info.js, lib/media.js, lib/playlist.js,
www/assets/js/player.js, www/assets/js/util.js:
Add ability to embed Google Docs videos with synchronization
Wed Nov 06 17:42 2013 CDT
* lib/server.js: Fix getChannel to properly handle case insensitive
channel names
Tue Nov 05 22:43 2013 CDT
* lib/channel.js: Set user.channel = null in userLeave to prevent
double execution of userLeave.
Tue Nov 05 22:38 2013 CDT
* lib/database.js: Add a check for registrations-in-progress to prevent
duplicate queries by an impatient user
* www/assets/js/account.js: Disable the registration button while the
registration is being processed
Mon Nov 04 16:15 2013 CDT
* lib/xss.js, tests/xss.js: Merge work-in-progress XSS filter
from xss branch
* lib/torblocker.js: Allow blocking of socket connections from
Tor IPs
Thu Oct 31 18:10 2013 CDT
* package.json: Require node-validator < 2.0.0 as a temporary
measure while in-house XSS filtering is developed.
Wed Oct 30 19:28 2013 CDT
* www/assets/js/util.js: Fix mod permission check breaking chat box
in chat only mode
Mon Oct 20 21:48 2013 CDT
* www/channel.html: Fix padding for channel.html on small screens
Thu Oct 24 17:29 2013 CDT
* www/assets/js/player.js: Add a special of special checks for
dailymotion because their player can only seek to the nearest
2 seconds
Tue Oct 22 13:41 2013 CDT
* lib/channel.js: Fix a channel dead race condition
Sun Oct 20 20:02 2013 CDT
* lib/channel.js: Fix MOTD XSS filter stripping style tags
Sat Oct 19 12:20 2013 CDT
* lib/channel.js: Remove some unnecessary packets
Wed Oct 16 23:29 2013 CDT
* www/assets/js/callbacks.js, www/channel.html,
www/assets/css/ytsync.css: Make queuefail errors always show in
a consistently visible location.
Wed Oct 16 23:21 2013 CDT
* www/assets/js/util.js: Add an errDialog function that shows an
error message over the chatbox (instead of alert())
Wed Oct 16 23:09 2013 CDT
* www/assets/js/util.js: Add a special handler so that clicking
anywhere on the page clears a userlist dropdown, not just right
clicking the same name.
Wed Oct 16 21:48 2013 CDT
* www/assets/js/player.js: Add a stupid timeout hack to address
a race condition in YouTube's HTML5 player. Seriously?
Wed Oct 16 17:34 2013 CDT
* lib/utilities.js: Add a "Set" wrapper around objects to represent
sets.
* lib/channel.js, lib/user.js: Change the way muting works- muted
users are stored in a set and are automatically muted when they
join (this set is not persisted across restarts, however).
Wed Oct 16 17:19 2013 CDT
* lib/channel.js, lib/user.js: Only kick users on permissions
violations. Fail silently on bad packets.
Mon Oct 14 18:13 2013 CDT
* lib/channel.js, lib/database.js: Add a separate database query
for saving ranks on login (insert only, do nothing if the name
exists). Should make it actually impossible to ever lose rank
when logging in (I thought it was impossible before but someone
claimed it happened).
Mon Oct 14 16:37 2013 CDT
* lib/bgtask.js: Add an interval for dumping all loaded channels
* lib/channel.js: Remove per-channel dump interval
* lib/config.js: Add config key for channel save interval
Mon Oct 14 16:30 2013 CDT
* lib/server.js: Rate-limit socket.io connections
* lib/bgtask.js: Periodically clear out old rate limiters
Sat Oct 12 19:43 2013 CDT
* lib/user.js: Fix jumpTo kick bug (and delete)
* lib/api.js: Fix unloaded channel API listing bug
Sat Oct 12 19:02 2013 CDT
* lib/chatcommand.js: Fix poll import
Sat Oct 12 18:58 2013 CDT
* lib/user.js, lib/channel.js: Improve strictness of data checking
to prevent errors from incoming bad data. Kick users who send
bad data or attempt channel moderation with insufficient rank.
Sat Oct 12 18:24 2013 CDT
* lib/user.js: Fix bad chatMsg packet causing exceptions
Sat Oct 12 15:53 2013 CDT
* lib/channel.js: Add a try-catch to playlist loading to catch
the mysterious error that's been coming up (corrupt pl?)
Mon Oct 07 19:00 2013 CDT
* lib/channel.js: Rearrange the callback order to prevent database
lookups from racing with the playlist queue.
* tests/naokosimulator2013.js: Flood the first 10 videos all at once.
Should provide better test coverage for race conditions (especially
on localhost where the latency is ~0)
Mon Oct 07 10:02 2013 CDT
* lib/channel.js: Fix several cases where an unregistered channel
might attempt to make a database call which then fails.
Mon Oct 07 00:08 2013 CDT
* lib/playlist.js: Fix /clean not behaving properly (actually was a
consequence of the remove() function)
* lib/get-info.js: Send more specific error messages
* www/assets/js/callbacks.js: Minor fix to the queueFail callback
Sun Oct 06 01:42 2013 CDT
* lib/channel.js, www/assets/js/callbacks.js: Include the link that
failed with the queueFail packet. Clicking the "+ n more" tag
shows all links that failed for stacked messages
* lib/utilities.js: Add a formatLink function
Sat Oct 05 20:41 2013 CDT
* lib/user.js: Fix a bug where duplicate guestnames were allowed with
different capitalizations
Thu Oct 03 22:09 2013 CDT
* www/assets/js/ui.js: Use sortable("cancel") to remove the need for
the `moveby` field of the movement packet
* www/assets/js/util.js, www/assets/js/callbacks.js, lib/channel.js:
Remove references to moveby
Thu Oct 03 22:05 2013 CDT
* lib/channel.js, lib/playlist.js: Fix 'next' bumping not properly
telling clients that the old item was deleted
Wed Oct 02 22:25 2013 CDT
* www/channel.html, www/assets/js/ui.js, www/assets/js/callbacks.js:
Add a small toggle for the MOTD
Wed Oct 02 09:35 2013 CDT
* lib/channel.js: Fix the use of the wrong variable when checking for
maximum video length
Tue Oct 01 22:57 2013 CDT
* lib/asyncqueue.js: Add a generalized queue class for queueing async
functions to execute in order
* lib/channel.js, lib/playlist.js: Clean up playlist addition/move/
deletion. Should fix #285.
* lib/server.js: Fix announcement initialization to null
* tests/naokosimulator2013.js: Add a simple bot that authenticates
and vomits a giant list of videos into a channel
* www/assets/js/callbacks.js, www/assets/js/util.js: Clean up
playlist add/move/delete
* www/assets/js/data.js: Update CL_VERSION which I always forget
to do.
* www/assets/js/ui.js: Small fix to comply with server changes
Sun Sep 29 21:30 2013 CDT
* lib/get-info.js: Add an extra check to ytv2 to make sure embedding is
allowed. If not, send a queueFail.
Fri Sep 27 10:27 2013 CDT
* lib/channel.js: Change the link regex, change the way 'affects links'
works. The default link filter will only transform a link of no
previous filters transform it (and filters will only transform a link
if 'affects links' is ticked).
Thu Sep 26 23:40 2013 CDT
* lib/config.js: Add config keys for statistics interval & max age,
alias purge interval & max age.
* lib/bgtask.js: Update intervals to reflect new config keys
Thu Sep 26 23:33 2013 CDT
* lib/stats.js: Remove this file, move the statistics tracking
interval to the new bgtask.js
* lib/bgtask.js: Generalize background tasks to this module. Add
tasks for statistics tracking and clearing old aliases.
* lib/database.js: Modify the way aliases work a bit. Rather than
constantly deleting to keep 5 aliases per person, have a function
that is periodically called to delete aliases more than one month old,
and have the listAliases function explicitly sort and request the most
recent 5 aliases.
* lib/server.js: Update references accordingly
Thu Sep 26 23:08 2013 CDT
* lib/acp.js, lib/api.js, lib/server.js, lib/stats.js, www/acp.html,
www/assets/js/acp.js: Remove the [realtime] 'connection stats'
tracking that was pretty worthless anyways.
Thu Sep 26 21:42 2013 CDT
* www/assets/js/player.js: Keep track of the duration of the current
video and use this information to prevent the player restarting when
it receives an out of range timestamp
Thu Sep 26 13:29 2013 CDT
* lib/user.js: Some code style cleanup
Thu Sep 26 13:17 2013 CDT
* lib/user.js: A few minor cleanups to login functions
* lib/api.js: Pass the login failure reason to the action log
Tue Sep 24 15:18 2013 CDT
* www/assets/js/callbacks.js: Double fix search result buttons because
the paginator was being duplicated
Tue Sep 24 13:45 2013 CDT
* lib/acp.js: Emit announcements to SSL sockets in addition to regular
ones.
Tue Sep 24 13:15 2013 CDT
* www/assets/js/callbacks.js: Instantiate a new paginator for every
search result- prevents buttons being incorrectly added or omitted.
Mon Sep 23 16:22 2013 CDT
* lib/user.js: distinguish search result return packets with a 'source'
field that specifies where the item came from (e.g 'yt' for youtube
search, 'library' for library search)
* www/assets/js/callbacks.js: account for the change in lib/user.js
which adds a 'source' field to library search results
* www/assets/js/util.js: don't add delete buttons for youtube search
results. Apparently people keep clicking them...
Sat Sep 21 23:53 2013 CDT
* lib/playlist.js, www/assets/js/player.js: Modify the server lead
function to have a 3 second lead-in period (to allow buffering).
Videos start at -3 seconds; the client is paused until the time
reaches 0 seconds.
Sat Sep 21 02:21 2013 CDT
* www/assets/js/ui.js, www/assets/js/ui.js, www/assets/js/util.js,
www/channel.html, www/assets/css/ytsync.css: Add quick buttons for
modflair and adminflair
Sat Sep 21 00:29 2013 CDT
* www/assets/js/util.js: support a "Nobody" permission level (set
arbitrarily high)
Sat Sep 21 00:23 2013 CDT
* www/assets/js/callbacks.js, www/assets/css/ytsync.css: make it more
obvious which option was voted for
Sat Sep 21 00:08 2013 CDT
* lib/playlist.js: Make .clean(filter) an instance method
* lib/chatcommand.js: Make a few minor changes to unbibium's /clean
Wed Sep 18 18:26 2013 CDT
* lib/user.js: Change channel checks to include checking for whether
the channel is dead
Wed Sep 18 18:14 2013 CDT
* lib/channel.js: Add a bunch of checks to prevent callbacks from doing
things with a dead channel
* tests/channelDeadRace.js: Add a few client tests that cause exceptions
in the pre-patched channel code
Tue Sep 17 22:24 2013 CDT
* lib/user.js: Fix what I assume was a race condition that caused an error message
when a user's login callback fired after the channel unloaded.
Tue Sep 17 13:16 2013 CDT
* lib/get-info.js: Fix ustream for channels that have /channel/ in the
path.
Fri Sep 13 10:10 2013 CDT
* www/reset.html: Fix data.js dependency
* lib/api.js: Fix ActionLog trying to record wrong data. How long has
this been broken?
* www/assets/js/acp.js: Prepend "/reset.html?" in case admins can't
remember what the URL is
Thu Sep 12 18:41 2013 CDT
* www/index.html: Fix index page not showing public channels
Thu Sep 12 16:25 2013 CDT
* lib/channel.js: Fix an error resulting from calcVoteskipMax being run
when the usercount is 0
Thu Sep 12 13:01 2013 CDT
* lib/channel.js: Fix the XSS filter hack that allows style attributes
to allow more than one in a chat filter replacement
Wed Sep 11 22:13 2013 CDT
* lib/channel.js, lib/user.js: Remove "afkers" array, replace afkcount
with a function that calculates how many users are eligible to
voteskip (not AFK + have permission). Check permission before
allowing voteskip
* lib/api.js: Replace "afkcount" in /api/channels/:channel with
voteskip_eligible
* www/assets/js/util.js: Add a permissions option for voteskip
Wed Sep 11 20:19 2013 CDT
* lib/poll.js: Add support for hidden polls
* lib/channel.js: Check permissions for viewing hidden polls
* lib/chatcommand.js: Add /hpoll for opening hidden polls
* www/assets/js/util.js: Update poll interface to include a checkbox
for whether or not to hide a poll. Update permissions interface to
allow for changing the permission to view obscured polls.
Tue Sep 10 22:40 2013 CDT
* www/account.html, www/acp.html, www/login.html:
Import www/assets/js/data.js for easy access to user preferences
* www/assets/js/account.js, www/assets/js/acp.js: remove redundant
cookie util functions
* www/assets/js/data.js, www/assets/js/util.js, www/assets/js/iourl.js:
Add a user option to enable SSL for websockets and API calls
* www/assets/js/ui.js: Add a warning if the user loaded the page over
SSL (because some media players throw warnings and others don't work
at all (Twitch.tv, Justin.tv: go eat a dick)
* www/assets/js/player.js: Prevent race conditions for media types that
depend on swfobject
Tue Sep 10 17:17 2013 CDT
* www/assets/js/player.js: Fix loading over SSL for everything except
TwitchTV and JustinTV
Tue Sep 10 16:45 2013 CDT
* lib/channel.js: Don't attempt to emit to SSL sockets if it's disabled
Tue Sep 10 16:34 2013 CDT
* www/channel.html, www/assets/js/jwplayer.js: Cache jwplayer script
locally since their server doesn't support SSL
Tue Sep 10 16:10 2013 CDT
* lib/server.js, lib/api.js: Implicitly trust X-Forwarded-For when the
source ip is 127.0.0.1
Tue Sep 10 14:09 2013 CDT
* lib/config.js, lib/server.js: Add a config key for the passphrase
to the ssl key.
Mon Sep 9 22:10 2013 CDT
* lib/acp.js: Change acp-channel-unload callback to duplicate the list
of users in the channel to prevent concurrent modification while
kicking
* lib/channel.js: As an extra precaution, set user.channel = null after
kicking a user
Mon Sep 9 17:11 2013 CDT
* lib/server.js: If SSL is enabled in config, create an additional
server listening with SSL for websockets and HTTPS traffic
* lib/config.js: Add config keys for SSL
* lib/channel.js: Broadcast messages to both regular and SSL sockets
* www/assets/js/iourl.js: Add SSL_URL and automatically set WEB_URL and
IO_URL to SSL_URL when the protocol is HTTPS
* www/assets/js/callbacks.js: Automatically set the secure option on
io.connect()
* www/assets/js/ui.js, www/index.html, www/channel.html: Fix links to
be dependent on the protocol
Sun Sep 8 17:41 2013 CDT
* lib/server.js: Change behavior of unloadChannel - deletes all object
keys in the channel object and then sets channel.dead = true
* lib/channel.js: Add extra checks in callbacks to ensure certain things
don't happen if the channel is dead
* lib/playlist.js: Add extra checks to kill the playlist if the channel
it is associated with is dead
* lib/database.js: Add extra checks to prevent loading channel data into
a dead channel object
* tests/fastQuit.js: A simple script to open a connection, join a
channel, and disconnect immediately to test for race conditions
Sat Sep 7 23:38 2013 CDT
* lib/user.js: Add "loggingIn" field to act as a lock on async logins.
Delay further login attempts until the current login attempt finishes.
Should prevent cases where sending multiple logins quickly in
succession caused race conditions and thus odd "duplicate login"
kicks.
Sat Sep 7 15:43 2013 CDT
* lib/channel.js, lib/user.js: Add an extra check for channel.users[i]
to write an error message (instead of kicking) when the same user that
is connecting is the one being kicked.
Fri Sep 6 16:29 2013 CDT
* lib/config.js: Add an io-host option to allow binding socket.io to
a different IP than the webserver
Fri Sep 6 15:51 2013 CDT
* lib/utilities.js: Tweak the throttle code for rate limiters to fix
incorrect behavior of the burst cap after the cooldown period has
been passed
* tests/rateLimiter.js: Write a couple quick test cases to ensure that
rate limiting is handled properly
Thu Sep 5 22:52 2013 CDT
* www/assets/js/callbacks.js: Disable the channel registration button
and change its text while a registration attempt is being processed
(prevents DB errors from impatient people spamming the button)
(addresses Issue #276 <Remove register channel button after click> )
Thu Sep 5 13:45 2013 CDT
* acp.js, actionlog.js, api.js, channel.js, chatcommand.js, config.js,
customembed.js, database.js, filter.js, get-info.js, logger.js,
media.js, notwebsocket.js, playlist.js, poll.js, rank.js, server.js,
stats.js, ullist.js, user.js, utilities.js: move server
files into lib/ folder to clean up the root directory of the project.
* api.js: replace regex with $util.isValidChannelName (L68);
fix relative file paths (per moving api.js to lib/)
* server.js: fix relative file paths
* channel.js: fix relative file paths
Wed Sep 4 22:45 2013 CDT
* changelog: initialize changelog file
Wed Sep 4 17:47 2013 CDT
* www/assets/js/data.js: add CHANNEL.usercount variable
* www/assets/js/callbacks.js: update CHANNEL.usercount variable when
the usercount changes
* www/assets/js/util.js: add "Anonymous" to the usercount breakdown
display (addresses Issue #270 <Suggestion: Put Mute button on Right
click menu + anon count>)
Changelog established on Wed Sep 4 2013

View file

@ -1,15 +0,0 @@
# Configuration for proxying images to a camo (or camo-compatible) proxy server.
# To use, copy to conf/camo.toml.
# More info on camo: https://github.com/atmos/camo
[camo]
enabled = true
server = 'https://my-camo-server'
# The key must match the `CAMO_KEY` environment variable passed to the camo server.
key = 'ABCDEFGH'
# Bypass the proxy for domains you trust that already support HTTPS and won't be harmful to users.
whitelisted-domains = [
'i.imgur.com',
'i.4cdn.org'
]
# Whether to use URL encoding ("url") or hex encoding ("hex") for the target URL.
encoding = 'url'

View file

@ -1,9 +0,0 @@
[hcaptcha]
# Site key from hCaptcha. The value here by default is the dummy test key for local testing
site-key = "10000000-ffff-ffff-ffff-000000000001"
# Secret key from hCaptcha. The value here by default is the dummy test key for local testing
secret = "0x0000000000000000000000000000000000000000"
[register]
# Whether to require a captcha for registration
enabled = true

View file

@ -1,66 +0,0 @@
# SMTP configuration for sending mail
[smtp]
host = 'smtp.gmail.com'
port = 465
secure = true
user = 'some-user@example.com'
password = 'secretpassword'
# Email configuration for password reset emails
# Be sure to update both html-template AND text-template
# nodemailer will send both and the email client will render whichever one is supported
[password-reset]
enabled = true
# Template to use for HTML-formatted emails
# $user$ will be replaced by the username for which the reset was requested
# $url$ will be replaced by the password reset confirmation link
html-template = """
Hi $user$,<br>
<br>
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: <a href="$url$">$url$</a><br>
<br>
This link will expire in 24 hours.<br>
<br>
This email address is not monitored for replies. For assistance with password resets, please <a href="http://example.com/contact">contact an administrator</a>.
"""
# Template to use for plaintext emails
# Same substitutions as the HTML template
text-template = """
Hi $user$,
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: $url$
This link will expire in 24 hours.
This email address is not monitored for replies. For assistance with password resets, please contact an administrator. See http://example.com/contact for contact information.
"""
from = "Example Website <website@example.com>"
subject = "Password reset request"
# Email configuration for account deletion request notifications
[delete-account]
enabled = true
html-template = """
Hi $user$,
<br>
<br>
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
<br>
<br>
This email address is not monitored for replies. For assistance, please <a href="http://example.com/contact">contact an administrator</a>.
"""
text-template = """
Hi $user$,
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
This email address is not monitored for replies. For assistance, please contact an administrator. See http://example.com/contact for contact information.
"""
from = "Example Website <website@example.com>"
subject = "Account deletion request"

View file

@ -1,14 +0,0 @@
# Configuration for binding an HTTP server to export prometheus metrics.
# See https://prometheus.io/ and https://github.com/siimon/prom-client
# for more details.
[prometheus]
enabled = true
# Host, port to bind. This is separate from the main CyTube HTTP server
# because it may be desirable to bind a different IP/port for monitoring
# purposes. Default: localhost port 19820 (arbitrary port chosen not to
# conflict with existing prometheus exporters).
host = '127.0.0.1'
port = 19820
# Request path to serve metrics. All other paths are rejected with
# 400 Bad Request.
path = '/metrics'

View file

@ -1,217 +0,0 @@
# MySQL server details
# server: domain or IP of MySQL server
# database: a MySQL database that the user specified has read/write access to
# user: username to authenticate as
# password: password for user
mysql:
server: 'localhost'
port: 3306
database: 'cytube3'
user: 'cytube3'
password: ''
pool-size: 10
# Define IPs/ports to listen on
# Each entry MUST define ip and port (ip can be '' to bind all available addresses)
# Each entry should set http, https, and/or io to true to listen for the corresponding
# service on that port. http/io and https/io can be combined, but if http and https
# are both specified, only https will be bound to that port.
#
# If you don't specify a url, the url io.domain:port or https.domain:port will be assumed
# for non-ssl and ssl websockets, respectively. You can override this by specifying the
# url for a websocket listener.
listen:
# Default HTTP server - default interface, port 8080
- ip: ''
port: 8080
http: true
# Uncomment below to enable HTTPS/SSL websockets
# Note that you must also set https->enabled = true in the https definition
# - ip: ''
# port: 8443
# https: true
# io: true
# Default Socket.IO server - default interface, port 1337
- ip: ''
port: 1337
io: true
# Example of how to bind an extra port to HTTP and Socket.IO
# - ip: ''
# port: 8081
# http: true
# io: true
# url: 'http://my-other-thing.site.com:8081'
# HTTP server details
http:
# Even though you may specify multiple ports to listen on for HTTP above,
# one port must be specified as default for the purposes of generating
# links with the appropriate port
default-port: 8080
# Specifies the root domain for cookies. If you have multiple domains
# e.g. a.example.com and b.example.com, the root domain is example.com
root-domain: 'localhost'
# Specify alternate domains/hosts that are allowed to set the login cookie
# Leave out the http://
alt-domains:
- '127.0.0.1'
# Use express-minify to minify CSS and Javascript
minify: false
# Max-Age for caching. Value should be an integer in milliseconds or a string accepted by
# the `ms` module. Set to 0 to disable caching.
max-age: '7d'
# Set to false to disable gzip compression
gzip: true
# Customize the threshold byte size for applying gzip
gzip-threshold: 1024
# Secret used for signed cookies. Can be anything, but make it unique and hard to guess
cookie-secret: 'change-me'
index:
# Maximum number of channels to display on the index page public channel list
max-entries: 50
# Configure trusted proxy addresses to map X-Forwarded-For to the client IP.
# See also: https://github.com/jshttp/proxy-addr
trust-proxies: ['loopback']
# HTTPS server details
https:
enabled: false
# Even though you may specify multiple ports to listen on for HTTPS above,
# one port must be specified as default for the purposes of generating
# links with the appropriate port
default-port: 8443
domain: 'https://localhost'
keyfile: 'localhost.key'
passphrase: ''
certfile: 'localhost.cert'
cafile: ''
ciphers: 'HIGH:!DSS:!aNULL@STRENGTH'
# Page template values
# title goes in the upper left corner, description goes in a <meta> tag
html-template:
title: 'Sync'
description: 'Free, open source synchtube'
# Socket.IO server details
io:
# In most cases this will be the same as the http.domain.
# However, if your HTTP traffic is going through a proxy (e.g. cloudflare)
# you will want to set up a passthrough domain for socket.io.
# If the root of this domain is not the same as the root of your HTTP domain
# (or HTTPS if SSL is enabled), logins won't work.
domain: 'http://localhost'
# Even though you may specify multiple ports to listen on for HTTP above,
# one port must be specified as default for the purposes of generating
# links with the appropriate port
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)
# 2. Make sure the YouTube Data v3 API is "enabled" for your project: https://console.developers.google.com/apis/library/youtube.googleapis.com
# 3. Go to "Credentials" on the sidebar of https://console.developers.google.com/, click "Create credentials" and choose type "API key"
# 4. Optionally restrict the key for security, or just copy the key.
# 5. Test your key (may take a few minutes to become active):
#
# $ export YOUTUBE_API_KEY="your key here"
# $ curl "https://www.googleapis.com/youtube/v3/search?key=$YOUTUBE_API_KEY&part=id&maxResults=1&q=test+video&type=video"
youtube-v3-key: ''
# Limit for the number of channels a user can register
max-channels-per-user: 5
# Limit for the number of accounts an IP address can register
max-accounts-per-ip: 5
# Minimum number of seconds between guest logins from the same IP
guest-login-delay: 60
# Maximum character length of a chat message, capped at 1000 characters
max-chat-message-length: 320
# Allows you to customize the path divider. The /r/ in http://localhost/r/yourchannel
# Acceptable characters are a-z A-Z 0-9 _ and -
channel-path: 'r'
# Allows you to blacklist certain channels. Users will be automatically kicked
# upon trying to join one.
channel-blacklist: []
# Minutes between saving channel state to disk
channel-save-interval: 5
# Configure periodic clearing of old alias data
aliases:
# Interval (in milliseconds) between subsequent runs of clearing
purge-interval: 3600000
# Maximum age of an alias (in milliseconds) - default 1 month
max-age: 2592000000
# Workaround for Vimeo blocking my domain
vimeo-workaround: false
# Regular expressions for defining reserved user and channel names and page titles
# The list of regular expressions will be joined with an OR, and compared without
# case sensitivity.
#
# Default: reserve any name containing "admin[istrator]" or "owner" as a word
# but only if it is separated by a dash or underscore (e.g. dadmin is not reserved
# but d-admin is)
reserved-names:
usernames:
- '^(.*?[-_])?admin(istrator)?([-_].*)?$'
- '^(.*?[-_])?owner([-_].*)?$'
channels:
- '^(.*?[-_])?admin(istrator)?([-_].*)?$'
- '^(.*?[-_])?owner([-_].*)?$'
pagetitles: []
# Provide a contact list for the /contact page
# Example:
# contacts:
# - name: 'my_name'
# title: 'administrator
# email: 'me@my.site'
contacts: []
playlist:
max-items: 4000
# How often (in seconds), mediaUpdate packets are broadcast to clients
update-interval: 5
# If set to true, when the ipThrottle and lastguestlogin rate limiters are cleared
# periodically, the garbage collector will be invoked immediately.
# The server must be invoked with node --expose-gc index.js for this to have any effect.
aggressive-gc: false
# If you have ffmpeg installed, you can query metadata from raw files, allowing
# server-synched raw file playback. This requires the following:
# * ffmpeg must be installed on the server
ffmpeg:
enabled: false
# Executable name for ffprobe if it is not "ffprobe". On Debian and Ubuntu (on which
# libav is used rather than ffmpeg proper), this is "avprobe"
ffprobe-exec: 'ffprobe'
link-domain-blacklist: []
# Drop root if started as root!!
setuid:
enabled: false
group: 'users'
user: 'user'
# how long to wait in ms before changing uid/gid
timeout: 15
# Allows for external services to access the system commandline
# Useful for setups where stdin isn't available such as when using PM2
service-socket:
enabled: false
socket: 'service.sock'
# Twitch Client ID for the data API (used for VOD lookups)
# https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup
twitch-client-id: null
poll:
max-options: 50

View file

@ -1,20 +0,0 @@
## Registering an Account ##
To register an account, click the Account dropdown on the top bar, then click Register. This option will only appear if you are not already logged in. When choosing a username, make sure it meets the following requirements:
* Must be 1-20 characters long
* May only contain the letters 'A' through 'Z' (upper or lowercase), the digits '0' through '9', hyphens '-', and underscores '_'
Be sure to pick a strong password that is not easy to guess and is not the same as your accounts on other websites. Entering an email address is optional, and will allow you to recover your account if you ever forget your password.
## Changing your Password or Email Address ##
On the top bar of the website, click Account, then "Change Password/Email". You must provide your current password in order to change either of these. You can leave the email address box blank if you wish to remove the email address from your account (note that this means you will no longer be able to receive password reset emails).
## Recovering an Account If You Lost the Password ##
From the login page, click "Forgot password?" You will be prompted to enter your username and email address. The email address must match the one associated with your account. If you have not added an email address to your account, you cannot reset your password automatically and will need to contact an administrator for help. Otherwise, you will receive an email with a link to reset your password.
## Account Profile ##
Each CyTube account can set a profile photo and short description. On the top bar, click Account, then Profile. You can then enter the URL of a profile image, and enter a short text blurb that will be displayed when users hover over your name in the chat username list. Note that this is publicly visible to anyone, so don't use a private photo or include private information in your description.

View file

@ -1,67 +0,0 @@
## Please do not use chat filters for emoticons! ##
CyTube has an emotes feature which is better-suited for adding emoticons.
Adding them as chat filters is more difficult to manage and uses more server
resources.
## Managing Chat Filters ##
You can access the Chat Filters editor by clicking on "Channel Settings" at the
top of the page, then the "Edit" dropdown, and selecting "Chat Filters".
### Adding a New Chat Filter ###
The first field allows you to enter a unique name for the filter. This can be
anything you like, but it must be unique among all filters on your channel.
The "Filter regex" field is where you input the [regular
expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp)
that you would like to match. Regular expressions allow you to build
sophisticated filters that can find and replace patterns rather than simple
words. If you simply want to filter a word, you can just use `\bword\b`, as
long as the word does not contain any of the special characters listed on the
linked regular expression guide. The leading and trailing `\b` ensure that you
only match the whole word "word", and do not match instances where it is nested
inside other words.
If you're looking for a way to test your regular expression, there are many free
tools available online, such as [this one](http://regexpal.com/).
The "Flags" field allows you to control certain aspects of matching. The "g"
flag specifies that replacement will be done "globally"-- it will replace all
instances of the regular expression instaed of just the first one. The "i" flag
makes matching case-insensitive, so that the capitalization of the message
doesn't matter. Flags can be combined by putting both of them in the box, e.g.
"gi".
The "Replacement" field is where you specify the text to be substituted for the
original messagse. This allows a limited subset of HTML tags to be used.
## Editing Filters ##
From the chat filter list, you can drag and drop filters to rearrange the order
in which they are executed. For each filter, there are two buttons. The left
button allows you to edit the filter, to update the regular expression, flags,
replacement, and whether or not the filter should be applied to links inside of
messages (this defaults to off). The red trash can button removes the filter.
## Export/Import ##
The export/import feature allows you to back up your filter list and restore it
later, or clone filters to a new channel. Clicking "Export filter list" will
populate the below textarea with a JSON encoded version of the filter list.
Copy this and save it somewhere safe. Later, you can paste this same text back
into the box and click "Import filter list" to overwrite your current filters
with the exported list.
## Notes ##
* By default, CyTube automatically replaces URLs in chat messages with
clickable links. You can disable this from the "Chat Settings" section
under the "General Settings" tab.
* By default, chat filters will not replace text inside of links, to prevent
them from being broken by the filter. You can override this by editing the
filter and checking the "Filter Links" box.
* Incoming messages have HTML special characters sanitized before messages are
filtered. You will have to account for this if you want to filter these
characters. For example, instead of matching `<`, you must match `&lt;`.

View file

@ -1,210 +0,0 @@
CyTube Custom Content Metadata
==============================
*Last updated: 2022-02-12*
## Purpose ##
CyTube currently supports adding custom audio/video content by allowing the user
to supply a direct URL to an audio/video file. The server uses `ffprobe` to
probe the file for various metadata, including the codec/container format and
the duration. This approach has a few disadvantages over the officially
supported media providers, namely:
* Since it accepts a single file, it is not possible to provide multiple
source URLs with varying formats or bitrates to allow viewers to select the
best source for their computer.
- It also means it is not possible to provide text tracks for subtitles or
closed captioning, or to provide image URLs for thumbnails/previews.
* Probing the file with `ffprobe` is slow, especially if the content is hosted
in a far away network location, which at best is inconvenient and at worst
results in timeouts and inability to add the content.
* Parsing the `ffprobe` output is inexact, and may sometimes result in
detecting the wrong format, or failing to detect the title.
This document specifies a new supported media provider which allows users to
provide a JSON manifest specifying the metadata for custom content in a way that
avoids the above issues and is more flexible for extension.
## Custom Manifest URLs ##
Custom media manifests are added to CyTube by adding a link to a public URL
hosting the JSON metadata manifest. Pasting the JSON directly into CyTube is
not supported. Valid JSON manifests must:
* Have a URL path ending with the file extension `.json` (not counting
querystring parameters)
* Be served with the `Content-Type` header set to `application/json`
* Be retrievable at any time while the item is on the playlist (CyTube may
re-request the metadata for an item already on the playlist to revalidate)
* Respond to valid requests with a 200 OK HTTP response code (redirects are
not supported)
* Respond within 10 seconds
* Not exceed 100 KiB in size
## Manifest Format ##
To add custom content, the user provides a JSON object with the following keys:
* `title`: A nonempty string specifying the title of the content. For legacy
reasons, CyTube currently truncates this to 100 UTF-8 characters.
* `duration`: A non-negative, finite number specifying the duration, in
seconds, of the content. This is what the server will use for timing
purposes. Decimals are allowed, but CyTube's timer truncates the value as
an integer number of seconds, so including fractional seconds lends no
advantage.
* `live`: An optional boolean (default: `false`) indicating whether the
content is live or pre-recorded. For live content, the `duration` is
ignored, and the server won't advance the playlist automatically.
* `thumbnail`: An optional string specifying a URL for a thumbnail image of
the content. CyTube currently does not support displaying thumbnails in the
playlist, but this functionality may be offered in the future.
* `sources`: A nonempty list of playable sources for the content. The format
is described below.
* `audioTracks`: An optional list of audio tracks for using demuxed audio
and providing multiple audio selections. The format is described below.
* `textTracks`: An optional list of text tracks for subtitles or closed
captioning. The format is described below.
### Source Format ###
Each source entry is a JSON object with the following keys:
* `url`: A valid URL that browsers can use to retrieve the content. The URL
must resolve to a publicly-routed IP address, and must the `https:` scheme.
* `contentType`: A string representing the MIME type of the content at `url`.
A list of acceptable MIME types is provided below.
* `quality`: A number representing the quality level of the source. The
supported quality levels are `240`, `360`, `480`, `540`, `720`, `1080`,
`1440`, and `2160`. This may be extended in the future.
* `bitrate`: An optional number indicating the bitrate (in Kbps) of the
content. It must be a positive, finite number if provided. The bitrate is
not currently used by CyTube, but may be used by extensions or custom
scripts to determine whether this source is feasible to play on the viewer's
internet connection.
#### Acceptable MIME Types ####
The following MIME types are accepted for the `contentType` field:
* `video/mp4`
* `video/webm`
* `video/ogg`
* `application/x-mpegURL` (HLS streams)
- HLS is only tested with livestreams. VODs are accepted, but I do not test
this functionality.
* `application/dash+xml` (DASH streams)
- Support for DASH is experimental
* ~~`rtmp/flv`~~
- In light of Adobe phasing out support for Flash, and many browsers
already dropping support, RTMP is not supported by this feature.
RTMP streams are only supported through the existing `rt:` media
type.
* `audio/aac`
* `audio/mp4`
* `audio/mpeg`
* `audio/ogg`
Other audio or video formats, such as AVI, MKV, and FLAC, are not supported due
to lack of common support across browsers for playing these formats. For more
information, refer to
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats#Browser_compatibility).
### Audio Track Format ###
Each audio track entry is a JSON object with the following keys:
* `label`: A label for the audio track. This is displayed in the menu for the
viewer to select a text track.
* `language`: A two or three letter IETF BCP 47 subtype code indicating the
language of the audio track.
* `url`: A valid URL that browsers can use to retrieve the track. The URL
must resolve to a publicly-routed IP address, and must use the `https:` scheme.
* `contentType`: A string representing the MIME type of the track at `url`.
Any type starting with `audio` from the list above is acceptable. However
the usage of audio/aac is known to cause audio syncrhonization problems
for some users. It is recommended to use an m4a file to wrap aac streams.
**Important note regarding audio tracks:**
Because of browsers trying to be too smart for their own good, you should
include a silent audio stream in the video sources when using separate audio
tracks. If you do not, the browser will automatically pause the video whenever
the browser detects the page as not visible. There is no way to instruct it to
not do so. You can readily accomplish the inclusion of a silent audio track
with ffmpeg using the anullsrc filter like so:
`ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 -i input.mp4 -c:v copy -c:a aac -shortest output.mp4`
It is recommended to match the sample rate and codec you intend to use in your
audioTracks in your silent track.
### Text Track Format ###
Each text track entry is a JSON object with the following keys:
* `url`: A valid URL that browsers can use to retrieve the track. The URL
must resolve to a publicly-routed IP address, and must the `https:` scheme.
* `contentType`: A string representing the MIME type of the track at `url`.
The only currently supported MIME type is
[`text/vtt`](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API).
* `name`: A name for the text track. This is displayed in the menu for the
viewer to select a text track.
* `default`: Enable track by default. Optional boolean attribute to enable
a subtitle track to the user by default.
**Important note regarding text tracks and CORS:**
By default, browsers block requests for WebVTT tracks hosted on different
domains than the current page. In order for text tracks to work cross-origin,
the `Access-Control-Allow-Origin` header needs to be set by the remote server
when serving the VTT file. See
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)
for more information about setting this header.
## Example ##
{
"title": "Test Video",
"duration": 10,
"live": false,
"thumbnail": "https://example.com/thumb.jpg",
"sources": [
{
"url": "https://example.com/video.mp4",
"contentType": "video/mp4",
"quality": 1080,
"bitrate": 5000
}
],
"textTracks": [
{
"url": "https://example.com/subtitles.vtt",
"contentType": "text/vtt",
"name": "English Subtitles",
"default": true
}
]
}
## Permissions ##
The permission node to allow users to add custom content is the same as the
permission node for the existing raw file support. Custom content is considered
as an extension of the existing feature.
## Unsupported/Undefined Behavior ##
The behavior under any the following circumstances is not defined by this
specification, and any technical support in these cases is voided. This list is
non-exhaustive.
* Source URLs or text track URLs are hosted on a third-party website that does
not have knowledge of its content being played on CyTube.
* The webserver hosting the source or text track URLs serves a different MIME
type than the one specified in the manifest.
* The webserver hosting the source or text track URLs serves a file that does
not match the MIME type specified in the `Content-Type` HTTP header returned
to the browser.
* The manifest includes source URLs or text track URLs with expiration times,
session IDs, etc. in the URL querystring.
* The manifest provides source URLs with non-silent audio as well as a list
of audioTracks.

View file

@ -1,24 +0,0 @@
# Google Drive Userscript Setup
In response to increasing difficulty and complexity of maintaining Google Drive
support, the native player is being phased out in favor of requiring a
userscript to allow each client to fetch the video stream links for themselves.
Users will be prompted with a link to `/google_drive_userscript`, which explains
the situation and instructs how to install the userscript.
As a server admin, you must generate the userscript from the template by using
the following command:
```sh
npm run generate-userscript <site name> <url> [<url>...]
```
The first argument is the site name as it will appear in the userscript title.
The remaining arguments are the URL patterns on which the script will run. For
example, for cytu.be I use:
```sh
npm run generate-userscript CyTube http://cytu.be/r/* https://cytu.be/r/*
```
This will generate `www/js/cytube-google-drive.user.js`. If you've changed the channel path, be sure to take that into account.

View file

@ -1,24 +0,0 @@
Adding subtitles to Google Drive
================================
1. Upload your video to Google Drive
2. Right click the video in Google Drive and click Manage caption tracks
3. Click Add new captions or transcripts
4. Upload a supported subtitle file
* I have verified that Google Drive will accept .srt and .vtt subtitles. It
might accept others as well, but I have not tested them.
Once you have uploaded your subtitles, they should be available the next time
the video is refreshed by CyTube (either restart it or delete the playlist item
and add it again). On the video you should see a speech bubble icon in the
controls, which will pop up a menu of available subtitle tracks.
## Limitations ##
* Google Drive converts the subtitles you upload into a custom format which
loses some information from the original captions. For example, annotations
for who is speaking are not preserved.
* As far as I know, Google Drive is not able to automatically detect when
subtitle tracks are embedded within the video file. You must upload the
subtitles separately (there are plenty of tools to extract
captions/subtitles from MKV and MP4 files).

View file

@ -1,15 +0,0 @@
# User Guide #
This user guide is a work in progress rewrite of the old user guide. If you notice something is missing, it probably hasn't been ported over from [here](https://github.com/calzoneman/sync/wiki/CyTube-3.0-User-Guide) yet.
## I want to know more about... ##
* [Registering and managing my user account](account-mgmt.md)
* [Available user preferences](user-settings.md)
* [Adding subtitles to Google Drive videos](google-drive-subtitles.md)
* [Managing chat filters](chat-filters.md)
## I need help! ##
1. Please read the [FAQ](https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions) and check whether that answers your question.
2. If not, you can contact someone for help. IRC support is provided on `irc.esper.net #cytube` ([webchat](https://webchat.esper.net/?channels=cytube) available) for https://cytu.be and general questions about using the software. If nobody is available on IRC, or you want to speak privately, email one of the contacts on https://cytu.be/contact.

View file

@ -1,29 +0,0 @@
Restricting New Accounts from Chat
==================================
With the rising availability and popularity of VPNs and proxies, dedicated
trolls may often come back again and again with a new proxy after being IP
banned and continue spamming. In order to combat this, a new feature has been
added to make it more difficult to rejoin quickly and continue spamming.
Channel moderators now have the ability to configure 2 different settings:
* How long an account must be active before the user can send any chat message
* How long an account must be active before the user can send a chat message
containing a link
This limit applies to both chat messages sent to the channel as well as private
messages. Both of these settings can be configured from the Channel Settings
menu at the top of the page, under the General Settings tab. By default,
accounts must be at least 10 minutes old to chat, and 1 hour old to send links
in chat. Setting either restriction to 0 will disable that restriction.
The age of an account is determined as follows:
* If the user is logged in as a registered account, the registration time of
the account is used.
* Otherwise, the timestamp of the session cookie is used.
The session cookie is set whenever a user first joins a channel, and is reset
whenever the user's IP address changes. Different browsers will have different
session cookies.

View file

@ -1,59 +0,0 @@
# Raw Videos / Audio #
Want to host your own video/audio files for use on CyTube? For servers with the
ffprobe module enabled, CyTube supports this! However, in order to provide a
consistent experience, there are limitations.
## Hosting the File ##
CyTube requires a direct link to the file in order to query it for metadata such
as duration and encoding. The website where you host the file needs to be able
to serve the video directly (rather than embedding it in a flash
player/iframe/etc.). It also needs to serve the correct MIME type for the video
in the `Content-Type` HTTP header, e.g. `video/mp4`.
I don't recommend hosting videos on Dropbox-type services, as they aren't built
to distribute video to many users at a time and often have strict bandwidth
limits. File hosting sites such as Putlocker also cause problems due to being
unable to serve the file directly, or due to binding the link to the IP address
of the user who retrieved it. For best results when using raw video, host the
video yourself on a VPS or dedicated server with plenty of bandwidth.
Note that CyTube only queries the file for metadata, it does not proxy it for
users! Every user watching the video will be downloading it individually.
## Encoding the Video ##
Current internet browsers are very limited in what codecs they can play
natively. Accordingly, CyTube only supports a few codecs:
**Video**
* MP4 (AV1)
* MP4 (H.264)
* WebM (AV1)
* WebM (VP8)
* WebM (VP9)
* Ogg/Theora
**Audio**
* MP3
* Ogg/Vorbis
If your video is in some other format (such as MKV or AVI), then it will need to
be re-encoded. There are plenty of free programs available to re-encode video
files, such as [ffmpeg](http://ffmpeg.org/) and
[handbrake](http://handbrake.fr/).
For best results, encode as an MP4 using H.264. This is natively supported by
many browsers, and can also be played using a fallback flash player for older
browsers that don't support it natively. Always encode with the
[faststart](https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo)
flag.
### Subtitles ###
Unfortunately, soft-subtitles are not supported right now. This is something
that may be supported in the future, but currently if you need subtitles, they
will have to be hardsubbed onto the video itself.

View file

@ -1,57 +0,0 @@
Socket.IO Client Configuration
==============================
As of 2015-10-25, the legacy `/sioconfig` JavaScript for retrieving connection
information is being deprecated in favor of a new API. The purpose of this
change is to allow partitioning channels across multiple servers in order to
better handle increasing traffic.
To get the socket.io configuration for the server hosting a particular channel,
make a `GET` request to `/socketconfig/<channel name>.json`. The response will
be a JSON object containing a list of acceptable servers to connect to, or an
error message.
Examples:
```
GET /socketconfig/test.json
200 OK
{
"servers": [
{
"url": "https://localhost:8443",
"secure": true
},
{
"url": "http://localhost:1337",
"secure": false
},
{
"url": "https://local6:8443",
"secure": true,
"ipv6": true
},
{
"url": "http://local6:1337",
"secure": false,
"ipv6": true
}
]
}
GET /socketconfig/$invalid$.json
404 Not Found
{
"error": "Channel \"$invalid$\" does not exist."
}
```
Each entry in the `servers` array has `"secure":true` if the connection is
secured with TLS, otherwise it it is false. An entry with `"ipv6":true`
indicates that the server is listening on the IPv6 protocol.
You can pick any URL to connect socket.io to in order to join the specified
channel. I recommend picking one with `"secure":true`, only choosing an
insecure connection if implementing a TLS connection is infeasible.

View file

@ -1,55 +0,0 @@
# User Preferences #
From any CyTube channel, you can click the Options link at the top of the page to open a dialog where you can change your personal preferences. This page explains each of the available options.
## General ##
General interface preferences.
Setting | Description
--------|------------
Theme | Choose from different colorschemes for the website.
Layout | Choose from different layouts for elements on the page. Fluid layouts will expand to fill the entire window, while compact layouts will remain the same size. "Synchtube" layout is the same as the default layout, but mirrored.
Ignore Channel CSS | Don't load custom stylesheets for each channel. Requires a refresh to take effect.
Ignore Channel JavaScript | Don't load custom scripts for each channel. The Script Access tab allows you to manage your preferences on a per-channel basis, but if this setting is checked, scripts will be globally disallowed and you will not be prompted to accept them when joining a channel.
## Playback ##
Preferences for video playback and the playlist.
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.
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.
Quality Preference | Sets the preferred quality for player types that support quality selection (currently, this is YouTube, Vimeo, Dailymotion, Google Drive, and Google+). If your preferred quality is not available, the next lowest quality will be used.
## Chat ##
Preferences for the integrated chatroom.
Setting | Description
--------|------------
Show timestamps in chat | When enabled, a timestamp is prepended to each chat message. For example, `[09:45:10] message here`.
Sort userlist by rank | Controls whether the username list is sorted alphabetically and by rank, or just alphabetically.
Sort AFKers to bottom | When enabled, usernames of AFK users will be sorted to the bottom of the username list.
Blink page title on new messages | Controls the conditions under which the tab title blinks between the channel title and `*Chat*` when a new message arrives.
Notification sound on new messages | Controls the conditions under which a notification sound is played when a new message arrives.
Add a send button to chat | Adds a clickable button to send chat messages. Only really useful for virtual keyboards that lack a dedicated Enter key.
Disable chat emotes | Disables the automatic conversion of channel-defined emote codes to inline images.
## Script Access ##
Manage your preferences for allowing or denying custom scripts for channels you've visited. A channel will only appear here if you checked "Remember my preference" when allowing or denying a channel script. You can toggle the preference between "Allow" and "Deny", or click "Clear Preference" to remove the saved preference, so that you will be asked every time you join the channel.
## Moderator ##
Settings that only apply to channel moderators.
Setting | Description
--------|------------
Show name color | Colors your username in chat (the same color as in the username list). This setting is also controlled by the small button labeled "M" in the upper right corner of chat.
Show join messages | Display a message every time a user logs in to the chat.
Show shadowmuted messages | Show chat messages from shadowmuted users. These messages will appear ~~struck through~~, and only moderators with this setting enabled will see them.

View file

@ -1,243 +0,0 @@
// ==UserScript==
// @name Google Drive Video Player for {SITENAME}
// @namespace gdcytube
// @description Play Google Drive videos on {SITENAME}
// {INCLUDE_BLOCK}
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect docs.google.com
// @run-at document-end
// @version 1.7.0
// ==/UserScript==
try {
function debug(message) {
try {
unsafeWindow.console.log('[Drive]', message);
} catch (error) {
unsafeWindow.console.error(error);
}
}
function httpRequest(opts) {
if (typeof GM_xmlhttpRequest === 'undefined') {
// Assume GM4.0
debug('Using GM4.0 GM.xmlHttpRequest');
GM.xmlHttpRequest(opts);
} else {
debug('Using old-style GM_xmlhttpRequest');
GM_xmlhttpRequest(opts);
}
}
var ITAG_QMAP = {
37: 1080,
46: 1080,
22: 720,
45: 720,
59: 480,
44: 480,
35: 480,
18: 360,
43: 360,
34: 360
};
var ITAG_CMAP = {
43: 'video/webm',
44: 'video/webm',
45: 'video/webm',
46: 'video/webm',
18: 'video/mp4',
22: 'video/mp4',
37: 'video/mp4',
59: 'video/mp4',
35: 'video/flv',
34: 'video/flv'
};
function getVideoInfo(id, cb) {
var url = 'https://docs.google.com/get_video_info?authuser='
+ '&docid=' + id
+ '&sle=true'
+ '&hl=en';
debug('Fetching ' + url);
httpRequest({
method: 'GET',
url: url,
onload: function (res) {
try {
debug('Got response ' + res.responseText);
if (res.status !== 200) {
debug('Response status not 200: ' + res.status);
return cb(
'Google Drive request failed: HTTP ' + res.status
);
}
var data = {};
var error;
// Google Santa sometimes eats login cookies and gets mad if there aren't any.
if(/accounts\.google\.com\/ServiceLogin/.test(res.responseText)){
error = 'Google Docs request failed: ' +
'This video requires you be logged into a Google account. ' +
'Open your Gmail in another tab and then refresh video.';
return cb(error);
}
res.responseText.split('&').forEach(function (kv) {
var pair = kv.split('=');
data[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
});
if (data.status === 'fail') {
error = 'Google Drive request failed: ' +
unescape(data.reason).replace(/\+/g, ' ');
return cb(error);
}
if (!data.fmt_stream_map) {
error = (
'Google has removed the video streams associated' +
' with this item. It can no longer be played.'
);
return cb(error);
}
data.links = {};
data.fmt_stream_map.split(',').forEach(function (item) {
var pair = item.split('|');
data.links[pair[0]] = pair[1];
});
data.videoMap = mapLinks(data.links);
cb(null, data);
} catch (error) {
unsafeWindow.console.error(error);
}
},
onerror: function () {
var error = 'Google Drive request failed: ' +
'metadata lookup HTTP request failed';
error.reason = 'HTTP_ONERROR';
return cb(error);
}
});
}
function mapLinks(links) {
var videos = {
1080: [],
720: [],
480: [],
360: []
};
Object.keys(links).forEach(function (itag) {
itag = parseInt(itag, 10);
if (!ITAG_QMAP.hasOwnProperty(itag)) {
return;
}
videos[ITAG_QMAP[itag]].push({
itag: itag,
contentType: ITAG_CMAP[itag],
link: links[itag]
});
});
return videos;
}
/*
* Greasemonkey 2.0 has this wonderful sandbox that attempts
* to prevent script developers from shooting themselves in
* the foot by removing the trigger from the gun, i.e. it's
* impossible to cross the boundary between the browser JS VM
* and the privileged sandbox that can run GM_xmlhttpRequest().
*
* So in this case, we have to resort to polling a special
* variable to see if getGoogleDriveMetadata needs to be called
* and deliver the result into another special variable that is
* being polled on the browser side.
*/
/*
* Browser side function -- sets gdUserscript.pollID to the
* ID of the Drive video to be queried and polls
* gdUserscript.pollResult for the result.
*/
function getGoogleDriveMetadata_GM(id, callback) {
debug('Setting GD poll ID to ' + id);
unsafeWindow.gdUserscript.pollID = id;
var tries = 0;
var i = setInterval(function () {
if (unsafeWindow.gdUserscript.pollResult) {
debug('Got result');
clearInterval(i);
var result = unsafeWindow.gdUserscript.pollResult;
unsafeWindow.gdUserscript.pollResult = null;
callback(result.error, result.result);
} else if (++tries > 100) {
// Took longer than 10 seconds, give up
clearInterval(i);
}
}, 100);
}
/*
* Sandbox side function -- polls gdUserscript.pollID for
* the ID of a Drive video to be queried, looks up the
* metadata, and stores it in gdUserscript.pollResult
*/
function setupGDPoll() {
unsafeWindow.gdUserscript = cloneInto({}, unsafeWindow);
var pollInterval = setInterval(function () {
if (unsafeWindow.gdUserscript.pollID) {
var id = unsafeWindow.gdUserscript.pollID;
unsafeWindow.gdUserscript.pollID = null;
debug('Polled and got ' + id);
getVideoInfo(id, function (error, data) {
unsafeWindow.gdUserscript.pollResult = cloneInto({
error: error,
result: data
}, unsafeWindow);
});
}
}, 1000);
}
var TM_COMPATIBLES = [
'Tampermonkey',
'Violentmonkey' // https://github.com/calzoneman/sync/issues/713
];
function isTampermonkeyCompatible() {
try {
return TM_COMPATIBLES.indexOf(GM_info.scriptHandler) >= 0;
} catch (error) {
return false;
}
}
if (isTampermonkeyCompatible()) {
unsafeWindow.getGoogleDriveMetadata = getVideoInfo;
} else {
debug('Using non-TM polling workaround');
unsafeWindow.getGoogleDriveMetadata = exportFunction(
getGoogleDriveMetadata_GM, unsafeWindow);
setupGDPoll();
}
unsafeWindow.console.log('Initialized userscript Google Drive player');
unsafeWindow.hasDriveUserscript = true;
// Checked against GS_VERSION from data.js
unsafeWindow.driveUserscriptVersion = '1.7';
} catch (error) {
unsafeWindow.console.error(error);
}

View file

@ -1,37 +0,0 @@
var fs = require('fs');
var path = require('path');
var sitename = process.argv[2];
var includes = process.argv.slice(3).map(function (include) {
return '// @include ' + include;
}).join('\n');
var lines = String(fs.readFileSync(
path.resolve(__dirname, 'cytube-google-drive.user.js'))).split('\n');
var userscriptOutput = '';
var metaOutput = '';
lines.forEach(function (line) {
if (line.match(/\{INCLUDE_BLOCK\}/)) {
userscriptOutput += includes + '\n';
} else if (line.match(/\{SITENAME\}/)) {
line = line.replace(/\{SITENAME\}/, sitename) + '\n';
userscriptOutput += line;
metaOutput += line;
} else {
if (line.match(/==\/?UserScript|@name|@version/)) {
metaOutput += line + '\n';
}
userscriptOutput += line + '\n';
}
});
fs.writeFileSync(
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.user.js'),
userscriptOutput
);
fs.writeFileSync(
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.meta.js'),
metaOutput
);

110
index.js Executable file → Normal file
View file

@ -1,98 +1,28 @@
#!/usr/bin/env node
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
const ver = process.version.match(/v(\d+)\.\d+\.\d+/);
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:
if (parseInt(ver[1], 10) < 12) {
console.error(
`node.js ${process.version} is not supported. ` +
'CyTube requires node v12 or later.'
)
process.exit(1);
}
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
checkPlayerExists();
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const args = parseArgs();
var Server = require("./lib/server");
var Config = require("./lib/config");
var Logger = require("./lib/logger");
if (args.has('--daemonize')) {
fork();
} else {
try {
require('./lib/main');
} catch (err) {
console.error('FATAL: Failed to require() lib/main.js');
handleStartupError(err);
}
}
function fork() {
try {
console.log('Warning: --daemonize support is experimental. Use with caution.');
const spawn = require('child_process').spawn;
const path = require('path');
const main = path.resolve(__dirname, 'lib', 'main.js');
const child = spawn(process.argv[0], [main], {
detached: true,
stdio: 'ignore' // TODO: support setting stdout/stderr logfile
Config.load("cfg.json", function (cfg) {
var sv = Server.init(cfg);
if(!cfg["debug"]) {
process.on("uncaughtException", function (err) {
Logger.errlog.log("[SEVERE] Uncaught Exception: " + err);
Logger.errlog.log(err.stack);
});
child.unref();
console.log('Forked with PID ' + child.pid);
} catch (error) {
console.error('FATAL: Failed to fork lib/main.js');
handleStartupError(error);
process.on("SIGINT", function () {
sv.shutdown();
});
}
}
function handleStartupError(err) {
if (/module version mismatch/i.test(err.message)) {
console.error('Module version mismatch, try running `npm rebuild` or ' +
'removing the node_modules folder and re-running ' +
'`npm install`');
} else {
console.error('Possible causes:\n' +
' * You haven\'t run `npm run build-server` to regenerate ' +
'the runtime\n' +
' * You\'ve upgraded node/npm and haven\'t rebuilt dependencies ' +
'(try `npm rebuild` or `rm -rf node_modules && npm install`)\n' +
' * A dependency failed to install correctly (check the output ' +
'of `npm install` next time)');
}
console.error(err.stack);
process.exit(1);
}
function parseArgs() {
const args = new Map();
for (var i = 2; i < process.argv.length; i++) {
if (/^--/.test(process.argv[i])) {
var val;
if (i+1 < process.argv.length) val = process.argv[i+1];
else val = null;
args.set(process.argv[i], val);
}
}
return args;
}
function checkPlayerExists() {
const fs = require('fs');
const path = require('path');
const playerDotJs = path.join(__dirname, 'www', 'js', 'player.js');
if (!fs.existsSync(playerDotJs)) {
console.error(
'Missing video player: www/js/player.js. This should have been ' +
'automatically generated by the postinstall step of ' +
'`npm install`, but you can manually regenerate it by running ' +
'`npm run build-player`'
);
process.exit(1);
}
}
});

View file

@ -1,603 +0,0 @@
const assert = require('assert');
const KickbanModule = require('../../lib/channel/kickban');
const database = require('../../lib/database');
const Promise = require('bluebird');
const testDB = require('../testutil/db').testDB;
database.init(testDB);
describe('KickbanModule', () => {
const channelName = `test_${Math.random().toString(31).substring(2)}`;
let mockChannel;
let mockUser;
let kickban;
beforeEach(() => {
mockChannel = {
name: channelName,
refCounter: {
ref() { },
unref() { }
},
logger: {
log() { }
},
modules: {
permissions: {
canBan() {
return true;
}
}
},
users: []
};
mockUser = {
getName() {
return 'The_Admin';
},
getLowerName() {
return 'the_admin';
},
socket: {
emit(frame) {
if (frame === 'errorMsg') {
throw new Error(arguments[1].msg);
}
}
},
account: {
effectiveRank: 3
}
};
kickban = new KickbanModule(mockChannel);
});
afterEach(async () => {
await database.getDB().runTransaction(async tx => {
await tx.table('channel_bans')
.where({ channel: channelName })
.del();
await tx.table('channel_ranks')
.where({ channel: channelName })
.del();
});
});
describe('#handleCmdBan', () => {
it('inserts a valid ban', done => {
let kicked = false;
mockChannel.refCounter.unref = () => {
assert(kicked, 'Expected user to be kicked');
database.getDB().runTransaction(async tx => {
const ban = await tx.table('channel_bans')
.where({
channel: channelName,
name: 'test_user'
})
.first();
assert.strictEqual(ban.ip, '*');
assert.strictEqual(ban.reason, 'because reasons');
assert.strictEqual(ban.bannedby, mockUser.getName());
done();
});
};
mockChannel.users = [{
getLowerName() {
return 'test_user';
},
kick(reason) {
assert.strictEqual(reason, "You're banned!");
kicked = true;
}
}];
kickban.handleCmdBan(
mockUser,
'/ban test_user because reasons',
{}
);
});
it('rejects if the username is invalid', done => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'Invalid username'
);
done();
}
};
kickban.handleCmdBan(
mockUser,
'/ban test_user<>%$# because reasons',
{}
);
});
it('rejects if the user does not have ban permission', done => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'You do not have ban permissions on this channel'
);
done();
}
};
mockChannel.modules.permissions.canBan = () => false;
kickban.handleCmdBan(
mockUser,
'/ban test_user because reasons',
{}
);
});
it('rejects if the user tries to ban themselves', done => {
let costanza = false;
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'You cannot ban yourself'
);
if (!costanza) {
throw new Error('Expected costanza for banning self');
}
done();
} else if (frame === 'costanza') {
assert.strictEqual(
obj.msg,
"You can't ban yourself"
);
costanza = true;
}
};
kickban.handleCmdBan(
mockUser,
'/ban the_Admin because reasons',
{}
);
});
it('rejects if the user is ranked below the ban recipient', done => {
database.getDB().runTransaction(tx => {
return tx.table('channel_ranks')
.insert({
channel: channelName,
name: 'test_user',
rank: 5
});
}).then(() => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
"You don't have permission to ban test_user"
);
done();
}
};
kickban.handleCmdBan(
mockUser,
'/ban test_user because reasons',
{}
);
});
});
it('rejects if the the ban recipient is already banned', done => {
database.getDB().runTransaction(tx => {
return tx.table('channel_bans')
.insert({
channel: channelName,
name: 'test_user',
ip: '*',
bannedby: 'somebody',
reason: 'I dunno'
});
}).then(() => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'test_user is already banned'
);
done();
}
};
kickban.handleCmdBan(
mockUser,
'/ban test_user because reasons',
{}
);
});
});
});
describe('#handleCmdIPBan', () => {
beforeEach(async () => {
await database.getDB().runTransaction(async tx => {
await tx.table('aliases')
.insert([{
name: 'test_user',
ip: '1.2.3.4',
time: Date.now()
}]);
});
});
afterEach(async () => {
await database.getDB().runTransaction(async tx => {
await tx.table('aliases')
.where({ name: 'test_user' })
.orWhere({ ip: '1.2.3.4' })
.del();
});
});
it('inserts a valid ban', done => {
let firstUserKicked = false;
let secondUserKicked = false;
mockChannel.refCounter.unref = () => {
assert(firstUserKicked, 'Expected banned user to be kicked');
assert(
secondUserKicked,
'Expected user with banned IP to be kicked'
);
database.getDB().runTransaction(async tx => {
const nameBan = await tx.table('channel_bans')
.where({
channel: channelName,
name: 'test_user',
ip: '*'
})
.first();
assert.strictEqual(nameBan.reason, 'because reasons');
assert.strictEqual(nameBan.bannedby, mockUser.getName());
const ipBan = await tx.table('channel_bans')
.where({
channel: channelName,
ip: '1.2.3.4'
})
.first();
assert.strictEqual(ipBan.name, 'test_user');
assert.strictEqual(ipBan.reason, 'because reasons');
assert.strictEqual(ipBan.bannedby, mockUser.getName());
done();
});
};
mockChannel.users = [{
getLowerName() {
return 'test_user';
},
realip: '1.2.3.4',
kick(reason) {
assert.strictEqual(reason, "You're banned!");
firstUserKicked = true;
}
}, {
getLowerName() {
return 'second_user_same_ip';
},
realip: '1.2.3.4',
kick(reason) {
assert.strictEqual(reason, "You're banned!");
secondUserKicked = true;
}
}];
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
it('inserts a valid range ban', done => {
mockChannel.refCounter.unref = () => {
database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans')
.where({
channel: channelName,
ip: '1.2.3'
})
.first();
assert.strictEqual(ipBan.name, 'test_user');
assert.strictEqual(ipBan.reason, 'because reasons');
assert.strictEqual(ipBan.bannedby, mockUser.getName());
done();
});
};
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user range because reasons',
{}
);
});
it('inserts a valid wide-range ban', done => {
mockChannel.refCounter.unref = () => {
database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans')
.where({
channel: channelName,
ip: '1.2'
})
.first();
assert.strictEqual(ipBan.name, 'test_user');
assert.strictEqual(ipBan.reason, 'because reasons');
assert.strictEqual(ipBan.bannedby, mockUser.getName());
done();
});
};
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user wrange because reasons',
{}
);
});
it('inserts a valid IPv6 ban', done => {
const longIP = require('../../lib/utilities').expandIPv6('::abcd');
mockChannel.refCounter.unref = () => {
database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans')
.where({
channel: channelName,
ip: longIP
})
.first();
assert.strictEqual(ipBan.name, 'test_user');
assert.strictEqual(ipBan.reason, 'because reasons');
assert.strictEqual(ipBan.bannedby, mockUser.getName());
done();
});
};
database.getDB().runTransaction(async tx => {
await tx.table('aliases')
.insert({
name: 'test_user',
ip: longIP,
time: Date.now()
});
}).then(() => {
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
});
it('rejects if the user does not have ban permission', done => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'You do not have ban permissions on this channel'
);
done();
}
};
mockChannel.modules.permissions.canBan = () => false;
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
it('rejects if the user tries to ban themselves', done => {
let costanza = false;
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'You cannot ban yourself'
);
if (!costanza) {
throw new Error('Expected costanza for banning self');
}
done();
} else if (frame === 'costanza') {
assert.strictEqual(
obj.msg,
"You can't ban yourself"
);
costanza = true;
}
};
kickban.handleCmdIPBan(
mockUser,
'/ipban the_Admin because reasons',
{}
);
});
it('rejects if the user is ranked below the ban recipient', done => {
database.getDB().runTransaction(tx => {
return tx.table('channel_ranks')
.insert({
channel: channelName,
name: 'test_user',
rank: 5
});
}).then(() => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
"You don't have permission to ban IP " +
"09l.TFb.5To.HBB"
);
done();
}
};
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
});
it('rejects if the user is ranked below an alias of the ban recipient', done => {
database.getDB().runTransaction(async tx => {
await tx.table('channel_ranks')
.insert({
channel: channelName,
name: 'another_user',
rank: 5
});
await tx.table('aliases')
.insert({
name: 'another_user',
ip: '1.2.3.3', // different IP, same /24 range
time: Date.now()
});
}).then(() => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
"You don't have permission to ban IP " +
"09l.TFb.5To.*"
);
done();
}
};
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user range because reasons',
{}
);
});
});
it('rejects if the the ban recipient IP is already banned', done => {
database.getDB().runTransaction(tx => {
return tx.table('channel_bans')
.insert({
channel: channelName,
name: 'another_user',
ip: '1.2.3.4',
bannedby: 'somebody',
reason: 'I dunno'
});
}).then(() => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'09l.TFb.5To.HBB is already banned'
);
done();
}
};
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
});
it('still adds the IP ban even if the name is already banned', done => {
mockChannel.refCounter.unref = () => {
database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans')
.where({
channel: channelName,
ip: '1.2.3.4'
})
.first();
assert.strictEqual(ipBan.name, 'test_user');
assert.strictEqual(ipBan.reason, 'because reasons');
assert.strictEqual(ipBan.bannedby, mockUser.getName());
done();
});
};
database.getDB().runTransaction(tx => {
return tx.table('channel_bans')
.insert({
channel: channelName,
name: 'test_user',
ip: '*',
bannedby: 'somebody',
reason: 'I dunno'
});
}).then(() => {
kickban.handleCmdIPBan(
mockUser,
'/ipban test_user because reasons',
{}
);
});
});
});
});

View file

@ -1,109 +0,0 @@
const assert = require('assert');
const { BannedChannelsController } = require('../../lib/controller/banned-channels');
const dbChannels = require('../../lib/database/channels');
const testDB = require('../testutil/db').testDB;
const { EventEmitter } = require('events');
require('../../lib/database').init(testDB);
const testBan = {
name: 'ban_test_1',
externalReason: 'because I said so',
internalReason: 'illegal content',
bannedBy: 'admin'
};
async function cleanupTestBan() {
return dbChannels.removeBannedChannel(testBan.name);
}
describe('BannedChannelsController', () => {
let controller;
let messages;
beforeEach(async () => {
await cleanupTestBan();
messages = new EventEmitter();
controller = new BannedChannelsController(
dbChannels,
messages
);
});
afterEach(async () => {
await cleanupTestBan();
});
it('bans a channel', async () => {
assert.strictEqual(await controller.getBannedChannel(testBan.name), null);
let received = null;
messages.once('ChannelBanned', cb => {
received = cb;
});
await controller.banChannel(testBan);
let info = await controller.getBannedChannel(testBan.name);
for (let field of Object.keys(testBan)) {
// Consider renaming parameter to avoid this branch
if (field === 'name') {
assert.strictEqual(info.channelName, testBan.name);
} else {
assert.strictEqual(info[field], testBan[field]);
}
}
assert.notEqual(received, null);
assert.strictEqual(received.channel, testBan.name);
assert.strictEqual(received.externalReason, testBan.externalReason);
});
it('updates an existing ban', async () => {
let received = [];
messages.on('ChannelBanned', cb => {
received.push(cb);
});
await controller.banChannel(testBan);
let testBan2 = { ...testBan, externalReason: 'because of reasons' };
await controller.banChannel(testBan2);
let info = await controller.getBannedChannel(testBan2.name);
for (let field of Object.keys(testBan2)) {
// Consider renaming parameter to avoid this branch
if (field === 'name') {
assert.strictEqual(info.channelName, testBan2.name);
} else {
assert.strictEqual(info[field], testBan2[field]);
}
}
assert.deepStrictEqual(received, [
{
channel: testBan.name,
externalReason: testBan.externalReason
},
{
channel: testBan2.name,
externalReason: testBan2.externalReason
},
]);
});
it('unbans a channel', async () => {
let received = null;
messages.once('ChannelUnbanned', cb => {
received = cb;
});
await controller.banChannel(testBan);
await controller.unbanChannel(testBan.name, testBan.bannedBy);
let info = await controller.getBannedChannel(testBan.name);
assert.strictEqual(info, null);
assert.notEqual(received, null);
assert.strictEqual(received.channel, testBan.name);
});
});

View file

@ -1,88 +0,0 @@
const assert = require('assert');
const { testDB } = require('../testutil/db');
const accounts = require('../../lib/database/accounts');
require('../../lib/database').init(testDB);
describe('AccountsDatabase', () => {
describe('#verifyLogin', () => {
let ip = '169.254.111.111';
let user;
let password;
beforeEach(async () => {
return testDB.knex.table('users')
.where({ ip })
.delete();
});
beforeEach(done => {
user = `u${Math.random().toString(31).substring(2)}`;
password = 'int!gration_Test';
accounts.register(
user,
password,
'',
ip,
(error, res) => {
if (error) {
throw error;
}
console.log(`Created test user ${user}`);
done();
}
)
});
it('verifies a correct login', done => {
accounts.verifyLogin(
user,
password,
(error, res) => {
if (error) {
throw error;
}
assert.strictEqual(res.name, user);
done();
}
);
});
it('verifies a correct login with an older hash', done => {
testDB.knex.table('users')
.where({ name: user })
.update({
// 'test' hashed with old version of bcrypt module
password: '$2b$10$2oCG7O9FFqie7T8O33yQDugFPS0NqkgbQjtThTs7Jr8E1QOzdRruK'
})
.then(() => {
accounts.verifyLogin(
user,
'test',
(error, res) => {
if (error) {
throw error;
}
assert.strictEqual(res.name, user);
done();
}
);
});
});
it('rejects an incorrect login', done => {
accounts.verifyLogin(
user,
'not the right password',
(error, res) => {
assert.strictEqual(error, 'Invalid username/password combination');
done();
}
);
});
});
});

View file

@ -1,76 +0,0 @@
const assert = require('assert');
const AliasesDB = require('../../lib/db/aliases').AliasesDB;
const testDB = require('../testutil/db').testDB;
const aliasesDB = new AliasesDB(testDB);
const testIPs = ['111.111.111.111', '111.111.111.222'];
const testNames = ['itest1', 'itest2'];
function cleanup() {
return testDB.knex.table('aliases')
.where('ip', 'in', testIPs)
.del()
.then(() => {
return testDB.knex.table('aliases')
.where('name', 'in', testNames)
.del();
});
}
function addSomeAliases() {
return cleanup().then(() => {
return testDB.knex.table('aliases')
.insert([
{ ip: testIPs[0], name: testNames[0], time: Date.now() },
{ ip: testIPs[0], name: testNames[1], time: Date.now() },
{ ip: testIPs[1], name: testNames[1], time: Date.now() }
]);
});
}
describe('AliasesDB', () => {
describe('#addAlias', () => {
beforeEach(cleanup);
afterEach(cleanup);
it('adds a new alias', () => {
return aliasesDB.addAlias(testIPs[0], testNames[0])
.then(() => {
return testDB.knex.table('aliases')
.where({ ip: testIPs[0], name: testNames[0] })
.select()
.then(rows => {
assert.strictEqual(rows.length, 1, 'expected 1 row');
});
});
});
});
describe('#getAliasesByIP', () => {
beforeEach(addSomeAliases);
afterEach(cleanup);
it('retrieves aliases by IP', () => {
return aliasesDB.getAliasesByIP(testIPs[0])
.then(names => assert.deepStrictEqual(
names.sort(), testNames.sort()));
});
it('retrieves aliases by partial IP', () => {
return aliasesDB.getAliasesByIP(testIPs[0].substring(4))
.then(names => assert.deepStrictEqual(
names.sort(), testNames.sort()));
});
});
describe('#getIPsByName', () => {
beforeEach(addSomeAliases);
afterEach(cleanup);
it('retrieves IPs by name', () => {
return aliasesDB.getIPsByName(testNames[1])
.then(ips => assert.deepStrictEqual(
ips.sort(), testIPs.sort()));
});
});
});

View file

@ -1,92 +0,0 @@
const assert = require('assert');
const GlobalBanDB = require('../../lib/db/globalban').GlobalBanDB;
const testDB = require('../testutil/db').testDB;
const { o } = require('../testutil/o');
const globalBanDB = new GlobalBanDB(testDB);
const testBan = { ip: '8.8.8.8', reason: 'test' };
function cleanupTestBan() {
return testDB.knex.table('global_bans')
.where({ ip: testBan.ip })
.del();
}
function setupTestBan() {
return testDB.knex.table('global_bans')
.insert(testBan)
.catch(error => {
if (error.code === 'ER_DUP_ENTRY') {
return testDB.knex.table('global_bans')
.where({ ip: testBan.ip })
.update({ reason: testBan.reason });
}
throw error;
});
}
describe('GlobalBanDB', () => {
describe('#listGlobalBans', () => {
beforeEach(setupTestBan);
afterEach(cleanupTestBan);
it('lists existing IP bans', () => {
return globalBanDB.listGlobalBans().then(bans => {
assert.deepStrictEqual([{
ip: '8.8.8.8',
reason: 'test'
}], bans.map(o));
});
});
});
describe('#addGlobalIPBan', () => {
beforeEach(cleanupTestBan);
afterEach(cleanupTestBan);
it('adds a new ban', () => {
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
return testDB.knex.table('global_bans')
.where({ ip: '8.8.8.8' })
.select()
.then(rows => {
assert.strictEqual(rows.length, 1, 'Expected 1 row');
assert.strictEqual(rows[0].ip, '8.8.8.8');
assert.strictEqual(rows[0].reason, 'test');
});
});
});
it('updates the reason on an existing ban', () => {
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
return globalBanDB.addGlobalIPBan('8.8.8.8', 'different').then(() => {
return testDB.knex.table('global_bans')
.where({ ip: '8.8.8.8' })
.select()
.then(rows => {
assert.strictEqual(rows.length, 1, 'Expected 1 row');
assert.strictEqual(rows[0].ip, '8.8.8.8');
assert.strictEqual(rows[0].reason, 'different');
});
});
});
});
});
describe('#removeGlobalIPBan', () => {
beforeEach(setupTestBan);
afterEach(cleanupTestBan);
it('removes a ban', () => {
return globalBanDB.removeGlobalIPBan('8.8.8.8').then(() => {
return testDB.knex.table('global_bans')
.where({ ip: '8.8.8.8' })
.select()
.then(rows => {
assert.strictEqual(rows.length, 0, 'Expected 0 rows');
});
});
});
});
});

View file

@ -1,144 +0,0 @@
const assert = require('assert');
const PasswordResetDB = require('../../lib/db/password-reset').PasswordResetDB;
const testDB = require('../testutil/db').testDB;
const { o } = require('../testutil/o');
const passwordResetDB = new PasswordResetDB(testDB);
function cleanup() {
return testDB.knex.table('password_reset').del();
}
describe('PasswordResetDB', () => {
describe('#insert', () => {
beforeEach(cleanup);
const params = {
ip: '1.2.3.4',
name: 'testing',
email: 'test@example.com',
hash: 'abcdef',
expire: 5678
};
it('adds a new password reset', () => {
return passwordResetDB.insert(params).then(() => {
return testDB.knex.table('password_reset')
.where({ name: 'testing' })
.select();
}).then(rows => {
assert.strictEqual(rows.length, 1);
assert.deepStrictEqual(o(rows[0]), params);
});
});
it('overwrites an existing reset for the same name', () => {
return passwordResetDB.insert(params).then(() => {
params.ip = '5.6.7.8';
params.email = 'somethingelse@example.com';
params.hash = 'qwertyuiop';
params.expire = 9999;
return passwordResetDB.insert(params);
}).then(() => {
return testDB.knex.table('password_reset')
.where({ name: 'testing' })
.select();
}).then(rows => {
assert.strictEqual(rows.length, 1);
assert.deepStrictEqual(o(rows[0]), params);
});
});
});
describe('#get', () => {
const reset = {
ip: '1.2.3.4',
name: 'testing',
email: 'test@example.com',
hash: 'abcdef',
expire: 5678
};
beforeEach(() => cleanup().then(() => {
return testDB.knex.table('password_reset').insert(reset);
}));
it('gets a password reset by hash', () => {
return passwordResetDB.get(reset.hash).then(result => {
assert.deepStrictEqual(o(result), reset);
});
});
it('throws when no reset exists for the input', () => {
return passwordResetDB.get('lalala').then(() => {
assert.fail('Expected not found error');
}).catch(error => {
assert.strictEqual(
error.message,
'No password reset found for hash lalala'
);
});
});
});
describe('#delete', () => {
const reset = {
ip: '1.2.3.4',
name: 'testing',
email: 'test@example.com',
hash: 'abcdef',
expire: 5678
};
beforeEach(() => cleanup().then(() => {
return testDB.knex.table('password_reset').insert(reset);
}));
it('deletes a password reset by hash', () => {
return passwordResetDB.delete(reset.hash).then(() => {
return testDB.knex.table('password_reset')
.where({ name: 'testing' })
.select();
}).then(rows => {
assert.strictEqual(rows.length, 0);
});
});
});
describe('#cleanup', () => {
const now = Date.now();
const reset1 = {
ip: '1.2.3.4',
name: 'testing',
email: 'test@example.com',
hash: 'abcdef',
expire: now - 25 * 60 * 60 * 1000
};
const reset2 = {
ip: '5.6.7.8',
name: 'testing2',
email: 'test@example.com',
hash: 'abcdef',
expire: now
};
beforeEach(() => cleanup().then(() => {
return testDB.knex.table('password_reset')
.insert([reset1, reset2]);
}));
it('cleans up old password resets', () => {
return passwordResetDB.cleanup().then(() => {
return testDB.knex.table('password_reset')
.whereIn('name', ['testing1', 'testing2'])
.select();
}).then(rows => {
assert.strictEqual(rows.length, 1);
assert.deepStrictEqual(o(rows[0]), reset2);
});
});
});
});

View file

@ -1,136 +0,0 @@
const assert = require('assert');
const KickbanModule = require('../../lib/channel/kickban');
const database = require('../../lib/database');
const dbChannels = require('../../lib/database/channels');
const Promise = require('bluebird');
const ChannelModule = require('../../lib/channel/module');
const Flags = require('../../lib/flags');
const testDB = require('../testutil/db').testDB;
function randomString(length) {
const chars = 'abcdefgihkmnpqrstuvwxyz0123456789';
let str = '';
for (let i = 0; i < length; i++) {
str += chars[Math.floor(Math.random() * chars.length)];
}
return str;
}
database.init(testDB);
describe('onPreUserJoin Ban Check', () => {
const channelName = `test_${randomString(20)}`;
const bannedIP = '1.1.1.1';
const bannedName = 'troll';
const mockChannel = {
name: channelName,
modules: {},
is(flag) {
return flag === Flags.C_REGISTERED;
}
};
const module = new KickbanModule(mockChannel);
before(done => {
dbChannels.ban(channelName, bannedIP, bannedName, '', '', () => {
dbChannels.ban(channelName, bannedIP, '', '', '', () => {
dbChannels.ban(channelName, '*', bannedName, '', '', () => {
done();
});
});
});
});
after(done => {
dbChannels.deleteBans(channelName, null, () => {
done();
});
});
it('handles a banned IP with a different name', done => {
const user = {
getName() {
return 'anotherTroll';
},
realip: bannedIP,
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
assert.equal(error, null, `Unexpected error: ${error}`);
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
done();
});
});
it('handles a banned name with a different IP', done => {
const user = {
getName() {
return 'troll';
},
realip: '5.5.5.5',
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
assert.equal(error, null, `Unexpected error: ${error}`);
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
done();
});
});
it('handles a banned IP with a blank name', done => {
const user = {
getName() {
return '';
},
realip: bannedIP,
kick() {
}
};
module.onUserPreJoin(user, null, (error, res) => {
assert.equal(error, null, `Unexpected error: ${error}`);
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
done();
});
});
it('handles a non-banned IP with a blank name', done => {
const user = {
getName() {
return '';
},
realip: '5.5.5.5'
};
module.onUserPreJoin(user, null, (error, res) => {
assert.equal(error, null, `Unexpected error: ${error}`);
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
done();
});
});
it('handles a non-banned IP with a non-banned name', done => {
const user = {
getName() {
return 'some_user';
},
realip: '5.5.5.5'
};
module.onUserPreJoin(user, null, (error, res) => {
assert.equal(error, null, `Unexpected error: ${error}`);
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
done();
});
});
});

View file

@ -1,14 +0,0 @@
const loadFromToml = require('../../lib/configuration/configloader').loadFromToml;
const path = require('path');
class IntegrationTestConfig {
constructor(config) {
this.config = config;
}
get knexConfig() {
return this.config.database;
}
}
exports.testConfig = loadFromToml(IntegrationTestConfig, path.resolve(__dirname, '..', '..', 'conf', 'integration-test.toml'));

View file

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

View file

@ -1,4 +0,0 @@
exports.o = function o(obj) {
// Workaround for knex returning RowDataPacket and failing assertions
return Object.assign({}, obj);
}

179
lib/acp.js Normal file
View file

@ -0,0 +1,179 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Logger = require("./logger");
var Server = require("./server");
var ActionLog = require("./actionlog");
module.exports = {
init: function (user) {
var sv = Server.getServer();
var db = sv.db;
ActionLog.record(user.ip, user.name, "acp-init");
user.socket.on("acp-announce", function(data) {
ActionLog.record(user.ip, user.name, "acp-announce", data);
sv.announcement = data;
sv.io.sockets.emit("announcement", data);
if (sv.cfg["enable-ssl"])
sv.ioSecure.sockets.emit("announcement", data);
});
user.socket.on("acp-announce-clear", function() {
ActionLog.record(user.ip, user.name, "acp-announce-clear");
sv.announcement = null;
});
user.socket.on("acp-global-ban", function(data) {
ActionLog.record(user.ip, user.name, "acp-global-ban", data.ip);
db.setGlobalIPBan(data.ip, data.note, function (err, res) {
db.listGlobalIPBans(function (err, res) {
res = res || [];
user.socket.emit("acp-global-banlist", res);
});
});
});
user.socket.on("acp-global-unban", function(ip) {
ActionLog.record(user.ip, user.name, "acp-global-unban", ip);
db.clearGlobalIPBan(ip, function (err, res) {
db.listGlobalIPBans(function (err, res) {
res = res || [];
user.socket.emit("acp-global-banlist", res);
});
});
});
db.listGlobalIPBans(function (err, res) {
res = res || [];
user.socket.emit("acp-global-banlist", res);
});
user.socket.on("acp-lookup-user", function(name) {
db.searchUser(name, function (err, res) {
res = res || [];
user.socket.emit("acp-userdata", res);
});
});
user.socket.on("acp-lookup-channel", function (data) {
db.searchChannel(data.field, data.value, function (e, res) {
res = res || [];
user.socket.emit("acp-channeldata", res);
});
});
user.socket.on("acp-reset-password", function(data) {
db.getGlobalRank(data.name, function (err, rank) {
if(err || rank >= user.global_rank)
return;
db.genPasswordReset(user.ip, data.name, data.email,
function (err, hash) {
var pkt = {
success: !err
};
if(err) {
pkt.error = err;
} else {
pkt.hash = hash;
}
user.socket.emit("acp-reset-password", pkt);
ActionLog.record(user.ip, user.name,
"acp-reset-password", data.name);
});
});
});
user.socket.on("acp-set-rank", function(data) {
if(data.rank < 1 || data.rank >= user.global_rank)
return;
db.getGlobalRank(data.name, function (err, rank) {
if(err || rank >= user.global_rank)
return;
db.setGlobalRank(data.name, data.rank,
function (err, res) {
ActionLog.record(user.ip, user.name, "acp-set-rank",
data);
if(!err)
user.socket.emit("acp-set-rank", data);
});
});
});
user.socket.on("acp-list-loaded", function() {
var chans = [];
var all = sv.channels;
for(var c in all) {
var chan = all[c];
chans.push({
name: chan.name,
title: chan.opts.pagetitle,
usercount: chan.users.length,
mediatitle: chan.playlist.current ? chan.playlist.current.media.title : "-",
is_public: chan.opts.show_public,
registered: chan.registered
});
}
user.socket.emit("acp-list-loaded", chans);
});
user.socket.on("acp-channel-unload", function(data) {
if(sv.isChannelLoaded(data.name)) {
var c = sv.getChannel(data.name);
if(!c)
return;
ActionLog.record(user.ip, user.name, "acp-channel-unload");
c.initialized = data.save;
// copy the list of users to prevent concurrent
// modification
var users = Array.prototype.slice.call(c.users);
users.forEach(function (u) {
c.kick(u, "Channel shutting down");
});
// At this point c should be unloaded
// if it's still loaded, kill it
if(sv.isChannelLoaded(data.name))
sv.unloadChannel(sv.getChannel(data.name));
}
});
user.socket.on("acp-actionlog-list", function () {
ActionLog.listActionTypes(function (err, types) {
if(!err)
user.socket.emit("acp-actionlog-list", types);
});
});
user.socket.on("acp-actionlog-clear", function(data) {
ActionLog.clear(data);
ActionLog.record(user.ip, user.name, "acp-actionlog-clear", data);
});
user.socket.on("acp-actionlog-clear-one", function(data) {
ActionLog.clearOne(data);
ActionLog.record(user.ip, user.name, "acp-actionlog-clear-one", data);
});
user.socket.on("acp-view-stats", function () {
db.listStats(function (err, res) {
if(!err)
user.socket.emit("acp-view-stats", res);
});
});
}
}

62
lib/actionlog.js Normal file
View file

@ -0,0 +1,62 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Logger = require("./logger");
var Server = require("./server");
module.exports = {
record: function (ip, name, action, args) {
var db = Server.getServer().db;
if(!args)
args = "";
else {
try {
args = JSON.stringify(args);
} catch(e) {
args = "";
}
}
db.recordAction(ip, name, action, args);
},
clear: function (actions) {
var db = Server.getServer().db;
db.clearActions(actions);
},
clearOne: function (item) {
var db = Server.getServer().db;
db.clearSingleAction(item);
},
throttleRegistrations: function (ip, callback) {
var db = Server.getServer().db;
db.recentRegistrationCount(ip, function (err, count) {
if(err) {
callback(err, null);
return;
}
callback(null, count > 4);
});
},
listActionTypes: function (callback) {
var db = Server.getServer().db;
db.listActionTypes(callback);
},
listActions: function (types, callback) {
var db = Server.getServer().db;
db.listActions(types, callback);
}
};

871
lib/api.js Normal file
View file

@ -0,0 +1,871 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Logger = require("./logger");
var fs = require("fs");
var path = require("path");
var $util = require("./utilities");
var ActionLog = require("./actionlog");
module.exports = function (Server) {
function getIP(req) {
var raw = req.connection.remoteAddress;
var forward = req.header("x-forwarded-for");
if((Server.cfg["trust-x-forward"] || raw === "127.0.0.1") && forward) {
var ip = forward.split(",")[0];
Logger.syslog.log("REVPROXY " + raw + " => " + ip);
if (ip === undefined) {
Logger.errlog.log("WTF: ip is undefined from getIP, forward=" + forward);
}
return ip;
}
return raw;
}
function getChannelData(channel) {
var data = {
name: channel.name,
loaded: true
};
data.pagetitle = channel.opts.pagetitle;
data.mediatitle = channel.playlist.current ?
channel.playlist.current.media.title :
"-";
data.usercount = channel.users.length;
data.voteskip_eligible = channel.calcVoteskipMax();
data.users = [];
for(var i in channel.users) {
if(channel.users[i].name !== "") {
var name = channel.users[i].name;
var rank = channel.users[i].rank;
if (rank >= 255) {
name = "!" + name;
} else if (rank >= 10) {
name = "~" + name;
} else if (rank >= 3) {
name = "&" + name;
} else if (rank === 2) {
name = "@" + name;
}
data.users.push(name);
}
}
data.chat = [];
for(var i in channel.chatbuffer)
data.chat.push(channel.chatbuffer[i]);
return data;
}
var app = Server.express;
var db = Server.db;
/* <https://en.wikipedia.org/wiki/Hyper_Text_Coffee_Pot_Control_Protocol> */
app.get("/api/coffee", function (req, res) {
res.send(418); // 418 I'm a teapot
});
/* REGION channels */
/* data about a specific channel */
app.get("/api/channels/:channel", function (req, res) {
var name = req.params.channel;
if(!$util.isValidChannelName(name)) {
res.send(404);
return;
}
var data = {
name: name,
loaded: false
};
var needPassword = false;
var chan = null;
if (Server.isChannelLoaded(name)) {
chan = Server.getChannel(name);
data = getChannelData(chan);
needPassword = chan.opts.password;
}
if (needPassword !== false) {
var pw = req.query.password;
if (pw !== needPassword) {
var uname = req.cookies.cytube_uname;
var session = req.cookies.cytube_session;
Server.db.userLoginSession(uname, session, function (err, row) {
if (err) {
res.status(403);
res.type("application/json");
res.jsonp({
error: "Password required to view this channel"
});
return;
}
if (chan !== null) {
chan.getRank(uname, function (err, rank) {
if (err || rank < 2) {
res.status(403);
res.type("application/json");
res.jsonp({
error: "Password required to view this channel"
});
return;
}
res.type("application/json");
res.jsonp(data);
});
}
});
return;
}
}
res.type("application/json");
res.jsonp(data);
});
/* data about all channels (filter= public or all) */
app.get("/api/allchannels/:filter", function (req, res) {
var filter = req.params.filter;
if(filter !== "public" && filter !== "all") {
res.send(400);
return;
}
var query = req.query;
// Listing non-public channels requires authenticating as an admin
if(filter !== "public") {
var name = query.name || "";
var session = query.session || "";
db.userLoginSession(name, session, function (err, row) {
if(err) {
if(err !== "Invalid session" &&
err !== "Session expired") {
res.send(500);
} else {
res.send(403);
}
return;
}
if(row.global_rank < 255) {
res.send(403);
return;
}
var channels = [];
for(var key in Server.channels) {
var channel = Server.channels[key];
channels.push(getChannelData(channel));
}
res.type("application/jsonp");
res.jsonp(channels);
});
}
// If we get here, the filter is public channels
var channels = [];
for(var key in Server.channels) {
var channel = Server.channels[key];
if(channel.opts.show_public && channel.opts.password === false)
channels.push(getChannelData(channel));
}
res.type("application/jsonp");
res.jsonp(channels);
});
/* ENDREGION channels */
/* REGION authentication, account management */
/* login */
app.post("/api/login", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name || "";
var pw = req.body.pw || "";
var session = req.body.session || "";
// for some reason CyTube previously allowed guest logins
// over the API...wat
if(!pw && !session) {
res.jsonp({
success: false,
error: "You must provide a password"
});
return;
}
db.userLogin(name, pw, session, function (err, row) {
if(err) {
if(err !== "Session expired")
ActionLog.record(getIP(req), name, "login-failure", err);
res.jsonp({
success: false,
error: err
});
return;
}
// Only record login-success for admins
if(row.global_rank >= 255)
ActionLog.record(getIP(req), name, "login-success");
res.jsonp({
success: true,
name: name,
session: row.session_hash
});
});
});
/* register an account */
app.post("/api/register", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name;
var pw = req.body.pw;
if (typeof name !== "string" ||
typeof pw !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
var ip = getIP(req);
// Limit registrations per IP within a certain time period
ActionLog.throttleRegistrations(ip, function (err, toomany) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
if(toomany) {
ActionLog.record(ip, name, "register-failure",
"Too many recent registrations");
res.jsonp({
success: false,
error: "Your IP address has registered too many " +
"accounts in the past 48 hours. Please wait " +
"a while before registering another."
});
return;
}
if(!pw) {
// costanza.jpg
res.jsonp({
success: false,
error: "You must provide a password"
});
return;
}
if(!$util.isValidUserName(name)) {
ActionLog.record(ip, name, "register-failure",
"Invalid name");
res.jsonp({
success: false,
error: "Invalid username. Valid usernames must be " +
"1-20 characters long and consist only of " +
"alphanumeric characters and underscores (_)"
});
return;
}
// db.registerUser checks if the name is taken already
db.registerUser(name, pw, function (err, session) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
ActionLog.record(ip, name, "register-success");
res.jsonp({
success: true,
session: session
});
});
});
});
/* password change */
app.post("/api/account/passwordchange", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name;
var oldpw = req.body.oldpw;
var newpw = req.body.newpw;
if (typeof name !== "string" ||
typeof oldpw !== "string" ||
typeof newpw !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
if(!oldpw || !newpw) {
res.jsonp({
success: false,
error: "Password cannot be empty"
});
return;
}
db.userLoginPassword(name, oldpw, function (err, row) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
db.setUserPassword(name, newpw, function (err, row) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
ActionLog.record(getIP(req), name, "password-change");
res.jsonp({
success: true
});
});
});
});
/* password reset */
app.post("/api/account/passwordreset", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name;
var email = req.body.email;
if (typeof name !== "string" ||
typeof email !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
var ip = getIP(req);
var hash = false;
db.genPasswordReset(ip, name, email, function (err, hash) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
ActionLog.record(ip, name, "password-reset-generate", email);
if(!Server.cfg["enable-mail"]) {
res.jsonp({
success: false,
error: "This server does not have email recovery " +
"enabled. Contact an administrator for " +
"assistance."
});
return;
}
if(!email) {
res.jsonp({
success: false,
error: "You don't have a recovery email address set. "+
"Contact an administrator for assistance."
});
return;
}
var msg = "A password reset request was issued for your " +
"account '"+ name + "' on " + Server.cfg["domain"] +
". This request is valid for 24 hours. If you did "+
"not initiate this, there is no need to take action."+
" To reset your password, copy and paste the " +
"following link into your browser: " +
Server.cfg["domain"] + "/reset.html?"+hash;
var mail = {
from: "CyTube Services <" + Server.cfg["mail-from"] + ">",
to: email,
subject: "Password reset request",
text: msg
};
Server.cfg["nodemailer"].sendMail(mail, function (err, response) {
if(err) {
Logger.errlog.log("mail fail: " + err);
res.jsonp({
success: false,
error: "Email send failed. Contact an administrator "+
"if this persists"
});
} else {
res.jsonp({
success: true
});
}
});
});
});
/* password recovery */
app.get("/api/account/passwordrecover", function (req, res) {
res.type("application/jsonp");
var hash = req.query.hash;
if (typeof hash !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
var ip = getIP(req);
db.recoverUserPassword(hash, function (err, auth) {
if(err) {
ActionLog.record(ip, "", "password-recover-failure", hash);
res.jsonp({
success: false,
error: err
});
return;
}
ActionLog.record(ip, auth.name, "password-recover-success");
res.jsonp({
success: true,
name: auth.name,
pw: auth.pw
});
});
});
/* profile retrieval */
app.get("/api/users/:user/profile", function (req, res) {
res.type("application/jsonp");
var name = req.params.user;
db.getUserProfile(name, function (err, profile) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
res.jsonp({
success: true,
profile_image: profile.profile_image,
profile_text: profile.profile_text
});
});
});
/* profile change */
app.post("/api/account/profile", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name;
var session = req.body.session;
var img = req.body.profile_image;
var text = req.body.profile_text;
if (typeof name !== "string" ||
typeof session !== "string" ||
typeof img !== "string" ||
typeof text !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
if (img.length > 255) {
img = img.substring(0, 255);
}
if (text.length > 255) {
text = text.substring(0, 255);
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
db.setUserProfile(name, { image: img, text: text },
function (err, dbres) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
res.jsonp({ success: true });
name = name.toLowerCase();
for(var i in Server.channels) {
var chan = Server.channels[i];
for(var j in chan.users) {
var user = chan.users[j];
if(user.name.toLowerCase() == name) {
user.profile = {
image: img,
text: text
};
chan.sendAll("setUserProfile", {
name: user.name,
profile: user.profile
});
}
}
}
});
});
});
/* set email */
app.post("/api/account/email", function (req, res) {
res.type("application/jsonp");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.body.name;
var pw = req.body.pw;
var email = req.body.email;
if (typeof name !== "string" ||
typeof pw !== "string" ||
typeof email !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
if(!email.match(/^[\w_\.]+@[\w_\.]+[a-z]+$/i)) {
res.jsonp({
success: false,
error: "Invalid email address"
});
return;
}
if(email.match(/.*@(localhost|127\.0\.0\.1)/i)) {
res.jsonp({ success: false,
error: "Nice try, but no"
});
return;
}
db.userLoginPassword(name, pw, function (err, row) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
db.setUserEmail(name, email, function (err, dbres) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
ActionLog.record(getIP(req), name, "email-update", email);
res.jsonp({
success: true,
session: row.session_hash
});
});
});
});
/* my channels */
app.get("/api/account/mychannels", function (req, res) {
res.type("application/jsonp");
var name = req.query.name;
var session = req.query.session;
if (typeof name !== "string" ||
typeof session !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
res.jsonp({
success: false,
error: err
});
return;
}
db.listUserChannels(name, function (err, dbres) {
if(err) {
res.jsonp({
success: false,
channels: []
});
return;
}
res.jsonp({
success: true,
channels: dbres
});
});
});
});
/* END REGION */
/* REGION log reading */
/* action log */
app.get("/api/logging/actionlog", function (req, res) {
res.type("application/jsonp");
var name = req.query.name;
var session = req.query.session;
var types = req.query.actions;
if (typeof name !== "string" ||
typeof session !== "string" ||
typeof types !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
if(err !== "Invalid session" &&
err !== "Session expired") {
res.send(500);
} else {
res.send(403);
}
return;
}
if(row.global_rank < 255) {
res.send(403);
return;
}
types = types.split(",");
ActionLog.listActions(types, function (err, actions) {
if(err)
actions = [];
res.jsonp(actions);
});
});
});
/* helper function to pipe the last N bytes of a file */
function pipeLast(res, file, len) {
fs.stat(file, function (err, data) {
if(err) {
res.send(500);
return;
}
var start = data.size - len;
if(start < 0)
start = 0;
var end = data.size - 1;
if(end < 0)
end = 0;
fs.createReadStream(file, { start: start, end: end })
.pipe(res);
});
}
app.get("/api/logging/syslog", function (req, res) {
res.type("text/plain");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.query.name;
var session = req.query.session;
if (typeof name !== "string" ||
typeof session !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
if(err !== "Invalid session" &&
err !== "Session expired") {
res.send(500);
} else {
res.send(403);
}
return;
}
if(row.global_rank < 255) {
res.send(403);
return;
}
pipeLast(res, path.join(__dirname, "../sys.log"), 1048576);
});
});
app.get("/api/logging/errorlog", function (req, res) {
res.type("text/plain");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.query.name;
var session = req.query.session;
if (typeof name !== "string" ||
typeof session !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
if(err !== "Invalid session" &&
err !== "Session expired") {
res.send(500);
} else {
res.send(403);
}
return;
}
if(row.global_rank < 255) {
res.send(403);
return;
}
pipeLast(res, path.join(__dirname, "../error.log"), 1048576);
});
});
app.get("/api/logging/channels/:channel", function (req, res) {
res.type("text/plain");
res.setHeader("Access-Control-Allow-Origin", "*");
var name = req.query.name;
var session = req.query.session;
if (typeof name !== "string" ||
typeof session !== "string") {
res.status(400);
res.jsonp({
success: false,
error: "Invalid request"
});
return;
}
db.userLoginSession(name, session, function (err, row) {
if(err) {
if(err !== "Invalid session" &&
err !== "Session expired") {
res.send(500);
} else {
res.send(403);
}
return;
}
if(row.global_rank < 255) {
res.send(403);
return;
}
var chan = req.params.channel || "";
if(!$util.isValidChannelName(chan)) {
res.send(400);
return;
}
fs.exists(path.join(__dirname, "../chanlogs", chan + ".log"),
function(exists) {
if(exists) {
pipeLast(res, path.join(__dirname, "../chanlogs",
chan + ".log"), 1048576);
} else {
res.send(404);
}
});
});
});
return null;
}

65
lib/asyncqueue.js Normal file
View file

@ -0,0 +1,65 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var AsyncQueue = function () {
this._q = [];
this._lock = false;
this._tm = 0;
};
AsyncQueue.prototype.next = function () {
if (this._q.length > 0) {
if (!this.lock())
return;
var item = this._q.shift();
var fn = item[0], tm = item[1];
this._tm = Date.now() + item[1];
fn(this);
}
};
AsyncQueue.prototype.lock = function () {
if (this._lock) {
if (this._tm > 0 && Date.now() > this._tm) {
this._tm = 0;
return true;
}
return false;
}
this._lock = true;
return true;
};
AsyncQueue.prototype.release = function () {
var self = this;
if (!self._lock)
return false;
self._lock = false;
setImmediate(function () {
self.next();
});
return true;
};
AsyncQueue.prototype.queue = function (fn) {
var self = this;
self._q.push([fn, 20000]);
self.next();
};
AsyncQueue.prototype.reset = function () {
this._q = [];
this._lock = false;
};
module.exports = AsyncQueue;

93
lib/bgtask.js Normal file
View file

@ -0,0 +1,93 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/*
bgtask.js
Registers background jobs to run periodically while the server is
running.
*/
var Logger = require("./logger");
var init = null;
/* Stats */
function initStats(Server) {
var STAT_INTERVAL = Server.cfg["stat-interval"];
var STAT_EXPIRE = Server.cfg["stat-max-age"];
setInterval(function () {
var db = Server.db;
var chancount = Server.channels.length;
var usercount = 0;
Server.channels.forEach(function (chan) {
usercount += chan.users.length;
});
var mem = process.memoryUsage().rss;
db.addStatPoint(Date.now(), usercount, chancount, mem, function () {
db.pruneStats(Date.now() - STAT_EXPIRE);
});
}, STAT_INTERVAL);
}
/* Alias cleanup */
function initAliasCleanup(Server) {
var CLEAN_INTERVAL = Server.cfg["alias-purge-interval"];
var CLEAN_EXPIRE = Server.cfg["alias-max-age"];
setInterval(function () {
Server.db.cleanOldAliases(CLEAN_EXPIRE, function (err) {
Logger.syslog.log("Cleaned old aliases");
if (err)
Logger.errlog.log(err);
});
}, CLEAN_INTERVAL);
}
/* Clean out old rate limiters */
function initIpThrottleCleanup(Server) {
setInterval(function () {
for (var ip in Server.ipThrottle) {
if (Server.ipThrottle[ip].lastTime < Date.now() - 60 * 1000) {
delete Server.ipThrottle[ip];
}
}
}, 5 * 60 * 1000);
}
function initChannelDumper(Server) {
var CHANNEL_SAVE_INTERVAL = Server.cfg["channel-save-interval"] * 60000;
setInterval(function () {
for (var i = 0; i < Server.channels.length; i++) {
var chan = Server.channels[i];
if (!chan.dead && chan.users && chan.users.length > 0) {
chan.saveDump();
}
}
}, CHANNEL_SAVE_INTERVAL);
}
module.exports = function (Server) {
if (init === Server) {
Logger.errlog.log("WARNING: Attempted to re-init background tasks");
return;
}
init = Server;
initStats(Server);
initAliasCleanup(Server);
initIpThrottleCleanup(Server);
initChannelDumper(Server);
};

2592
lib/channel.js Normal file

File diff suppressed because it is too large Load diff

383
lib/chatcommand.js Normal file
View file

@ -0,0 +1,383 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Logger = require("./logger.js");
var Poll = require("./poll").Poll;
var handlers = {
/* commands that send chat messages */
"me": function (chan, user, msg, meta) {
meta.addClass = "action";
meta.action = true;
chan.sendMessage(user, msg, meta);
},
"sp": function (chan, user, msg, meta) {
meta.addClass = "spoiler";
chan.sendMessage(user, msg, meta);
},
"say": function (chan, user, msg, meta) {
if (user.rank >= 1.5) {
meta.addClass = "shout";
meta.addClassToNameAndTimestamp = true;
meta.forceShowName = true;
chan.sendMessage(user, msg, meta);
}
},
"a": function (chan, user, msg, meta) {
if (user.global_rank < 255) {
return;
}
var superadminflair = {
labelclass: "label-important",
icon: "icon-globe"
};
var args = msg.split(" ");
var cargs = [];
for (var i = 0; i < args.length; i++) {
var a = args[i];
if (a.indexOf("!icon-") === 0) {
superadminflair.icon = a.substring(1);
} else if (a.indexOf("!label-") === 0) {
superadminflair.labelclass = a.substring(1);
} else {
cargs.push(a);
}
}
meta.superadminflair = superadminflair;
meta.forceShowName = true;
chan.sendMessage(user, cargs.join(" "), meta);
},
"poll": function (chan, user, msg, meta) {
handlePoll(chan, user, msg, false);
},
"hpoll": function (chan, user, msg, meta) {
handlePoll(chan, user, msg, true);
},
/* commands that do not send chat messages */
"afk": function (chan, user, msg, meta) {
user.setAFK(!user.meta.afk);
},
"mute": function (chan, user, msg, meta) {
handleMute(chan, user, msg.split(" "));
},
"smute": function (chan, user, msg, meta) {
handleShadowMute(chan, user, msg.split(" "));
},
"unmute": function (chan, user, msg, meta) {
handleUnmute(chan, user, msg.split(" "));
},
"kick": function (chan, user, msg, meta) {
handleKick(chan, user, msg.split(" "));
},
"ban": function (chan, user, msg, meta) {
handleBan(chan, user, msg.split(" "));
},
"ipban": function (chan, user, msg, meta) {
handleIPBan(chan, user, msg.split(" "));
},
"unban": function (chan, user, msg, meta) {
handleUnban(chan, user, msg.split(" "));
},
"clear": function (chan, user, msg, meta) {
handleClear(chan, user);
},
"clean": function (chan, user, msg, meta) {
handleClean(chan, user, msg);
},
"cleantitle": function (chan, user, msg, meta) {
handleCleanTitle(chan, user, msg);
}
};
var handlerList = [];
for (var key in handlers) {
handlerList.push({
// match /command followed by a space or end of string
re: new RegExp("^\\/" + key + "(?:\\s|$)"),
fn: handlers[key]
});
}
function handle(chan, user, msg, meta) {
// Special case because the drink command can vary
var m = msg.match(/^\/d(-?[0-9]*)(?:\s|$)(.*)/);
if (m) {
handleDrink(chan, user, m[1], m[2], meta);
return;
}
for (var i = 0; i < handlerList.length; i++) {
var h = handlerList[i];
if (msg.match(h.re)) {
var rest;
if (msg.indexOf(" ") >= 0) {
rest = msg.substring(msg.indexOf(" ") + 1);
} else {
rest = "";
}
h.fn(chan, user, rest, meta);
break;
}
}
}
function handleDrink(chan, user, count, msg, meta) {
if (!chan.hasPermission(user, "drink")) {
return;
}
if (count === "") {
count = 1;
}
count = parseInt(count);
if (isNaN(count)) {
return;
}
meta.drink = true;
meta.forceShowName = true;
meta.addClass = "drink";
chan.drinks += count;
chan.broadcastDrinks();
if (count < 0 && msg.trim() === "") {
return;
}
msg = msg + " drink!";
if (count !== 1) {
msg += " (x" + count + ")";
}
chan.sendMessage(user, msg, meta);
}
function handleShadowMute(chan, user, args) {
if (chan.hasPermission(user, "mute") && args.length > 0) {
args[0] = args[0].toLowerCase();
var person = false;
for (var i = 0; i < chan.users.length; i++) {
if (chan.users[i].name.toLowerCase() === args[0]) {
person = chan.users[i];
break;
}
}
if (person) {
if (person.rank >= user.rank) {
user.socket.emit("errorMsg", {
msg: "You don't have permission to mute that person."
});
return;
}
/* Reset a previous regular mute */
if (chan.mutedUsers.contains(person.name.toLowerCase())) {
chan.mutedUsers.remove(person.name.toLowerCase());
chan.sendAll("setUserIcon", {
name: person.name,
icon: false
});
}
chan.mutedUsers.add("[shadow]" + person.name.toLowerCase());
chan.logger.log("*** " + user.name + " shadow muted " + args[0]);
person.meta.icon = "icon-volume-off";
chan.sendAllExcept(person, "setUserIcon", {
name: person.name,
icon: "icon-volume-off"
});
var pkt = {
username: "[server]",
msg: user.name + " shadow muted " + args[0],
meta: {
addClass: "server-whisper",
addClassToNameAndTimestamp: true
},
time: Date.now()
};
chan.users.forEach(function (u) {
if (u.rank >= 2) {
u.socket.emit("chatMsg", pkt);
}
});
}
}
}
function handleMute(chan, user, args) {
if (chan.hasPermission(user, "mute") && args.length > 0) {
args[0] = args[0].toLowerCase();
var person = false;
for (var i = 0; i < chan.users.length; i++) {
if (chan.users[i].name.toLowerCase() == args[0]) {
person = chan.users[i];
break;
}
}
if (person) {
if (person.rank >= user.rank) {
user.socket.emit("errorMsg", {
msg: "You don't have permission to mute that person."
});
return;
}
person.meta.icon = "icon-volume-off";
chan.mutedUsers.add(person.name.toLowerCase());
chan.sendAll("setUserIcon", {
name: person.name,
icon: "icon-volume-off"
});
chan.logger.log("*** " + user.name + " muted " + args[0]);
}
}
}
function handleUnmute(chan, user, args) {
if (chan.hasPermission(user, "mute") && args.length > 0) {
args[0] = args[0].toLowerCase();
var person = false;
for (var i = 0; i < chan.users.length; i++) {
if (chan.users[i].name.toLowerCase() == args[0]) {
person = chan.users[i];
break;
}
}
if (person) {
if (person.rank >= user.rank) {
user.socket.emit("errorMsg", {
msg: "You don't have permission to unmute that person."
});
return;
}
person.meta.icon = false;
chan.mutedUsers.remove(person.name.toLowerCase());
chan.mutedUsers.remove("[shadow]" + person.name.toLowerCase());
chan.sendAll("setUserIcon", {
name: person.name,
icon: false
});
chan.logger.log("*** " + user.name + " unmuted " + args[0]);
}
}
}
function handleKick(chan, user, args) {
if (chan.hasPermission(user, "kick") && args.length > 0) {
args[0] = args[0].toLowerCase();
if (args[0] == user.name.toLowerCase()) {
user.socket.emit("costanza", {
msg: "Kicking yourself?"
});
return;
}
var kickee;
for (var i = 0; i < chan.users.length; i++) {
if (chan.users[i].name.toLowerCase() == args[0]) {
if (chan.users[i].rank >= user.rank) {
user.socket.emit("errorMsg", {
msg: "You don't have permission to kick " + args[0]
});
return;
}
kickee = chan.users[i];
break;
}
}
if (kickee) {
chan.logger.log("*** " + user.name + " kicked " + args[0]);
args[0] = "";
var reason = args.join(" ");
chan.kick(kickee, reason);
}
}
}
function handleIPBan(chan, user, args) {
chan.tryIPBan(user, args[0], args[1]);
// Ban the name too for good measure
chan.tryNameBan(user, args[0]);
}
function handleBan(chan, user, args) {
chan.tryNameBan(user, args[0]);
}
function handleUnban(chan, user, args) {
if (chan.hasPermission(user, "ban") && args.length > 0) {
chan.logger.log("*** " + user.name + " unbanned " + args[0]);
if (args[0].match(/(\d+)\.(\d+)\.(\d+)\.(\d+)/)) {
chan.unbanIP(user, args[0]);
}
else {
chan.unbanName(user, args[0]);
}
}
}
function handlePoll(chan, user, msg, hidden) {
if (chan.hasPermission(user, "pollctl")) {
var args = msg.split(",");
var title = args[0];
args.splice(0, 1);
var poll = new Poll(user.name, title, args, hidden === true);
chan.poll = poll;
chan.broadcastPoll();
chan.logger.log("*** " + user.name + " Opened Poll: '" + poll.title + "'");
}
}
function handleClear(chan, user) {
if (user.rank < 2) {
return;
}
chan.chatbuffer = [];
chan.sendAll("clearchat");
}
/*
/clean and /cleantitle contributed by http://github.com/unbibium.
Modifications by Calvin Montgomery
*/
function generateTargetRegex(target) {
const flagsre = /^(-[img]+\s+)/i
var m = target.match(flagsre);
var flags = "";
if (m) {
flags = m[0].slice(1,-1);
target = target.replace(flagsre, "");
}
return new RegExp(target, flags);
}
function handleClean(chan, user, target) {
if (!chan.hasPermission(user, "playlistdelete"))
return;
target = generateTargetRegex(target);
chan.playlist.clean(function (item) {
return target.test(item.queueby);
});
}
function handleCleanTitle(chan, user, target) {
if (!chan.hasPermission(user, "playlistdelete"))
return;
target = generateTargetRegex(target);
chan.playlist.clean(function (item) {
return target.exec(item.media.title) !== null;
});
}
exports.handle = handle;

109
lib/config.js Normal file
View file

@ -0,0 +1,109 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var fs = require("fs");
var Logger = require("./logger");
var nodemailer = require("nodemailer");
var defaults = {
"mysql-server" : "localhost",
"mysql-db" : "cytube",
"mysql-user" : "cytube",
"mysql-pw" : "supersecretpass",
"express-host" : "0.0.0.0",
"io-host" : "0.0.0.0",
"enable-ssl" : false,
"ssl-keyfile" : "",
"ssl-passphrase" : "",
"ssl-certfile" : "",
"ssl-port" : 443,
"asset-cache-ttl" : 0,
"web-port" : 8080,
"io-port" : 1337,
"ip-connection-limit" : 10,
"guest-login-delay" : 60,
"channel-save-interval" : 5,
"enable-mail" : false,
"mail-transport" : "SMTP",
"mail-config" : {
"service" : "Gmail",
"auth" : {
"user" : "some.user@gmail.com",
"pass" : "supersecretpassword"
}
},
"mail-from" : "some.user@gmail.com",
"domain" : "http://localhost",
"ytv3apikey" : "",
"enable-ytv3" : false,
"ytv2devkey" : "",
"vimeo-workaround" : false,
"stat-interval" : 3600000,
"stat-max-age" : 86400000,
"alias-purge-interval" : 3600000,
"alias-max-age" : 2592000000,
"tor-blocker" : false
}
function save(cfg, file) {
if(!cfg.loaded)
return;
var x = {};
for(var k in cfg) {
if(k !== "nodemailer" && k !== "loaded")
x[k] = cfg[k];
}
fs.writeFileSync(file, JSON.stringify(x, null, 4));
}
exports.load = function (file, callback) {
var cfg = {};
for(var k in defaults)
cfg[k] = defaults[k];
fs.readFile(file, function (err, data) {
if(err) {
if(err.code == "ENOENT") {
Logger.syslog.log("Config file not found, generating default");
Logger.syslog.log("Edit cfg.json to configure");
data = "{}";
}
else {
Logger.errlog.log("Config load failed");
Logger.errlog.log(err);
return;
}
}
try {
data = JSON.parse(data + "");
} catch(e) {
Logger.errlog.log("Config JSON is invalid: ");
Logger.errlog.log(e);
return;
}
for(var k in data)
cfg[k] = data[k];
if(cfg["enable-mail"]) {
cfg["nodemailer"] = nodemailer.createTransport(
cfg["mail-transport"],
cfg["mail-config"]
);
}
cfg["loaded"] = true;
save(cfg, file);
callback(cfg);
});
}

17
lib/customembed.js Normal file
View file

@ -0,0 +1,17 @@
const allowed = ["iframe", "object", "param", "embed"];
const tag_re = /<\s*\/?\s*([a-z]+)(\s*([a-z]+)\s*=\s*('[^']*'|"[^"]*"|[^"'>]*))*\s*>/ig;
function filter(str) {
str = str.replace(tag_re, function (match, tag) {
if(!~allowed.indexOf(tag.toLowerCase())) {
return match.replace("<", "&lt;").replace(">", "&gt;");
}
return match;
});
str = str.replace(/(\bon\w*\s*=\s*('[^']*'|"[^"]"|[^\s><]*))/ig, function () {
return "";
});
return str;
}
exports.filter = filter;

1495
lib/database.js Normal file

File diff suppressed because it is too large Load diff

37
lib/filter.js Normal file
View file

@ -0,0 +1,37 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Filter = function(name, regex, flags, replace) {
this.name = name;
this.source = regex;
this.flags = flags;
this.regex = new RegExp(this.source, this.flags);
this.replace = replace;
this.active = true;
this.filterlinks = false;
}
Filter.prototype.pack = function() {
return {
name: this.name,
source: this.source,
flags: this.flags,
replace: this.replace,
active: this.active,
filterlinks: this.filterlinks
}
}
Filter.prototype.filter = function(text) {
return text.replace(this.regex, this.replace);
}
exports.Filter = Filter;

815
lib/get-info.js Normal file
View file

@ -0,0 +1,815 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var http = require("http");
var https = require("https");
var domain = require("domain");
var Logger = require("./logger.js");
var Media = require("./media.js").Media;
var CustomEmbedFilter = require("./customembed").filter;
var Server = require("./server");
var urlRetrieve = function (transport, options, callback) {
// Catch any errors that crop up along the way of the request
// in order to prevent them from reaching the global handler.
// This should cut down on needing to restart the server
var d = domain.create();
d.on("error", function (err) {
if (typeof err.trace === "function") {
Logger.errlog.log(err.trace());
}
Logger.errlog.log("urlRetrieve failed: " + err);
Logger.errlog.log("Request was: " + options.host + options.path);
callback(503, err);
});
d.run(function () {
var req = transport.request(options, function (res) {
var buffer = "";
res.setEncoding("utf-8");
res.on("data", function (chunk) {
buffer += chunk;
});
res.on("end", function () {
callback(res.statusCode, buffer);
});
});
req.end();
});
};
var Getters = {
/* youtube.com */
yt: function (id, callback) {
var sv = Server.getServer();
if (sv.cfg["enable-ytv3"] && sv.cfg["ytv3apikey"]) {
Getters["ytv3"](id, callback);
return;
}
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "gdata.youtube.com",
port: 443,
path: "/feeds/api/videos/" + id + "?v=2&alt=json",
method: "GET",
dataType: "jsonp",
timeout: 1000
};
if(sv.cfg["ytv2devkey"]) {
options.headers = {
"X-Gdata-Key": "key=" + sv.cfg["ytv2devkey"]
};
}
urlRetrieve(https, options, function (status, data) {
if(status === 404) {
callback("Video not found", null);
return;
} else if(status === 403) {
callback("Private video", null);
return;
} else if(status === 400) {
callback("Invalid video", null);
return;
} else if(status === 503) {
callback("API failure", null);
return;
} else if(status !== 200) {
callback("HTTP " + status, null);
return;
}
var buffer = data;
try {
data = JSON.parse(data);
if (data.entry.yt$accessControl) {
var ac = data.entry.yt$accessControl;
for (var i = 0; i < ac.length; i++) {
if (ac[i].action === "embed") {
if (ac[i].permission === "denied") {
callback("Embedding disabled", null);
return;
}
break;
}
}
}
var seconds = data.entry.media$group.yt$duration.seconds;
var title = data.entry.title.$t;
var media = new Media(id, title, seconds, "yt");
if (data.entry.media$group.media$restriction) {
var rest = data.entry.media$group.media$restriction;
if (rest.length > 0) {
if (rest[0].relationship === "deny") {
media.restricted = rest[0].$t;
}
}
}
callback(false, media);
} catch(e) {
// Gdata version 2 has the rather silly habit of
// returning error codes in XML when I explicitly asked
// for JSON
var m = buffer.match(/<internalReason>([^<]+)<\/internalReason>/);
if(m === null)
m = buffer.match(/<code>([^<]+)<\/code>/);
var err = e;
if(m) {
if(m[1] === "too_many_recent_calls") {
err = "YouTube is throttling the server right "+
"now for making too many requests. "+
"Please try again in a moment.";
} else {
err = m[1];
}
}
callback(err, null);
}
});
},
/* youtube.com API v3 (requires API key) */
ytv3: function (id, callback) {
var sv = Server.getServer();
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var params = [
"part=" + encodeURIComponent("id,snippet,contentDetails"),
"id=" + id,
"key=" + sv.cfg["ytv3apikey"]
].join("&");
var options = {
host: "www.googleapis.com",
port: 443,
path: "/youtube/v3/videos?" + params,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
if(status !== 200) {
callback("YTv3: HTTP " + status, null);
return;
}
try {
data = JSON.parse(data);
// I am a bit disappointed that the API v3 just doesn't
// return anything in any error case
if(data.pageInfo.totalResults !== 1) {
callback("Video not found", null);
return;
}
var vid = data.items[0];
var title = vid.snippet.title;
// No, it's not possible to get a number representing
// the video length. Instead, I get a time of the format
// PT#M#S which represents
// "Period of Time" # Minutes, # Seconds
var m = vid.contentDetails.duration.match(/PT(\d+)M(\d+)S/);
var seconds = parseInt(m[1]) * 60 + parseInt(m[2]);
var media = new Media(id, title, seconds, "yt");
callback(false, media);
} catch(e) {
callback(e, null);
}
});
},
/* youtube.com playlists */
yp: function (id, callback, url) {
var sv = Server.getServer();
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var path = "/feeds/api/playlists/" + id + "?v=2&alt=json";
// YouTube only returns 25 at a time, so I have to keep asking
// for more with the URL they give me
if(url !== undefined) {
path = "/" + url.split("gdata.youtube.com")[1];
}
var options = {
host: "gdata.youtube.com",
port: 443,
path: path,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
if(sv.cfg["ytv2devkey"]) {
options.headers = {
"X-Gdata-Key": "key=" + sv.cfg["ytv2devkey"]
};
}
urlRetrieve(https, options, function (status, data) {
if(status === 404) {
callback("Playlist not found", null);
return;
} else if(status === 403) {
callback("Playlist is private", null);
return;
} else if(status === 503) {
callback("API failure", null);
return;
} else if(status !== 200) {
callback("YTPlaylist HTTP " + status, null);
}
try {
data = JSON.parse(data);
var vids = [];
for(var i in data.feed.entry) {
try {
var item = data.feed.entry[i];
var id = item.media$group.yt$videoid.$t;
var title = item.title.$t;
var seconds = item.media$group.yt$duration.seconds;
var media = new Media(id, title, seconds, "yt");
vids.push(media);
} catch(e) {
}
}
callback(false, vids);
var links = data.feed.link;
for(var i in links) {
if(links[i].rel === "next")
Getters["yp"](id, callback, links[i].href);
}
} catch(e) {
callback(e, null);
}
});
},
/* youtube.com search */
ytSearch: function (terms, callback) {
var sv = Server.getServer();
for(var i in terms)
terms[i] = encodeURIComponent(terms[i]);
var query = terms.join("+");
var options = {
host: "gdata.youtube.com",
port: 443,
path: "/feeds/api/videos/?q=" + query + "&v=2&alt=json",
method: "GET",
dataType: "jsonp",
timeout: 1000
};
if(sv.cfg["ytv2devkey"]) {
options.headers = {
"X-Gdata-Key": "key=" + sv.cfg["ytv2devkey"]
};
}
urlRetrieve(https, options, function (status, data) {
if(status !== 200) {
callback("YTSearch HTTP " + status, null);
return;
}
try {
data = JSON.parse(data);
var vids = [];
for(var i in data.feed.entry) {
try {
var item = data.feed.entry[i];
var id = item.media$group.yt$videoid.$t;
var title = item.title.$t;
var seconds = item.media$group.yt$duration.seconds;
var media = new Media(id, title, seconds, "yt");
media.thumb = item.media$group.media$thumbnail[0];
vids.push(media);
} catch(e) {
}
}
callback(false, vids);
} catch(e) {
callback(e, null);
}
});
},
/* vimeo.com */
vi: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "vimeo.com",
port: 443,
path: "/api/v2/video/" + id + ".json",
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
if(status === 404) {
callback("Video not found", null);
return;
} else if(status === 403) {
callback("Private video", null);
return;
} else if(status === 503) {
callback("API failure", null);
return;
} else if(status !== 200) {
callback("Vimeo HTTP " + status, null);
return;
}
try {
data = JSON.parse(data);
data = data[0];
var seconds = data.duration;
var title = data.title;
var media = new Media(id, title, seconds, "vi");
callback(false, media);
} catch(e) {
var err = e;
if(buffer.match(/not found/))
err = "Video not found";
callback(err, null);
}
});
},
/* dailymotion.com */
dm: function (id, callback) {
// Dailymotion's API is an example of an API done right
// - Supports SSL
// - I can ask for exactly which fields I want
// - URL is simple
// - Field names are sensible
// Other media providers take notes, please
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "api.dailymotion.com",
port: 443,
path: "/video/" + id + "?fields=duration,title",
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
if (status === 404) {
callback("Video not found", null);
return;
} else if (status !== 200) {
callback("DM HTTP " + status, null);
return;
}
try {
data = JSON.parse(data);
var title = data.title;
var seconds = data.duration;
if(title === "Deleted video" && seconds === 10) {
callback("Video not found", null);
return;
}
var media = new Media(id, title, seconds, "dm");
callback(false, media);
} catch(e) {
callback(err, null);
}
});
},
/* soundcloud.com */
sc: function (id, callback) {
// Soundcloud's API is badly designed and badly documented
// In order to lookup track data from a URL, I have to first
// make a call to /resolve to get the track id, then make a second
// call to /tracks/{track.id} to actally get useful data
// This is a waste of bandwidth and a pain in the ass
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) {
if(status === 404) {
callback("Sound not found", null);
return;
} else if(status === 503) {
callback("API failure", null);
return;
} else if(status !== 302) {
callback("SC HTTP " + status, null);
return;
}
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
};
// I want to get off async's wild ride
urlRetrieve(https, options2, function (status, data) {
if(status !== 200) {
callback("SC HTTP " + status, null);
return;
}
try {
data = JSON.parse(data);
// Duration is in ms, but I want s
var seconds = data.duration / 1000;
var title = data.title;
var media = new Media(id, title, seconds, "sc");
callback(false, media);
} catch(e) {
callback(e, null);
}
});
});
},
/* livestream.com */
li: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Livestream.com - " + id;
var media = new Media(id, title, "--:--", "li");
callback(false, media);
},
/* twitch.tv */
tw: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Twitch.tv - " + id;
var media = new Media(id, title, "--:--", "tw");
callback(false, media);
},
/* justin.tv */
jt: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Justin.tv - " + id;
var media = new Media(id, title, "--:--", "jt");
callback(false, media);
},
/* ustream.tv */
us: function (id, callback) {
// 2013-09-17
// They couldn't fucking decide whether channels should
// be at http://www.ustream.tv/channel/foo or just
// http://www.ustream.tv/foo so they do both.
// [](/cleese)
var m = id.match(/([^\?&#]+)|(channel\/[^\?&#]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "www.ustream.tv",
port: 80,
path: "/" + id,
method: "GET",
timeout: 1000
};
urlRetrieve(http, options, function (status, data) {
if(status !== 200) {
callback("Ustream HTTP " + status, null);
return;
}
// Regexing the ID out of the HTML because
// Ustream's API is so horribly documented
// I literally could not figure out how to retrieve
// this information.
//
// [](/eatadick)
var m = data.match(/cid":([0-9]+)/);
if(m) {
var title = "Ustream.tv - " + id;
var media = new Media(m[1], title, "--:--", "us");
callback(false, media);
} else {
callback(true, null);
}
});
},
/* JWPlayer */
jw: function (id, callback) {
var title = "JWPlayer - " + id;
var media = new Media(id, title, "--:--", "jw");
callback(false, media);
},
/* rtmp stream */
rt: function (id, callback) {
var title = "Livestream";
var media = new Media(id, title, "--:--", "rt");
callback(false, media);
},
/* imgur.com albums */
im: function (id, callback) {
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) {
id = CustomEmbedFilter(id);
var media = new Media(id, "Custom Media", "--:--", "cu");
callback(false, media);
},
/* google docs */
gd: function (id, callback) {
var options = {
host: "docs.google.com",
path: "/file/d/" + id + "/edit",
port: 443
};
urlRetrieve(https, options, function (status, res) {
if (status !== 200) {
callback("Google Docs rejected: HTTP " + status, false);
return;
}
var m = res.match(/main\((.*?)\);<\/script>/);
if (m) {
try {
var data = m[1];
data = data.substring(data.indexOf(",") + 1);
data = data.replace(/'(.*?)'([:\,\}\]])/g, "\"$1\"$2");
data = data.replace(/\\x(\d*)/g, function (sub, s1) {
return String.fromCharCode(parseInt(s1, 16));
});
data = "[" + data + "]";
var js = JSON.parse(data);
var title = js[0].title;
var seconds = js[1].videodetails.duration / 1000;
var med = new Media(id, title, seconds, "gd");
var fv = js[1].videoplay.flashVars;
var fvstr = "";
for (var k in fv) {
if (k === "autoplay")
fv[k] = "1";
fvstr += "&" + k + "=" + encodeURIComponent(fv[k]);
}
fvstr = fvstr.substring(1);
var url = js[1].videoplay.swfUrl + "&enablejsapi=1";
med.object = {
type: "application/x-shockwave-flash",
allowscriptaccess: "always",
allowfullscreen: "true",
wmode: "opaque",
data: url
};
med.params = [
{
name: "allowFullScreen",
value: "true"
},
{
name: "allowScriptAccess",
value: "always"
},
{
name: "wmode",
value: "opaque"
},
{
name: "flashvars",
value: fvstr
}
];
callback(false, med);
} catch (e) {
callback("Parsing of Google Docs output failed", null);
}
} else {
callback(res, null);
}
});
}
};
/**
* A function name like this deserves an explanation.
*
* Vimeo blocked my domain from using their embed API, citing "volume of abuse" as the
* reason. After attempts to reach a compromise, Vimeo refused to unblock my domain.
*
* This function downloads the HTML for the embedded player and extracts direct links
* to the h264 encoded MP4 video files (with a session hash required for downloading it).
*
* This rivals the Google Drive playback functionality as quite possibly the hackiest code
* in CyTube. Thanks Vimeo, and may you never see a dime in advertising revenue from a
* CyTube client again.
*/
function VimeoIsADoucheCopter(id, cb) {
var failcount = 0;
var inner = function () {
var options = {
host: "player.vimeo.com",
path: "/video/" + id,
headers: {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:29.0) Gecko/20100101 Firefox/29.0",
"Referrer": "player.vimeo.com"
}
};
var parse = function (data) {
var i = data.indexOf("a={");
if (i === -1) i = data.indexOf("b={");
if (i === -1) i = data.indexOf("c={");
if (i === -1) {
Logger.errlog.log("Bad embed response for http://vimeo.com/" + id);
setImmediate(function () {
cb({});
});
return;
}
var j = data.indexOf("};", i);
var json = data.substring(i+2, j+1);
try {
json = JSON.parse(json);
var codec = json.request.files.codecs[0];
var files = json.request.files[codec];
setImmediate(function () {
cb(files);
});
} catch (e) {
// Not sure why the response sometimes differs. Trying to weed out
// search engines perhaps?
// Maybe adding the User-Agent above will fix this, I dunno
if (data.indexOf("crawler") !== -1) {
Logger.syslog.log("Warning: VimeoIsADoucheCopter got crawler response");
failcount++;
if (failcount > 4) {
Logger.errlog.log("VimeoIsADoucheCopter got bad response 5 times!"+
" Giving up.");
setImmediate(function () {
cb({});
});
} else {
setImmediate(function () {
inner();
});
}
return;
} else if (data.indexOf("This video does not exist.") !== -1) {
cb({});
return;
} else if (data.indexOf("Because of its privacy settings, this video cannot be played here.") !== -1) {
cb({});
}
Logger.errlog.log("Vimeo workaround error: ");
Logger.errlog.log(e);
Logger.errlog.log("http://vimeo.com/" + id);
setImmediate(function () {
cb({});
});
}
};
http.get(options, function (res) {
res.setEncoding("utf-8");
var buffer = "";
res.on("data", function (data) {
buffer += data;
});
res.on("end", function () {
parse(buffer);
});
});
};
inner();
};
module.exports = {
Getters: Getters,
getMedia: function (id, type, callback) {
if(type in this.Getters) {
this.Getters[type](id, callback);
} else {
callback("Unknown media type '" + type + "'", null);
}
},
VimeoIsADoucheCopter: VimeoIsADoucheCopter
};

64
lib/logger.js Normal file
View file

@ -0,0 +1,64 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var fs = require("fs");
var path = require("path");
function getTimeString() {
var d = new Date();
return d.toDateString() + " " + d.toTimeString().split(" ")[0];
}
var Logger = function(filename) {
this.filename = filename;
this.writer = fs.createWriteStream(filename, {
flags: "a",
encoding: "utf-8"
});
}
Logger.prototype.log = function () {
var msg = "";
for(var i in arguments)
msg += arguments[i];
if(this.dead) {
return;
}
var str = "[" + getTimeString() + "] " + msg + "\n";
try {
this.writer.write(str);
} catch(e) {
errlog.log("WARNING: Attempted logwrite failed: " + this.filename);
errlog.log("Message was: " + msg);
errlog.log(e);
}
}
Logger.prototype.close = function () {
try {
this.writer.end();
} catch(e) {
errlog.log("Log close failed: " + this.filename);
}
}
var errlog = new Logger(path.join(__dirname, "../error.log"));
var syslog = new Logger(path.join(__dirname, "../sys.log"));
errlog.actualLog = errlog.log;
errlog.log = function(what) { console.log(what); this.actualLog(what); }
syslog.actualLog = syslog.log;
syslog.log = function(what) { console.log(what); this.actualLog(what); }
exports.Logger = Logger;
exports.errlog = errlog;
exports.syslog = syslog;

90
lib/media.js Normal file
View file

@ -0,0 +1,90 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var formatTime = require("./utilities").formatTime;
// Represents a media entry
var Media = function(id, title, seconds, type) {
this.id = id;
this.title = title;
if(this.title.length > 100)
this.title = this.title.substring(0, 97) + "...";
this.seconds = seconds == "--:--" ? "--:--" : parseInt(seconds);
this.duration = formatTime(this.seconds);
if(seconds == "--:--") {
this.seconds = 0;
}
this.type = type;
}
Media.prototype.dup = function() {
var m = new Media(this.id, this.title, this.seconds, this.type);
return m;
}
// Returns an object containing the data in this Media but not the
// prototype
Media.prototype.pack = function() {
var x = {
id: this.id,
title: this.title,
seconds: this.seconds,
duration: this.duration,
type: this.type,
};
if (this.object) {
x.object = this.object;
}
if (this.params) {
x.params = this.params;
}
return x;
}
// Same as pack() but includes the currentTime variable set by the channel
// when the media is being synchronized
Media.prototype.fullupdate = function() {
var x = {
id: this.id,
title: this.title,
seconds: this.seconds,
duration: this.duration,
type: this.type,
currentTime: this.currentTime,
paused: this.paused,
};
if (this.object) {
x.object = this.object;
}
if (this.params) {
x.params = this.params;
}
if (this.direct) {
x.direct = this.direct;
}
return x;
}
Media.prototype.timeupdate = function() {
//return this.fullupdate();
return {
currentTime: this.currentTime,
paused: this.paused
};
}
Media.prototype.reset = function () {
delete this.currentTime;
delete this.direct;
};
exports.Media = Media;

195
lib/notwebsocket.js Normal file
View file

@ -0,0 +1,195 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Logger = require("./logger");
const chars = "abcdefghijklmnopqsrtuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789";
var NotWebsocket = function() {
this.hash = "";
for(var i = 0; i < 30; i++) {
this.hash += chars[parseInt(Math.random() * (chars.length - 1))];
}
this.pktqueue = [];
this.handlers = {};
this.room = "";
this.lastpoll = Date.now();
this.noflood = {};
}
NotWebsocket.prototype.checkFlood = function(id, rate) {
if(id in this.noflood) {
this.noflood[id].push(Date.now());
}
else {
this.noflood[id] = [Date.now()];
}
if(this.noflood[id].length > 10) {
this.noflood[id].shift();
var hz = 10000 / (this.noflood[id][9] - this.noflood[id][0]);
if(hz > rate) {
throw "Rate is too high: " + id;
}
}
}
NotWebsocket.prototype.emit = function(msg, data) {
var pkt = [msg, data];
this.pktqueue.push(pkt);
}
NotWebsocket.prototype.poll = function() {
this.checkFlood("poll", 100);
this.lastpoll = Date.now();
var q = [];
for(var i = 0; i < this.pktqueue.length; i++) {
q.push(this.pktqueue[i]);
}
this.pktqueue.length = 0;
return q;
}
NotWebsocket.prototype.on = function(msg, callback) {
if(!(msg in this.handlers))
this.handlers[msg] = [];
this.handlers[msg].push(callback);
}
NotWebsocket.prototype.recv = function(urlstr) {
this.checkFlood("recv", 100);
var msg, data;
try {
var js = JSON.parse(urlstr);
msg = js[0];
data = js[1];
}
catch(e) {
Logger.errlog.log("Failed to parse NWS string");
Logger.errlog.log(urlstr);
}
if(!msg)
return;
if(!(msg in this.handlers))
return;
for(var i = 0; i < this.handlers[msg].length; i++) {
this.handlers[msg][i](data);
}
}
NotWebsocket.prototype.join = function(rm) {
if(!(rm in rooms)) {
rooms[rm] = [];
}
rooms[rm].push(this);
}
NotWebsocket.prototype.leave = function(rm) {
if(rm in rooms) {
var idx = rooms[rm].indexOf(this);
if(idx >= 0) {
rooms[rm].splice(idx, 1);
}
}
}
NotWebsocket.prototype.disconnect = function() {
for(var rm in rooms) {
this.leave(rm);
}
this.recv(JSON.stringify(["disconnect", undefined]));
this.emit("disconnect");
clients[this.hash] = null;
delete clients[this.hash];
}
function sendJSON(res, obj) {
var response = JSON.stringify(obj, null, 4);
if(res.callback) {
response = res.callback + "(" + response + ")";
}
var len = unescape(encodeURIComponent(response)).length;
res.setHeader("Content-Type", "application/json");
res.setHeader("Content-Length", len);
res.end(response);
}
var clients = {};
var rooms = {};
function newConnection(req, res) {
var nws = new NotWebsocket();
clients[nws.hash] = nws;
res.callback = req.query.callback;
sendJSON(res, nws.hash);
return nws;
}
exports.newConnection = newConnection;
function msgReceived(req, res) {
res.callback = req.query.callback;
var h = req.params.hash;
if(h in clients && clients[h] != null) {
var str = req.params.str;
res.callback = req.query.callback;
try {
if(str == "poll") {
sendJSON(res, clients[h].poll());
}
else {
clients[h].recv(decodeURIComponent(str));
sendJSON(res, "");
}
}
catch(e) {
res.send(429); // 429 Too Many Requests
}
}
else {
res.send(404);
}
}
exports.msgReceived = msgReceived;
function inRoom(rm) {
var cl = [];
if(rm in rooms) {
for(var i = 0; i < rooms[rm].length; i++) {
cl.push(rooms[rm][i]);
}
}
cl.emit = function(msg, data) {
for(var i = 0; i < this.length; i++) {
this[i].emit(msg, data);
}
};
return cl;
}
exports.inRoom = inRoom;
function checkDeadSockets() {
for(var h in clients) {
if(Date.now() - clients[h].lastpoll >= 2000) {
clients[h].disconnect();
}
}
}
setInterval(checkDeadSockets, 2000);

463
lib/playlist.js Normal file
View file

@ -0,0 +1,463 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
ULList = require("./ullist").ULList;
var AsyncQueue = require("./asyncqueue");
var Media = require("./media").Media;
var AllPlaylists = {};
var Server = require("./server");
var VimeoIsADoucheCopter = require("./get-info").VimeoIsADoucheCopter;
function PlaylistItem(media, uid) {
this.media = media;
this.uid = uid;
this.temp = false;
this.queueby = "";
this.prev = null;
this.next = null;
}
PlaylistItem.prototype.pack = function() {
return {
media: this.media.pack(),
uid: this.uid,
temp: this.temp,
queueby: this.queueby
};
}
function Playlist(chan) {
var name = chan.canonical_name;
if(name in AllPlaylists && AllPlaylists[name]) {
var pl = AllPlaylists[name];
if(!pl.dead)
pl.die();
}
this.items = new ULList();
this.current = null;
this.next_uid = 0;
this._leadInterval = false;
this._lastUpdate = 0;
this._counter = 0;
this.leading = true;
this.callbacks = {
"changeMedia": [],
"mediaUpdate": [],
"remove": [],
};
this.fnqueue = new AsyncQueue();
AllPlaylists[name] = this;
this.channel = chan;
this.server = chan.server;
var pl = this;
this.on("mediaUpdate", function(m) {
if (chan.dead) {
pl.die();
return;
}
chan.sendAll("mediaUpdate", m.timeupdate());
});
this.on("changeMedia", function(m) {
if (chan.dead) {
pl.die();
return;
}
chan.onVideoChange();
chan.sendAll("setCurrent", pl.current.uid);
chan.sendAll("changeMedia", m.fullupdate());
});
this.on("remove", function(item) {
if (chan.dead) {
pl.die();
return;
}
chan.broadcastPlaylistMeta();
chan.sendAll("delete", {
uid: item.uid
});
});
}
Playlist.prototype.dump = function() {
var arr = this.items.toArray();
var pos = 0;
for(var i in arr) {
if(this.current && arr[i].uid == this.current.uid) {
pos = i;
break;
}
}
var time = 0;
if(this.current)
time = this.current.media.currentTime;
return {
pl: arr,
pos: pos,
time: time
};
}
Playlist.prototype.die = function () {
this.clear();
if(this._leadInterval) {
clearInterval(this._leadInterval);
this._leadInterval = false;
}
if(this._qaInterval) {
clearInterval(this._qaInterval);
this._qaInterval = false;
}
//for(var key in this)
// delete this[key];
this.dead = true;
}
Playlist.prototype.load = function(data, callback) {
this.clear();
for(var i in data.pl) {
var e = data.pl[i].media;
var m = new Media(e.id, e.title, e.seconds, e.type);
m.object = e.object;
m.params = e.params;
var it = this.makeItem(m);
it.temp = data.pl[i].temp;
it.queueby = data.pl[i].queueby;
this.items.append(it);
if(i == parseInt(data.pos)) {
this.current = it;
}
}
if(callback)
callback();
}
Playlist.prototype.on = function(ev, fn) {
if(typeof fn === "undefined") {
var pl = this;
return function() {
for(var i = 0; i < pl.callbacks[ev].length; i++) {
pl.callbacks[ev][i].apply(this, arguments);
}
}
}
else if(typeof fn === "function") {
this.callbacks[ev].push(fn);
}
}
Playlist.prototype.makeItem = function(media) {
return new PlaylistItem(media, this.next_uid++);
}
Playlist.prototype.add = function(item, pos) {
var self = this;
if(this.items.length >= 4000) {
return "Playlist limit reached (4,000)";
}
var it = this.items.findVideoId(item.media.id);
if(it) {
if(pos === "append" || it == this.current) {
return "This item is already on the playlist";
}
self.remove(it.uid);
self.channel.sendAll("delete", {
uid: it.uid
});
self.channel.broadcastPlaylistMeta();
}
if(pos == "append") {
if(!this.items.append(item)) {
return "Playlist failure";
}
} else if(pos == "prepend") {
if(!this.items.prepend(item)) {
return "Playlist failure";
}
} else {
if(!this.items.insertAfter(item, pos)) {
return "Playlist failure";
}
}
if(this.items.length == 1) {
this.current = item;
this.startPlayback();
}
return false;
}
Playlist.prototype.addMedia = function (data) {
var pos = data.pos;
if (pos === "next") {
if (this.current !== null)
pos = this.current.uid;
else
pos = "append";
}
var m = new Media(data.id, data.title, data.seconds, data.type);
m.object = data.object;
m.params = data.params;
m.direct = data.direct;
var item = this.makeItem(m);
item.queueby = data.queueby;
item.temp = data.temp;
return {
item: item,
error: this.add(item, pos)
};
};
Playlist.prototype.remove = function (uid) {
var self = this;
var item = self.items.find(uid);
if (item && self.items.remove(uid)) {
if (item === self.current) {
self._next();
}
return true;
} else {
return false;
}
}
Playlist.prototype.move = function (from, after) {
var it = this.items.find(from);
if (!this.items.remove(from))
return false;
if (after === "prepend") {
if (!this.items.prepend(it))
return false;
} else if (after === "append") {
if (!this.items.append(it))
return false;
} else if (!this.items.insertAfter(it, after)) {
return false;
}
return true;
}
Playlist.prototype.next = function() {
if(!this.current)
return;
var it = this.current;
it.media.reset();
if (it.temp) {
if (this.remove(it.uid)) {
this.on("remove")(it);
}
} else {
this._next();
}
return this.current;
}
Playlist.prototype._next = function() {
if(!this.current)
return;
this.current = this.current.next;
if(this.current === null && this.items.first !== null)
this.current = this.items.first;
if(this.current) {
this.startPlayback();
}
}
Playlist.prototype.jump = function(uid) {
if(!this.current)
return false;
var jmp = this.items.find(uid);
if(!jmp)
return false;
var it = this.current;
it.media.reset();
this.current = jmp;
if(this.current) {
this.startPlayback();
}
if(it.temp) {
if (this.remove(it.uid)) {
this.on("remove")(it);
}
}
return this.current;
}
Playlist.prototype.clear = function() {
this.items.clear();
this.next_uid = 0;
this.current = null;
clearInterval(this._leadInterval);
}
Playlist.prototype.count = function (id) {
var count = 0;
this.items.forEach(function (i) {
if(i.media.id === id)
count++;
});
return count;
}
Playlist.prototype.lead = function(lead) {
this.leading = lead;
var pl = this;
if(!this.leading && this._leadInterval) {
clearInterval(this._leadInterval);
this._leadInterval = false;
}
else if(this.leading && !this._leadInterval) {
this._lastUpdate = Date.now();
this._leadInterval = setInterval(function() {
pl._leadLoop();
}, 1000);
}
}
Playlist.prototype.startPlayback = function (time) {
var self = this;
if (!self.current || !self.current.media) {
return false;
}
if (self.current.media.type === "vi" &&
!self.current.media.direct &&
Server.getServer().cfg["vimeo-workaround"]) {
VimeoIsADoucheCopter(self.current.media.id, function (direct) {
if (self.current != null && self.current.media != null) {
self.current.media.direct = direct;
self.startPlayback(time);
}
});
return;
}
if (!self.leading) {
self.current.media.paused = false;
self.current.media.currentTime = time || 0;
self.on("changeMedia")(self.current.media);
return;
}
time = time || -3;
self.current.media.paused = time < 0;
self.current.media.currentTime = time;
if(self._leadInterval) {
clearInterval(self._leadInterval);
self._leadInterval = false;
}
self.on("changeMedia")(self.current.media);
if(!isLive(self.current.media.type)) {
self._lastUpdate = Date.now();
self._leadInterval = setInterval(function() {
self._leadLoop();
}, 1000);
}
}
function isLive(type) {
return type == "li" // Livestream.com
|| type == "tw" // Twitch.tv
|| type == "jt" // Justin.tv
|| type == "rt" // RTMP
|| type == "jw" // JWPlayer
|| type == "us" // Ustream.tv
|| type == "im" // Imgur album
|| type == "cu";// Custom embed
}
const UPDATE_INTERVAL = 5;
Playlist.prototype._leadLoop = function() {
if(this.current == null)
return;
if(this.channel.name == "") {
this.die();
return;
}
var dt = (Date.now() - this._lastUpdate) / 1000.0;
var t = this.current.media.currentTime;
// Transition from lead-in
if (t < 0 && (t + dt) >= 0) {
this.current.media.currentTime = 0;
this.current.media.paused = false;
this._counter = 0;
this._lastUpdate = Date.now();
this.on("mediaUpdate")(this.current.media);
return;
}
this.current.media.currentTime += dt;
this._lastUpdate = Date.now();
this._counter++;
if(this.current.media.currentTime >= this.current.media.seconds + 2) {
this.next();
}
else if(this._counter % UPDATE_INTERVAL == 0) {
this.on("mediaUpdate")(this.current.media);
}
}
/*
Delete items from the playlist for which filter(item) returns
a truthy value
based on code contributed by http://github.com/unbibium
*/
Playlist.prototype.clean = function (filter) {
var self = this;
var matches = self.items.findAll(filter);
var count = 0;
var deleteNext = function () {
if (count < matches.length) {
var uid = matches[count].uid;
count++;
if (self.remove(uid)) {
self.channel.sendAll("delete", {
uid: uid
});
}
deleteNext();
} else {
// refresh meta only once, at the end
self.channel.broadcastPlaylistMeta();
}
};
// start initial callback
deleteNext();
};
module.exports = Playlist;

64
lib/poll.js Normal file
View file

@ -0,0 +1,64 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
var 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 (var 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>");
}
this.obscured = obscured || false;
this.counts = new Array(options.length);
for(var i = 0; i < this.counts.length; i++) {
this.counts[i] = 0;
}
this.votes = {};
}
Poll.prototype.vote = function(ip, option) {
if(!(ip in this.votes) || this.votes[ip] == null) {
this.votes[ip] = option;
this.counts[option]++;
}
}
Poll.prototype.unvote = function(ip) {
if(ip in this.votes && this.votes[ip] != null) {
this.counts[this.votes[ip]]--;
this.votes[ip] = null;
}
}
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] += "?";
}
}
var packed = {
title: this.title,
options: this.options,
counts: counts,
initiator: this.initiator
};
return packed;
}
exports.Poll = Poll;

337
lib/server.js Normal file
View file

@ -0,0 +1,337 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const VERSION = "2.4.6";
var singleton = null;
module.exports = {
init: function (cfg) {
Logger.syslog.log("Starting CyTube v" + VERSION);
var chanlogpath = path.join(__dirname, "../chanlogs");
fs.exists(chanlogpath, function (exists) {
exists || fs.mkdir(chanlogpath);
});
var chandumppath = path.join(__dirname, "../chandump");
fs.exists(chandumppath, function (exists) {
exists || fs.mkdir(chandumppath);
});
singleton = new Server(cfg);
return singleton;
},
getServer: function () {
return singleton;
}
};
var path = require("path");
var fs = require("fs");
var http = require("http");
var https = require("https");
var express = require("express");
var Config = require("./config");
var Logger = require("./logger");
var Channel = require("./channel");
var User = require("./user");
var $util = require("./utilities");
var ActionLog = require("./actionlog");
var Server = function (cfg) {
var self = this;
self.cfg = cfg;
self.channels = [],
self.express = null;
self.http = null;
self.https = null;
self.io = null;
self.ioWeb = null;
self.ioSecure = null;
self.ipCount = {};
self.ipThrottle = {};
self.db = null;
self.api = null;
self.announcement = null;
self.httplog = null;
self.infogetter = null;
self.torblocker = null;
// database init ------------------------------------------------------
var Database = require("./database");
self.db = new Database(self.cfg);
// webserver init -----------------------------------------------------
self.httplog = new Logger.Logger(path.join(__dirname,
"../httpaccess.log"));
self.express = express();
self.express.use(express.urlencoded());
self.express.use(express.json());
self.express.use(express.cookieParser());
// channel route
self.express.get("/r/:channel(*)", function (req, res, next) {
var c = req.params.channel;
if (!$util.isValidChannelName(c)) {
res.redirect("/" + c);
return;
}
self.logHTTP(req);
res.sendfile("channel.html", {
root: path.join(__dirname, "../www")
});
});
// api route
self.api = require("./api")(self);
// index
self.express.get("/", function (req, res, next) {
self.logHTTP(req);
res.sendfile("index.html", {
root: path.join(__dirname, "../www")
});
});
// default route
self.express.get("/:thing(*)", function (req, res, next) {
var opts = {
root: path.join(__dirname, "../www"),
maxAge: self.cfg["asset-cache-ttl"]
};
res.sendfile(req.params.thing, opts, function (e) {
if (e) {
self.logHTTP(req, e.status);
if (req.params.thing.match(/\.\.|(%25)?%2e(%25)?%2e/)) {
res.send("Don't try that again.");
Logger.syslog.log("WARNING: Attempted path traversal "+
"from IP " + self.getHTTPIP(req));
Logger.syslog.log("Path was: " + req.url);
ActionLog.record(self.getHTTPIP(req), "",
"path-traversal",
req.url);
} else if (e.status >= 500) {
Logger.errlog.log(e);
}
res.send(e.status);
} else {
self.logHTTP(req);
}
});
});
// fallback route
self.express.use(function (err, req, res, next) {
self.logHTTP(req, err.status);
if (err.status === 404) {
res.send(404);
} else {
next(err);
}
});
// http/https/sio server init -----------------------------------------
if (self.cfg["enable-ssl"]) {
var key = fs.readFileSync(path.resolve(__dirname, "..",
self.cfg["ssl-keyfile"]));
var cert = fs.readFileSync(path.resolve(__dirname, "..",
self.cfg["ssl-certfile"]));
var opts = {
key: key,
cert: cert,
passphrase: self.cfg["ssl-passphrase"]
};
self.https = https.createServer(opts, self.express)
.listen(self.cfg["ssl-port"]);
self.ioSecure = require("socket.io").listen(self.https);
self.ioSecure.set("log level", 1);
self.ioSecure.on("connection", function (sock) {
self.handleSocketConnection(sock);
});
}
self.http = self.express.listen(self.cfg["web-port"],
self.cfg["express-host"]);
self.ioWeb = express().listen(self.cfg["io-port"], self.cfg["io-host"]);
self.io = require("socket.io").listen(self.ioWeb);
self.io.set("log level", 1);
self.io.sockets.on("connection", function (sock) {
self.handleSocketConnection(sock);
});
// background tasks init ----------------------------------------------
require("./bgtask")(self);
// tor blocker init ---------------------------------------------------
if (self.cfg["tor-blocker"]) {
self.torblocker = require("./torblocker")();
}
};
Server.prototype.getHTTPIP = function (req) {
var ip = req.ip;
if (ip === "127.0.0.1" || ip === "::1") {
var fwd = req.header("x-forwarded-for");
if (fwd && typeof fwd === "string") {
return fwd;
}
}
return ip;
};
Server.prototype.getSocketIP = function (socket) {
var raw = socket.handshake.address.address;
if (raw === "127.0.0.1" || raw === "::1") {
var fwd = socket.handshake.headers["x-forwarded-for"];
if (fwd && typeof fwd === "string") {
return fwd;
}
}
return raw;
};
Server.prototype.isChannelLoaded = function (name) {
name = name.toLowerCase();
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].canonical_name == name)
return true;
}
return false;
};
Server.prototype.getChannel = function (name) {
var cname = name.toLowerCase();
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].canonical_name === cname)
return this.channels[i];
}
var c = new Channel(name);
this.channels.push(c);
return c;
};
Server.prototype.unloadChannel = function (chan) {
if (chan.registered)
chan.saveDump();
chan.playlist.die();
chan.logger.close();
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].canonical_name === chan.canonical_name) {
this.channels.splice(i, 1);
i--;
}
}
// Empty all outward references from the channel
var keys = Object.keys(chan);
for (var i in keys) {
delete chan[keys[i]];
}
chan.dead = true;
};
Server.prototype.logHTTP = function (req, status) {
if (status === undefined)
status = 200;
var ip = this.getHTTPIP(req);
var url = req.url;
// Remove query
if(url.indexOf("?") != -1)
url = url.substring(0, url.lastIndexOf("?"));
this.httplog.log([
ip,
req.method,
url,
status,
req.header("user-agent")
].join(" "));
};
const IP_THROTTLE = {
burst: 5,
sustained: 0.1
};
Server.prototype.handleSocketConnection = function (socket) {
var self = this;
var ip = self.getSocketIP(socket);
socket._ip = ip;
if (self.torblocker && self.torblocker.shouldBlockIP(ip)) {
socket.emit("kick", {
reason: "This server does not allow connections from Tor. "+
"Please log in with your regular internet connection."
});
Logger.syslog.log("Blocked Tor IP: " + ip);
socket.disconnect(true);
return;
}
if (!(ip in self.ipThrottle)) {
self.ipThrottle[ip] = $util.newRateLimiter();
}
if (self.ipThrottle[ip].throttle(IP_THROTTLE)) {
Logger.syslog.log("WARN: IP throttled: " + ip);
socket.emit("kick", {
reason: "Your IP address is connecting too quickly. Please "+
"wait 10 seconds before joining again."
});
return;
}
// Check for global ban on the IP
self.db.isGlobalIPBanned(ip, function (err, banned) {
if (banned) {
Logger.syslog.log("Disconnecting " + ip + " - global banned");
socket.emit("kick", { reason: "Your IP is globally banned." });
socket.disconnect(true);
}
});
socket.on("disconnect", function () {
self.ipCount[ip]--;
});
if (!(ip in self.ipCount))
self.ipCount[ip] = 0;
self.ipCount[ip]++;
if (self.ipCount[ip] > self.cfg["ip-connection-limit"]) {
socket.emit("kick", {
reason: "Too many connections from your IP address"
});
socket.disconnect(true);
return;
}
Logger.syslog.log("Accepted socket from " + ip);
new User(socket);
};
Server.prototype.shutdown = function () {
Logger.syslog.log("Unloading channels");
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].registered) {
Logger.syslog.log("Saving /r/" + this.channels[i].name);
this.channels[i].saveDump();
}
}
Logger.syslog.log("Goodbye");
process.exit(0);
};

88
lib/torblocker.js Normal file
View file

@ -0,0 +1,88 @@
var https = require("https");
var path = require("path");
var fs = require("fs");
var domain = require("domain");
var Logger = require("./logger");
function retrieveIPs(cb) {
var options = {
host: "www.dan.me.uk",
port: 443,
path: "/torlist/",
method: "GET"
};
var finish = function (status, data) {
if (status !== 200) {
cb(new Error("Failed to retrieve Tor IP list (HTTP " + status + ")"), null);
return;
}
var ips = data.split("\n");
cb(false, ips);
};
var d = domain.create();
d.on("error", function (err) {
if (err.trace)
Logger.errlog.log(err.trace());
else
Logger.errlog.log(err);
});
d.run(function () {
var req = https.request(options, function (res) {
var buffer = "";
res.setEncoding("utf-8");
res.on("data", function (data) { buffer += data; });
res.on("end", function () { finish(res.statusCode, buffer); });
});
req.end();
});
}
function getTorIPs(cb) {
retrieveIPs(function (err, ips) {
if (!err) {
cb(false, ips);
fs.writeFile(path.join(__dirname, "..", "torlist"),
ips.join("\n"));
return;
}
fs.readFile(path.join(__dirname, "..", "torlist"), function (err, data) {
if (err) {
cb(err, null);
return;
}
data = (""+data).split("\n");
cb(false, data);
});
});
}
module.exports = function () {
var x = {
ipList: [],
shouldBlockIP: function (ip) {
return this.ipList.indexOf(ip) >= 0;
}
};
var init = function () {
getTorIPs(function (err, ips) {
if (err) {
Logger.errlog.log(err);
return;
}
Logger.syslog.log("Loaded Tor IP list");
x.ipList = ips;
});
};
init();
return x;
};

View file

@ -1,3 +1,14 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/*
ullist.js
@ -17,28 +28,30 @@ ULList.prototype.prepend = function(item) {
if(this.first !== null) {
item.next = this.first;
this.first.prev = item;
} else {
}
else {
this.last = item;
}
this.first = item;
this.first.prev = null;
this.length++;
return true;
};
}
/* Add an item to the end of the list */
ULList.prototype.append = function(item) {
if(this.last !== null) {
item.prev = this.last;
this.last.next = item;
} else {
}
else {
this.first = item;
}
this.last = item;
this.last.next = null;
this.length++;
return true;
};
}
/* Insert an item after one which has a specified UID */
ULList.prototype.insertAfter = function(item, uid) {
@ -61,7 +74,7 @@ ULList.prototype.insertAfter = function(item, uid) {
this.length++;
return true;
};
}
/* Insert an item before one that has a specified UID */
ULList.prototype.insertBefore = function(item, uid) {
@ -84,7 +97,7 @@ ULList.prototype.insertBefore = function(item, uid) {
this.length++;
return true;
};
}
/* Remove an item from the list */
ULList.prototype.remove = function(uid) {
@ -106,7 +119,7 @@ ULList.prototype.remove = function(uid) {
this.length--;
return true;
};
}
/* Find an element in the list, return false if specified UID not found */
ULList.prototype.find = function(uid) {
@ -124,14 +137,14 @@ ULList.prototype.find = function(uid) {
if(item && item.uid == uid)
return item;
return false;
};
}
/* Clear all elements from the list */
ULList.prototype.clear = function() {
this.first = null;
this.last = null;
this.length = 0;
};
}
/* Dump the contents of the list into an array */
ULList.prototype.toArray = function(pack) {
@ -146,7 +159,7 @@ ULList.prototype.toArray = function(pack) {
item = item.next;
}
return arr;
};
}
/* iterate across the playlist */
ULList.prototype.forEach = function (fn) {
@ -176,6 +189,6 @@ ULList.prototype.findAll = function(fn) {
}
});
return result;
};
}
module.exports = ULList;
exports.ULList = ULList;

786
lib/user.js Normal file
View file

@ -0,0 +1,786 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var Channel = require("./channel.js").Channel;
var Logger = require("./logger.js");
var $util = require("./utilities");
var ActionLog = require("./actionlog");
var Server = require("./server");
var ACP = require("./acp");
var InfoGetter = require("./get-info");
// Represents a client connected via socket.io
var User = function (socket) {
this.ip = socket._ip;
this.server = Server.getServer();
this.socket = socket;
this.loggedIn = false;
this.loggingIn = false;
this.saverank = false;
this.rank = -1
this.global_rank = -1;
this.channel = null;
this.pendingChannel = null;
this.name = "";
this.meta = {
afk: false,
icon: false
};
this.queueLimiter = $util.newRateLimiter();
this.chatLimiter = $util.newRateLimiter();
this.rankListLimiter = $util.newRateLimiter();
this.profile = {
image: "",
text: ""
};
this.awaytimer = false;
this.autoAFK();
this.initCallbacks();
if (this.server.announcement !== null) {
this.socket.emit("announcement", this.server.announcement);
}
};
User.prototype.inChannel = function () {
return this.channel !== null && !this.channel.dead;
};
User.prototype.inPendingChannel = function () {
return this.pendingChannel != null && !this.pendingChannel.dead;
};
User.prototype.setAFK = function (afk) {
if (!this.inChannel())
return;
if (this.meta.afk === afk)
return;
var chan = this.channel;
this.meta.afk = afk;
if (afk) {
if (chan.voteskip)
chan.voteskip.unvote(this.ip);
} else {
this.autoAFK();
}
chan.checkVoteskipPass();
chan.sendAll("setAFK", {
name: this.name,
afk: afk
});
};
User.prototype.autoAFK = function () {
var self = this;
if (self.awaytimer)
clearTimeout(self.awaytimer);
if (!self.inChannel()) {
return;
}
var timeout = parseFloat(self.channel.opts.afk_timeout);
if (isNaN(timeout)) {
return;
}
if (timeout <= 0) {
return;
}
self.awaytimer = setTimeout(function () {
self.setAFK(true);
}, timeout * 1000);
};
User.prototype.kick = function (reason) {
this.socket.emit("kick", { reason: reason });
this.socket.disconnect(true);
};
User.prototype.initCallbacks = function () {
var self = this;
self.socket.on("disconnect", function () {
self.awaytimer && clearTimeout(self.awaytimer);
if (self.inChannel())
self.channel.userLeave(self);
else if (self.inPendingChannel())
self.pendingChannel.userLeave(self);
});
self.socket.on("joinChannel", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel() || self.inPendingChannel()) {
return;
}
if (typeof data.name != "string") {
return;
}
if (!data.name.match(/^[\w-_]{1,30}$/)) {
self.socket.emit("errorMsg", {
msg: "Invalid channel name. Channel names may consist of"+
" 1-30 characters in the set a-z, A-Z, 0-9, -, and _"
});
self.kick("Invalid channel name");
return;
}
data.name = data.name.toLowerCase();
self.pendingChannel = self.server.getChannel(data.name);
if (self.loggedIn) {
// TODO fix
// I'm not sure what I meant by "fix", but I suppose I'll find out soon
self.pendingChannel.getRank(self.name, function (err, rank) {
if (!err && rank > self.rank)
self.rank = rank;
});
}
self.pendingChannel.userJoin(self);
});
self.socket.on("channelPassword", function (pw) {
if (!self.inChannel() && self.inPendingChannel()) {
self.pendingChannel.userJoin(self, pw);
}
});
self.socket.on("login", function (data) {
data = (typeof data !== "object") ? {} : data;
var name = (typeof data.name === "string") ? data.name : "";
var pw = (typeof data.pw === "string") ? data.pw : "";
var session = (typeof data.session === "string") ? data.session : "";
if (pw.length > 100)
pw = pw.substring(0, 100);
if (session.length > 64)
session = session.substring(0, 64);
if (self.loggedIn)
return;
if (self.loggingIn) {
var j = 0;
// Wait until current login finishes
var i = setInterval(function () {
j++;
if (!self.loggingIn) {
clearInterval(i);
if (!self.loggedIn)
self.login(name, pw, session);
return;
}
// Just in case to prevent the interval from going wild
if (j >= 4)
clearInterval(i);
}, 1000);
} else {
self.login(name, pw, session);
}
});
self.socket.on("assignLeader", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryChangeLeader(self, data);
}
});
self.socket.on("setChannelRank", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.trySetRank(self, data);
}
});
self.socket.on("unban", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUnban(self, data);
}
});
self.socket.on("chatMsg", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
if (typeof data.msg !== "string") {
return;
}
if (data.msg.indexOf("/afk") !== 0) {
self.setAFK(false);
self.autoAFK();
}
self.channel.tryChat(self, data);
}
});
self.socket.on("newPoll", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryOpenPoll(self, data);
}
});
self.socket.on("playerReady", function () {
if (self.inChannel()) {
self.channel.sendMediaUpdate(self);
}
});
self.socket.on("requestPlaylist", function () {
if (self.inChannel()) {
self.channel.sendPlaylist(self);
}
});
self.socket.on("queue", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryQueue(self, data);
}
});
self.socket.on("setTemp", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.trySetTemp(self, data);
}
});
self.socket.on("delete", function (data) {
if (self.inChannel()) {
self.channel.tryDequeue(self, data);
}
});
self.socket.on("uncache", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUncache(self, data);
}
});
self.socket.on("moveMedia", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryMove(self, data);
}
});
self.socket.on("jumpTo", function (data) {
if (self.inChannel()) {
self.channel.tryJumpTo(self, data);
}
});
self.socket.on("playNext", function () {
if (self.inChannel()) {
self.channel.tryPlayNext(self);
}
});
self.socket.on("clearPlaylist", function () {
if (self.inChannel()) {
self.channel.tryClearqueue(self);
}
});
self.socket.on("shufflePlaylist", function () {
if (self.inChannel()) {
self.channel.tryShufflequeue(self);
}
});
self.socket.on("togglePlaylistLock", function () {
if (self.inChannel()) {
self.channel.tryToggleLock(self);
}
});
self.socket.on("mediaUpdate", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUpdate(self, data);
}
});
self.socket.on("searchMedia", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
if (typeof data.query !== "string") {
return;
}
// Soft limit to prevent someone from making a massive query
if (data.query.length > 255) {
data.query = data.query.substring(0, 255);
}
if (data.source === "yt") {
var searchfn = InfoGetter.Getters.ytSearch;
searchfn(data.query.split(" "), function (e, vids) {
if (!e) {
self.socket.emit("searchResults", {
source: "yt",
results: vids
});
}
});
} else {
self.channel.search(data.query, function (vids) {
if (vids.length === 0) {
var searchfn = InfoGetter.Getters.ytSearch;
searchfn(data.query.split(" "), function (e, vids) {
if (!e) {
self.socket.emit("searchResults", {
source: "yt",
results: vids
});
}
});
return;
}
self.socket.emit("searchResults", {
source: "library",
results: vids
});
});
}
}
});
self.socket.on("closePoll", function () {
if (self.inChannel()) {
self.channel.tryClosePoll(self);
}
});
self.socket.on("vote", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryVote(self, data);
}
});
self.socket.on("registerChannel", function (data) {
if (!self.inChannel()) {
self.socket.emit("channelRegistration", {
success: false,
error: "You're not in any channel!"
});
} else {
self.channel.tryRegister(self);
}
});
self.socket.on("unregisterChannel", function () {
if (!self.inChannel()) {
return;
}
if (self.rank < 10) {
self.kick("Attempted unregisterChannel with insufficient permission");
return;
}
self.channel.unregister(self);
});
self.socket.on("setOptions", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUpdateOptions(self, data);
}
});
self.socket.on("setPermissions", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUpdatePermissions(self, data);
}
});
self.socket.on("setChannelCSS", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.trySetCSS(self, data);
}
});
self.socket.on("setChannelJS", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.trySetJS(self, data);
}
});
self.socket.on("updateFilter", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUpdateFilter(self, data);
}
});
self.socket.on("removeFilter", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryRemoveFilter(self, data);
}
});
self.socket.on("moveFilter", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryMoveFilter(self, data);
}
});
self.socket.on("setMotd", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryUpdateMotd(self, data);
}
});
self.socket.on("requestLoginHistory", function () {
if (self.inChannel()) {
self.channel.sendLoginHistory(self);
}
});
self.socket.on("requestBanlist", function () {
if (self.inChannel()) {
self.channel.sendBanlist(self);
}
});
self.socket.on("requestChatFilters", function () {
if (self.inChannel()) {
self.channel.sendChatFilters(self);
}
});
self.socket.on("requestChannelRanks", function () {
if (self.inChannel()) {
if (self.rankListLimiter.throttle({
burst: 0,
sustained: 0.1,
cooldown: 10
})) {
self.socket.emit("noflood", {
action: "channel ranks",
msg: "You may only refresh channel ranks once every 10 seconds"
});
} else {
self.channel.sendChannelRanks(self);
}
}
});
self.socket.on("voteskip", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryVoteskip(self);
}
});
self.socket.on("listPlaylists", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.name === "" || self.rank < 1) {
self.socket.emit("listPlaylists", {
pllist: [],
error: "You must be logged in to manage playlists"
});
return;
}
self.server.db.listUserPlaylists(self.name, function (err, list) {
if (err)
list = [];
for(var i = 0; i < list.length; i++) {
list[i].time = $util.formatTime(list[i].time);
}
self.socket.emit("listPlaylists", {
pllist: list
});
});
});
self.socket.on("savePlaylist", function (data) {
data = (typeof data !== "object") ? {} : data;
if (typeof data.name !== "string") {
return;
}
// Soft limit to prevent someone from saving a list with a massive name
if (data.name.length > 200) {
data.name = data.name.substring(0, 200);
}
if (self.rank < 1) {
self.socket.emit("savePlaylist", {
success: false,
error: "You must be logged in to manage playlists"
});
return;
}
if (!self.inChannel()) {
self.socket.emit("savePlaylist", {
success: false,
error: "Not in a channel"
});
return;
}
if (typeof data.name != "string") {
return;
}
var pl = self.channel.playlist.items.toArray();
self.server.db.saveUserPlaylist(pl, self.name, data.name,
function (err, res) {
if (err) {
self.socket.emit("savePlaylist", {
success: false,
error: err
});
return;
}
self.socket.emit("savePlaylist", {
success: true
});
self.server.db.listUserPlaylists(self.name,
function (err, list) {
if (err)
list = [];
for(var i = 0; i < list.length; i++) {
list[i].time = $util.formatTime(list[i].time);
}
self.socket.emit("listPlaylists", {
pllist: list
});
});
});
});
self.socket.on("queuePlaylist", function (data) {
data = (typeof data !== "object") ? {} : data;
if (self.inChannel()) {
self.channel.tryQueuePlaylist(self, data);
}
});
self.socket.on("deletePlaylist", function (data) {
data = (typeof data !== "object") ? {} : data;
if (typeof data.name != "string") {
return;
}
self.server.db.deleteUserPlaylist(self.name, data.name,
function () {
self.server.db.listUserPlaylists(self.name,
function (err, list) {
if (err)
list = [];
for(var i = 0; i < list.length; i++) {
list[i].time = $util.formatTime(list[i].time);
}
self.socket.emit("listPlaylists", {
pllist: list
});
});
});
});
self.socket.on("readChanLog", function () {
if (self.inChannel()) {
self.channel.tryReadLog(self);
}
});
self.socket.on("acp-init", function () {
if (self.global_rank >= 255)
ACP.init(self);
});
self.socket.on("borrow-rank", function (rank) {
if (self.global_rank < 255)
return;
if (rank > self.global_rank)
return;
self.rank = rank;
self.socket.emit("rank", rank);
if (self.inChannel()) {
self.channel.sendAll("setUserRank", {
name: self.name,
rank: rank
});
}
});
};
var lastguestlogin = {};
User.prototype.guestLogin = function (name) {
var self = this;
if (self.ip in lastguestlogin) {
var diff = (Date.now() - lastguestlogin[self.ip])/1000;
if (diff < self.server.cfg["guest-login-delay"]) {
self.socket.emit("login", {
success: false,
error: "Guest logins are restricted to one per IP address "+
"per " + self.server.cfg["guest-login-delay"] +
" seconds."
});
return false;
}
}
if (!$util.isValidUserName(name)) {
self.socket.emit("login", {
success: false,
error: "Invalid username. Usernames must be 1-20 characters "+
"long and consist only of characters a-z, A-Z, 0-9, -, "+
"and _"
});
return;
}
// Set the loggingIn flag to avoid race conditions with the callback
self.loggingIn = true;
self.server.db.isUsernameTaken(name, function (err, taken) {
self.loggingIn = false;
if (err) {
self.socket.emit("login", {
success: false,
error: "Internal error: " + err
});
return;
}
if (taken) {
self.socket.emit("login", {
success: false,
error: "That username is registered and protected."
});
return;
}
if (self.inChannel()) {
var lname = name.toLowerCase();
for(var i = 0; i < self.channel.users.length; i++) {
if (self.channel.users[i].name.toLowerCase() === lname) {
self.socket.emit("login", {
success: false,
error: "That name is already in use on this channel"
});
return;
}
}
}
lastguestlogin[self.ip] = Date.now();
self.rank = 0;
Logger.syslog.log(self.ip + " signed in as " + name);
self.server.db.recordVisit(self.ip, name);
self.name = name;
self.loggedIn = false;
self.socket.emit("login", {
success: true,
name: name
});
self.socket.emit("rank", self.rank);
if (self.inChannel()) {
self.channel.logger.log(self.ip + " signed in as " + name);
self.channel.broadcastNewUser(self);
}
});
};
// Attempt to login
User.prototype.login = function (name, pw, session) {
var self = this;
// No password => try guest login
if (pw === "" && session === "") {
this.guestLogin(name);
} else {
self.loggingIn = true;
self.server.db.userLogin(name, pw, session, function (err, row) {
if (err) {
self.loggingIn = false;
ActionLog.record(self.ip, name, "login-failure",
err);
self.socket.emit("login", {
success: false,
error: err
});
return;
}
if (self.inChannel()) {
var n = name.toLowerCase();
for(var i = 0; i < self.channel.users.length; i++) {
if (self.channel.users[i].name.toLowerCase() === n) {
if (self.channel.users[i] === self) {
Logger.errlog.log("Wat: user.login() but user "+
"already logged in on channel");
break;
}
self.channel.kick(self.channel.users[i],
"Duplicate login");
}
}
}
// Record logins for administrator accounts
if (self.global_rank >= 255)
ActionLog.record(self.ip, name, "login-success");
self.loggedIn = true;
self.loggingIn = false;
self.socket.emit("login", {
success: true,
session: row.session_hash,
name: name
});
Logger.syslog.log(self.ip + " logged in as " + name);
self.server.db.recordVisit(self.ip, name);
self.profile = {
image: row.profile_image,
text: row.profile_text
};
self.global_rank = row.global_rank;
var afterRankLookup = function () {
self.socket.emit("rank", self.rank);
self.name = name;
if (self.inChannel()) {
self.channel.logger.log(self.ip + " logged in as " +
name);
self.channel.broadcastNewUser(self);
} else if (self.inPendingChannel()) {
self.pendingChannel.userJoin(self);
}
};
if (self.inChannel() || self.inPendingChannel()) {
var chan = self.channel != null ? self.channel : self.pendingChannel;
chan.getRank(name, function (err, rank) {
if (!err) {
self.saverank = true;
self.rank = rank;
} else {
// If there was an error in retrieving the rank,
// don't overwrite it with a bad value
self.saverank = false;
self.rank = self.global_rank;
}
afterRankLookup();
});
} else {
self.rank = self.global_rank;
afterRankLookup();
}
});
}
};
module.exports = User;

167
lib/utilities.js Normal file
View file

@ -0,0 +1,167 @@
/*
Set prototype- simple wrapper around JS objects to
manipulate them like a set
*/
var Set = function (items) {
this._items = {};
var self = this;
if (items instanceof Array)
items.forEach(function (it) { self.add(it); });
};
Set.prototype.contains = function (what) {
return (what in this._items);
};
Set.prototype.add = function (what) {
this._items[what] = true;
};
Set.prototype.remove = function (what) {
if (what in this._items)
delete this._items[what];
};
Set.prototype.clear = function () {
this._items = {};
};
Set.prototype.forEach = function (fn) {
for (var k in this._items) {
fn(k);
}
};
module.exports = {
isValidChannelName: function (name) {
return name.match(/^[\w-_]{1,30}$/);
},
isValidUserName: function (name) {
return name.match(/^[\w-_]{1,20}$/);
},
randomSalt: function (length) {
var chars = "abcdefgihjklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789!@#$%^&*_+=~";
var salt = [];
for(var i = 0; i < length; i++) {
salt.push(chars[parseInt(Math.random()*chars.length)]);
}
return salt.join('');
},
maskIP: function (ip) {
if(ip.match(/^\d+\.\d+\.\d+\.\d+$/)) {
// standard 32 bit IP
return ip.replace(/\d+\.\d+\.(\d+\.\d+)/, "x.x.$1");
} else if(ip.match(/^\d+\.\d+\.\d+/)) {
// /24 range
return ip.replace(/\d+\.\d+\.(\d+)/, "x.x.$1.*");
}
},
formatTime: function (sec) {
if(sec === "--:--")
return sec;
sec = Math.floor(+sec);
var h = "", m = "", s = "";
if(sec >= 3600) {
h = "" + Math.floor(sec / 3600);
if(h.length < 2)
h = "0" + h;
sec %= 3600;
}
m = "" + Math.floor(sec / 60);
if(m.length < 2)
m = "0" + m;
s = "" + (sec % 60);
if(s.length < 2)
s = "0" + s;
if(h === "")
return [m, s].join(":");
return [h, m, s].join(":");
},
newRateLimiter: function () {
return {
count: 0,
lastTime: 0,
throttle: function (opts) {
if (typeof opts === "undefined")
opts = {};
var burst = +opts.burst,
sustained = +opts.sustained,
cooldown = +opts.cooldown;
if (isNaN(burst))
burst = 10;
if (isNaN(sustained))
sustained = 2;
if (isNaN(cooldown))
cooldown = burst / sustained;
// Cooled down, allow and clear buffer
if (this.lastTime < Date.now() - cooldown*1000) {
this.count = 1;
this.lastTime = Date.now();
return false;
}
// Haven't reached burst cap yet, allow
if (this.count < burst) {
this.count++;
this.lastTime = Date.now();
return false;
}
var diff = Date.now() - this.lastTime;
if (diff < 1000/sustained)
return true;
this.lastTime = Date.now();
return false;
}
};
},
formatLink: function (id, type) {
switch (type) {
case "yt":
return "http://youtu.be/" + id;
case "vi":
return "http://vimeo.com/" + id;
case "dm":
return "http://dailymotion.com/video/" + id;
case "sc":
return id;
case "li":
return "http://livestream.com/" + id;
case "tw":
return "http://twitch.tv/" + id;
case "jt":
return "http://justin.tv/" + id;
case "rt":
return id;
case "jw":
return id;
case "im":
return "http://imgur.com/a/" + id;
case "us":
return "http://ustream.tv/" + id;
default:
return "";
}
},
Set: Set
};

282
lib/xss.js Normal file
View file

@ -0,0 +1,282 @@
/*
The MIT License (MIT)
Copyright (c) 2013 Calvin Montgomery
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/*
WARNING
This file contains an XSS prevention module I wrote myself. It has not
been verified by any external agency, and due to the nature of XSS I cannot
guarantee that it will filter correctly. Feel free to send me bug reports
and I will do my best to fix them, but use at your own risk.
*/
/* Prototype for a basic XML tag parser */
function TagParser(text) {
this.text = text;
this.i = 0;
this.tag = this.parse();
}
/* Moves the position marker past any whitespace characters */
TagParser.prototype.skipWhitespace = function () {
while (this.i < this.text.length && this.text[this.i].match(/\s/)) {
this.i++;
}
};
/* Reads a literal value matching the given regexp. Defaults
to /[^\s>]/; i.e. any string not containing whitespace or
the end of tag character '>'
*/
TagParser.prototype.readLiteral = function (regexp) {
if (regexp === void 0) {
regexp = /[^\s>]/;
}
var str = "";
while (this.i < this.text.length && this.text[this.i].match(regexp)) {
str += this.text[this.i];
this.i++;
}
return str;
};
/* If the character at the current position is a quote, read
a string. Otherwise, read a literal
*/
TagParser.prototype.readLiteralOrString = function (regexp) {
if (this.text[this.i].match(/["']/)) {
return this.readString();
}
return this.readLiteral(regexp);
};
/* Read a string delimited by the character at the current
position. For XML tags this means strings enclosed in
" or '. Treats \" as a literal '"' symbol and not a
delimiter.
*/
TagParser.prototype.readString = function () {
var delim = this.text[this.i++];
var str = "";
while (this.i < this.text.length && this.text[this.i] !== delim) {
if (this.text[this.i] === "\\" && this.text[this.i+1] === delim) {
str += this.text[this.i+1];
this.i++;
} else {
str += this.text[this.i];
}
this.i++;
}
this.i++;
return str;
};
/* Attempts to parse a tagname and attributes from an
XML tag.
NOTE: Does not actually parse a DOM node, only parses
the tag between '<' and '>' because that's all I need
to do XSS filtering, I don't care what's between a tag
and its end tag (if it's another tag I handle that
separately)
*/
TagParser.prototype.parse = function () {
this.i = this.text.indexOf("<");
// Not a tag
if (this.i === -1) {
return null;
}
this.i++;
this.skipWhitespace();
// First non-whitespace string after the opening '<' is the tag name
var tname = this.readLiteral();
var attrs = {};
// Continue parsing attributes until the end of string is reached or
// the end of tag is reached
while (this.i < this.text.length && this.text[this.i] !== ">") {
// Read any string not containing equals, possibly delimited by
// " or '
var key = this.readLiteralOrString(/[^\s=>]/);
this.skipWhitespace();
// It's possible for tags to have attributes with no value, where
// the equals sign is not necessary
if (this.text[this.i] !== "=") {
if (key.trim().length > 0) {
attrs[key] = "";
}
continue;
}
this.i++;
this.skipWhitespace();
var value = this.readLiteralOrString();
if (key.trim().length > 0) {
attrs[key] = value;
}
this.skipWhitespace();
}
// If end-of-string was not reached, consume the ending '>'
if (this.i < this.text.length) {
this.i++;
}
return {
tagName: tname,
attributes: attrs,
text: this.text.substring(0, this.i) // Original text (for replacement)
};
};
/* Some of these may not even be HTML tags, I borrowed them from the
[now deprecated] XSS module of node-validator
*/
const badTags = new RegExp([
"alert",
"applet",
"audio",
"basefont",
"base",
"behavior",
"bgsound",
"blink",
"body",
"embed",
"expression",
"form",
"frameset",
"frame",
"head",
"html",
"ilayer",
"iframe",
"input",
"layer",
"link",
"meta",
"object",
"style",
"script",
"textarea",
"title",
"video",
"xml",
"xss"
].join("|"), "i");
/* Nasty attributes. Anything starting with "on" is probably a javascript
callback, and I hope you see why formaction is a bad idea.
*/
const badAttrs = new RegExp([
"\\bon\\S*",
"\\bformaction"
].join("|"), "i");
/* These are things commonly used in the values of HTML attributes of
XSS injections. Go ahead and strip them, they don't have any other
use besides javascript
*/
const badAttrValues = new RegExp([
"alert",
"document.cookie",
"expression",
"javascript",
"location",
"window"
].join("|"), "ig");
function sanitizeHTML(str) {
var i = str.indexOf("<");
if (i === -1) {
// No HTML tags in the string
return str;
}
// Loop across all tag delimiters '<' in string, parse each one,
// and replace the results with sanitized tags
while (i !== -1) {
var t = new TagParser(str.substring(i)).tag;
if (t.tagName.replace("/", "").match(badTags)) {
// Note: Important that I replace the tag with a nonempty value,
// otherwise <scr<script>ipt> would possibly defeat the filter.
str = str.replace(t.text, "[tag removed]");
i = str.indexOf("<", i+1);
continue;
}
for (var k in t.attributes) {
// Keys should not contain non-word characters.
var k2 = k.replace(/[^\w]/g, "");
if (k2 !== k) {
t.attributes[k2] = t.attributes[k];
delete t.attributes[k];
k = k2;
}
// If it's an evil attribute, just nuke it entirely
if (k.match(badAttrs)) {
delete t.attributes[k];
} else {
if (t.attributes[k].match(badAttrValues)) {
// As above, replacing with a nonempty string is important.
t.attributes[k] = t.attributes[k].replace(badAttrValues, "[removed]");
}
}
}
// Build the sanitized tag
var fmt = "<" + t.tagName;
for (var k in t.attributes) {
if (k.trim().length > 0) {
fmt += " " + k;
if (t.attributes[k].trim().length > 0) {
fmt += '="' + t.attributes[k] + '"';
}
}
}
str = str.replace(t.text, fmt + ">");
i = str.indexOf("<", i + fmt.length + 1);
}
return str;
}
/* WIP: Sanitize a string where HTML is prohibited */
function sanitizeText(str) {
str = str.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\(/g, "&#40;")
.replace(/\)/g, "&#41;");
return str;
}
function decodeText(str) {
str = str.replace(/&#([0-9]{2,4});?/g, function (m, p1) {
return String.fromCharCode(parseInt(p1));
});
str = str.replace(/&#x([0-9a-f]{2,4});?/ig, function (m, p1) {
return String.fromCharCode(parseInt(p1, 16));
});
str = str.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&amp;/g, "&");
return str;
}
module.exports.sanitizeHTML = sanitizeHTML;
module.exports.sanitizeText = sanitizeText;
module.exports.decodeText = decodeText;

7261
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,83 +1,18 @@
{
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.86.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
"license": "MIT",
"dependencies": {
"@calzoneman/jsli": "^2.0.1",
"@cytube/mediaquery": "github:CyTube/mediaquery#564d0c4615e80f72722b0f68ac81f837a4c5fc81",
"bcrypt": "^5.0.1",
"bluebird": "^3.7.2",
"body-parser": "^1.20.1",
"cheerio": "^1.0.0-rc.10",
"clone": "^2.1.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
"create-error": "^0.3.1",
"csrf": "^3.1.0",
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
"express": "^4.18.2",
"express-minify": "^1.0.0",
"json-typecheck": "^0.1.3",
"knex": "^2.4.0",
"lodash": "^4.17.21",
"morgan": "^1.10.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.1.1",
"sanitize-html": "^2.7.0",
"serve-static": "^1.15.0",
"socket.io": "^4.5.4",
"source-map-support": "^0.5.19",
"toml": "^3.0.0",
"uuid": "^8.3.2",
"yamljs": "^0.2.8"
},
"scripts": {
"build-player": "./bin/build-player.js",
"build-server": "babel -D --source-maps --out-dir lib/ src/",
"flow": "flow",
"lint": "eslint src",
"pretest": "npm run lint",
"postinstall": "./postinstall.sh",
"server-dev": "babel -D --watch --source-maps --verbose --out-dir lib/ src/",
"generate-userscript": "$npm_node_execpath gdrive-userscript/generate-userscript $@ > www/js/cytube-google-drive.user.js",
"test": "mocha --recursive --exit test",
"integration-test": "mocha --recursive --exit integration_test"
},
"devDependencies": {
"@babel/cli": "^7.15.7",
"@babel/core": "^7.15.8",
"@babel/eslint-parser": "^7.15.8",
"@babel/preset-env": "^7.15.8",
"babel-plugin-add-module-exports": "^1.0.4",
"coffeescript": "^1.9.2",
"eslint": "^7.32.0",
"eslint-plugin-no-jquery": "^2.7.0",
"mocha": "^9.2.2",
"sinon": "^10.0.0"
},
"babel": {
"presets": [
[
"@babel/env",
{
"targets": {
"node": "12"
}
}
]
],
"plugins": [
"add-module-exports"
]
}
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "2.4.6",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
"dependencies": {
"socket.io": ">=0.9",
"express": ">=3.2",
"mysql": "2.0.0-alpha8",
"node_hash": "*",
"bcrypt": "*",
"nodemailer": "*",
"validator": "<2.0.0"
}
}

View file

@ -1,36 +0,0 @@
window.Player = class Player
constructor: (data) ->
if not (this instanceof Player)
return new Player(data)
@setMediaProperties(data)
@paused = false
load: (data) ->
@setMediaProperties(data)
setMediaProperties: (data) ->
@mediaId = data.id
@mediaType = data.type
@mediaLength = data.seconds
play: ->
@paused = false
pause: ->
@paused = true
seekTo: (time) ->
setVolume: (volume) ->
getTime: (cb) ->
cb(0)
isPaused: (cb) ->
cb(@paused)
getVolume: (cb) ->
cb(VOLUME)
destroy: ->

View file

@ -1,39 +0,0 @@
CUSTOM_EMBED_WARNING = 'This channel is embedding custom content from %link%.
Since this content is not trusted, you must click "Embed" below to allow
the content to be embedded.<hr>'
window.CustomEmbedPlayer = class CustomEmbedPlayer extends EmbedPlayer
constructor: (data) ->
if not (this instanceof CustomEmbedPlayer)
return new CustomEmbedPlayer(data)
@load(data)
load: (data) ->
if not data.meta.embed?
console.error('CustomEmbedPlayer::load(): missing meta.embed')
return
embedSrc = data.meta.embed.src
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')
.on('click', =>
super(data)
)
.appendTo(alert.find('.alert'))
removeOld(alert)

View file

@ -1,130 +0,0 @@
window.DailymotionPlayer = class DailymotionPlayer extends Player
constructor: (data) ->
if not (this instanceof DailymotionPlayer)
return new DailymotionPlayer(data)
@setMediaProperties(data)
@initialVolumeSet = false
@playbackReadyCb = null
waitUntilDefined(window, 'DM', =>
removeOld()
params =
autoplay: 1
logo: 0
quality = @mapQuality(USEROPTS.default_quality)
if quality != 'auto'
params.quality = quality
@dm = DM.player('ytapiplayer',
video: data.id
width: parseInt(VWIDTH, 10)
height: parseInt(VHEIGHT, 10)
params: params
)
@dm.addEventListener('apiready', =>
@dmReady = true
@dm.addEventListener('ended', ->
if CLIENT.leader
socket.emit('playNext')
)
@dm.addEventListener('pause', =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@dm.addEventListener('playing', =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
if not @initialVolumeSet
@setVolume(VOLUME)
@initialVolumeSet = true
)
# Once the video stops, the internal state of the player
# becomes unusable and attempting to load() will corrupt it and
# crash the player with an error. As a shortmedium term
# workaround, mark the player as "not ready" until the next
# playback_ready event
@dm.addEventListener('video_end', =>
@dmReady = false
)
@dm.addEventListener('playback_ready', =>
@dmReady = true
if @playbackReadyCb
@playbackReadyCb()
@playbackReadyCb = null
)
)
)
load: (data) ->
@setMediaProperties(data)
if @dm and @dmReady
@dm.load(data.id)
@dm.seek(data.currentTime)
else if @dm
# TODO: Player::load() needs to be made asynchronous in the future
console.log('Warning: load() called before DM is ready, queueing callback')
@playbackReadyCb = () =>
@dm.load(data.id)
@dm.seek(data.currentTime)
else
console.error('WTF? DailymotionPlayer::load() called but @dm is undefined')
pause: ->
if @dm and @dmReady
@paused = true
@dm.pause()
play: ->
if @dm and @dmReady
@paused = false
@dm.play()
seekTo: (time) ->
if @dm and @dmReady
@dm.seek(time)
setVolume: (volume) ->
if @dm and @dmReady
@dm.setVolume(volume)
getTime: (cb) ->
if @dm and @dmReady
cb(@dm.currentTime)
else
cb(0)
getVolume: (cb) ->
if @dm and @dmReady
if @dm.muted
cb(0)
else
volume = @dm.volume
# There was once a bug in Dailymotion where it sometimes gave back
# volumes in the wrong range. Not sure if this is still a necessary
# check.
if volume > 1
volume /= 100
cb(volume)
else
cb(VOLUME)
mapQuality: (quality) ->
switch String(quality)
when '240', '480', '720', '1080' then String(quality)
when '360' then '380'
when 'best' then '1080'
else 'auto'
destroy: ->
if @dm
@dm.destroy('ytapiplayer')

View file

@ -1,49 +0,0 @@
DEFAULT_ERROR = 'You are currently connected via HTTPS but the embedded content
uses non-secure plain HTTP. Your browser therefore blocks it from
loading due to mixed content policy. To fix this, embed the video using a
secure link if available (https://...), or find another source for the content.'
genParam = (name, value) ->
$('<param/>').attr(
name: name
value: value
)
window.EmbedPlayer = class EmbedPlayer extends Player
constructor: (data) ->
if not (this instanceof EmbedPlayer)
return new EmbedPlayer(data)
@load(data)
load: (data) ->
@setMediaProperties(data)
embed = data.meta.embed
if not embed?
console.error('EmbedPlayer::load(): missing meta.embed')
return
@player = @loadIframe(embed)
removeOld(@player)
loadIframe: (embed) ->
if embed.src.indexOf('http:') == 0 and location.protocol == 'https:'
if @__proto__.mixedContentError?
error = @__proto__.mixedContentError
else
error = DEFAULT_ERROR
alert = makeAlert('Mixed Content Error', error, 'alert-danger')
.removeClass('col-md-12')
alert.find('.close').remove()
return alert
else
iframe = $('<iframe/>').attr(
src: embed.src
frameborder: '0'
allow: 'autoplay'
allowfullscreen: '1'
)
return iframe

View file

@ -1,86 +0,0 @@
window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
constructor: (data) ->
if not (this instanceof GoogleDrivePlayer)
return new GoogleDrivePlayer(data)
super(data)
load: (data) ->
if not window.hasDriveUserscript
window.promptToInstallDriveUserscript()
else if window.hasDriveUserscript
window.maybePromptToUpgradeUserscript()
if typeof window.getGoogleDriveMetadata is 'function'
setTimeout(=>
backoffRetry((cb) ->
window.getGoogleDriveMetadata(data.id, cb)
, (error, metadata) =>
if error
console.error(error)
alertBox = window.document.createElement('div')
alertBox.className = 'alert alert-danger'
alertBox.textContent = error
document.getElementById('ytapiplayer').appendChild(alertBox)
else
data.meta.direct = metadata.videoMap
super(data)
, {
maxTries: 3
delay: 1000
factor: 1.2
jitter: 500
})
, Math.random() * 1000)
window.promptToInstallDriveUserscript = ->
if document.getElementById('prompt-install-drive-userscript')
return
alertBox = document.createElement('div')
alertBox.id = 'prompt-install-drive-userscript'
alertBox.className = 'alert alert-info'
alertBox.innerHTML = """
Due to continual breaking changes making it increasingly difficult to
maintain Google Drive support, Google Drive now requires installing
a userscript in order to play the video."""
alertBox.appendChild(document.createElement('br'))
infoLink = document.createElement('a')
infoLink.className = 'btn btn-info'
infoLink.href = '/google_drive_userscript'
infoLink.textContent = 'Click here for details'
infoLink.target = '_blank'
alertBox.appendChild(infoLink)
closeButton = document.createElement('button')
closeButton.className = 'close pull-right'
closeButton.innerHTML = '&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,23 +0,0 @@
window.HLSPlayer = class HLSPlayer extends VideoJSPlayer
constructor: (data) ->
if not (this instanceof HLSPlayer)
return new HLSPlayer(data)
@setupMeta(data)
super(data)
load: (data) ->
@setupMeta(data)
super(data)
setupMeta: (data) ->
data.meta.direct =
# Quality is required for data.meta.direct processing but doesn't
# matter here because it's dictated by the stream. Arbitrarily
# choose 480.
480: [
{
link: data.id
contentType: 'application/x-mpegURL'
}
]

View file

@ -1,33 +0,0 @@
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,17 +0,0 @@
window.LivestreamPlayer = class LivestreamPlayer extends EmbedPlayer
constructor: (data) ->
if not (this instanceof LivestreamPlayer)
return new LivestreamPlayer(data)
@load(data)
load: (data) ->
[ 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)

View file

@ -1,66 +0,0 @@
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)

View file

@ -1,21 +0,0 @@
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)
)

View file

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

@ -1,85 +0,0 @@
window.PlayerJSPlayer = class PlayerJSPlayer extends Player
constructor: (data) ->
if not (this instanceof PlayerJSPlayer)
return new PlayerJSPlayer(data)
@load(data)
load: (data) ->
@setMediaProperties(data)
@ready = false
if not data.meta.playerjs
throw new Error('Invalid input: missing meta.playerjs')
waitUntilDefined(window, 'playerjs', =>
iframe = $('<iframe/>')
.attr(
src: data.meta.playerjs.src
allow: 'autoplay; fullscreen'
)
removeOld(iframe)
@setupPlayer(iframe[0])
)
setupPlayer: (iframe) ->
@player = new playerjs.Player(iframe)
@player.on('ready', =>
@player.on('error', (error) =>
console.error('PlayerJS error', error.stack)
)
@player.on('ended', ->
if CLIENT.leader
socket.emit('playNext')
)
@player.on('play', ->
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
@player.on('pause', ->
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@player.setVolume(VOLUME * 100)
if not @paused
@player.play()
@ready = true
)
play: ->
@paused = false
if @player and @ready
@player.play()
pause: ->
@paused = true
if @player and @ready
@player.pause()
seekTo: (time) ->
if @player and @ready
@player.setCurrentTime(time)
setVolume: (volume) ->
if @player and @ready
@player.setVolume(volume * 100)
getTime: (cb) ->
if @player and @ready
@player.getCurrentTime(cb)
else
cb(0)
getVolume: (cb) ->
if @player and @ready
@player.getVolume((volume) ->
cb(volume / 100)
)
else
cb(VOLUME)

View file

@ -1,31 +0,0 @@
codecToMimeType = (codec) ->
switch codec
when 'mov/h264', 'mov/av1' then 'video/mp4'
when 'flv/h264' then 'video/flv'
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
constructor: (data) ->
if not (this instanceof FilePlayer)
return new FilePlayer(data)
data.meta.direct =
480: [{
contentType: codecToMimeType(data.meta.codec)
link: data.id
}]
super(data)
load: (data) ->
data.meta.direct =
480: [{
contentType: codecToMimeType(data.meta.codec)
link: data.id
}]
super(data)

View file

@ -1,23 +0,0 @@
window.RTMPPlayer = class RTMPPlayer extends VideoJSPlayer
constructor: (data) ->
if not (this instanceof RTMPPlayer)
return new RTMPPlayer(data)
@setupMeta(data)
super(data)
load: (data) ->
@setupMeta(data)
super(data)
setupMeta: (data) ->
data.meta.direct =
# Quality is required for data.meta.direct processing but doesn't
# matter here because it's dictated by the stream. Arbitrarily
# choose 480.
480: [
{
link: data.id
contentType: 'rtmp/flv'
}
]

View file

@ -1,108 +0,0 @@
window.SoundCloudPlayer = class SoundCloudPlayer extends Player
constructor: (data) ->
if not (this instanceof SoundCloudPlayer)
return new SoundCloudPlayer(data)
@setMediaProperties(data)
waitUntilDefined(window, 'SC', =>
removeOld()
# For tracks that are private, but embeddable, the API returns a
# special URL to load into the player.
# TODO: rename scuri?
if data.meta.scuri
soundUrl = data.meta.scuri
else
soundUrl = data.id
widget = $('<iframe/>').appendTo($('#ytapiplayer'))
widget.attr(
id: 'scplayer'
src: "https://w.soundcloud.com/player/?url=#{soundUrl}"
)
# Soundcloud embed widget doesn't have a volume control.
sliderHolder = $('<div/>').attr('id', 'soundcloud-volume-holder')
.insertAfter(widget)
$('<span/>').attr('id', 'soundcloud-volume-label')
.addClass('label label-default')
.text('Volume')
.appendTo(sliderHolder)
volumeSlider = $('<div/>').attr('id', 'soundcloud-volume')
.appendTo(sliderHolder)
.slider(
range: 'min'
value: VOLUME * 100
stop: (event, ui) =>
@setVolume(ui.value / 100)
)
@soundcloud = SC.Widget(widget[0])
@soundcloud.bind(SC.Widget.Events.READY, =>
@soundcloud.ready = true
@setVolume(VOLUME)
@play()
@soundcloud.bind(SC.Widget.Events.PAUSE, =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@soundcloud.bind(SC.Widget.Events.PLAY, =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
@soundcloud.bind(SC.Widget.Events.FINISH, =>
if CLIENT.leader
socket.emit('playNext')
)
)
)
load: (data) ->
@setMediaProperties(data)
if @soundcloud and @soundcloud.ready
if data.meta.scuri
soundUrl = data.meta.scuri
else
soundUrl = data.id
@soundcloud.load(soundUrl, auto_play: true)
@soundcloud.bind(SC.Widget.Events.READY, =>
@setVolume(VOLUME)
)
else
console.error('SoundCloudPlayer::load() called but soundcloud is not ready')
play: ->
@paused = false
if @soundcloud and @soundcloud.ready
@soundcloud.play()
pause: ->
@paused = true
if @soundcloud and @soundcloud.ready
@soundcloud.pause()
seekTo: (time) ->
if @soundcloud and @soundcloud.ready
# SoundCloud measures time in milliseconds while CyTube uses seconds.
@soundcloud.seekTo(time * 1000)
setVolume: (volume) ->
if @soundcloud and @soundcloud.ready
@soundcloud.setVolume(volume * 100)
getTime: (cb) ->
if @soundcloud and @soundcloud.ready
# Returned time is in milliseconds; CyTube expects seconds
@soundcloud.getPosition((time) -> cb(time / 1000))
else
cb(0)
getVolume: (cb) ->
if @soundcloud and @soundcloud.ready
@soundcloud.getVolume((vol) -> cb(vol / 100))
else
cb(VOLUME)

View file

@ -1,35 +0,0 @@
window.StreamablePlayer = class StreamablePlayer extends PlayerJSPlayer
constructor: (data) ->
if not (this instanceof StreamablePlayer)
return new StreamablePlayer(data)
super(data)
load: (data) ->
@ready = false
@finishing = false
@setMediaProperties(data)
waitUntilDefined(window, 'playerjs', =>
iframe = $('<iframe/>')
.attr(
src: "https://streamable.com/e/#{data.id}"
allow: 'autoplay; fullscreen'
)
removeOld(iframe)
@setupPlayer(iframe[0])
@player.on('ready', =>
# Streamable does not implement ended event since it loops
# gotta use a timeupdate hack
@player.on('timeupdate', (time) =>
if time.duration - time.seconds < 1 and not @finishing
setTimeout(=>
if CLIENT.leader
socket.emit('playNext')
@pause()
, (time.duration - time.seconds) * 1000)
@finishing = true
)
)
)

View file

@ -1,128 +0,0 @@
window.TWITCH_PARAMS_ERROR = 'The Twitch embed player now uses parameters which only
work if the following requirements are met: (1) The embedding website uses
HTTPS; (2) The embedding website uses the default port (443) and is accessed
via https://example.com instead of https://example.com:port. I have no
control over this -- see <a href="https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588" rel="noopener noreferrer" target="_blank">this Twitch post</a>
for details'
window.TwitchPlayer = class TwitchPlayer extends Player
constructor: (data) ->
if not (this instanceof TwitchPlayer)
return new TwitchPlayer(data)
@setMediaProperties(data)
waitUntilDefined(window, 'Twitch', =>
waitUntilDefined(Twitch, 'Player', =>
@init(data)
)
)
init: (data) ->
removeOld()
if location.hostname != location.host or location.protocol != 'https:'
alert = makeAlert(
'Twitch API Parameters',
window.TWITCH_PARAMS_ERROR,
'alert-danger'
).removeClass('col-md-12')
removeOld(alert)
@twitch = null
return
options =
parent: [location.hostname]
width: $('#ytapiplayer').width()
height: $('#ytapiplayer').height()
if data.type is 'tv'
# VOD
options.video = data.id
else
# Livestream
options.channel = data.id
@twitch = new Twitch.Player('ytapiplayer', options)
@twitch.addEventListener(Twitch.Player.READY, =>
@setVolume(VOLUME)
@twitch.setQuality(@mapQuality(USEROPTS.default_quality))
@twitch.addEventListener(Twitch.Player.PLAY, =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
@twitch.addEventListener(Twitch.Player.PAUSE, =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@twitch.addEventListener(Twitch.Player.ENDED, =>
if CLIENT.leader
socket.emit('playNext')
)
)
load: (data) ->
@setMediaProperties(data)
try
if data.type is 'tv'
# VOD
@twitch.setVideo(data.id)
else
# Livestream
@twitch.setChannel(data.id)
catch error
console.error(error)
pause: ->
try
@twitch.pause()
@paused = true
catch error
console.error(error)
play: ->
try
@twitch.play()
@paused = false
catch error
console.error(error)
seekTo: (time) ->
try
@twitch.seek(time)
catch error
console.error(error)
getTime: (cb) ->
try
cb(@twitch.getCurrentTime())
catch error
console.error(error)
setVolume: (volume) ->
try
@twitch.setVolume(volume)
if volume > 0
@twitch.setMuted(false)
catch error
console.error(error)
getVolume: (cb) ->
try
if @twitch.isPaused()
cb(0)
else
cb(@twitch.getVolume())
catch error
console.error(error)
mapQuality: (quality) ->
switch String(quality)
when '1080' then 'chunked'
when '720' then 'high'
when '480' then 'medium'
when '360' then 'low'
when '240' then 'mobile'
when 'best' then 'chunked'
else ''

View file

@ -1,21 +0,0 @@
window.TwitchClipPlayer = class TwitchClipPlayer extends EmbedPlayer
constructor: (data) ->
if not (this instanceof TwitchClipPlayer)
return new TwitchClipPlayer(data)
@load(data)
load: (data) ->
if location.hostname != location.host or location.protocol != 'https:'
alert = makeAlert(
'Twitch API Parameters',
window.TWITCH_PARAMS_ERROR,
'alert-danger'
).removeClass('col-md-12')
removeOld(alert)
return
data.meta.embed =
tag: 'iframe'
src: "https://clips.twitch.tv/embed?clip=#{data.id}&parent=#{location.host}"
super(data)

View file

@ -1,117 +0,0 @@
TYPE_MAP =
yt: YouTubePlayer
vi: VimeoPlayer
dm: DailymotionPlayer
gd: GoogleDrivePlayer
fi: FilePlayer
sc: SoundCloudPlayer
li: LivestreamPlayer
tw: TwitchPlayer
tv: TwitchPlayer
cu: CustomEmbedPlayer
rt: RTMPPlayer
hl: HLSPlayer
sb: StreamablePlayer
tc: TwitchClipPlayer
cm: VideoJSPlayer
pt: PeerPlayer
bc: IframeChild
bn: IframeChild
od: OdyseePlayer
nv: NicoPlayer
window.loadMediaPlayer = (data) ->
try
if window.PLAYER
window.PLAYER.destroy()
catch error
console.error error
if data.meta.direct and data.type is 'vi'
try
window.PLAYER = new VideoJSPlayer(data)
catch e
console.error e
else if data.type of TYPE_MAP
try
window.PLAYER = TYPE_MAP[data.type](data)
catch e
console.error e
window.handleMediaUpdate = (data) ->
PLAYER = window.PLAYER
# Do not update if the current time is past the end of the video, unless
# the video has length 0 (which is a special case for livestreams)
if typeof PLAYER.mediaLength is 'number' and
PLAYER.mediaLength > 0 and
data.currentTime > PLAYER.mediaLength
return
# Negative currentTime indicates a lead-in for clients to load the video,
# but not play it yet (helps with initial buffering)
waiting = data.currentTime < 0
# Load a new video in the same player if the ID changed
if data.id and data.id != PLAYER.mediaId
if data.currentTime < 0
data.currentTime = 0
PLAYER.load(data)
PLAYER.play()
if waiting
PLAYER.seekTo(0)
# YouTube player has a race condition that crashes the player if
# play(), seek(0), and pause() are called quickly without waiting
# for events to fire. Setting a flag variable that is checked in the
# event handler mitigates this.
if PLAYER instanceof YouTubePlayer
PLAYER.pauseSeekRaceCondition = true
else
PLAYER.pause()
return
else if PLAYER instanceof YouTubePlayer
PLAYER.pauseSeekRaceCondition = false
if CLIENT.leader or not USEROPTS.synch
return
if data.paused and not PLAYER.paused
PLAYER.seekTo(data.currentTime)
PLAYER.pause()
else if PLAYER.paused and not data.paused
PLAYER.play()
PLAYER.getTime((seconds) ->
time = data.currentTime
diff = (time - seconds) or time
accuracy = USEROPTS.sync_accuracy
# Dailymotion can't seek very accurately in Flash due to keyframe
# placement. Accuracy should not be set lower than 5 or the video
# may be very choppy.
if PLAYER instanceof DailymotionPlayer
accuracy = Math.max(accuracy, 5)
if diff > accuracy
# The player is behind the correct time
PLAYER.seekTo(time)
else if diff < -accuracy
# The player is ahead of the correct time
# Don't seek all the way back, to account for possible buffering.
# However, do seek all the way back for Dailymotion due to the
# keyframe issue mentioned above.
if not (PLAYER instanceof DailymotionPlayer)
time += 1
PLAYER.seekTo(time)
)
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()
return replace

View file

@ -1,239 +0,0 @@
sortSources = (sources) ->
if not sources
console.error('sortSources() called with null source list')
return []
qualities = ['2160', '1440', '1080', '720', '540', '480', '360', '240']
pref = String(USEROPTS.default_quality)
if USEROPTS.default_quality == 'best'
pref = '2160'
idx = qualities.indexOf(pref)
if idx < 0
idx = 5 # 480p
qualityOrder = qualities.slice(idx).concat(qualities.slice(0, idx).reverse())
qualityOrder.unshift('auto')
sourceOrder = []
flvOrder = []
for quality in qualityOrder
if quality of sources
flv = []
nonflv = []
sources[quality].forEach((source) ->
source.quality = quality
if source.contentType == 'video/flv'
flv.push(source)
else
nonflv.push(source)
)
sourceOrder = sourceOrder.concat(nonflv)
flvOrder = flvOrder.concat(flv)
return sourceOrder.concat(flvOrder).map((source) ->
type: source.contentType
src: source.link
res: source.quality
label: getSourceLabel(source)
)
getSourceLabel = (source) ->
if source.res is 'auto'
return 'auto'
else
return "#{source.quality}p #{source.contentType.split('/')[1]}"
hasAnyTextTracks = (data) ->
ntracks = data?.meta?.textTracks?.length ? 0
return ntracks > 0
hasAnyAudioTracks = (data) ->
ntracks = data?.meta?.audioTracks?.length ? 0
return ntracks > 0
window.VideoJSPlayer = class VideoJSPlayer extends Player
constructor: (data) ->
if not (this instanceof VideoJSPlayer)
return new VideoJSPlayer(data)
@load(data)
loadPlayer: (data) ->
waitUntilDefined(window, 'videojs', =>
attrs =
width: '100%'
height: '100%'
if @mediaType == 'cm' and hasAnyTextTracks(data)
attrs.crossorigin = 'anonymous'
video = $('<video/>')
.addClass('video-js vjs-default-skin embed-responsive-item')
.attr(attrs)
removeOld(video)
@sources = sortSources(data.meta.direct)
if @sources.length == 0
console.error('VideoJSPlayer::constructor(): data.meta.direct
has no sources!')
@mediaType = null
return
@sourceIdx = 0
# TODO: Refactor VideoJSPlayer to use a preLoad()/load()/postLoad() pattern
# VideoJSPlayer should provide the core functionality and logic for specific
# dependent player types (gdrive) should be an extension
if data.meta.gdrive_subtitles
data.meta.gdrive_subtitles.available.forEach((subt) ->
label = subt.lang_original
if subt.name
label += " (#{subt.name})"
$('<track/>').attr(
src: "/gdvtt/#{data.id}/#{subt.lang}/#{subt.name}.vtt?\
vid=#{data.meta.gdrive_subtitles.vid}"
kind: 'subtitles'
srclang: subt.lang
label: label
).appendTo(video)
)
if data.meta.textTracks
data.meta.textTracks.forEach((track) ->
label = track.name
attrs =
src: track.url
kind: 'subtitles'
type: track.type
label: label
if track.default? and track.default
attrs.default = ''
$('<track/>').attr(attrs).appendTo(video)
)
pluginData =
videoJsResolutionSwitcher:
default: @sources[0].res
if hasAnyAudioTracks(data)
pluginData.audioSwitch =
audioTracks: data.meta.audioTracks,
volume: VOLUME
@player = videojs(video[0],
# https://github.com/Dash-Industry-Forum/dash.js/issues/2184
autoplay: @sources[0].type != 'application/dash+xml',
controls: true,
plugins: pluginData
)
@player.ready(=>
# Have to use updateSrc instead of <source> tags
# see: https://github.com/videojs/video.js/issues/3428
@player.poster(data.meta.thumbnail)
@player.updateSrc(@sources)
@player.on('error', =>
err = @player.error()
if err and err.code == 4
console.error('Caught error, trying next source')
# Does this really need to be done manually?
@sourceIdx++
if @sourceIdx < @sources.length
@player.src(@sources[@sourceIdx])
else
console.error('Out of sources, video will not play')
if @mediaType is 'gd'
if not window.hasDriveUserscript
window.promptToInstallDriveUserscript()
else
window.tellUserNotToContactMeAboutThingsThatAreNotSupported()
)
@setVolume(VOLUME)
@player.on('ended', ->
if CLIENT.leader
socket.emit('playNext')
)
@player.on('pause', =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@player.on('play', =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
# Workaround for IE-- even after seeking completes, the loading
# spinner remains.
@player.on('seeked', =>
$('.vjs-waiting').removeClass('vjs-waiting')
)
# Workaround for Chrome-- it seems that the click bindings for
# the subtitle menu aren't quite set up until after the ready
# event finishes, so set a timeout for 1ms to force this code
# not to run until the ready() function returns.
setTimeout(->
$('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each((i, elem) ->
textNode = elem.childNodes[0]
if textNode.textContent == localStorage.lastSubtitle
elem.click()
elem.onclick = ->
if elem.attributes['aria-checked'].value == 'true'
localStorage.lastSubtitle = textNode.textContent
)
, 1)
)
)
load: (data) ->
@setMediaProperties(data)
# Note: VideoJS does have facilities for loading new videos into the
# existing player object, however it appears to be pretty glitchy when
# a video can't be played (either previous or next video). It's safer
# to just reset the entire thing.
@destroy()
@loadPlayer(data)
play: ->
@paused = false
if @player and @player.readyState() > 0
@player.play()
pause: ->
@paused = true
if @player and @player.readyState() > 0
@player.pause()
seekTo: (time) ->
if @player and @player.readyState() > 0
@player.currentTime(time)
setVolume: (volume) ->
if @player
@player.volume(volume)
getTime: (cb) ->
if @player and @player.readyState() > 0
cb(@player.currentTime())
else
cb(0)
getVolume: (cb) ->
if @player and @player.readyState() > 0
if @player.muted()
cb(0)
else
cb(@player.volume())
else
cb(VOLUME)
destroy: ->
removeOld()
if @player
@player.dispose()

View file

@ -1,86 +0,0 @@
window.VimeoPlayer = class VimeoPlayer extends Player
constructor: (data) ->
if not (this instanceof VimeoPlayer)
return new VimeoPlayer(data)
@load(data)
load: (data) ->
@setMediaProperties(data)
waitUntilDefined(window, 'Vimeo', =>
video = $('<iframe/>')
removeOld(video)
video.attr(
src: "https://player.vimeo.com/video/#{data.id}"
allow: 'autoplay; fullscreen'
)
@vimeo = new Vimeo.Player(video[0])
@vimeo.on('ended', =>
if CLIENT.leader
socket.emit('playNext')
)
@vimeo.on('pause', =>
@paused = true
if CLIENT.leader
sendVideoUpdate()
)
@vimeo.on('play', =>
@paused = false
if CLIENT.leader
sendVideoUpdate()
)
@play()
@setVolume(VOLUME)
)
play: ->
@paused = false
if @vimeo
@vimeo.play().catch((error) ->
console.error('vimeo::play():', error)
)
pause: ->
@paused = true
if @vimeo
@vimeo.pause().catch((error) ->
console.error('vimeo::pause():', error)
)
seekTo: (time) ->
if @vimeo
@vimeo.setCurrentTime(time).catch((error) ->
console.error('vimeo::setCurrentTime():', error)
)
setVolume: (volume) ->
if @vimeo
@vimeo.setVolume(volume).catch((error) ->
console.error('vimeo::setVolume():', error)
)
getTime: (cb) ->
if @vimeo
@vimeo.getCurrentTime().then((time) ->
cb(parseFloat(time))
).catch((error) ->
console.error('vimeo::getCurrentTime():', error)
)
else
cb(0)
getVolume: (cb) ->
if @vimeo
@vimeo.getVolume().then((volume) ->
cb(parseFloat(volume))
).catch((error) ->
console.error('vimeo::getVolume():', error)
)
else
cb(VOLUME)

View file

@ -1,96 +0,0 @@
window.YouTubePlayer = class YouTubePlayer extends Player
constructor: (data) ->
if not (this instanceof YouTubePlayer)
return new YouTubePlayer(data)
@setMediaProperties(data)
@pauseSeekRaceCondition = false
waitUntilDefined(window, 'YT', =>
# Even after window.YT is defined, YT.Player may not be, which causes a
# 'YT.Player is not a constructor' error occasionally
waitUntilDefined(YT, 'Player', =>
removeOld()
@yt = new YT.Player('ytapiplayer',
videoId: data.id
playerVars:
autohide: 1
autoplay: 1
controls: 1
iv_load_policy: 3 # iv_load_policy 3 indicates no annotations
rel: 0
events:
onReady: @onReady.bind(this)
onStateChange: @onStateChange.bind(this)
)
)
)
load: (data) ->
@setMediaProperties(data)
if @yt and @yt.ready
@yt.loadVideoById(data.id, data.currentTime)
else
console.error('WTF? YouTubePlayer::load() called but yt is not ready')
onReady: ->
@yt.ready = true
@setVolume(VOLUME)
onStateChange: (ev) ->
# 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
if (ev.data == YT.PlayerState.PAUSED and not @paused) or
(ev.data == YT.PlayerState.PLAYING and @paused)
@paused = (ev.data == YT.PlayerState.PAUSED)
if CLIENT.leader
sendVideoUpdate()
if ev.data == 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) ->
# https://github.com/calzoneman/sync/issues/726
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)

View file

@ -1,14 +0,0 @@
#!/bin/sh
set -e
if ! command -v npm >/dev/null; then
echo "Could not find npm in \$PATH"
exit 1
fi
echo "Building from src/ to lib/"
npm run build-server
echo "Building from player/ to www/js/player.js"
npm run build-player
echo "Done"

View file

@ -1,122 +0,0 @@
#!/usr/bin/env node
/*
** CyTube Service Socket Commandline
*/
const readline = require('readline');
const spawn = require('child_process').spawn;
const util = require('util');
const net = require('net');
const fs = require('fs');
const COMPLETIONS = [
"/delete_old_tables",
"/gc",
"/globalban",
"/reload",
"/reloadcert",
"/reload-partitions",
"/switch",
"/unglobalban",
"/unloadchan"
];
var Config = require("./lib/config");
Config.load("config.yaml");
if(!Config.get("service-socket.enabled")){
console.error('The Service Socket is not enabled.');
process.exit(1);
}
const SOCKETFILE = Config.get("service-socket.socket");
// Wipe the TTY
process.stdout.write('\x1Bc');
var commandline, eventlog, syslog, errorlog;
var client = net.createConnection(SOCKETFILE).on('connect', () => {
commandline = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: tabcomplete
});
commandline.setPrompt("> ", 2);
commandline.on("line", function(line) {
if(line === 'exit'){ return cleanup(); }
if(line === 'quit'){ return cleanup(); }
if(line.match(/^\/globalban/) && line.split(/\s+/).length === 2){
console.log('You must provide a reason')
return commandline.prompt();
}
client.write(line);
commandline.prompt();
});
commandline.on('close', function() {
return cleanup();
});
commandline.on("SIGINT", function() {
commandline.clearLine();
commandline.question("Terminate connection? ", function(answer) {
return answer.match(/^y(es)?$/i) ? cleanup() : commandline.output.write("> ");
});
});
commandline.prompt();
console.log = function() { cmdouthndlr("log", arguments); }
console.warn = function() { cmdouthndlr("warn", arguments); }
console.error = function() { cmdouthndlr("error", arguments); }
// console.info is reserved in this script for the exit message
// this prevents an extraneous final prompt from readline on terminate
eventlog = spawn('tail', ['-f', 'events.log']);
eventlog.stdout.on('data', function (data) {
console.log(data.toString().replace(/^(.+)$/mg, 'events: $1'));
});
syslog = spawn('tail', ['-f', 'sys.log']);
syslog.stdout.on('data', function (data) {
console.log(data.toString().replace(/^(.+)$/mg, 'sys: $1'));
});
errorlog = spawn('tail', ['-f', 'error.log']);
errorlog.stdout.on('data', function (data) {
console.log(data.toString().replace(/^(.+)$/mg, 'error: $1'));
});
}).on('data', (msg) => {
msg = msg.toString();
if(msg === '__disconnect'){
console.log('Server shutting down.');
return cleanup();
}
// Generic message handler
console.log('server: ', data)
}).on('error', (data) => {
console.error('Unable to connect to Service Socket.', data);
process.exit(1);
});
function cmdouthndlr(type, args) {
var t = Math.ceil((commandline.line.length + 3) / process.stdout.columns);
var text = util.format.apply(console, args);
commandline.output.write("\n\x1B[" + t + "A\x1B[0J");
commandline.output.write(text + "\n");
commandline.output.write(Array(t).join("\n\x1B[E"));
commandline._refreshLine();
}
function cleanup(){
console.info('\n',"Terminating.",'\n');
eventlog.kill('SIGTERM');
syslog.kill('SIGTERM');
client.end();
process.exit(0);
}
function tabcomplete(line) {
return [COMPLETIONS.filter((cv)=>{ return cv.indexOf(line) == 0; }), line];
}

View file

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

View file

@ -1,59 +0,0 @@
import db from './database';
import Promise from 'bluebird';
const dbGetGlobalRank = Promise.promisify(db.users.getGlobalRank);
const dbMultiGetGlobalRank = Promise.promisify(db.users.getGlobalRanks);
const dbGetChannelRank = Promise.promisify(db.channels.getRank);
const dbMultiGetChannelRank = Promise.promisify(db.channels.getRanks);
const dbGetAliases = Promise.promisify(db.getAliases);
const DEFAULT_PROFILE = Object.freeze({ image: '', text: '' });
class Account {
constructor(ip, user, aliases) {
this.ip = ip;
this.user = user;
this.aliases = aliases;
this.channelRank = -1;
this.guestName = null;
this.update();
}
update() {
if (this.user !== null) {
this.name = this.user.name;
this.globalRank = this.user.global_rank;
} else if (this.guestName !== null) {
this.name = this.guestName;
this.globalRank = 0;
} else {
this.name = '';
this.globalRank = -1;
}
this.lowername = this.name.toLowerCase();
this.effectiveRank = Math.max(this.channelRank, this.globalRank);
this.profile = (this.user === null) ? DEFAULT_PROFILE : this.user.profile;
}
}
module.exports.Account = Account;
module.exports.rankForName = async function rankForNameAsync(name, channel) {
const [globalRank, channelRank] = await Promise.all([
dbGetGlobalRank(name),
dbGetChannelRank(channel, name)
]);
return Math.max(globalRank, channelRank);
};
module.exports.rankForIP = async function rankForIP(ip, channel) {
const aliases = await dbGetAliases(ip);
const [globalRanks, channelRanks] = await Promise.all([
dbMultiGetGlobalRank(aliases),
dbMultiGetChannelRank(channel, aliases)
]);
return Math.max.apply(Math, globalRanks.concat(channelRanks));
};

View file

@ -1,285 +0,0 @@
var Logger = require("./logger");
var Server = require("./server");
var db = require("./database");
var util = require("./utilities");
import { v4 as uuidv4 } from 'uuid';
function eventUsername(user) {
return user.getName() + "@" + user.realip;
}
function handleAnnounce(user, data) {
var sv = Server.getServer();
sv.announce({
id: uuidv4(),
title: data.title,
text: data.content,
from: user.getName()
});
Logger.eventlog.log("[acp] " + eventUsername(user) + " opened announcement `" +
data.title + "`");
}
function handleAnnounceClear(user) {
Server.getServer().announce(null);
Logger.eventlog.log("[acp] " + eventUsername(user) + " cleared announcement");
}
function handleGlobalBan(user, data) {
const globalBanDB = db.getGlobalBanDB();
globalBanDB.addGlobalIPBan(data.ip, data.note).then(() => {
Logger.eventlog.log("[acp] " + eventUsername(user) + " global banned " + data.ip);
return globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
});
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
}
function handleGlobalBanDelete(user, data) {
const globalBanDB = db.getGlobalBanDB();
globalBanDB.removeGlobalIPBan(data.ip).then(() => {
Logger.eventlog.log("[acp] " + eventUsername(user) + " un-global banned " +
data.ip);
return globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
});
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
}
function handleListUsers(user, data) {
var value = data.value;
var field = data.field;
value = (typeof value !== 'string') ? '' : value;
field = (typeof field !== 'string') ? 'name' : field;
var fields = ["id", "name", "global_rank", "email", "ip", "time"];
if(!fields.includes(field)){
user.socket.emit("errMessage", {
msg: `The field "${field}" doesn't exist or isn't searchable.`
});
return;
}
db.users.search(field, value, fields, function (err, users) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
user.socket.emit("acp-list-users", users);
});
}
function handleSetRank(user, data) {
var name = data.name;
var rank = data.rank;
if (typeof name !== "string" || typeof rank !== "number") {
return;
}
if (rank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You are not permitted to promote others to equal or higher rank than " +
"yourself."
});
return;
}
db.users.getGlobalRank(name, function (err, oldrank) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
if (oldrank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You are not permitted to change the rank of users who rank " +
"higher than you."
});
return;
}
db.users.setGlobalRank(name, rank, function (err) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
} else {
Logger.eventlog.log("[acp] " + eventUsername(user) + " set " + name +
"'s global_rank to " + rank);
user.socket.emit("acp-set-rank", data);
}
});
});
}
function handleResetPassword(user, data, ack) {
var name = data.name;
var email = data.email;
if (typeof name !== "string" || typeof email !== "string") {
return;
}
db.users.getGlobalRank(name, function (err, rank) {
if (rank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You don't have permission to reset the password for " + name
});
return;
}
var hash = util.sha1(util.randomSalt(64));
var expire = Date.now() + 86400000;
db.addPasswordReset({
ip: "",
name: name,
email: email,
hash: hash,
expire: expire
}, function (err) {
if (err) {
ack && ack({ error: err });
return;
}
Logger.eventlog.log("[acp] " + eventUsername(user) + " initialized a " +
"password recovery for " + name);
ack && ack({ hash });
});
});
}
function handleListChannels(user, data) {
var field = data.field;
var value = data.value;
if (typeof field !== "string" || typeof value !== "string") {
return;
}
var dbfunc;
if (field === "owner") {
dbfunc = db.channels.searchOwner;
} else {
dbfunc = db.channels.search;
}
dbfunc(value, function (err, rows) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
user.socket.emit("acp-list-channels", rows);
});
}
function handleDeleteChannel(user, data) {
var name = data.name;
if (typeof data.name !== "string") {
return;
}
var sv = Server.getServer();
if (sv.isChannelLoaded(name)) {
sv.getChannel(name).users.forEach(function (u) {
u.kick("Channel shutting down");
});
}
db.channels.drop(name, function (err) {
Logger.eventlog.log("[acp] " + eventUsername(user) + " deleted channel " + name);
if (err) {
user.socket.emit("errMessage", {
msg: err
});
} else {
user.socket.emit("acp-delete-channel", {
name: name
});
}
});
}
function handleListActiveChannels(user) {
user.socket.emit("acp-list-activechannels", Server.getServer().packChannelList(false, true));
}
function handleForceUnload(user, data) {
var name = data.name;
if (typeof name !== "string") {
return;
}
var sv = Server.getServer();
if (!sv.isChannelLoaded(name)) {
return;
}
var chan = sv.getChannel(name);
var users = Array.prototype.slice.call(chan.users);
chan.emit("empty");
users.forEach(function (u) {
u.kick("Channel shutting down");
});
Logger.eventlog.log("[acp] " + eventUsername(user) + " forced unload of " + name);
}
function init(user) {
var s = user.socket;
s.on("acp-announce", handleAnnounce.bind(this, user));
s.on("acp-announce-clear", handleAnnounceClear.bind(this, user));
s.on("acp-gban", handleGlobalBan.bind(this, user));
s.on("acp-gban-delete", handleGlobalBanDelete.bind(this, user));
s.on("acp-list-users", handleListUsers.bind(this, user));
s.on("acp-set-rank", handleSetRank.bind(this, user));
s.on("acp-reset-password", handleResetPassword.bind(this, user));
s.on("acp-list-channels", handleListChannels.bind(this, user));
s.on("acp-delete-channel", handleDeleteChannel.bind(this, user));
s.on("acp-list-activechannels", handleListActiveChannels.bind(this, user));
s.on("acp-force-unload", handleForceUnload.bind(this, user));
const globalBanDB = db.getGlobalBanDB();
globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
Logger.eventlog.log("[acp] Initialized ACP for " + eventUsername(user));
}
module.exports.init = init;

View file

@ -1,54 +0,0 @@
var AsyncQueue = function () {
this._q = [];
this._lock = false;
this._tm = 0;
};
AsyncQueue.prototype.next = function () {
if (this._q.length > 0) {
if (!this.lock())
return;
var item = this._q.shift();
var fn = item[0];
this._tm = Date.now() + item[1];
fn(this);
}
};
AsyncQueue.prototype.lock = function () {
if (this._lock) {
if (this._tm > 0 && Date.now() > this._tm) {
this._tm = 0;
return true;
}
return false;
}
this._lock = true;
return true;
};
AsyncQueue.prototype.release = function () {
var self = this;
if (!self._lock)
return false;
self._lock = false;
setImmediate(function () {
self.next();
});
return true;
};
AsyncQueue.prototype.queue = function (fn) {
var self = this;
self._q.push([fn, 20000]);
self.next();
};
AsyncQueue.prototype.reset = function () {
this._q = [];
this._lock = false;
};
module.exports = AsyncQueue;

View file

@ -1,106 +0,0 @@
/*
bgtask.js
Registers background jobs to run periodically while the server is
running.
*/
var Config = require("./config");
var db = require("./database");
var Promise = require("bluebird");
const LOGGER = require('@calzoneman/jsli')('bgtask');
var init = null;
/* Alias cleanup */
function initAliasCleanup() {
var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval"));
var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age"));
setInterval(function () {
db.cleanOldAliases(CLEAN_EXPIRE, function (err) {
LOGGER.info("Cleaned old aliases");
if (err)
LOGGER.error(err);
});
}, CLEAN_INTERVAL);
}
/* Password reset cleanup */
function initPasswordResetCleanup() {
var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () {
db.cleanOldPasswordResets(function (err) {
if (err)
LOGGER.error(err);
});
}, CLEAN_INTERVAL);
}
function initChannelDumper(Server) {
const chanPath = Config.get('channel-path');
var CHANNEL_SAVE_INTERVAL = parseInt(Config.get("channel-save-interval"))
* 60000;
setInterval(function () {
if (Server.channels.length === 0) {
return;
}
var wait = CHANNEL_SAVE_INTERVAL / Server.channels.length;
LOGGER.info(`Saving channels with delay ${wait}`);
Promise.reduce(Server.channels, (_, chan) => {
return Promise.delay(wait).then(async () => {
if (!chan.dead && chan.users && chan.users.length > 0) {
try {
await chan.saveState();
LOGGER.info(`Saved /${chanPath}/${chan.name}`);
} catch (error) {
LOGGER.error(
'Failed to save /%s/%s: %s',
chanPath,
chan ? chan.name : '<undefined>',
error.stack
);
}
}
}).catch(error => {
LOGGER.error(`Failed to save channel: ${error.stack}`);
});
}, 0).catch(error => {
LOGGER.error(`Failed to save channels: ${error.stack}`);
});
}, CHANNEL_SAVE_INTERVAL);
}
function initAccountCleanup() {
setInterval(() => {
(async () => {
let rows = await db.users.findAccountsPendingDeletion();
for (let row of rows) {
try {
await db.users.purgeAccount(row.id);
LOGGER.info('Purged account from request %j', row);
} catch (error) {
LOGGER.error('Error purging account %j: %s', row, error.stack);
}
}
})().catch(error => {
LOGGER.error('Error purging deleted accounts: %s', error.stack);
});
}, 3600 * 1000);
}
module.exports = function (Server) {
if (init === Server) {
LOGGER.warn("Attempted to re-init background tasks");
return;
}
init = Server;
initAliasCleanup();
initChannelDumper(Server);
initPasswordResetCleanup();
initAccountCleanup();
};

View file

@ -1,46 +0,0 @@
import crypto from 'crypto';
import * as urlparse from 'url';
const LOGGER = require('@calzoneman/jsli')('camo');
function isWhitelisted(camoConfig, url) {
const whitelistedDomains = camoConfig.getWhitelistedDomainsRegexp();
const parsed = urlparse.parse(url);
return whitelistedDomains.test('.' + parsed.hostname);
}
export function camoify(camoConfig, url) {
if (typeof url !== 'string') {
throw new TypeError(`camoify expected a string, not [${url}]`);
}
if (isWhitelisted(camoConfig, url)) {
return url.replace(/^http:/, 'https:');
}
const hmac = crypto.createHmac('sha1', camoConfig.getKey());
hmac.update(url);
const digest = hmac.digest('hex');
// https://github.com/atmos/camo#url-formats
if (camoConfig.getEncoding() === 'hex') {
const hexUrl = Buffer.from(url, 'utf8').toString('hex');
return `${camoConfig.getServer()}/${digest}/${hexUrl}`;
} else {
const encoded = encodeURIComponent(url);
return `${camoConfig.getServer()}/${digest}?url=${encoded}`;
}
}
export function transformImgTags(camoConfig, tagName, attribs) {
if (typeof attribs.src === 'string') {
try {
const oldSrc = attribs.src;
attribs.src = camoify(camoConfig, attribs.src);
LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src);
} catch (error) {
LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`);
}
}
return { tagName, attribs };
}

View file

@ -1,36 +0,0 @@
import { DatabaseStore } from './dbstore';
import Config from '../config';
import Promise from 'bluebird';
var CHANNEL_STORE = null;
export function init() {
CHANNEL_STORE = loadChannelStore();
}
export function load(id, channelName) {
if (CHANNEL_STORE === null) {
return Promise.reject(new Error('ChannelStore not initialized yet'));
}
return CHANNEL_STORE.load(id, channelName);
}
export function save(id, channelName, data) {
if (CHANNEL_STORE === null) {
return Promise.reject(new Error('ChannelStore not initialized yet'));
}
return CHANNEL_STORE.save(id, channelName, data);
}
function loadChannelStore() {
if (Config.get('channel-storage.type') === 'file') {
throw new Error(
'channel-storage type "file" is no longer supported. Please see ' +
'NEWS.md for instructions on upgrading.'
);
}
return new DatabaseStore();
}

View file

@ -1,24 +0,0 @@
import Promise from 'bluebird';
import Config from '../config';
import db from '../database';
import { DatabaseStore } from './dbstore';
/* eslint no-console: off */
function main() {
Config.load('config.yaml');
db.init();
const dbStore = new DatabaseStore();
Promise.delay(1000).then(() => {
return dbStore.load(process.argv[2]);
}).then((data) => {
console.log(JSON.stringify(data, null, 4));
process.exit(0);
}).catch((err) => {
console.error(`Error retrieving channel data: ${err.stack}`);
process.exit(1);
});
}
main();

View file

@ -1,122 +0,0 @@
import Promise from 'bluebird';
import { ChannelStateSizeError } from '../errors';
import db from '../database';
import { Counter } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('dbstore');
const SIZE_LIMIT = 1048576;
const QUERY_CHANNEL_DATA = 'SELECT `key`, `value` FROM channel_data WHERE channel_id = ?';
const loadRowcount = new Counter({
name: 'cytube_channel_db_load_rows_total',
help: 'Total rows loaded from the channel_data table'
});
const loadCharcount = new Counter({
name: 'cytube_channel_db_load_chars_total',
help: 'Total characters (JSON length) loaded from the channel_data table'
});
const saveRowcount = new Counter({
name: 'cytube_channel_db_save_rows_total',
help: 'Total rows saved in the channel_data table'
});
const saveCharcount = new Counter({
name: 'cytube_channel_db_save_chars_total',
help: 'Total characters (JSON length) saved in the channel_data table'
});
function queryAsync(query, substitutions) {
return new Promise((resolve, reject) => {
db.query(query, substitutions, (err, res) => {
if (err) {
if (!(err instanceof Error)) {
err = new Error(err);
}
reject(err);
} else {
resolve(res);
}
});
});
}
function buildUpdateQuery(numEntries) {
const values = [];
for (let i = 0; i < numEntries; i++) {
values.push('(?, ?, ?)');
}
return `INSERT INTO channel_data VALUES ${values.join(', ')} ` +
'ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)';
}
export class DatabaseStore {
load(id, channelName) {
if (!id || id === 0) {
return Promise.reject(new Error(`Cannot load state for [${channelName}]: ` +
`id was passed as [${id}]`));
}
return queryAsync(QUERY_CHANNEL_DATA, [id]).then(rows => {
loadRowcount.inc(rows.length);
const data = {};
rows.forEach(row => {
try {
data[row.key] = JSON.parse(row.value);
loadCharcount.inc(row.value.length);
} catch (e) {
LOGGER.error(`Channel data for channel "${channelName}", ` +
`key "${row.key}" is invalid: ${e}`);
}
});
return data;
});
}
async save(id, channelName, data) {
if (!id || id === 0) {
throw new Error(
`Cannot save state for [${channelName}]: ` +
`id was passed as [${id}]`
);
}
let totalSize = 0;
let rowCount = 0;
const substitutions = [];
for (const key in data) {
if (typeof data[key] === 'undefined') {
continue;
}
rowCount++;
const value = JSON.stringify(data[key]);
totalSize += value.length;
substitutions.push(id);
substitutions.push(key);
substitutions.push(value);
}
if (rowCount === 0) {
return;
}
if (totalSize > SIZE_LIMIT) {
throw new ChannelStateSizeError(
'Channel state size is too large',
{
limit: SIZE_LIMIT,
actual: totalSize
}
);
}
saveRowcount.inc(rowCount);
saveCharcount.inc(totalSize);
return await queryAsync(buildUpdateQuery(rowCount), substitutions);
}
}

View file

@ -1,63 +0,0 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
function AccessControlModule(_channel) {
ChannelModule.apply(this, arguments);
}
AccessControlModule.prototype = Object.create(ChannelModule.prototype);
AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
var chan = this.channel,
opts = this.channel.modules.options;
if (user.socket.disconnected) {
return cb("User disconnected", ChannelModule.DENY);
}
if (opts.get("password") !== false && data.pw !== opts.get("password")) {
user.socket.on("disconnect", function () {
if (!user.is(Flags.U_IN_CHANNEL)) {
cb("User disconnected", ChannelModule.DENY);
}
});
if (user.is(Flags.U_LOGGED_IN) && user.account.effectiveRank >= 2) {
cb(null, ChannelModule.PASSTHROUGH);
user.socket.emit("cancelNeedPassword");
} else {
user.socket.emit("needPassword", typeof data.pw !== "undefined");
/* Option 1: log in as a moderator */
user.waitFlag(Flags.U_HAS_CHANNEL_RANK, function () {
if (user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (user.account.effectiveRank >= 2) {
cb(null, ChannelModule.PASSTHROUGH);
user.socket.emit("cancelNeedPassword");
}
});
/* Option 2: Enter correct password */
var pwListener = function (pw) {
if (chan.dead || user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (pw !== opts.get("password")) {
user.socket.emit("needPassword", true);
return;
}
user.socket.emit("cancelNeedPassword");
cb(null, ChannelModule.PASSTHROUGH);
};
user.socket.on("channelPassword", pwListener);
}
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
};
module.exports = AccessControlModule;

View file

@ -1,35 +0,0 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
function AnonymousCheck(_channel) {
ChannelModule.apply(this, arguments);
}
AnonymousCheck.prototype = Object.create(ChannelModule.prototype);
AnonymousCheck.prototype.onUserPreJoin = function (user, data, cb) {
const opts = this.channel.modules.options;
var anonymousBanned = opts.get("block_anonymous_users");
if (user.socket.disconnected) {
return cb("User disconnected", ChannelModule.DENY);
}
if(anonymousBanned && user.isAnonymous()) {
user.socket.on("disconnect", function () {
if (!user.is(Flags.U_IN_CHANNEL)) {
cb("User disconnected", ChannelModule.DENY);
}
});
user.socket.emit("errorMsg", { msg : "This channel has blocked anonymous users. Please provide a user name to join."});
user.waitFlag(Flags.U_LOGGED_IN, function () {
cb(null, ChannelModule.PASSTHROUGH);
});
return;
} else{
cb(null, ChannelModule.PASSTHROUGH);
}
};
module.exports = AnonymousCheck;

View file

@ -1,763 +0,0 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var fs = require("fs");
var path = require("path");
var sio = require("socket.io");
var db = require("../database");
import * as ChannelStore from '../channel-storage/channelstore';
import { ChannelStateSizeError } from '../errors';
import { EventEmitter } from 'events';
import { throttle } from '../util/throttle';
import Logger from '../logger';
const LOGGER = require('@calzoneman/jsli')('channel');
const USERCOUNT_THROTTLE = 10000;
class ReferenceCounter {
constructor(channel) {
this.channel = channel;
this.channelName = channel.name;
this.refCount = 0;
this.references = {};
}
ref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]++;
} else {
this.references[caller] = 1;
}
}
this.refCount++;
}
unref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]--;
if (this.references[caller] === 0) {
delete this.references[caller];
}
} else {
LOGGER.error("ReferenceCounter::unref() called by caller [" +
caller + "] but this caller had no active references! " +
`(channel: ${this.channelName})`);
return;
}
}
this.refCount--;
this.checkRefCount();
}
checkRefCount() {
if (this.refCount === 0) {
if (Object.keys(this.references).length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
"active references: " +
JSON.stringify(Object.keys(this.references)) +
` (channel: ${this.channelName})`);
for (var caller in this.references) {
this.refCount += this.references[caller];
}
} else if (this.channel.users && this.channel.users.length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
this.channel.users.length + " active users" +
` (channel: ${this.channelName})`);
this.refCount = this.channel.users.length;
} else {
this.channel.emit("empty");
}
}
}
}
function Channel(name) {
this.name = name;
this.uniqueName = name.toLowerCase();
this.modules = {};
this.logger = new Logger.Logger(
path.join(
__dirname, "..", "..", "chanlogs", this.uniqueName + ".log"
)
);
this.users = [];
this.refCounter = new ReferenceCounter(this);
this.flags = 0;
this.id = 0;
this.ownerName = null;
this.broadcastUsercount = throttle(() => {
this.broadcastAll("usercount", this.users.length);
}, USERCOUNT_THROTTLE);
const self = this;
db.channels.load(this, function (err) {
if (err && err.code === 'EBANNED') {
self.emit("loadFail", err.message);
self.setFlag(Flags.C_ERROR);
} else if (err && err !== "Channel is not registered") {
self.emit("loadFail", "Failed to load channel data from the database. Please try again later.");
self.setFlag(Flags.C_ERROR);
} else {
self.initModules();
self.loadState();
db.channels.updateLastLoaded(self.id);
}
});
}
Channel.prototype = Object.create(EventEmitter.prototype);
Channel.prototype.is = function (flag) {
return Boolean(this.flags & flag);
};
Channel.prototype.setFlag = function (flag) {
this.flags |= flag;
this.emit("setFlag", flag);
};
Channel.prototype.clearFlag = function (flag) {
this.flags &= ~flag;
this.emit("clearFlag", flag);
};
Channel.prototype.waitFlag = function (flag, cb) {
var self = this;
if (self.is(flag)) {
cb();
} else {
var wait = function (f) {
if (f === flag) {
self.removeListener("setFlag", wait);
cb();
}
};
self.on("setFlag", wait);
}
};
Channel.prototype.moderators = function () {
return this.users.filter(function (u) {
return u.account.effectiveRank >= 2;
});
};
Channel.prototype.initModules = function () {
const modules = {
"./permissions" : "permissions",
"./emotes" : "emotes",
"./chat" : "chat",
"./drink" : "drink",
"./filters" : "filters",
"./customization" : "customization",
"./opts" : "options",
"./library" : "library",
"./playlist" : "playlist",
"./mediarefresher": "mediarefresher",
"./voteskip" : "voteskip",
"./poll" : "poll",
"./kickban" : "kickban",
"./ranks" : "rank",
"./accesscontrol" : "password",
"./anonymouscheck": "anoncheck"
};
var self = this;
var inited = [];
Object.keys(modules).forEach(function (m) {
var ctor = require(m);
var module = new ctor(self);
self.modules[modules[m]] = module;
inited.push(modules[m]);
});
self.logger.log("[init] Loaded modules: " + inited.join(", "));
};
Channel.prototype.loadState = function () {
/* Don't load from disk if not registered */
if (!this.is(Flags.C_REGISTERED)) {
this.modules.permissions.loadUnregistered();
this.setFlag(Flags.C_READY);
return;
}
const self = this;
function errorLoad(msg, suggestTryAgain = true) {
const extra = suggestTryAgain ? " Please try again later." : "";
self.emit("loadFail", "Failed to load channel data from the database: " +
msg + extra);
self.setFlag(Flags.C_ERROR);
}
ChannelStore.load(this.id, this.uniqueName).then(data => {
Object.keys(this.modules).forEach(m => {
try {
this.modules[m].load(data);
} catch (e) {
LOGGER.error("Failed to load module " + m + " for channel " +
this.uniqueName);
}
});
this.setFlag(Flags.C_READY);
}).catch(ChannelStateSizeError, err => {
const message = "This channel's state size has exceeded the memory limit " +
"enforced by this server. Please contact an administrator " +
"for assistance.";
LOGGER.error(err.stack);
errorLoad(message, false);
}).catch(err => {
if (err.code === 'ENOENT') {
Object.keys(this.modules).forEach(m => {
this.modules[m].load({});
});
this.setFlag(Flags.C_READY);
return;
} else {
const message = "An error occurred when loading this channel's data from " +
"disk. Please contact an administrator for assistance. " +
`The error was: ${err}.`;
LOGGER.error(err.stack);
errorLoad(message);
}
});
};
Channel.prototype.saveState = async function () {
if (!this.is(Flags.C_REGISTERED)) {
return;
} else if (!this.is(Flags.C_READY)) {
throw new Error(
`Attempted to save channel ${this.name} ` +
`but it wasn't finished loading yet!`
);
}
if (this.is(Flags.C_ERROR)) {
throw new Error(`Channel is in error state`);
}
this.logger.log("[init] Saving channel state to disk");
const data = {};
Object.keys(this.modules).forEach(m => {
if (
this.modules[m].dirty ||
!this.modules[m].supportsDirtyCheck
) {
this.modules[m].save(data);
} else {
LOGGER.debug(
"Skipping save for %s[%s]: not dirty",
this.uniqueName,
m
);
}
});
try {
await ChannelStore.save(this.id, this.uniqueName, data);
Object.keys(this.modules).forEach(m => {
this.modules[m].dirty = false;
});
} catch (error) {
if (error instanceof ChannelStateSizeError) {
this.users.forEach(u => {
if (u.account.effectiveRank >= 2) {
u.socket.emit("warnLargeChandump", {
limit: error.limit,
actual: error.actual
});
}
});
}
throw error;
}
};
Channel.prototype.checkModules = function (fn, args, cb) {
const self = this;
const refCaller = `Channel::checkModules/${fn}`;
this.waitFlag(Flags.C_READY, function () {
if (self.dead) return;
self.refCounter.ref(refCaller);
var keys = Object.keys(self.modules);
var next = function (err, result) {
if (result !== ChannelModule.PASSTHROUGH) {
/* Either an error occured, or the module denied the user access */
cb(err, result);
self.refCounter.unref(refCaller);
return;
}
var m = keys.shift();
if (m === undefined) {
/* No more modules to check */
cb(null, ChannelModule.PASSTHROUGH);
self.refCounter.unref(refCaller);
return;
}
if (!self.modules) {
LOGGER.warn(
'checkModules(%s): self.modules is undefined; dead=%s,' +
' current=%s, remaining=%s',
fn,
self.dead,
m,
keys
);
return;
}
var module = self.modules[m];
module[fn].apply(module, args);
};
args.push(next);
process.nextTick(next, null, ChannelModule.PASSTHROUGH);
});
};
Channel.prototype.notifyModules = function (fn, args) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
if (self.dead) return;
var keys = Object.keys(self.modules);
keys.forEach(function (k) {
self.modules[k][fn].apply(self.modules[k], args);
});
});
};
Channel.prototype.joinUser = function (user, data) {
const self = this;
self.refCounter.ref("Channel::user");
self.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */
if (user.socket.disconnected) {
self.refCounter.unref("Channel::user");
return;
}
user.channel = self;
user.waitFlag(Flags.U_LOGGED_IN, () => {
if (self.dead) {
LOGGER.warn(
'Got U_LOGGED_IN for %s after channel already unloaded',
user.getName()
);
return;
}
if (user.is(Flags.U_REGISTERED)) {
db.channels.getRank(self.name, user.getName(), (error, rank) => {
if (!error) {
user.setChannelRank(rank);
user.setFlag(Flags.U_HAS_CHANNEL_RANK);
if (user.inChannel()) {
self.broadcastAll("setUserRank", {
name: user.getName(),
rank: user.account.effectiveRank
});
}
}
});
}
});
if (user.socket.disconnected) {
self.refCounter.unref("Channel::user");
return;
} else if (self.dead) {
return;
}
self.checkModules("onUserPreJoin", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
user.channel = self;
self.acceptUser(user);
} else {
user.channel = null;
user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank;
self.refCounter.unref("Channel::user");
}
});
});
};
Channel.prototype.acceptUser = function (user) {
user.setFlag(Flags.U_IN_CHANNEL);
user.socket.join(this.name);
user.autoAFK();
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
LOGGER.info(user.realip + " joined " + this.name);
if (user.socket.context.torConnection) {
if (this.modules.options && this.modules.options.get("torbanned")) {
user.kick("This channel has banned connections from Tor.");
this.logger.log("[login] Blocked connection from Tor exit at " +
user.displayip);
return;
}
this.logger.log("[login] Accepted connection from Tor exit at " +
user.displayip);
} else {
this.logger.log("[login] Accepted connection from " + user.displayip);
}
var self = this;
user.waitFlag(Flags.U_LOGGED_IN, function () {
for (var i = 0; i < self.users.length; i++) {
if (self.users[i] !== user &&
self.users[i].getLowerName() === user.getLowerName()) {
self.users[i].kick("Duplicate login");
}
}
var loginStr = "[login] " + user.displayip + " logged in as " + user.getName();
if (user.account.globalRank === 0) loginStr += " (guest)";
loginStr += " (aliases: " + user.account.aliases.join(",") + ")";
self.logger.log(loginStr);
self.sendUserJoin(self.users, user);
if (user.getName().toLowerCase() === self.ownerName) {
db.channels.updateOwnerLastSeen(self.id);
}
});
this.users.push(user);
user.socket.on("disconnect", this.partUser.bind(this, user));
Object.keys(this.modules).forEach(function (m) {
if (user.dead) return;
self.modules[m].onUserPostJoin(user);
});
this.sendUserlist([user]);
// Managing this from here is not great, but due to the sequencing involved
// and the limitations of the existing design, it'll have to do.
if (this.modules.playlist.leader !== null) {
user.socket.emit("setLeader", this.modules.playlist.leader.getName());
}
this.broadcastUsercount();
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("channelNotRegistered");
}
user.on('afk', function(afk){
self.sendUserMeta(self.users, user);
// TODO: Drop legacy setAFK frame after a few months
self.broadcastAll("setAFK", { name: user.getName(), afk: afk });
});
user.on("effectiveRankChange", (newRank, oldRank) => {
this.maybeResendUserlist(user, newRank, oldRank);
});
};
Channel.prototype.partUser = function (user) {
if (!this.logger) {
LOGGER.error("partUser called on dead channel");
return;
}
this.logger.log("[login] " + user.displayip + " (" + user.getName() + ") " +
"disconnected.");
user.channel = null;
/* Should be unnecessary because partUser only occurs if the socket dies */
user.clearFlag(Flags.U_IN_CHANNEL);
if (user.is(Flags.U_LOGGED_IN)) {
this.broadcastAll("userLeave", { name: user.getName() });
}
var idx = this.users.indexOf(user);
if (idx >= 0) {
this.users.splice(idx, 1);
}
var self = this;
Object.keys(this.modules).forEach(function (m) {
self.modules[m].onUserPart(user);
});
this.broadcastUsercount();
this.refCounter.unref("Channel::user");
user.die();
};
Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRank, oldRank) {
if ((newRank >= 2 && oldRank < 2)
|| (newRank < 2 && oldRank >= 2)
|| (newRank >= 255 && oldRank < 255)
|| (newRank < 255 && oldRank >= 255)) {
this.sendUserlist([user]);
}
};
Channel.prototype.packUserData = function (user) {
var base = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED)
}
};
var mod = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.displayip
}
};
var sadmin = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.realip
}
};
return {
base: base,
mod: mod,
sadmin: sadmin
};
};
Channel.prototype.sendUserMeta = function (users, user, minrank) {
var self = this;
var userdata = self.packUserData(user);
users.filter(function (u) {
return typeof minrank !== "number" || u.account.effectiveRank >= minrank;
}).forEach(function (u) {
if (u.account.globalRank >= 255) {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.sadmin.meta
});
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.mod.meta
});
} else {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.base.meta
});
}
});
};
Channel.prototype.sendUserProfile = function (users, user) {
var packet = {
name: user.getName(),
profile: user.account.profile
};
users.forEach(function (u) {
u.socket.emit("setUserProfile", packet);
});
};
Channel.prototype.sendUserlist = function (toUsers) {
var self = this;
var base = [];
var mod = [];
var sadmin = [];
for (var i = 0; i < self.users.length; i++) {
var u = self.users[i];
if (u.getName() === "") {
continue;
}
var data = self.packUserData(self.users[i]);
base.push(data.base);
mod.push(data.mod);
sadmin.push(data.sadmin);
}
toUsers.forEach(function (u) {
if (u.account.globalRank >= 255) {
u.socket.emit("userlist", sadmin);
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("userlist", mod);
} else {
u.socket.emit("userlist", base);
}
if (self.leader != null) {
u.socket.emit("setLeader", self.leader.name);
}
});
};
Channel.prototype.sendUsercount = function (users) {
var self = this;
if (users === self.users) {
self.broadcastAll("usercount", self.users.length);
} else {
users.forEach(function (u) {
u.socket.emit("usercount", self.users.length);
});
}
};
Channel.prototype.sendUserJoin = function (users, user) {
var self = this;
if (user.account.aliases.length === 0) {
user.account.aliases.push(user.getName());
}
var data = self.packUserData(user);
users.forEach(function (u) {
if (u.account.globalRank >= 255) {
u.socket.emit("addUser", data.sadmin);
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("addUser", data.mod);
} else {
u.socket.emit("addUser", data.base);
}
});
self.modules.chat.sendModMessage(user.getName() + " joined (aliases: " +
user.account.aliases.join(",") + ")", 2);
};
Channel.prototype.readLog = function (cb) {
const maxLen = 102400;
const file = this.logger.filename;
this.refCounter.ref("Channel::readLog");
const self = this;
fs.stat(file, function (err, data) {
if (err) {
self.refCounter.unref("Channel::readLog");
return cb(err, null);
}
const start = Math.max(data.size - maxLen, 0);
const end = data.size - 1;
const read = fs.createReadStream(file, {
start: start,
end: end
});
var buffer = "";
read.on("data", function (data) {
buffer += data;
});
read.on("end", function () {
cb(null, buffer);
self.refCounter.unref("Channel::readLog");
});
});
};
Channel.prototype.handleReadLog = function (user) {
if (user.account.effectiveRank < 3) {
user.kick("Attempted readChanLog with insufficient permission");
return;
}
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("readChanLog", {
success: false,
data: "Channel log is only available to registered channels."
});
return;
}
this.readLog(function (err, data) {
if (err) {
user.socket.emit("readChanLog", {
success: false,
data: "Error reading channel log"
});
} else {
user.socket.emit("readChanLog", {
success: true,
data: data
});
}
});
};
Channel.prototype.broadcastToRoom = function (msg, data, ns) {
sio.instance.in(ns).emit(msg, data);
};
Channel.prototype.broadcastAll = function (msg, data) {
this.broadcastToRoom(msg, data, this.name);
};
Channel.prototype.packInfo = function (isAdmin) {
var data = {
name: this.name,
usercount: this.users.length,
users: [],
registered: this.is(Flags.C_REGISTERED)
};
for (var i = 0; i < this.users.length; i++) {
if (this.users[i].name !== "") {
var name = this.users[i].getName();
var rank = this.users[i].account.effectiveRank;
if (rank >= 255) {
name = "!" + name;
} else if (rank >= 4) {
name = "~" + name;
} else if (rank >= 3) {
name = "&" + name;
} else if (rank >= 2) {
name = "@" + name;
}
data.users.push(name);
}
}
if (isAdmin) {
data.activeLockCount = this.refCounter.refCount;
}
var self = this;
var keys = Object.keys(this.modules);
keys.forEach(function (k) {
self.modules[k].packInfo(data, isAdmin);
});
return data;
};
module.exports = Channel;

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