init commit
This commit is contained in:
parent
ae639426d0
commit
7a491681cc
47
ISSUE_TEMPLATE.md
Normal file
47
ISSUE_TEMPLATE.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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:**
|
||||||
11
LICENSE
Normal file
11
LICENSE
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/*
|
||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2013-2021 Calvin Montgomery and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
471
NEWS.md
Normal file
471
NEWS.md
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
2021-08-14
|
||||||
|
==========
|
||||||
|
|
||||||
|
CyTube has been upgraded to socket.io v4 (from v2).
|
||||||
|
|
||||||
|
**Breaking change:** Newer versions of socket.io require CORS to validate the
|
||||||
|
origin initiating the socket connection. CyTube allows the origins specified in
|
||||||
|
the `io.domain` and `https.domain` configuration keys by default, which should
|
||||||
|
work for many use cases, however, if you host your website on a different domain
|
||||||
|
than the socket connection, you will need to configure the allowed origins (see
|
||||||
|
config.template.yaml under `io.cors`).
|
||||||
|
|
||||||
|
CyTube enables the `allowEIO3` configuration in socket.io by default, which
|
||||||
|
means that existing clients and bots using socket.io-client v2 should continue
|
||||||
|
to work.
|
||||||
|
|
||||||
|
2021-08-12
|
||||||
|
==========
|
||||||
|
|
||||||
|
The legacy metrics recorder (`counters.log` file) has been removed. For over 4
|
||||||
|
years now, CyTube has integrated with [Prometheus](https://prometheus.io/),
|
||||||
|
which provides a superior way to monitor the application. Copy
|
||||||
|
`conf/example/prometheus.toml` to `conf/prometheus.toml` and edit it to
|
||||||
|
configure CyTube's Prometheus support.
|
||||||
|
|
||||||
|
2021-08-12
|
||||||
|
==========
|
||||||
|
|
||||||
|
Due to changes in Soundcloud's authorization scheme, support has been dropped
|
||||||
|
from core due to requiring each server owner to register an API key (which is
|
||||||
|
currently impossible as they have not accepted new API key registrations for
|
||||||
|
*years*).
|
||||||
|
|
||||||
|
If you happen to already have an API key registered, or if Soundcloud reopens
|
||||||
|
registration at some point in the future, feel free to reach out to me for
|
||||||
|
patches to reintroduce support for it.
|
||||||
|
|
||||||
|
2020-08-21
|
||||||
|
==========
|
||||||
|
|
||||||
|
Some of CyTube's dependencies depends on features in newer versions of node.js.
|
||||||
|
Accordingly, node 10 is no longer supported. Administrators are recommended to
|
||||||
|
use node 12 (the active LTS), or node 14 (the current version).
|
||||||
|
|
||||||
|
2020-06-22
|
||||||
|
==========
|
||||||
|
|
||||||
|
Twitch has [updated their embed
|
||||||
|
player](https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588),
|
||||||
|
which adds new requirements for embedding Twitch:
|
||||||
|
|
||||||
|
1. The origin website must be served over HTTPS
|
||||||
|
2. The origin website must be served over the default port (i.e., the hostname
|
||||||
|
cannot include a port; https://example.com:8443 won't work)
|
||||||
|
|
||||||
|
Additionally, third-party cookies must be enabled for whatever internal
|
||||||
|
subdomains Twitch is using.
|
||||||
|
|
||||||
|
CyTube now sets the parameters expected by Twitch, and displays an error message
|
||||||
|
if it detects (1) or (2) above are not met.
|
||||||
|
|
||||||
|
2020-02-15
|
||||||
|
==========
|
||||||
|
|
||||||
|
Old versions of CyTube defaulted to storing channel state in flatfiles located
|
||||||
|
in the `chandump` directory. The default was changed a while ago, and the
|
||||||
|
flatfile storage mechanism has now been removed.
|
||||||
|
|
||||||
|
Admins who have not already migrated their installation to the "database"
|
||||||
|
channel storage type can do so by following these instructions:
|
||||||
|
|
||||||
|
1. Run `git checkout e3a9915b454b32e49d3871c94c839899f809520a` to temporarily
|
||||||
|
switch to temporarily revert to the previous version of the code that
|
||||||
|
supports the "file" channel storage type
|
||||||
|
2. Run `npm run build-server` to build the old version
|
||||||
|
3. Run `node lib/channel-storage/migrator.js |& tee migration.log` to migrate
|
||||||
|
channel state from files to the database
|
||||||
|
4. Inspect the output of the migration tool for errors
|
||||||
|
5. Set `channel-storage`/`type` to `"database"` in `config.yaml` and start the
|
||||||
|
server. Load a channel to verify the migration worked as expected
|
||||||
|
6. Upgrade back to the latest version with `git checkout 3.0` and `npm run
|
||||||
|
build-server`
|
||||||
|
7. Remove the `channel-storage` block from `config.yaml` and remove the
|
||||||
|
`chandump` directory since it is no longer needed (you may wish to archive
|
||||||
|
it somewhere in case you later discover the migration didn't work as
|
||||||
|
expected).
|
||||||
|
|
||||||
|
If you encounter any errors during the process, please file an issue on GitHub
|
||||||
|
and attach the output of the migration tool (which if you use the above commands
|
||||||
|
will be written to `migration.log`).
|
||||||
|
|
||||||
|
2019-12-01
|
||||||
|
==========
|
||||||
|
|
||||||
|
In accordance with node v8 LTS becoming end-of-life on 2019-12-31, CyTube no
|
||||||
|
longer supports v8.
|
||||||
|
|
||||||
|
Please upgrade to v10 or v12 (active LTS); refer to
|
||||||
|
https://nodejs.org/en/about/releases/ for the node.js support timelines.
|
||||||
|
|
||||||
|
2018-12-07
|
||||||
|
==========
|
||||||
|
|
||||||
|
Users can now self-service request their account to be deleted, and it will be
|
||||||
|
automatically purged after 7 days. In order to send a notification email to
|
||||||
|
the user about the request, copy the [email
|
||||||
|
configuration](https://github.com/calzoneman/sync/blob/3.0/conf/example/email.toml#L43)
|
||||||
|
to `conf/email.toml` (the same file used for password reset emails).
|
||||||
|
|
||||||
|
2018-10-21
|
||||||
|
==========
|
||||||
|
|
||||||
|
The `sanitize-html` dependency has made a change that results in `"` no longer
|
||||||
|
being replaced by `"` when not inside an HTML attribute value. This
|
||||||
|
potentially breaks any chat filters matching quotes as `"` (on my
|
||||||
|
particular instance, this seems to be quite rare). These filters will need to
|
||||||
|
be updated in order to continue matching quotes.
|
||||||
|
|
||||||
|
2018-08-27
|
||||||
|
==========
|
||||||
|
|
||||||
|
Support for node.js 6.x has been dropped, in order to bump the babel preset to
|
||||||
|
generate more efficient code (8.x supports async-await and other ES6+ features
|
||||||
|
natively and is the current node.js LTS).
|
||||||
|
|
||||||
|
If you are unable to upgrade to node.js 8.x, you can revert the changes to
|
||||||
|
package.json in this commit, however, be warned that I no longer test on 6.x.
|
||||||
|
|
||||||
|
2018-06-03
|
||||||
|
==========
|
||||||
|
|
||||||
|
## Dependency upgrades
|
||||||
|
|
||||||
|
In order to support node.js 10, the `bcrypt` dependency has been upgraded to
|
||||||
|
version 2. `bcrypt` version 2 defaults to the `$2b$` algorithm, whereas version
|
||||||
|
1 defaults to the `$2a$` algorithm. Existing password hashes will continue to
|
||||||
|
be readable, however hashes created with version 2 will not be readable by
|
||||||
|
version 1. See https://github.com/kelektiv/node.bcrypt.js for details.
|
||||||
|
|
||||||
|
In addition, the optional dependency on `v8-profiler` has been removed, since
|
||||||
|
this is not compatible with newer versions of v8.
|
||||||
|
|
||||||
|
## Supported node.js versions
|
||||||
|
|
||||||
|
In accordance with the node.js release schedule, node.js 4.x, 5.x, 7.x, and 9.x
|
||||||
|
are end-of-life and are no longer maintained upstream. Accordingly, these
|
||||||
|
versions are no longer supported by CyTube.
|
||||||
|
|
||||||
|
Please upgrade to 8.x (LTS) or 10.x (current). 6.x is still supported, but is
|
||||||
|
in the "maintenance" phase upstream, and should be phased out.
|
||||||
|
|
||||||
|
2018-01-07
|
||||||
|
==========
|
||||||
|
|
||||||
|
**Build changes:** When the `babel` dependency was first added to transpile ES6
|
||||||
|
code to ES5, an interactive prompt was added to the `postinstall` script before
|
||||||
|
transpilation, in case the user had made local modifications to the files in
|
||||||
|
`lib` which previously would have been detected as a git conflict when pulling.
|
||||||
|
|
||||||
|
It has now been sufficiently long that this is no longer needed, so I've removed
|
||||||
|
it. As always, users wishing to make local modifications (or forks) should edit
|
||||||
|
the code in `src/` and run `npm run build-server` to regenerate `lib/`.
|
||||||
|
|
||||||
|
This commit also removes the bundled `www/js/player.js` file in favor of having
|
||||||
|
`postinstall` generate it from the sources in `player/`.
|
||||||
|
|
||||||
|
2017-12-24
|
||||||
|
==========
|
||||||
|
|
||||||
|
As of December 2017, Vid.me is no longer in service. Accordingly, Vid.me
|
||||||
|
support in CyTube has been deprecated.
|
||||||
|
|
||||||
|
2017-11-27
|
||||||
|
==========
|
||||||
|
|
||||||
|
The Google Drive userscript has been updated once again. Violentmonkey is
|
||||||
|
now explicitly supported. Google login redirects are caught and handled.
|
||||||
|
See directly below on how to regenerate the user script again.
|
||||||
|
|
||||||
|
2017-11-15
|
||||||
|
==========
|
||||||
|
|
||||||
|
The Google Drive userscript has been updated due to breaking changes in
|
||||||
|
Greasemonkey 4.0. Remember to generate the script by running:
|
||||||
|
|
||||||
|
$ npm run generate-userscript "Your Site Name" http://your-site.example.com/r/*
|
||||||
|
|
||||||
|
2017-11-05
|
||||||
|
==========
|
||||||
|
|
||||||
|
The latest commit introduces a referrer check in the account page handlers.
|
||||||
|
This is added as a short-term mitigation for a recent report that account
|
||||||
|
management functions (such as deleting channels) can be executed without the
|
||||||
|
user's consent if placed in channel JS.
|
||||||
|
|
||||||
|
Longer term options are being considered, such as moving account management to a
|
||||||
|
separate subdomain to take advantage of cross-origin checks in browsers, and
|
||||||
|
requiring the user to re-enter their password to demonstrate intent. As always,
|
||||||
|
I recommend admins take extreme caution when accepting channel JS.
|
||||||
|
|
||||||
|
2017-09-26
|
||||||
|
==========
|
||||||
|
|
||||||
|
**Breaking change:** the `nodemailer` dependency has been upgraded to version
|
||||||
|
4.x. I also took this opportunity to make some modifications to the email
|
||||||
|
configuration and move it out of `config.yaml` to `conf/email.toml`.
|
||||||
|
|
||||||
|
To upgrade:
|
||||||
|
|
||||||
|
* Run `npm upgrade` (or `rm -rf node_modules; npm install`)
|
||||||
|
* Copy `conf/example/email.toml` to `conf/email.toml`
|
||||||
|
* Edit `conf/email.toml` to your liking
|
||||||
|
* Remove the `mail:` block from `config.yaml`
|
||||||
|
|
||||||
|
This feature only supports sending via SMTP for now. If there is demand for
|
||||||
|
other transports, feel free to open an issue or submit a pull request.
|
||||||
|
|
||||||
|
2017-09-19
|
||||||
|
==========
|
||||||
|
|
||||||
|
The `/useragreement` default page has been removed. Server administrators can
|
||||||
|
substitute their own terms of service page by editing `templates/footer.pug`
|
||||||
|
|
||||||
|
2017-09-19
|
||||||
|
==========
|
||||||
|
|
||||||
|
This commit removes an old kludge that redirected users to HTTPS (when enabled)
|
||||||
|
specifically for the account authorization pages (e.g., `/login`). The code for
|
||||||
|
doing this was to work around limitations that no longer exist, and does not
|
||||||
|
represent current security best practices.
|
||||||
|
|
||||||
|
The recommended solution to ensure that users are logged in securely (assuming
|
||||||
|
you've configured support for HTTPS) is to use
|
||||||
|
[Strict-Transport-Security](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security)
|
||||||
|
to direct browsers to access the HTTPS version of the website at all times. You
|
||||||
|
can enable this by configuring a reverse proxy (e.g. nginx) in front of CyTube
|
||||||
|
to intercept HTTP traffic and redirect it to HTTPS, and add the
|
||||||
|
`Strict-Transport-Security` header when returning the response from CyTube.
|
||||||
|
|
||||||
|
2017-07-22
|
||||||
|
==========
|
||||||
|
|
||||||
|
Support for the old version of Vimeo's OAuth API (the `vimeo-oauth`
|
||||||
|
configuration block) has been dropped. It's unlikely anyone was using this,
|
||||||
|
since you haven't been able to register new API keys for it in years (it was
|
||||||
|
superseded by a newer OAuth API, which CyTube does not support), and in fact I
|
||||||
|
lost my credentials for this API and no longer have a way to test it.
|
||||||
|
|
||||||
|
Vimeo videos can still be added -- the metadata will be queried from the
|
||||||
|
anonymous API which has been the default since the beginning.
|
||||||
|
|
||||||
|
2017-07-17
|
||||||
|
==========
|
||||||
|
|
||||||
|
The `stats` database table and associated ACP subpage have been removed in favor
|
||||||
|
of integration with [Prometheus](https://prometheus.io/). You can enable
|
||||||
|
Prometheus reporting by copying `conf/example/prometheus.toml` to
|
||||||
|
`conf/prometheus.toml` and editing it to your liking. I recommend integrating
|
||||||
|
Prometheus with [Grafana](https://grafana.com/) for dashboarding needs.
|
||||||
|
|
||||||
|
The particular metrics that were saved in the `stats` table are reported by the
|
||||||
|
following Prometheus metrics:
|
||||||
|
|
||||||
|
* Channel count: `cytube_channels_num_active` gauge.
|
||||||
|
* User count: `cytube_sockets_num_connected` gauge (labeled by socket.io
|
||||||
|
transport).
|
||||||
|
* CPU/Memory: default metrics emitted by the
|
||||||
|
[`prom-client`](https://github.com/siimon/prom-client) module.
|
||||||
|
|
||||||
|
More Prometheus metrics will be added in the future to make CyTube easier to
|
||||||
|
monitor :)
|
||||||
|
|
||||||
|
2017-07-15
|
||||||
|
==========
|
||||||
|
|
||||||
|
The latest commit upgrades `socket.io` to version 2.0, a major version change
|
||||||
|
from 1.4. This release improves performance by switching to `uws` for the
|
||||||
|
websocket transport, and fixes several bugs; you can read about it
|
||||||
|
[here](https://github.com/socketio/socket.io/releases/tag/2.0.0).
|
||||||
|
|
||||||
|
For browser clients, the upgrade should basically just work with no
|
||||||
|
intervention. For node.js clients, all that is needed is to upgrade
|
||||||
|
`socket.io-client` to 2.0. For other clients, work required may vary depending
|
||||||
|
on whether the implementation has compatibility problems with 2.0.
|
||||||
|
|
||||||
|
2017-06-20
|
||||||
|
==========
|
||||||
|
|
||||||
|
The latest commit drops support for node.js versions below 6 (the [current
|
||||||
|
LTS](https://github.com/nodejs/LTS#lts-schedule1)). This is to allow the babel
|
||||||
|
preset to avoid generating inefficient code to polyfill ES2015+ features that
|
||||||
|
are now implemented in the node.js core.
|
||||||
|
|
||||||
|
New versions of node.js can be downloaded from the [node.js
|
||||||
|
website](https://nodejs.org/en/download/), if they are not already available in
|
||||||
|
your distribution's package manager.
|
||||||
|
|
||||||
|
2017-03-20
|
||||||
|
==========
|
||||||
|
|
||||||
|
Polls are now more strictly validated, including the number of options. The
|
||||||
|
default limit is 50 options, which you can configure via `poll.max-options`.
|
||||||
|
|
||||||
|
2017-03-11
|
||||||
|
==========
|
||||||
|
|
||||||
|
Commit f8183bea1b37154d79db741ac2845adf282e7514 modifes the schema of the
|
||||||
|
`users` table to include a new column (`name_dedupe`) which has a `UNIQUE`
|
||||||
|
constraint. This column is populated with a modified version of the user's name
|
||||||
|
to prevent the registration of usernames which are bitwise distinct but visually
|
||||||
|
similar. 'l', 'L', and '1' are all mapped to '1'; 'o', 'O', and '0' are all
|
||||||
|
mapped to '0'; '\_' and '-' are mapped to '\_'. On first startup after
|
||||||
|
upgrading, the new column will be added and populated.
|
||||||
|
|
||||||
|
This replaces the earlier solution which was put in place to mitigate PR#489 but
|
||||||
|
was overly-restrictive since it wildcarded these characters against *any*
|
||||||
|
character, not just characters in the same group.
|
||||||
|
|
||||||
|
2017-03-03
|
||||||
|
==========
|
||||||
|
|
||||||
|
The dependency on `sanitize-html`, which previously pointed to a fork, has now
|
||||||
|
been switched back to the upstream module. XSS filtering has been turned off
|
||||||
|
for the chat filter replacement itself (since this provides no additional
|
||||||
|
security), and is now only run on the final chat message after filtering.
|
||||||
|
Certain chat filters and MOTDs which relied on syntactically incorrect HTML,
|
||||||
|
such as unclosed tags, may have different behavior now, since `sanitize-html`
|
||||||
|
fixes these.
|
||||||
|
|
||||||
|
2016-11-02
|
||||||
|
==========
|
||||||
|
|
||||||
|
After upgrading the dependency on `yamljs`, you may see this error if you didn't
|
||||||
|
notice and correct a typo in the config.yaml template:
|
||||||
|
|
||||||
|
Error loading config file config.yaml:
|
||||||
|
{ [Error: Unexpected characters near ",".]
|
||||||
|
message: 'Unexpected characters near ",".',
|
||||||
|
parsedLine: 88,
|
||||||
|
snippet: 'title: \'CyTube\',' }
|
||||||
|
|
||||||
|
The fix is to edit config.yaml and remove the trailing comma for the `title:`
|
||||||
|
property under `html-template`. If there are other syntax errors that the old
|
||||||
|
version didn't detect, you will need to correct those as well.
|
||||||
|
|
||||||
|
Longer term, I am looking to move away from using `yamljs` to parse
|
||||||
|
configuration because it's a little buggy and the current configuration system
|
||||||
|
is confusing.
|
||||||
|
|
||||||
|
2016-10-20
|
||||||
|
==========
|
||||||
|
|
||||||
|
Google Drive changed the URL schema for retrieving video metadata, which broke
|
||||||
|
CyTube's Google Drive support, even with the userscript. I have updated the
|
||||||
|
userscript source with the new URL, so server administrators will have to
|
||||||
|
regenerate the userscript for their site and users will be prompted to install
|
||||||
|
the newer version.
|
||||||
|
|
||||||
|
Additionally, fixing Drive lookups required an update to the `mediaquery`
|
||||||
|
module, so you will have to do an `npm install` to pull that fix in.
|
||||||
|
|
||||||
|
2016-08-23
|
||||||
|
==========
|
||||||
|
|
||||||
|
A few weeks ago, the previous Google Drive player stopped working. This is
|
||||||
|
nothing new; Google Drive has consistently broken a few times a year ever since
|
||||||
|
support for it was added. However, it's becoming increasingly difficult and
|
||||||
|
complicated to provide good support for Google Drive, so I've made the decision
|
||||||
|
to phase out the native player and require a userscript for it, in order to
|
||||||
|
bypass CORS and allow each browser to request the video stream itself.
|
||||||
|
|
||||||
|
See [the updated documentation](docs/gdrive-userscript-serveradmins.md) for
|
||||||
|
details on how to enable this for your users.
|
||||||
|
|
||||||
|
2016-04-27
|
||||||
|
==========
|
||||||
|
|
||||||
|
A new dependency has been added on `cytube-common`, a module that will hold
|
||||||
|
common code shared between the current version of CyTube and the upcoming work
|
||||||
|
around splitting it into multiple services. You will need to be sure to run
|
||||||
|
`npm install` after pulling in this change to pull in the new dependency.
|
||||||
|
|
||||||
|
2016-01-06
|
||||||
|
==========
|
||||||
|
|
||||||
|
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.
|
||||||
120
README.md
120
README.md
|
|
@ -1,93 +1,53 @@
|
||||||
# fore.st
|
fore.st
|
||||||
|
======
|
||||||
|
|
||||||
|
fore.st is the server software for ourfore.st, a streaming service tailored to service
|
||||||
|
the TTN community post-shutdown. The softwre is made freely available both for legal reasons
|
||||||
|
but also as it seems as that is what is best for the community in the advant of another shutdown.
|
||||||
|
|
||||||
|
fore.st is a fork of cytube and as such supports all of the same media sources,
|
||||||
|
and carries many of the same features. Modifcations to the software have been/are
|
||||||
|
being made to make the platform a more familiar place for TTN users. Contributions
|
||||||
|
are welcome.
|
||||||
|
|
||||||
## Getting started
|
Current dev goals:
|
||||||
|
- tokebot
|
||||||
|
- about page
|
||||||
|
- visibility checkbox on schedule items to allow or dissalow users from seeing a video, easier mod staging
|
||||||
|
- chat image support(auto embed) ~ [Taken care of by cytube plus, needs easy upload, currently only takes links instead of files]
|
||||||
|
- add video to channel lib without queing
|
||||||
|
- add random video, videos, or a random video with others in order after it from specific category in channel lib
|
||||||
|
- "Show all channel libraries" checkbox in channel lib UI to reduce dupes
|
||||||
|
- general lib that shows in all channels by default to cleanup
|
||||||
|
- tags added to video lib, videos can have multiple tags, list or search videos by tag instead of title for ez organization
|
||||||
|
- improved profiles (profile pages, badges, stats)
|
||||||
|
- forum (a la ttn discussions)
|
||||||
|
- other custom chat commands (thunder, birdup, etc..)
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
Current goals for ourfore.st instance:
|
||||||
|
- captcha(can wait until we get bigger)
|
||||||
|
- figure out moderation, reach out to current ttn/disc mods and longtime community members.
|
||||||
|
- continue w/ development goals and exist with the d00dz
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
|
||||||
|
|
||||||
## Add your files
|
|
||||||
|
|
||||||
- [ ] [Create](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
|
||||||
- [ ] [Add files using the command line](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd existing_repo
|
|
||||||
git remote add origin https://gitlab.com/rainbownapkin/fore.st.git
|
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrate with your tools
|
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://gitlab.com/rainbownapkin/fore.st/-/settings/integrations)
|
|
||||||
|
|
||||||
## Collaborate with your team
|
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/members/)
|
|
||||||
- [ ] [Create a new merge request](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
|
||||||
- [ ] [Automatically close issues from merge requests](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
|
||||||
- [ ] [Enable merge request approvals](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
|
||||||
- [ ] [Automatically merge when pipeline succeeds](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
|
||||||
|
|
||||||
## Test and Deploy
|
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
# Editing this README
|
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://gitlab.com/-/experiment/new_project_readme_content:e7323b580c24353b1290949e87e6a6a7?https://www.makeareadme.com/) for this template.
|
|
||||||
|
|
||||||
## Suggestions for a good README
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
|
||||||
|
|
||||||
## Name
|
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
There is currently no installation guide for the software, however since not much has
|
||||||
|
changed in the backend, you should be fine with official [cytube docs](https://github.com/calzoneman/sync/wiki/CyTube-3.0-Installation-Guide).
|
||||||
|
|
||||||
## Usage
|
## Contact
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
You can reach out by bugging rainbownapkin on the ttn discord or ourfore.st, you can also send an email to ourforest(at)420blaze.it
|
||||||
|
|
||||||
## Support
|
## Shoutouts
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
- Thanks to Simon for making TTN, we wouldn't be here if it wheren't that shit.
|
||||||
|
- Thanks to jaredlego-aka-goops for helping with moderation and content aggregation for ourfore.st, we'll be set fer fackin dayz lol.
|
||||||
## Roadmap
|
- Thanks to etchingham for being a community contact while TTN's been winding down, I think all of us see you as a pillar of the community so your support means a lot.
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
- Thanks to calzoneman for making [cytube](https://github.com/calzoneman/sync), that saved our asses.
|
||||||
|
- Thanks to the core TTN community and everyone else who's ever used it, I was only there for the last handful of years but it was an absolute fuckin' ride. You guys are the best, it isn't TTN but I hope this at least help fills the gap.
|
||||||
## Contributing
|
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
Original source code in this repository is provided under the MIT license
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
(see the LICENSE file for the full text).
|
||||||
|
|
||||||
|
Bundled source code, such as third-party CSS and JavaScript libraries, are
|
||||||
|
provided under their respective licenses.
|
||||||
|
|
|
||||||
41
bin/build-player.js
Executable file
41
bin/build-player.js
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
var coffee = require('coffeescript');
|
||||||
|
var fs = require('fs');
|
||||||
|
var path = require('path');
|
||||||
|
|
||||||
|
var order = [
|
||||||
|
'base.coffee',
|
||||||
|
'vimeo.coffee',
|
||||||
|
'youtube.coffee',
|
||||||
|
'dailymotion.coffee',
|
||||||
|
'videojs.coffee',
|
||||||
|
'playerjs.coffee',
|
||||||
|
'streamable.coffee',
|
||||||
|
'gdrive-player.coffee',
|
||||||
|
'raw-file.coffee',
|
||||||
|
'soundcloud.coffee',
|
||||||
|
'embed.coffee',
|
||||||
|
'twitch.coffee',
|
||||||
|
'livestream.com.coffee',
|
||||||
|
'custom-embed.coffee',
|
||||||
|
'rtmp.coffee',
|
||||||
|
'smashcast.coffee',
|
||||||
|
'ustream.coffee',
|
||||||
|
'imgur.coffee',
|
||||||
|
'hls.coffee',
|
||||||
|
'twitchclip.coffee',
|
||||||
|
'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)
|
||||||
|
);
|
||||||
15
conf/example/camo.toml
Normal file
15
conf/example/camo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Configuration for proxying images to a camo (or camo-compatible) proxy server.
|
||||||
|
# To use, copy to conf/camo.toml.
|
||||||
|
# More info on camo: https://github.com/atmos/camo
|
||||||
|
[camo]
|
||||||
|
enabled = true
|
||||||
|
server = 'https://my-camo-server'
|
||||||
|
# The key must match the `CAMO_KEY` environment variable passed to the camo server.
|
||||||
|
key = 'ABCDEFGH'
|
||||||
|
# Bypass the proxy for domains you trust that already support HTTPS and won't be harmful to users.
|
||||||
|
whitelisted-domains = [
|
||||||
|
'i.imgur.com',
|
||||||
|
'i.4cdn.org'
|
||||||
|
]
|
||||||
|
# Whether to use URL encoding ("url") or hex encoding ("hex") for the target URL.
|
||||||
|
encoding = 'url'
|
||||||
9
conf/example/captcha.toml
Normal file
9
conf/example/captcha.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[hcaptcha]
|
||||||
|
# Site key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||||
|
site-key = "10000000-ffff-ffff-ffff-000000000001"
|
||||||
|
# Secret key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||||
|
secret = "0x0000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
[register]
|
||||||
|
# Whether to require a captcha for registration
|
||||||
|
enabled = true
|
||||||
66
conf/example/email.toml
Normal file
66
conf/example/email.toml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# SMTP configuration for sending mail
|
||||||
|
[smtp]
|
||||||
|
host = 'smtp.gmail.com'
|
||||||
|
port = 465
|
||||||
|
secure = true
|
||||||
|
user = 'some-user@example.com'
|
||||||
|
password = 'secretpassword'
|
||||||
|
|
||||||
|
# Email configuration for password reset emails
|
||||||
|
# Be sure to update both html-template AND text-template
|
||||||
|
# nodemailer will send both and the email client will render whichever one is supported
|
||||||
|
[password-reset]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Template to use for HTML-formatted emails
|
||||||
|
# $user$ will be replaced by the username for which the reset was requested
|
||||||
|
# $url$ will be replaced by the password reset confirmation link
|
||||||
|
html-template = """
|
||||||
|
Hi $user$,<br>
|
||||||
|
<br>
|
||||||
|
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: <a href="$url$">$url$</a><br>
|
||||||
|
<br>
|
||||||
|
This link will expire in 24 hours.<br>
|
||||||
|
<br>
|
||||||
|
This email address is not monitored for replies. For assistance with password resets, please <a href="http://example.com/contact">contact an administrator</a>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Template to use for plaintext emails
|
||||||
|
# Same substitutions as the HTML template
|
||||||
|
text-template = """
|
||||||
|
Hi $user$,
|
||||||
|
|
||||||
|
A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: $url$
|
||||||
|
|
||||||
|
This link will expire in 24 hours.
|
||||||
|
|
||||||
|
This email address is not monitored for replies. For assistance with password resets, please contact an administrator. See http://example.com/contact for contact information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from = "Example Website <website@example.com>"
|
||||||
|
subject = "Password reset request"
|
||||||
|
|
||||||
|
# Email configuration for account deletion request notifications
|
||||||
|
[delete-account]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
html-template = """
|
||||||
|
Hi $user$,
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
This email address is not monitored for replies. For assistance, please <a href="http://example.com/contact">contact an administrator</a>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text-template = """
|
||||||
|
Hi $user$,
|
||||||
|
|
||||||
|
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
|
||||||
|
|
||||||
|
This email address is not monitored for replies. For assistance, please contact an administrator. See http://example.com/contact for contact information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from = "Example Website <website@example.com>"
|
||||||
|
subject = "Account deletion request"
|
||||||
14
conf/example/prometheus.toml
Normal file
14
conf/example/prometheus.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Configuration for binding an HTTP server to export prometheus metrics.
|
||||||
|
# See https://prometheus.io/ and https://github.com/siimon/prom-client
|
||||||
|
# for more details.
|
||||||
|
[prometheus]
|
||||||
|
enabled = true
|
||||||
|
# Host, port to bind. This is separate from the main CyTube HTTP server
|
||||||
|
# because it may be desirable to bind a different IP/port for monitoring
|
||||||
|
# purposes. Default: localhost port 19820 (arbitrary port chosen not to
|
||||||
|
# conflict with existing prometheus exporters).
|
||||||
|
host = '127.0.0.1'
|
||||||
|
port = 19820
|
||||||
|
# Request path to serve metrics. All other paths are rejected with
|
||||||
|
# 400 Bad Request.
|
||||||
|
path = '/metrics'
|
||||||
215
config.template.yaml
Normal file
215
config.template.yaml
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
# 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: 'forest'
|
||||||
|
user: 'forest'
|
||||||
|
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: 'ourfore.st'
|
||||||
|
description: 'ourfore.st, a fork of CyTube built to serve the TTN community'
|
||||||
|
|
||||||
|
# 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: 0
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
20
docs/account-mgmt.md
Normal file
20
docs/account-mgmt.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
## 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.
|
||||||
67
docs/chat-filters.md
Normal file
67
docs/chat-filters.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
## 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 `<`.
|
||||||
179
docs/custom-media.md
Normal file
179
docs/custom-media.md
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
CyTube Custom Content Metadata
|
||||||
|
==============================
|
||||||
|
|
||||||
|
*Last updated: 2019-05-05*
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
* `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/ogg`
|
||||||
|
* `audio/mpeg`
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
24
docs/gdrive-userscript-serveradmins.md
Normal file
24
docs/gdrive-userscript-serveradmins.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Google Drive Userscript Setup
|
||||||
|
|
||||||
|
In response to increasing difficulty and complexity of maintaining Google Drive
|
||||||
|
support, the native player is being phased out in favor of requiring a
|
||||||
|
userscript to allow each client to fetch the video stream links for themselves.
|
||||||
|
Users will be prompted with a link to `/google_drive_userscript`, which explains
|
||||||
|
the situation and instructs how to install the userscript.
|
||||||
|
|
||||||
|
As a server admin, you must generate the userscript from the template by using
|
||||||
|
the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run generate-userscript <site name> <url> [<url>...]
|
||||||
|
```
|
||||||
|
|
||||||
|
The first argument is the site name as it will appear in the userscript title.
|
||||||
|
The remaining arguments are the URL patterns on which the script will run. For
|
||||||
|
example, for cytu.be I use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run generate-userscript CyTube http://cytu.be/r/* https://cytu.be/r/*
|
||||||
|
```
|
||||||
|
|
||||||
|
This will generate `www/js/cytube-google-drive.user.js`. If you've changed the channel path, be sure to take that into account.
|
||||||
24
docs/google-drive-subtitles.md
Normal file
24
docs/google-drive-subtitles.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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).
|
||||||
15
docs/index.md
Normal file
15
docs/index.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 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.
|
||||||
29
docs/new-account-chat-restrictions.md
Normal file
29
docs/new-account-chat-restrictions.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
Restricting New Accounts from Chat
|
||||||
|
==================================
|
||||||
|
|
||||||
|
With the rising availability and popularity of VPNs and proxies, dedicated
|
||||||
|
trolls may often come back again and again with a new proxy after being IP
|
||||||
|
banned and continue spamming. In order to combat this, a new feature has been
|
||||||
|
added to make it more difficult to rejoin quickly and continue spamming.
|
||||||
|
|
||||||
|
Channel moderators now have the ability to configure 2 different settings:
|
||||||
|
|
||||||
|
* How long an account must be active before the user can send any chat message
|
||||||
|
* How long an account must be active before the user can send a chat message
|
||||||
|
containing a link
|
||||||
|
|
||||||
|
This limit applies to both chat messages sent to the channel as well as private
|
||||||
|
messages. Both of these settings can be configured from the Channel Settings
|
||||||
|
menu at the top of the page, under the General Settings tab. By default,
|
||||||
|
accounts must be at least 10 minutes old to chat, and 1 hour old to send links
|
||||||
|
in chat. Setting either restriction to 0 will disable that restriction.
|
||||||
|
|
||||||
|
The age of an account is determined as follows:
|
||||||
|
|
||||||
|
* If the user is logged in as a registered account, the registration time of
|
||||||
|
the account is used.
|
||||||
|
* Otherwise, the timestamp of the session cookie is used.
|
||||||
|
|
||||||
|
The session cookie is set whenever a user first joins a channel, and is reset
|
||||||
|
whenever the user's IP address changes. Different browsers will have different
|
||||||
|
session cookies.
|
||||||
59
docs/raw-videos.md
Normal file
59
docs/raw-videos.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Raw Videos / Audio #
|
||||||
|
|
||||||
|
Want to host your own video/audio files for use on CyTube? For servers with the
|
||||||
|
ffprobe module enabled, CyTube supports this! However, in order to provide a
|
||||||
|
consistent experience, there are limitations.
|
||||||
|
|
||||||
|
## Hosting the File ##
|
||||||
|
|
||||||
|
CyTube requires a direct link to the file in order to query it for metadata such
|
||||||
|
as duration and encoding. The website where you host the file needs to be able
|
||||||
|
to serve the video directly (rather than embedding it in a flash
|
||||||
|
player/iframe/etc.). It also needs to serve the correct MIME type for the video
|
||||||
|
in the `Content-Type` HTTP header, e.g. `video/mp4`.
|
||||||
|
|
||||||
|
I don't recommend hosting videos on Dropbox-type services, as they aren't built
|
||||||
|
to distribute video to many users at a time and often have strict bandwidth
|
||||||
|
limits. File hosting sites such as Putlocker also cause problems due to being
|
||||||
|
unable to serve the file directly, or due to binding the link to the IP address
|
||||||
|
of the user who retrieved it. For best results when using raw video, host the
|
||||||
|
video yourself on a VPS or dedicated server with plenty of bandwidth.
|
||||||
|
|
||||||
|
Note that CyTube only queries the file for metadata, it does not proxy it for
|
||||||
|
users! Every user watching the video will be downloading it individually.
|
||||||
|
|
||||||
|
## Encoding the Video ##
|
||||||
|
|
||||||
|
Current internet browsers are very limited in what codecs they can play
|
||||||
|
natively. Accordingly, CyTube only supports a few codecs:
|
||||||
|
|
||||||
|
**Video**
|
||||||
|
|
||||||
|
* MP4 (AV1)
|
||||||
|
* MP4 (H.264)
|
||||||
|
* WebM (AV1)
|
||||||
|
* WebM (VP8)
|
||||||
|
* WebM (VP9)
|
||||||
|
* Ogg/Theora
|
||||||
|
|
||||||
|
**Audio**
|
||||||
|
|
||||||
|
* MP3
|
||||||
|
* Ogg/Vorbis
|
||||||
|
|
||||||
|
If your video is in some other format (such as MKV or AVI), then it will need to
|
||||||
|
be re-encoded. There are plenty of free programs available to re-encode video
|
||||||
|
files, such as [ffmpeg](http://ffmpeg.org/) and
|
||||||
|
[handbrake](http://handbrake.fr/).
|
||||||
|
|
||||||
|
For best results, encode as an MP4 using H.264. This is natively supported by
|
||||||
|
many browsers, and can also be played using a fallback flash player for older
|
||||||
|
browsers that don't support it natively. Always encode with the
|
||||||
|
[faststart](https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo)
|
||||||
|
flag.
|
||||||
|
|
||||||
|
### Subtitles ###
|
||||||
|
|
||||||
|
Unfortunately, soft-subtitles are not supported right now. This is something
|
||||||
|
that may be supported in the future, but currently if you need subtitles, they
|
||||||
|
will have to be hardsubbed onto the video itself.
|
||||||
57
docs/socketconfig.md
Normal file
57
docs/socketconfig.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
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.
|
||||||
56
docs/user-settings.md
Normal file
56
docs/user-settings.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 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.
|
||||||
|
Set wmode=transparent | There's probably no reason to touch this unless you know what you're doing. Having a non-transparent wmode can cause modals to display behind the video player, but also can cause performance issues in some situations.
|
||||||
|
Remove the video player | Automatically remove the video player on page load. Equivalent to manually clicking Layout->Remove Video every time you load a channel.
|
||||||
|
Hide playlist buttons by default | Hides the control buttons from each video in the playlist, so that only the title is displayed. The control buttons can be shown by right clicking the video item in the playlist.
|
||||||
|
Old style playlist buttons | Legacy feature introduced in CyTube 2.0 for those who preferred the old 1.0-style video control buttons.
|
||||||
|
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.
|
||||||
243
gdrive-userscript/cytube-google-drive.user.js
Normal file
243
gdrive-userscript/cytube-google-drive.user.js
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
// ==UserScript==
|
||||||
|
// @name Google Drive Video Player for {SITENAME}
|
||||||
|
// @namespace gdcytube
|
||||||
|
// @description Play Google Drive videos on {SITENAME}
|
||||||
|
// {INCLUDE_BLOCK}
|
||||||
|
// @grant unsafeWindow
|
||||||
|
// @grant GM_xmlhttpRequest
|
||||||
|
// @grant GM.xmlHttpRequest
|
||||||
|
// @connect docs.google.com
|
||||||
|
// @run-at document-end
|
||||||
|
// @version 1.7.0
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
try {
|
||||||
|
function debug(message) {
|
||||||
|
try {
|
||||||
|
unsafeWindow.console.log('[Drive]', message);
|
||||||
|
} catch (error) {
|
||||||
|
unsafeWindow.console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpRequest(opts) {
|
||||||
|
if (typeof GM_xmlhttpRequest === 'undefined') {
|
||||||
|
// Assume GM4.0
|
||||||
|
debug('Using GM4.0 GM.xmlHttpRequest');
|
||||||
|
GM.xmlHttpRequest(opts);
|
||||||
|
} else {
|
||||||
|
debug('Using old-style GM_xmlhttpRequest');
|
||||||
|
GM_xmlhttpRequest(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ITAG_QMAP = {
|
||||||
|
37: 1080,
|
||||||
|
46: 1080,
|
||||||
|
22: 720,
|
||||||
|
45: 720,
|
||||||
|
59: 480,
|
||||||
|
44: 480,
|
||||||
|
35: 480,
|
||||||
|
18: 360,
|
||||||
|
43: 360,
|
||||||
|
34: 360
|
||||||
|
};
|
||||||
|
|
||||||
|
var ITAG_CMAP = {
|
||||||
|
43: 'video/webm',
|
||||||
|
44: 'video/webm',
|
||||||
|
45: 'video/webm',
|
||||||
|
46: 'video/webm',
|
||||||
|
18: 'video/mp4',
|
||||||
|
22: 'video/mp4',
|
||||||
|
37: 'video/mp4',
|
||||||
|
59: 'video/mp4',
|
||||||
|
35: 'video/flv',
|
||||||
|
34: 'video/flv'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getVideoInfo(id, cb) {
|
||||||
|
var url = 'https://docs.google.com/get_video_info?authuser='
|
||||||
|
+ '&docid=' + id
|
||||||
|
+ '&sle=true'
|
||||||
|
+ '&hl=en';
|
||||||
|
debug('Fetching ' + url);
|
||||||
|
|
||||||
|
httpRequest({
|
||||||
|
method: 'GET',
|
||||||
|
url: url,
|
||||||
|
onload: function (res) {
|
||||||
|
try {
|
||||||
|
debug('Got response ' + res.responseText);
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
debug('Response status not 200: ' + res.status);
|
||||||
|
return cb(
|
||||||
|
'Google Drive request failed: HTTP ' + res.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = {};
|
||||||
|
var error;
|
||||||
|
// Google Santa sometimes eats login cookies and gets mad if there aren't any.
|
||||||
|
if(/accounts\.google\.com\/ServiceLogin/.test(res.responseText)){
|
||||||
|
error = 'Google Docs request failed: ' +
|
||||||
|
'This video requires you be logged into a Google account. ' +
|
||||||
|
'Open your Gmail in another tab and then refresh video.';
|
||||||
|
return cb(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.responseText.split('&').forEach(function (kv) {
|
||||||
|
var pair = kv.split('=');
|
||||||
|
data[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.status === 'fail') {
|
||||||
|
error = 'Google Drive request failed: ' +
|
||||||
|
unescape(data.reason).replace(/\+/g, ' ');
|
||||||
|
return cb(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.fmt_stream_map) {
|
||||||
|
error = (
|
||||||
|
'Google has removed the video streams associated' +
|
||||||
|
' with this item. It can no longer be played.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return cb(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.links = {};
|
||||||
|
data.fmt_stream_map.split(',').forEach(function (item) {
|
||||||
|
var pair = item.split('|');
|
||||||
|
data.links[pair[0]] = pair[1];
|
||||||
|
});
|
||||||
|
data.videoMap = mapLinks(data.links);
|
||||||
|
|
||||||
|
cb(null, data);
|
||||||
|
} catch (error) {
|
||||||
|
unsafeWindow.console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onerror: function () {
|
||||||
|
var error = 'Google Drive request failed: ' +
|
||||||
|
'metadata lookup HTTP request failed';
|
||||||
|
error.reason = 'HTTP_ONERROR';
|
||||||
|
return cb(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLinks(links) {
|
||||||
|
var videos = {
|
||||||
|
1080: [],
|
||||||
|
720: [],
|
||||||
|
480: [],
|
||||||
|
360: []
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(links).forEach(function (itag) {
|
||||||
|
itag = parseInt(itag, 10);
|
||||||
|
if (!ITAG_QMAP.hasOwnProperty(itag)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
videos[ITAG_QMAP[itag]].push({
|
||||||
|
itag: itag,
|
||||||
|
contentType: ITAG_CMAP[itag],
|
||||||
|
link: links[itag]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Greasemonkey 2.0 has this wonderful sandbox that attempts
|
||||||
|
* to prevent script developers from shooting themselves in
|
||||||
|
* the foot by removing the trigger from the gun, i.e. it's
|
||||||
|
* impossible to cross the boundary between the browser JS VM
|
||||||
|
* and the privileged sandbox that can run GM_xmlhttpRequest().
|
||||||
|
*
|
||||||
|
* So in this case, we have to resort to polling a special
|
||||||
|
* variable to see if getGoogleDriveMetadata needs to be called
|
||||||
|
* and deliver the result into another special variable that is
|
||||||
|
* being polled on the browser side.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Browser side function -- sets gdUserscript.pollID to the
|
||||||
|
* ID of the Drive video to be queried and polls
|
||||||
|
* gdUserscript.pollResult for the result.
|
||||||
|
*/
|
||||||
|
function getGoogleDriveMetadata_GM(id, callback) {
|
||||||
|
debug('Setting GD poll ID to ' + id);
|
||||||
|
unsafeWindow.gdUserscript.pollID = id;
|
||||||
|
var tries = 0;
|
||||||
|
var i = setInterval(function () {
|
||||||
|
if (unsafeWindow.gdUserscript.pollResult) {
|
||||||
|
debug('Got result');
|
||||||
|
clearInterval(i);
|
||||||
|
var result = unsafeWindow.gdUserscript.pollResult;
|
||||||
|
unsafeWindow.gdUserscript.pollResult = null;
|
||||||
|
callback(result.error, result.result);
|
||||||
|
} else if (++tries > 100) {
|
||||||
|
// Took longer than 10 seconds, give up
|
||||||
|
clearInterval(i);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sandbox side function -- polls gdUserscript.pollID for
|
||||||
|
* the ID of a Drive video to be queried, looks up the
|
||||||
|
* metadata, and stores it in gdUserscript.pollResult
|
||||||
|
*/
|
||||||
|
function setupGDPoll() {
|
||||||
|
unsafeWindow.gdUserscript = cloneInto({}, unsafeWindow);
|
||||||
|
var pollInterval = setInterval(function () {
|
||||||
|
if (unsafeWindow.gdUserscript.pollID) {
|
||||||
|
var id = unsafeWindow.gdUserscript.pollID;
|
||||||
|
unsafeWindow.gdUserscript.pollID = null;
|
||||||
|
debug('Polled and got ' + id);
|
||||||
|
getVideoInfo(id, function (error, data) {
|
||||||
|
unsafeWindow.gdUserscript.pollResult = cloneInto({
|
||||||
|
error: error,
|
||||||
|
result: data
|
||||||
|
}, unsafeWindow);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
var TM_COMPATIBLES = [
|
||||||
|
'Tampermonkey',
|
||||||
|
'Violentmonkey' // https://github.com/calzoneman/sync/issues/713
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTampermonkeyCompatible() {
|
||||||
|
try {
|
||||||
|
return TM_COMPATIBLES.indexOf(GM_info.scriptHandler) >= 0;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTampermonkeyCompatible()) {
|
||||||
|
unsafeWindow.getGoogleDriveMetadata = getVideoInfo;
|
||||||
|
} else {
|
||||||
|
debug('Using non-TM polling workaround');
|
||||||
|
unsafeWindow.getGoogleDriveMetadata = exportFunction(
|
||||||
|
getGoogleDriveMetadata_GM, unsafeWindow);
|
||||||
|
setupGDPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafeWindow.console.log('Initialized userscript Google Drive player');
|
||||||
|
unsafeWindow.hasDriveUserscript = true;
|
||||||
|
// Checked against GS_VERSION from data.js
|
||||||
|
unsafeWindow.driveUserscriptVersion = '1.7';
|
||||||
|
} catch (error) {
|
||||||
|
unsafeWindow.console.error(error);
|
||||||
|
}
|
||||||
37
gdrive-userscript/generate-userscript.js
Normal file
37
gdrive-userscript/generate-userscript.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
var fs = require('fs');
|
||||||
|
var path = require('path');
|
||||||
|
|
||||||
|
var sitename = process.argv[2];
|
||||||
|
var includes = process.argv.slice(3).map(function (include) {
|
||||||
|
return '// @include ' + include;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
var lines = String(fs.readFileSync(
|
||||||
|
path.resolve(__dirname, 'cytube-google-drive.user.js'))).split('\n');
|
||||||
|
|
||||||
|
var userscriptOutput = '';
|
||||||
|
var metaOutput = '';
|
||||||
|
lines.forEach(function (line) {
|
||||||
|
if (line.match(/\{INCLUDE_BLOCK\}/)) {
|
||||||
|
userscriptOutput += includes + '\n';
|
||||||
|
} else if (line.match(/\{SITENAME\}/)) {
|
||||||
|
line = line.replace(/\{SITENAME\}/, sitename) + '\n';
|
||||||
|
userscriptOutput += line;
|
||||||
|
metaOutput += line;
|
||||||
|
} else {
|
||||||
|
if (line.match(/==\/?UserScript|@name|@version/)) {
|
||||||
|
metaOutput += line + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
userscriptOutput += line + '\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.user.js'),
|
||||||
|
userscriptOutput
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(__dirname, '..', 'www', 'js', 'cytube-google-drive.meta.js'),
|
||||||
|
metaOutput
|
||||||
|
);
|
||||||
98
index.js
Executable file
98
index.js
Executable file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const ver = process.version.match(/v(\d+)\.\d+\.\d+/);
|
||||||
|
|
||||||
|
if (parseInt(ver[1], 10) < 12) {
|
||||||
|
console.error(
|
||||||
|
`node.js ${process.version} is not supported. ` +
|
||||||
|
'CyTube requires node v12 or later.'
|
||||||
|
)
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPlayerExists();
|
||||||
|
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
if (args.has('--daemonize')) {
|
||||||
|
fork();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
require('./lib/main');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FATAL: Failed to require() lib/main.js');
|
||||||
|
handleStartupError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fork() {
|
||||||
|
try {
|
||||||
|
console.log('Warning: --daemonize support is experimental. Use with caution.');
|
||||||
|
|
||||||
|
const spawn = require('child_process').spawn;
|
||||||
|
const path = require('path');
|
||||||
|
const main = path.resolve(__dirname, 'lib', 'main.js');
|
||||||
|
|
||||||
|
const child = spawn(process.argv[0], [main], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore' // TODO: support setting stdout/stderr logfile
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
console.log('Forked with PID ' + child.pid);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('FATAL: Failed to fork lib/main.js');
|
||||||
|
handleStartupError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartupError(err) {
|
||||||
|
if (/module version mismatch/i.test(err.message)) {
|
||||||
|
console.error('Module version mismatch, try running `npm rebuild` or ' +
|
||||||
|
'removing the node_modules folder and re-running ' +
|
||||||
|
'`npm install`');
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
584
integration_test/channel/kickban.js
Normal file
584
integration_test/channel/kickban.js
Normal file
|
|
@ -0,0 +1,584 @@
|
||||||
|
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 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',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
88
integration_test/database/accounts.js
Normal file
88
integration_test/database/accounts.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const { testDB } = require('../testutil/db');
|
||||||
|
const accounts = require('../../lib/database/accounts');
|
||||||
|
|
||||||
|
require('../../lib/database').init(testDB);
|
||||||
|
|
||||||
|
describe('AccountsDatabase', () => {
|
||||||
|
describe('#verifyLogin', () => {
|
||||||
|
let ip = '169.254.111.111';
|
||||||
|
let user;
|
||||||
|
let password;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
return testDB.knex.table('users')
|
||||||
|
.where({ ip })
|
||||||
|
.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(done => {
|
||||||
|
user = `u${Math.random().toString(31).substring(2)}`;
|
||||||
|
password = 'int!gration_Test';
|
||||||
|
|
||||||
|
accounts.register(
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
'',
|
||||||
|
ip,
|
||||||
|
(error, res) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created test user ${user}`);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a correct login', done => {
|
||||||
|
accounts.verifyLogin(
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
(error, res) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(res.name, user);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a correct login with an older hash', done => {
|
||||||
|
testDB.knex.table('users')
|
||||||
|
.where({ name: user })
|
||||||
|
.update({
|
||||||
|
// 'test' hashed with old version of bcrypt module
|
||||||
|
password: '$2b$10$2oCG7O9FFqie7T8O33yQDugFPS0NqkgbQjtThTs7Jr8E1QOzdRruK'
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
accounts.verifyLogin(
|
||||||
|
user,
|
||||||
|
'test',
|
||||||
|
(error, res) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(res.name, user);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an incorrect login', done => {
|
||||||
|
accounts.verifyLogin(
|
||||||
|
user,
|
||||||
|
'not the right password',
|
||||||
|
(error, res) => {
|
||||||
|
assert.strictEqual(error, 'Invalid username/password combination');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
integration_test/db/aliases.js
Normal file
76
integration_test/db/aliases.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const AliasesDB = require('../../lib/db/aliases').AliasesDB;
|
||||||
|
const testDB = require('../testutil/db').testDB;
|
||||||
|
|
||||||
|
const aliasesDB = new AliasesDB(testDB);
|
||||||
|
const testIPs = ['111.111.111.111', '111.111.111.222'];
|
||||||
|
const testNames = ['itest1', 'itest2'];
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
return testDB.knex.table('aliases')
|
||||||
|
.where('ip', 'in', testIPs)
|
||||||
|
.del()
|
||||||
|
.then(() => {
|
||||||
|
return testDB.knex.table('aliases')
|
||||||
|
.where('name', 'in', testNames)
|
||||||
|
.del();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSomeAliases() {
|
||||||
|
return cleanup().then(() => {
|
||||||
|
return testDB.knex.table('aliases')
|
||||||
|
.insert([
|
||||||
|
{ ip: testIPs[0], name: testNames[0], time: Date.now() },
|
||||||
|
{ ip: testIPs[0], name: testNames[1], time: Date.now() },
|
||||||
|
{ ip: testIPs[1], name: testNames[1], time: Date.now() }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AliasesDB', () => {
|
||||||
|
describe('#addAlias', () => {
|
||||||
|
beforeEach(cleanup);
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('adds a new alias', () => {
|
||||||
|
return aliasesDB.addAlias(testIPs[0], testNames[0])
|
||||||
|
.then(() => {
|
||||||
|
return testDB.knex.table('aliases')
|
||||||
|
.where({ ip: testIPs[0], name: testNames[0] })
|
||||||
|
.select()
|
||||||
|
.then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1, 'expected 1 row');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getAliasesByIP', () => {
|
||||||
|
beforeEach(addSomeAliases);
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('retrieves aliases by IP', () => {
|
||||||
|
return aliasesDB.getAliasesByIP(testIPs[0])
|
||||||
|
.then(names => assert.deepStrictEqual(
|
||||||
|
names.sort(), testNames.sort()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrieves aliases by partial IP', () => {
|
||||||
|
return aliasesDB.getAliasesByIP(testIPs[0].substring(4))
|
||||||
|
.then(names => assert.deepStrictEqual(
|
||||||
|
names.sort(), testNames.sort()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getIPsByName', () => {
|
||||||
|
beforeEach(addSomeAliases);
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('retrieves IPs by name', () => {
|
||||||
|
return aliasesDB.getIPsByName(testNames[1])
|
||||||
|
.then(ips => assert.deepStrictEqual(
|
||||||
|
ips.sort(), testIPs.sort()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
integration_test/db/globalban.js
Normal file
92
integration_test/db/globalban.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const GlobalBanDB = require('../../lib/db/globalban').GlobalBanDB;
|
||||||
|
const testDB = require('../testutil/db').testDB;
|
||||||
|
const { o } = require('../testutil/o');
|
||||||
|
|
||||||
|
const globalBanDB = new GlobalBanDB(testDB);
|
||||||
|
const testBan = { ip: '8.8.8.8', reason: 'test' };
|
||||||
|
|
||||||
|
function cleanupTestBan() {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.where({ ip: testBan.ip })
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTestBan() {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.insert(testBan)
|
||||||
|
.catch(error => {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.where({ ip: testBan.ip })
|
||||||
|
.update({ reason: testBan.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GlobalBanDB', () => {
|
||||||
|
describe('#listGlobalBans', () => {
|
||||||
|
beforeEach(setupTestBan);
|
||||||
|
afterEach(cleanupTestBan);
|
||||||
|
|
||||||
|
it('lists existing IP bans', () => {
|
||||||
|
return globalBanDB.listGlobalBans().then(bans => {
|
||||||
|
assert.deepStrictEqual([{
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
reason: 'test'
|
||||||
|
}], bans.map(o));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#addGlobalIPBan', () => {
|
||||||
|
beforeEach(cleanupTestBan);
|
||||||
|
afterEach(cleanupTestBan);
|
||||||
|
|
||||||
|
it('adds a new ban', () => {
|
||||||
|
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.where({ ip: '8.8.8.8' })
|
||||||
|
.select()
|
||||||
|
.then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1, 'Expected 1 row');
|
||||||
|
assert.strictEqual(rows[0].ip, '8.8.8.8');
|
||||||
|
assert.strictEqual(rows[0].reason, 'test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the reason on an existing ban', () => {
|
||||||
|
return globalBanDB.addGlobalIPBan('8.8.8.8', 'test').then(() => {
|
||||||
|
return globalBanDB.addGlobalIPBan('8.8.8.8', 'different').then(() => {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.where({ ip: '8.8.8.8' })
|
||||||
|
.select()
|
||||||
|
.then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1, 'Expected 1 row');
|
||||||
|
assert.strictEqual(rows[0].ip, '8.8.8.8');
|
||||||
|
assert.strictEqual(rows[0].reason, 'different');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#removeGlobalIPBan', () => {
|
||||||
|
beforeEach(setupTestBan);
|
||||||
|
afterEach(cleanupTestBan);
|
||||||
|
|
||||||
|
it('removes a ban', () => {
|
||||||
|
return globalBanDB.removeGlobalIPBan('8.8.8.8').then(() => {
|
||||||
|
return testDB.knex.table('global_bans')
|
||||||
|
.where({ ip: '8.8.8.8' })
|
||||||
|
.select()
|
||||||
|
.then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 0, 'Expected 0 rows');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
integration_test/db/password-reset.js
Normal file
144
integration_test/db/password-reset.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const PasswordResetDB = require('../../lib/db/password-reset').PasswordResetDB;
|
||||||
|
const testDB = require('../testutil/db').testDB;
|
||||||
|
const { o } = require('../testutil/o');
|
||||||
|
|
||||||
|
const passwordResetDB = new PasswordResetDB(testDB);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
return testDB.knex.table('password_reset').del();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PasswordResetDB', () => {
|
||||||
|
describe('#insert', () => {
|
||||||
|
beforeEach(cleanup);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
name: 'testing',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hash: 'abcdef',
|
||||||
|
expire: 5678
|
||||||
|
};
|
||||||
|
|
||||||
|
it('adds a new password reset', () => {
|
||||||
|
return passwordResetDB.insert(params).then(() => {
|
||||||
|
return testDB.knex.table('password_reset')
|
||||||
|
.where({ name: 'testing' })
|
||||||
|
.select();
|
||||||
|
}).then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1);
|
||||||
|
assert.deepStrictEqual(o(rows[0]), params);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites an existing reset for the same name', () => {
|
||||||
|
return passwordResetDB.insert(params).then(() => {
|
||||||
|
params.ip = '5.6.7.8';
|
||||||
|
params.email = 'somethingelse@example.com';
|
||||||
|
params.hash = 'qwertyuiop';
|
||||||
|
params.expire = 9999;
|
||||||
|
|
||||||
|
return passwordResetDB.insert(params);
|
||||||
|
}).then(() => {
|
||||||
|
return testDB.knex.table('password_reset')
|
||||||
|
.where({ name: 'testing' })
|
||||||
|
.select();
|
||||||
|
}).then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1);
|
||||||
|
assert.deepStrictEqual(o(rows[0]), params);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#get', () => {
|
||||||
|
const reset = {
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
name: 'testing',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hash: 'abcdef',
|
||||||
|
expire: 5678
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => cleanup().then(() => {
|
||||||
|
return testDB.knex.table('password_reset').insert(reset);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('gets a password reset by hash', () => {
|
||||||
|
return passwordResetDB.get(reset.hash).then(result => {
|
||||||
|
assert.deepStrictEqual(o(result), reset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when no reset exists for the input', () => {
|
||||||
|
return passwordResetDB.get('lalala').then(() => {
|
||||||
|
assert.fail('Expected not found error');
|
||||||
|
}).catch(error => {
|
||||||
|
assert.strictEqual(
|
||||||
|
error.message,
|
||||||
|
'No password reset found for hash lalala'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#delete', () => {
|
||||||
|
const reset = {
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
name: 'testing',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hash: 'abcdef',
|
||||||
|
expire: 5678
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => cleanup().then(() => {
|
||||||
|
return testDB.knex.table('password_reset').insert(reset);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('deletes a password reset by hash', () => {
|
||||||
|
return passwordResetDB.delete(reset.hash).then(() => {
|
||||||
|
return testDB.knex.table('password_reset')
|
||||||
|
.where({ name: 'testing' })
|
||||||
|
.select();
|
||||||
|
}).then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#cleanup', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const reset1 = {
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
name: 'testing',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hash: 'abcdef',
|
||||||
|
expire: now - 25 * 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset2 = {
|
||||||
|
ip: '5.6.7.8',
|
||||||
|
name: 'testing2',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hash: 'abcdef',
|
||||||
|
expire: now
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => cleanup().then(() => {
|
||||||
|
return testDB.knex.table('password_reset')
|
||||||
|
.insert([reset1, reset2]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('cleans up old password resets', () => {
|
||||||
|
return passwordResetDB.cleanup().then(() => {
|
||||||
|
return testDB.knex.table('password_reset')
|
||||||
|
.whereIn('name', ['testing1', 'testing2'])
|
||||||
|
.select();
|
||||||
|
}).then(rows => {
|
||||||
|
assert.strictEqual(rows.length, 1);
|
||||||
|
assert.deepStrictEqual(o(rows[0]), reset2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
136
integration_test/regressions/checkban-blank-name.js
Normal file
136
integration_test/regressions/checkban-blank-name.js
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const KickbanModule = require('../../lib/channel/kickban');
|
||||||
|
const database = require('../../lib/database');
|
||||||
|
const dbChannels = require('../../lib/database/channels');
|
||||||
|
const Promise = require('bluebird');
|
||||||
|
const ChannelModule = require('../../lib/channel/module');
|
||||||
|
const Flags = require('../../lib/flags');
|
||||||
|
const testDB = require('../testutil/db').testDB;
|
||||||
|
|
||||||
|
function randomString(length) {
|
||||||
|
const chars = 'abcdefgihkmnpqrstuvwxyz0123456789';
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
str += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
database.init(testDB);
|
||||||
|
|
||||||
|
describe('onPreUserJoin Ban Check', () => {
|
||||||
|
const channelName = `test_${randomString(20)}`;
|
||||||
|
const bannedIP = '1.1.1.1';
|
||||||
|
const bannedName = 'troll';
|
||||||
|
const mockChannel = {
|
||||||
|
name: channelName,
|
||||||
|
modules: {},
|
||||||
|
is(flag) {
|
||||||
|
return flag === Flags.C_REGISTERED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const module = new KickbanModule(mockChannel);
|
||||||
|
before(done => {
|
||||||
|
dbChannels.ban(channelName, bannedIP, bannedName, '', '', () => {
|
||||||
|
dbChannels.ban(channelName, bannedIP, '', '', '', () => {
|
||||||
|
dbChannels.ban(channelName, '*', bannedName, '', '', () => {
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
after(done => {
|
||||||
|
dbChannels.deleteBans(channelName, null, () => {
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a banned IP with a different name', done => {
|
||||||
|
const user = {
|
||||||
|
getName() {
|
||||||
|
return 'anotherTroll';
|
||||||
|
},
|
||||||
|
|
||||||
|
realip: bannedIP,
|
||||||
|
|
||||||
|
kick() {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.onUserPreJoin(user, null, (error, res) => {
|
||||||
|
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||||
|
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a banned name with a different IP', done => {
|
||||||
|
const user = {
|
||||||
|
getName() {
|
||||||
|
return 'troll';
|
||||||
|
},
|
||||||
|
|
||||||
|
realip: '5.5.5.5',
|
||||||
|
|
||||||
|
kick() {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.onUserPreJoin(user, null, (error, res) => {
|
||||||
|
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||||
|
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a banned IP with a blank name', done => {
|
||||||
|
const user = {
|
||||||
|
getName() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
realip: bannedIP,
|
||||||
|
|
||||||
|
kick() {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.onUserPreJoin(user, null, (error, res) => {
|
||||||
|
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||||
|
assert.equal(res, ChannelModule.DENY, 'Expected user to be banned');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a non-banned IP with a blank name', done => {
|
||||||
|
const user = {
|
||||||
|
getName() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
realip: '5.5.5.5'
|
||||||
|
};
|
||||||
|
|
||||||
|
module.onUserPreJoin(user, null, (error, res) => {
|
||||||
|
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||||
|
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a non-banned IP with a non-banned name', done => {
|
||||||
|
const user = {
|
||||||
|
getName() {
|
||||||
|
return 'some_user';
|
||||||
|
},
|
||||||
|
|
||||||
|
realip: '5.5.5.5'
|
||||||
|
};
|
||||||
|
|
||||||
|
module.onUserPreJoin(user, null, (error, res) => {
|
||||||
|
assert.equal(error, null, `Unexpected error: ${error}`);
|
||||||
|
assert.equal(res, ChannelModule.PASSTHROUGH, 'Expected user not to be banned');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
integration_test/testutil/config.js
Normal file
14
integration_test/testutil/config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
const loadFromToml = require('../../lib/configuration/configloader').loadFromToml;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class IntegrationTestConfig {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
get knexConfig() {
|
||||||
|
return this.config.database;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.testConfig = loadFromToml(IntegrationTestConfig, path.resolve(__dirname, '..', '..', 'conf', 'integration-test.toml'));
|
||||||
4
integration_test/testutil/db.js
Normal file
4
integration_test/testutil/db.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const testConfig = require('./config').testConfig;
|
||||||
|
const Database = require('../../lib/database').Database;
|
||||||
|
|
||||||
|
exports.testDB = new Database(testConfig.knexConfig);
|
||||||
4
integration_test/testutil/o.js
Normal file
4
integration_test/testutil/o.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
exports.o = function o(obj) {
|
||||||
|
// Workaround for knex returning RowDataPacket and failing assertions
|
||||||
|
return Object.assign({}, obj);
|
||||||
|
}
|
||||||
5573
package-lock.json
generated
Normal file
5573
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
81
package.json
Normal file
81
package.json
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{
|
||||||
|
"author": "Calvin Montgomery",
|
||||||
|
"name": "CyTube",
|
||||||
|
"description": "Online media synchronizer and chat",
|
||||||
|
"version": "3.82.8",
|
||||||
|
"repository": {
|
||||||
|
"url": "http://github.com/calzoneman/sync"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@calzoneman/jsli": "^2.0.1",
|
||||||
|
"@cytube/mediaquery": "0.0.25",
|
||||||
|
"bcrypt": "^5.0.1",
|
||||||
|
"bluebird": "^3.7.2",
|
||||||
|
"body-parser": "^1.19.0",
|
||||||
|
"cheerio": "^1.0.0-rc.10",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cookie-parser": "^1.4.5",
|
||||||
|
"create-error": "^0.3.1",
|
||||||
|
"csrf": "^3.1.0",
|
||||||
|
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"express-minify": "^1.0.0",
|
||||||
|
"json-typecheck": "^0.1.3",
|
||||||
|
"knex": "^0.95.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"mysql": "^2.18.1",
|
||||||
|
"nodemailer": "^6.5.0",
|
||||||
|
"prom-client": "^13.1.0",
|
||||||
|
"proxy-addr": "^2.0.6",
|
||||||
|
"pug": "^3.0.2",
|
||||||
|
"redis": "^3.1.1",
|
||||||
|
"sanitize-html": "^2.3.3",
|
||||||
|
"serve-static": "^1.14.1",
|
||||||
|
"socket.io": "^4.1.3",
|
||||||
|
"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",
|
||||||
|
"mocha": "^8.4.0",
|
||||||
|
"sinon": "^10.0.0"
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@babel/env",
|
||||||
|
{
|
||||||
|
"targets": {
|
||||||
|
"node": "12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"add-module-exports"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
36
player/base.coffee
Normal file
36
player/base.coffee
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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: ->
|
||||||
28
player/custom-embed.coffee
Normal file
28
player/custom-embed.coffee
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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 = "<a href=\"#{embedSrc}\" target=\"_blank\"><strong>#{embedSrc}</strong></a>"
|
||||||
|
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link),
|
||||||
|
'alert-warning')
|
||||||
|
.removeClass('col-md-12')
|
||||||
|
$('<button/>').addClass('btn btn-default')
|
||||||
|
.text('Embed')
|
||||||
|
.click(=>
|
||||||
|
super(data)
|
||||||
|
)
|
||||||
|
.appendTo(alert.find('.alert'))
|
||||||
|
removeOld(alert)
|
||||||
131
player/dailymotion.coffee
Normal file
131
player/dailymotion.coffee
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
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
|
||||||
|
wmode: if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||||
|
logo: 0
|
||||||
|
|
||||||
|
quality = @mapQuality(USEROPTS.default_quality)
|
||||||
|
if quality != 'auto'
|
||||||
|
params.quality = quality
|
||||||
|
|
||||||
|
@dm = DM.player('ytapiplayer',
|
||||||
|
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 short–medium term
|
||||||
|
# workaround, mark the player as "not ready" until the next
|
||||||
|
# playback_ready event
|
||||||
|
@dm.addEventListener('video_end', =>
|
||||||
|
@dmReady = false
|
||||||
|
)
|
||||||
|
@dm.addEventListener('playback_ready', =>
|
||||||
|
@dmReady = true
|
||||||
|
if @playbackReadyCb
|
||||||
|
@playbackReadyCb()
|
||||||
|
@playbackReadyCb = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
@setMediaProperties(data)
|
||||||
|
if @dm and @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')
|
||||||
49
player/embed.coffee
Normal file
49
player/embed.coffee
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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
|
||||||
86
player/gdrive-player.coffee
Normal file
86
player/gdrive-player.coffee
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof GoogleDrivePlayer)
|
||||||
|
return new GoogleDrivePlayer(data)
|
||||||
|
|
||||||
|
super(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
if not window.hasDriveUserscript
|
||||||
|
window.promptToInstallDriveUserscript()
|
||||||
|
else if window.hasDriveUserscript
|
||||||
|
window.maybePromptToUpgradeUserscript()
|
||||||
|
if typeof window.getGoogleDriveMetadata is 'function'
|
||||||
|
setTimeout(=>
|
||||||
|
backoffRetry((cb) ->
|
||||||
|
window.getGoogleDriveMetadata(data.id, cb)
|
||||||
|
, (error, metadata) =>
|
||||||
|
if error
|
||||||
|
console.error(error)
|
||||||
|
alertBox = window.document.createElement('div')
|
||||||
|
alertBox.className = 'alert alert-danger'
|
||||||
|
alertBox.textContent = error
|
||||||
|
document.getElementById('ytapiplayer').appendChild(alertBox)
|
||||||
|
else
|
||||||
|
data.meta.direct = metadata.videoMap
|
||||||
|
super(data)
|
||||||
|
, {
|
||||||
|
maxTries: 3
|
||||||
|
delay: 1000
|
||||||
|
factor: 1.2
|
||||||
|
jitter: 500
|
||||||
|
})
|
||||||
|
, Math.random() * 1000)
|
||||||
|
|
||||||
|
window.promptToInstallDriveUserscript = ->
|
||||||
|
if document.getElementById('prompt-install-drive-userscript')
|
||||||
|
return
|
||||||
|
alertBox = document.createElement('div')
|
||||||
|
alertBox.id = 'prompt-install-drive-userscript'
|
||||||
|
alertBox.className = 'alert alert-info'
|
||||||
|
alertBox.innerHTML = """
|
||||||
|
Due to continual breaking changes making it increasingly difficult to
|
||||||
|
maintain Google Drive support, Google Drive now requires installing
|
||||||
|
a userscript in order to play the video."""
|
||||||
|
alertBox.appendChild(document.createElement('br'))
|
||||||
|
infoLink = document.createElement('a')
|
||||||
|
infoLink.className = 'btn btn-info'
|
||||||
|
infoLink.href = '/google_drive_userscript'
|
||||||
|
infoLink.textContent = 'Click here for details'
|
||||||
|
infoLink.target = '_blank'
|
||||||
|
alertBox.appendChild(infoLink)
|
||||||
|
|
||||||
|
closeButton = document.createElement('button')
|
||||||
|
closeButton.className = 'close pull-right'
|
||||||
|
closeButton.innerHTML = '×'
|
||||||
|
closeButton.onclick = ->
|
||||||
|
alertBox.parentNode.removeChild(alertBox)
|
||||||
|
alertBox.insertBefore(closeButton, alertBox.firstChild)
|
||||||
|
removeOld($('<div/>').append(alertBox))
|
||||||
|
|
||||||
|
window.tellUserNotToContactMeAboutThingsThatAreNotSupported = ->
|
||||||
|
if document.getElementById('prompt-no-gdrive-support')
|
||||||
|
return
|
||||||
|
alertBox = document.createElement('div')
|
||||||
|
alertBox.id = 'prompt-no-gdrive-support'
|
||||||
|
alertBox.className = 'alert alert-danger'
|
||||||
|
alertBox.innerHTML = """
|
||||||
|
CyTube has detected an error in Google Drive playback. Please note that the
|
||||||
|
staff in CyTube support channels DO NOT PROVIDE SUPPORT FOR GOOGLE DRIVE. It
|
||||||
|
is left in the code as-is for existing users, but we will not assist in
|
||||||
|
troubleshooting any errors that occur.<br>"""
|
||||||
|
alertBox.appendChild(document.createElement('br'))
|
||||||
|
infoLink = document.createElement('a')
|
||||||
|
infoLink.className = 'btn btn-danger'
|
||||||
|
infoLink.href = 'https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions#why-dont-you-support-google-drive-anymore'
|
||||||
|
infoLink.textContent = 'Click here for details'
|
||||||
|
infoLink.target = '_blank'
|
||||||
|
alertBox.appendChild(infoLink)
|
||||||
|
|
||||||
|
closeButton = document.createElement('button')
|
||||||
|
closeButton.className = 'close pull-right'
|
||||||
|
closeButton.innerHTML = '×'
|
||||||
|
closeButton.onclick = ->
|
||||||
|
alertBox.parentNode.removeChild(alertBox)
|
||||||
|
alertBox.insertBefore(closeButton, alertBox.firstChild)
|
||||||
|
removeOld($('<div/>').append(alertBox))
|
||||||
23
player/hls.coffee
Normal file
23
player/hls.coffee
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
window.HLSPlayer = class HLSPlayer extends VideoJSPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof HLSPlayer)
|
||||||
|
return new HLSPlayer(data)
|
||||||
|
|
||||||
|
@setupMeta(data)
|
||||||
|
super(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
@setupMeta(data)
|
||||||
|
super(data)
|
||||||
|
|
||||||
|
setupMeta: (data) ->
|
||||||
|
data.meta.direct =
|
||||||
|
# Quality is required for data.meta.direct processing but doesn't
|
||||||
|
# matter here because it's dictated by the stream. Arbitrarily
|
||||||
|
# choose 480.
|
||||||
|
480: [
|
||||||
|
{
|
||||||
|
link: data.id
|
||||||
|
contentType: 'application/x-mpegURL'
|
||||||
|
}
|
||||||
|
]
|
||||||
12
player/imgur.coffee
Normal file
12
player/imgur.coffee
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
window.ImgurPlayer = class ImgurPlayer extends EmbedPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof ImgurPlayer)
|
||||||
|
return new ImgurPlayer(data)
|
||||||
|
|
||||||
|
@load(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
data.meta.embed =
|
||||||
|
tag: 'iframe'
|
||||||
|
src: "https://imgur.com/a/#{data.id}/embed"
|
||||||
|
super(data)
|
||||||
16
player/livestream.com.coffee
Normal file
16
player/livestream.com.coffee
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
window.LivestreamPlayer = class LivestreamPlayer extends EmbedPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof LivestreamPlayer)
|
||||||
|
return new LivestreamPlayer(data)
|
||||||
|
|
||||||
|
@load(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
data.meta.embed =
|
||||||
|
src: "https://cdn.livestream.com/embed/#{data.id}?\
|
||||||
|
layout=4&\
|
||||||
|
color=0x000000&\
|
||||||
|
iconColorOver=0xe7e7e7&\
|
||||||
|
iconColor=0xcccccc"
|
||||||
|
tag: 'iframe'
|
||||||
|
super(data)
|
||||||
92
player/playerjs.coffee
Normal file
92
player/playerjs.coffee
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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
|
||||||
|
@finishing = false
|
||||||
|
|
||||||
|
if not data.meta.playerjs
|
||||||
|
throw new Error('Invalid input: missing meta.playerjs')
|
||||||
|
|
||||||
|
waitUntilDefined(window, 'playerjs', =>
|
||||||
|
iframe = $('<iframe/>')
|
||||||
|
.attr(src: data.meta.playerjs.src)
|
||||||
|
|
||||||
|
removeOld(iframe)
|
||||||
|
|
||||||
|
@player = new playerjs.Player(iframe[0])
|
||||||
|
@player.on('ready', =>
|
||||||
|
@player.on('error', (error) =>
|
||||||
|
console.error('PlayerJS error', error.stack)
|
||||||
|
)
|
||||||
|
@player.on('ended', ->
|
||||||
|
# Streamable seems to not implement this since it loops
|
||||||
|
# gotta use the timeupdate hack below
|
||||||
|
if CLIENT.leader
|
||||||
|
socket.emit('playNext')
|
||||||
|
)
|
||||||
|
@player.on('timeupdate', (time) =>
|
||||||
|
if time.duration - time.seconds < 1 and not @finishing
|
||||||
|
setTimeout(=>
|
||||||
|
if CLIENT.leader
|
||||||
|
socket.emit('playNext')
|
||||||
|
@pause()
|
||||||
|
, (time.duration - time.seconds) * 1000)
|
||||||
|
@finishing = true
|
||||||
|
)
|
||||||
|
@player.on('play', ->
|
||||||
|
@paused = false
|
||||||
|
if CLIENT.leader
|
||||||
|
sendVideoUpdate()
|
||||||
|
)
|
||||||
|
@player.on('pause', ->
|
||||||
|
@paused = true
|
||||||
|
if CLIENT.leader
|
||||||
|
sendVideoUpdate()
|
||||||
|
)
|
||||||
|
|
||||||
|
@player.setVolume(VOLUME * 100)
|
||||||
|
|
||||||
|
if not @paused
|
||||||
|
@player.play()
|
||||||
|
|
||||||
|
@ready = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
31
player/raw-file.coffee
Normal file
31
player/raw-file.coffee
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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)
|
||||||
23
player/rtmp.coffee
Normal file
23
player/rtmp.coffee
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
12
player/smashcast.coffee
Normal file
12
player/smashcast.coffee
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
window.SmashcastPlayer = class SmashcastPlayer extends EmbedPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof SmashcastPlayer)
|
||||||
|
return new SmashcastPlayer(data)
|
||||||
|
|
||||||
|
@load(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
data.meta.embed =
|
||||||
|
src: "https://www.smashcast.tv/embed/#{data.id}"
|
||||||
|
tag: 'iframe'
|
||||||
|
super(data)
|
||||||
108
player/soundcloud.coffee
Normal file
108
player/soundcloud.coffee
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
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)
|
||||||
12
player/streamable.coffee
Normal file
12
player/streamable.coffee
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
window.StreamablePlayer = class StreamablePlayer extends PlayerJSPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof StreamablePlayer)
|
||||||
|
return new StreamablePlayer(data)
|
||||||
|
|
||||||
|
super(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
data.meta.playerjs =
|
||||||
|
src: "https://streamable.com/e/#{data.id}"
|
||||||
|
|
||||||
|
super(data)
|
||||||
128
player/twitch.coffee
Normal file
128
player/twitch.coffee
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
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 ''
|
||||||
21
player/twitchclip.coffee
Normal file
21
player/twitchclip.coffee
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
window.TwitchClipPlayer = class TwitchClipPlayer extends EmbedPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof TwitchClipPlayer)
|
||||||
|
return new TwitchClipPlayer(data)
|
||||||
|
|
||||||
|
@load(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
if location.hostname != location.host or location.protocol != 'https:'
|
||||||
|
alert = makeAlert(
|
||||||
|
'Twitch API Parameters',
|
||||||
|
window.TWITCH_PARAMS_ERROR,
|
||||||
|
'alert-danger'
|
||||||
|
).removeClass('col-md-12')
|
||||||
|
removeOld(alert)
|
||||||
|
return
|
||||||
|
|
||||||
|
data.meta.embed =
|
||||||
|
tag: 'iframe'
|
||||||
|
src: "https://clips.twitch.tv/embed?clip=#{data.id}&parent=#{location.host}"
|
||||||
|
super(data)
|
||||||
115
player/update.coffee
Normal file
115
player/update.coffee
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
TYPE_MAP =
|
||||||
|
yt: YouTubePlayer
|
||||||
|
vi: VimeoPlayer
|
||||||
|
dm: DailymotionPlayer
|
||||||
|
gd: GoogleDrivePlayer
|
||||||
|
gp: VideoJSPlayer
|
||||||
|
fi: FilePlayer
|
||||||
|
sc: SoundCloudPlayer
|
||||||
|
li: LivestreamPlayer
|
||||||
|
tw: TwitchPlayer
|
||||||
|
tv: TwitchPlayer
|
||||||
|
cu: CustomEmbedPlayer
|
||||||
|
rt: RTMPPlayer
|
||||||
|
hb: SmashcastPlayer
|
||||||
|
us: UstreamPlayer
|
||||||
|
im: ImgurPlayer
|
||||||
|
hl: HLSPlayer
|
||||||
|
sb: StreamablePlayer
|
||||||
|
tc: TwitchClipPlayer
|
||||||
|
cm: VideoJSPlayer
|
||||||
|
|
||||||
|
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')
|
||||||
|
replace.insertBefore(old)
|
||||||
|
old.remove()
|
||||||
|
replace.attr('id', 'ytapiplayer')
|
||||||
|
return replace
|
||||||
12
player/ustream.coffee
Normal file
12
player/ustream.coffee
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
window.UstreamPlayer = class UstreamPlayer extends EmbedPlayer
|
||||||
|
constructor: (data) ->
|
||||||
|
if not (this instanceof UstreamPlayer)
|
||||||
|
return new UstreamPlayer(data)
|
||||||
|
|
||||||
|
@load(data)
|
||||||
|
|
||||||
|
load: (data) ->
|
||||||
|
data.meta.embed =
|
||||||
|
tag: 'iframe'
|
||||||
|
src: "https://www.ustream.tv/embed/#{data.id}?html5ui"
|
||||||
|
super(data)
|
||||||
230
player/videojs.coffee
Normal file
230
player/videojs.coffee
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
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]}"
|
||||||
|
|
||||||
|
waitUntilDefined(window, 'videojs', =>
|
||||||
|
videojs.options.flash.swf = '/video-js.swf'
|
||||||
|
)
|
||||||
|
|
||||||
|
hasAnyTextTracks = (data) ->
|
||||||
|
ntracks = data?.meta?.textTracks?.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
@player = videojs(video[0],
|
||||||
|
# https://github.com/Dash-Industry-Forum/dash.js/issues/2184
|
||||||
|
autoplay: @sources[0].type != 'application/dash+xml',
|
||||||
|
controls: true,
|
||||||
|
plugins:
|
||||||
|
videoJsResolutionSwitcher:
|
||||||
|
default: @sources[0].res
|
||||||
|
)
|
||||||
|
@player.ready(=>
|
||||||
|
# Have to use updateSrc instead of <source> tags
|
||||||
|
# see: https://github.com/videojs/video.js/issues/3428
|
||||||
|
@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()
|
||||||
91
player/vimeo.coffee
Normal file
91
player/vimeo.coffee
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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}"
|
||||||
|
webkitallowfullscreen: true
|
||||||
|
mozallowfullscreen: true
|
||||||
|
allowfullscreen: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if USEROPTS.wmode_transparent
|
||||||
|
video.attr('wmode', 'transparent')
|
||||||
|
|
||||||
|
@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)
|
||||||
98
player/youtube.coffee
Normal file
98
player/youtube.coffee
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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()
|
||||||
|
|
||||||
|
wmode = if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||||
|
@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
|
||||||
|
wmode: wmode
|
||||||
|
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)
|
||||||
14
postinstall.sh
Executable file
14
postinstall.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/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"
|
||||||
122
servcmd.sh.js
Executable file
122
servcmd.sh.js
Executable file
|
|
@ -0,0 +1,122 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
** CyTube Service Socket Commandline
|
||||||
|
*/
|
||||||
|
|
||||||
|
const readline = require('readline');
|
||||||
|
const spawn = require('child_process').spawn;
|
||||||
|
const util = require('util');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const COMPLETIONS = [
|
||||||
|
"/delete_old_tables",
|
||||||
|
"/gc",
|
||||||
|
"/globalban",
|
||||||
|
"/reload",
|
||||||
|
"/reloadcert",
|
||||||
|
"/reload-partitions",
|
||||||
|
"/switch",
|
||||||
|
"/unglobalban",
|
||||||
|
"/unloadchan"
|
||||||
|
];
|
||||||
|
|
||||||
|
var Config = require("./lib/config");
|
||||||
|
Config.load("config.yaml");
|
||||||
|
|
||||||
|
if(!Config.get("service-socket.enabled")){
|
||||||
|
console.error('The Service Socket is not enabled.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOCKETFILE = Config.get("service-socket.socket");
|
||||||
|
|
||||||
|
// Wipe the TTY
|
||||||
|
process.stdout.write('\x1Bc');
|
||||||
|
|
||||||
|
var commandline, eventlog, syslog, errorlog;
|
||||||
|
var client = net.createConnection(SOCKETFILE).on('connect', () => {
|
||||||
|
commandline = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
completer: tabcomplete
|
||||||
|
});
|
||||||
|
commandline.setPrompt("> ", 2);
|
||||||
|
commandline.on("line", function(line) {
|
||||||
|
if(line === 'exit'){ return cleanup(); }
|
||||||
|
if(line === 'quit'){ return cleanup(); }
|
||||||
|
if(line.match(/^\/globalban/) && line.split(/\s+/).length === 2){
|
||||||
|
console.log('You must provide a reason')
|
||||||
|
return commandline.prompt();
|
||||||
|
}
|
||||||
|
client.write(line);
|
||||||
|
commandline.prompt();
|
||||||
|
});
|
||||||
|
commandline.on('close', function() {
|
||||||
|
return cleanup();
|
||||||
|
});
|
||||||
|
commandline.on("SIGINT", function() {
|
||||||
|
commandline.clearLine();
|
||||||
|
commandline.question("Terminate connection? ", function(answer) {
|
||||||
|
return answer.match(/^y(es)?$/i) ? cleanup() : commandline.output.write("> ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
commandline.prompt();
|
||||||
|
|
||||||
|
console.log = function() { cmdouthndlr("log", arguments); }
|
||||||
|
console.warn = function() { cmdouthndlr("warn", arguments); }
|
||||||
|
console.error = function() { cmdouthndlr("error", arguments); }
|
||||||
|
// console.info is reserved in this script for the exit message
|
||||||
|
// this prevents an extraneous final prompt from readline on terminate
|
||||||
|
|
||||||
|
eventlog = spawn('tail', ['-f', 'events.log']);
|
||||||
|
eventlog.stdout.on('data', function (data) {
|
||||||
|
console.log(data.toString().replace(/^(.+)$/mg, 'events: $1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
syslog = spawn('tail', ['-f', 'sys.log']);
|
||||||
|
syslog.stdout.on('data', function (data) {
|
||||||
|
console.log(data.toString().replace(/^(.+)$/mg, 'sys: $1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
errorlog = spawn('tail', ['-f', 'error.log']);
|
||||||
|
errorlog.stdout.on('data', function (data) {
|
||||||
|
console.log(data.toString().replace(/^(.+)$/mg, 'error: $1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
}).on('data', (msg) => {
|
||||||
|
msg = msg.toString();
|
||||||
|
|
||||||
|
if(msg === '__disconnect'){
|
||||||
|
console.log('Server shutting down.');
|
||||||
|
return cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic message handler
|
||||||
|
console.log('server: ', data)
|
||||||
|
|
||||||
|
}).on('error', (data) => {
|
||||||
|
console.error('Unable to connect to Service Socket.', data);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cmdouthndlr(type, args) {
|
||||||
|
var t = Math.ceil((commandline.line.length + 3) / process.stdout.columns);
|
||||||
|
var text = util.format.apply(console, args);
|
||||||
|
commandline.output.write("\n\x1B[" + t + "A\x1B[0J");
|
||||||
|
commandline.output.write(text + "\n");
|
||||||
|
commandline.output.write(Array(t).join("\n\x1B[E"));
|
||||||
|
commandline._refreshLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(){
|
||||||
|
console.info('\n',"Terminating.",'\n');
|
||||||
|
eventlog.kill('SIGTERM');
|
||||||
|
syslog.kill('SIGTERM');
|
||||||
|
client.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabcomplete(line) {
|
||||||
|
return [COMPLETIONS.filter((cv)=>{ return cv.indexOf(line) == 0; }), line];
|
||||||
|
}
|
||||||
59
src/account.js
Normal file
59
src/account.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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));
|
||||||
|
};
|
||||||
285
src/acp.js
Normal file
285
src/acp.js
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
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;
|
||||||
54
src/asyncqueue.js
Normal file
54
src/asyncqueue.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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;
|
||||||
106
src/bgtask.js
Normal file
106
src/bgtask.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
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();
|
||||||
|
};
|
||||||
46
src/camo.js
Normal file
46
src/camo.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import * as urlparse from 'url';
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('camo');
|
||||||
|
|
||||||
|
function isWhitelisted(camoConfig, url) {
|
||||||
|
const whitelistedDomains = camoConfig.getWhitelistedDomainsRegexp();
|
||||||
|
const parsed = urlparse.parse(url);
|
||||||
|
return whitelistedDomains.test('.' + parsed.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function camoify(camoConfig, url) {
|
||||||
|
if (typeof url !== 'string') {
|
||||||
|
throw new TypeError(`camoify expected a string, not [${url}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWhitelisted(camoConfig, url)) {
|
||||||
|
return url.replace(/^http:/, 'https:');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmac = crypto.createHmac('sha1', camoConfig.getKey());
|
||||||
|
hmac.update(url);
|
||||||
|
const digest = hmac.digest('hex');
|
||||||
|
// https://github.com/atmos/camo#url-formats
|
||||||
|
if (camoConfig.getEncoding() === 'hex') {
|
||||||
|
const hexUrl = Buffer.from(url, 'utf8').toString('hex');
|
||||||
|
return `${camoConfig.getServer()}/${digest}/${hexUrl}`;
|
||||||
|
} else {
|
||||||
|
const encoded = encodeURIComponent(url);
|
||||||
|
return `${camoConfig.getServer()}/${digest}?url=${encoded}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformImgTags(camoConfig, tagName, attribs) {
|
||||||
|
if (typeof attribs.src === 'string') {
|
||||||
|
try {
|
||||||
|
const oldSrc = attribs.src;
|
||||||
|
attribs.src = camoify(camoConfig, attribs.src);
|
||||||
|
LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src);
|
||||||
|
} catch (error) {
|
||||||
|
LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tagName, attribs };
|
||||||
|
}
|
||||||
36
src/channel-storage/channelstore.js
Normal file
36
src/channel-storage/channelstore.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
24
src/channel-storage/db-chandump.js
Normal file
24
src/channel-storage/db-chandump.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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();
|
||||||
122
src/channel-storage/dbstore.js
Normal file
122
src/channel-storage/dbstore.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/channel/accesscontrol.js
Normal file
63
src/channel/accesscontrol.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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;
|
||||||
35
src/channel/anonymouscheck.js
Normal file
35
src/channel/anonymouscheck.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
|
||||||
|
function AnonymousCheck(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnonymousCheck.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
AnonymousCheck.prototype.onUserPreJoin = function (user, data, cb) {
|
||||||
|
const opts = this.channel.modules.options;
|
||||||
|
var anonymousBanned = opts.get("block_anonymous_users");
|
||||||
|
|
||||||
|
if (user.socket.disconnected) {
|
||||||
|
return cb("User disconnected", ChannelModule.DENY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(anonymousBanned && user.isAnonymous()) {
|
||||||
|
user.socket.on("disconnect", function () {
|
||||||
|
if (!user.is(Flags.U_IN_CHANNEL)) {
|
||||||
|
cb("User disconnected", ChannelModule.DENY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user.socket.emit("errorMsg", { msg : "This channel has blocked anonymous users. Please provide a user name to join."});
|
||||||
|
user.waitFlag(Flags.U_LOGGED_IN, function () {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else{
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = AnonymousCheck;
|
||||||
760
src/channel/channel.js
Normal file
760
src/channel/channel.js
Normal file
|
|
@ -0,0 +1,760 @@
|
||||||
|
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 !== "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;
|
||||||
688
src/channel/chat.js
Normal file
688
src/channel/chat.js
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
var Config = require("../config");
|
||||||
|
var XSS = require("../xss");
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var util = require("../utilities");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
import { transformImgTags } from '../camo';
|
||||||
|
import { Counter } from 'prom-client';
|
||||||
|
|
||||||
|
const SHADOW_TAG = "[shadow]";
|
||||||
|
const LINK = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
|
||||||
|
const LINK_PLACEHOLDER = '\ueeee';
|
||||||
|
const LINK_PLACEHOLDER_RE = /\ueeee/g;
|
||||||
|
|
||||||
|
const TYPE_CHAT = {
|
||||||
|
msg: "string",
|
||||||
|
meta: "object,optional"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_PM = {
|
||||||
|
msg: "string",
|
||||||
|
to: "string",
|
||||||
|
meta: "object,optional"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limit to 10 messages/sec
|
||||||
|
const MIN_ANTIFLOOD = {
|
||||||
|
burst: 20,
|
||||||
|
sustained: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChatModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.buffer = [];
|
||||||
|
this.muted = new Set();
|
||||||
|
this.commandHandlers = {};
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
|
||||||
|
/* Default commands */
|
||||||
|
this.registerCommand("/me", this.handleCmdMe.bind(this));
|
||||||
|
this.registerCommand("/sp", this.handleCmdSp.bind(this));
|
||||||
|
this.registerCommand("/say", this.handleCmdSay.bind(this));
|
||||||
|
this.registerCommand("/rcv", this.handleCmdSay.bind(this));
|
||||||
|
this.registerCommand("/shout", this.handleCmdSay.bind(this));
|
||||||
|
this.registerCommand("/clear", this.handleCmdClear.bind(this));
|
||||||
|
this.registerCommand("/a", this.handleCmdAdminflair.bind(this));
|
||||||
|
this.registerCommand("/afk", this.handleCmdAfk.bind(this));
|
||||||
|
this.registerCommand("/mute", this.handleCmdMute.bind(this));
|
||||||
|
this.registerCommand("/smute", this.handleCmdSMute.bind(this));
|
||||||
|
this.registerCommand("/unmute", this.handleCmdUnmute.bind(this));
|
||||||
|
this.registerCommand("/unsmute", this.handleCmdUnmute.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
ChatModule.prototype.load = function (data) {
|
||||||
|
this.buffer = [];
|
||||||
|
this.muted = new Set();
|
||||||
|
|
||||||
|
if ("chatbuffer" in data) {
|
||||||
|
for (var i = 0; i < data.chatbuffer.length; i++) {
|
||||||
|
this.buffer.push(data.chatbuffer[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("chatmuted" in data) {
|
||||||
|
for (i = 0; i < data.chatmuted.length; i++) {
|
||||||
|
this.muted.add(data.chatmuted[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.save = function (data) {
|
||||||
|
data.chatbuffer = this.buffer;
|
||||||
|
data.chatmuted = Array.from(this.muted);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.packInfo = function (data, _isAdmin) {
|
||||||
|
data.chat = Array.prototype.slice.call(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
var self = this;
|
||||||
|
user.waitFlag(Flags.U_LOGGED_IN, function () {
|
||||||
|
var muteperm = self.channel.modules.permissions.permissions.mute;
|
||||||
|
if (self.isShadowMuted(user.getName())) {
|
||||||
|
user.setFlag(Flags.U_SMUTED | Flags.U_MUTED);
|
||||||
|
self.channel.sendUserMeta(self.channel.users, user, muteperm);
|
||||||
|
} else if (self.isMuted(user.getName())) {
|
||||||
|
user.setFlag(Flags.U_MUTED);
|
||||||
|
self.channel.sendUserMeta(self.channel.users, user, muteperm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user.socket.typecheckedOn("chatMsg", TYPE_CHAT, this.handleChatMsg.bind(this, user));
|
||||||
|
user.socket.typecheckedOn("pm", TYPE_PM, this.handlePm.bind(this, user));
|
||||||
|
this.buffer.forEach(function (msg) {
|
||||||
|
user.socket.emit("chatMsg", msg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.isMuted = function (name) {
|
||||||
|
return this.muted.has(name.toLowerCase()) ||
|
||||||
|
this.muted.has(SHADOW_TAG + name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.mutedUsers = function () {
|
||||||
|
var self = this;
|
||||||
|
return self.channel.users.filter(function (u) {
|
||||||
|
return self.isMuted(u.getName());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.isShadowMuted = function (name) {
|
||||||
|
return this.muted.has(SHADOW_TAG + name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.shadowMutedUsers = function () {
|
||||||
|
var self = this;
|
||||||
|
return self.channel.users.filter(function (u) {
|
||||||
|
return self.isShadowMuted(u.getName());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.anonymousUsers = function () {
|
||||||
|
return this.channel.users.filter(function (u) {
|
||||||
|
return u.getName() === "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.restrictNewAccount = function restrictNewAccount(user, data) {
|
||||||
|
if (user.account.effectiveRank < 2 && this.channel.modules.options) {
|
||||||
|
const firstSeen = user.getFirstSeenTime();
|
||||||
|
const opts = this.channel.modules.options;
|
||||||
|
if (firstSeen > Date.now() - opts.get("new_user_chat_delay")*1000) {
|
||||||
|
user.socket.emit("spamFiltered", {
|
||||||
|
reason: "NEW_USER_CHAT"
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else if ((firstSeen > Date.now() - opts.get("new_user_chat_link_delay")*1000)
|
||||||
|
&& data.msg.match(LINK)) {
|
||||||
|
user.socket.emit("spamFiltered", {
|
||||||
|
reason: "NEW_USER_CHAT_LINK"
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatIncomingCount = new Counter({
|
||||||
|
name: 'cytube_chat_incoming_total',
|
||||||
|
help: 'Number of incoming chatMsg frames'
|
||||||
|
});
|
||||||
|
ChatModule.prototype.handleChatMsg = function (user, data) {
|
||||||
|
var self = this;
|
||||||
|
chatIncomingCount.inc(1, new Date());
|
||||||
|
|
||||||
|
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.msg = data.msg.substring(0, 320);
|
||||||
|
|
||||||
|
// Restrict new accounts/IPs from chatting and posting links
|
||||||
|
if (this.restrictNewAccount(user, data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If channel doesn't permit them, strip ASCII control characters
|
||||||
|
if (!this.channel.modules.options ||
|
||||||
|
!this.channel.modules.options.get("allow_ascii_control")) {
|
||||||
|
|
||||||
|
data.msg = data.msg.replace(/[\x00-\x1f]+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow blankposting
|
||||||
|
if (!data.msg.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is(Flags.U_LOGGED_IN)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = {};
|
||||||
|
data.meta = data.meta || {};
|
||||||
|
if (user.account.effectiveRank >= 2) {
|
||||||
|
if ("modflair" in data.meta && data.meta.modflair === user.account.effectiveRank) {
|
||||||
|
meta.modflair = data.meta.modflair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.meta = meta;
|
||||||
|
|
||||||
|
this.channel.checkModules("onUserPreChat", [user, data], function (err, result) {
|
||||||
|
if (result === ChannelModule.PASSTHROUGH) {
|
||||||
|
self.processChatMsg(user, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handlePm = function (user, data) {
|
||||||
|
if (!this.channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is(Flags.U_LOGGED_IN)) {
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: "You must be signed in to send PMs"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict new accounts/IPs from chatting and posting links
|
||||||
|
if (this.restrictNewAccount(user, data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
|
||||||
|
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
|
||||||
|
"blacklisted domain");
|
||||||
|
user.kick();
|
||||||
|
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
|
||||||
|
"private message", 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.to = data.to.toLowerCase();
|
||||||
|
|
||||||
|
if (data.to === user.getLowerName()) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "You can't PM yourself!"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!util.isValidUserName(data.to)) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "PM failed: " + data.to + " isn't a valid username."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.chatLimiter.throttle(MIN_ANTIFLOOD)) {
|
||||||
|
user.socket.emit("cooldown", 1000 / MIN_ANTIFLOOD.sustained);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data.msg = data.msg.substring(0, 320);
|
||||||
|
var to = null;
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === data.to) {
|
||||||
|
to = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "PM failed: " + data.to + " isn't connected to this channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = {};
|
||||||
|
data.meta = data.meta || {};
|
||||||
|
if (user.rank >= 2) {
|
||||||
|
if ("modflair" in data.meta && data.meta.modflair === user.rank) {
|
||||||
|
meta.modflair = data.meta.modflair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.msg.indexOf(">") === 0) {
|
||||||
|
meta.addClass = "greentext";
|
||||||
|
}
|
||||||
|
|
||||||
|
data.meta = meta;
|
||||||
|
var msgobj = this.formatMessage(user.getName(), data);
|
||||||
|
msgobj.to = to.getName();
|
||||||
|
|
||||||
|
to.socket.emit("pm", msgobj);
|
||||||
|
user.socket.emit("pm", msgobj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatSentCount = new Counter({
|
||||||
|
name: 'cytube_chat_sent_total',
|
||||||
|
help: 'Number of broadcast chat messages'
|
||||||
|
});
|
||||||
|
ChatModule.prototype.processChatMsg = function (user, data) {
|
||||||
|
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
|
||||||
|
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
|
||||||
|
"blacklisted domain");
|
||||||
|
user.kick();
|
||||||
|
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
|
||||||
|
"chat message", 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.msg.indexOf("/afk") === -1) {
|
||||||
|
user.setAFK(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgobj = this.formatMessage(user.getName(), data);
|
||||||
|
var antiflood = MIN_ANTIFLOOD;
|
||||||
|
if (this.channel.modules.options &&
|
||||||
|
this.channel.modules.options.get("chat_antiflood") &&
|
||||||
|
user.account.effectiveRank < 2) {
|
||||||
|
|
||||||
|
antiflood = this.channel.modules.options.get("chat_antiflood_params");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.chatLimiter.throttle(antiflood)) {
|
||||||
|
user.socket.emit("cooldown", 1000 / antiflood.sustained);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.msg.indexOf(">") === 0) {
|
||||||
|
msgobj.meta.addClass = "greentext";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.msg.indexOf("/") === 0) {
|
||||||
|
var space = data.msg.indexOf(" ");
|
||||||
|
var cmd;
|
||||||
|
if (space < 0) {
|
||||||
|
cmd = data.msg.substring(1);
|
||||||
|
} else {
|
||||||
|
cmd = data.msg.substring(1, space);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd in this.commandHandlers) {
|
||||||
|
this.commandHandlers[cmd](user, data.msg, data.meta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is(Flags.U_SMUTED)) {
|
||||||
|
this.shadowMutedUsers().forEach(function (u) {
|
||||||
|
u.socket.emit("chatMsg", msgobj);
|
||||||
|
});
|
||||||
|
// This prevents shadowmuted users from easily detecting their state
|
||||||
|
this.anonymousUsers().forEach(function (u) {
|
||||||
|
u.socket.emit("chatMsg", msgobj);
|
||||||
|
});
|
||||||
|
msgobj.meta.shadow = true;
|
||||||
|
this.channel.moderators().forEach(function (u) {
|
||||||
|
u.socket.emit("chatMsg", msgobj);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (user.is(Flags.U_MUTED)) {
|
||||||
|
user.socket.emit("noflood", {
|
||||||
|
action: "chat",
|
||||||
|
msg: "You have been muted on this channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendMessage(msgobj);
|
||||||
|
chatSentCount.inc(1, new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.formatMessage = function (username, data) {
|
||||||
|
var msg = XSS.sanitizeText(data.msg);
|
||||||
|
if (this.channel.modules.filters) {
|
||||||
|
msg = this.filterMessage(msg);
|
||||||
|
}
|
||||||
|
var obj = {
|
||||||
|
username: username,
|
||||||
|
msg: msg,
|
||||||
|
meta: data.meta,
|
||||||
|
time: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.filterMessage = function (msg) {
|
||||||
|
var filters = this.channel.modules.filters.filters;
|
||||||
|
var convertLinks = this.channel.modules.options.get("enable_link_regex");
|
||||||
|
var links = msg.match(LINK);
|
||||||
|
var intermediate = msg.replace(LINK, LINK_PLACEHOLDER);
|
||||||
|
|
||||||
|
var result = filters.filter(intermediate, false);
|
||||||
|
result = result.replace(LINK_PLACEHOLDER_RE, function () {
|
||||||
|
var link = links.shift();
|
||||||
|
if (!link) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered = filters.filter(link, true);
|
||||||
|
if (filtered !== link) {
|
||||||
|
return filtered;
|
||||||
|
} else if (convertLinks) {
|
||||||
|
return "<a href=\"" + link + "\" target=\"_blank\" " +
|
||||||
|
"rel=\"noopener noreferrer\">" + link + "</a>";
|
||||||
|
} else {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = {};
|
||||||
|
const camoConfig = Config.getCamoConfig();
|
||||||
|
if (camoConfig.isEnabled()) {
|
||||||
|
settings = {
|
||||||
|
transformTags: {
|
||||||
|
img: transformImgTags.bind(null, camoConfig)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return XSS.sanitizeHTML(result, settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.sendModMessage = function (msg, minrank) {
|
||||||
|
if (isNaN(minrank)) {
|
||||||
|
minrank = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgobj = {
|
||||||
|
username: "[server]",
|
||||||
|
msg: msg,
|
||||||
|
meta: {
|
||||||
|
addClass: "server-whisper",
|
||||||
|
addClassToNameAndTimestamp: true
|
||||||
|
},
|
||||||
|
time: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.channel.users.forEach(function (u) {
|
||||||
|
if (u.account.effectiveRank >= minrank) {
|
||||||
|
u.socket.emit("chatMsg", msgobj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.sendMessage = function (msgobj) {
|
||||||
|
this.channel.broadcastAll("chatMsg", msgobj);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
this.buffer.push(msgobj);
|
||||||
|
if (this.buffer.length > 15) {
|
||||||
|
this.buffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channel.logger.log(
|
||||||
|
"<" + msgobj.username +
|
||||||
|
(msgobj.meta.addClass ? "." + msgobj.meta.addClass : "") +
|
||||||
|
"> " + XSS.decodeText(msgobj.msg)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.registerCommand = function (cmd, cb) {
|
||||||
|
cmd = cmd.replace(/^\//, "");
|
||||||
|
this.commandHandlers[cmd] = cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* == Default commands ==
|
||||||
|
*/
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdMe = function (user, msg, meta) {
|
||||||
|
meta.addClass = "action";
|
||||||
|
meta.action = true;
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift();
|
||||||
|
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdSp = function (user, msg, meta) {
|
||||||
|
meta.addClass = "spoiler";
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift();
|
||||||
|
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdSay = function (user, msg, meta) {
|
||||||
|
if (user.account.effectiveRank < 1.5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
meta.addClass = "shout";
|
||||||
|
meta.addClassToNameAndTimestamp = true;
|
||||||
|
meta.forceShowName = true;
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift();
|
||||||
|
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdClear = function (user, _msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canClearChat(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
this.buffer = [];
|
||||||
|
this.channel.broadcastAll("clearchat", { clearedBy: user.getName() });
|
||||||
|
this.sendModMessage(user.getName() + " cleared chat.", -1);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " used /clear");
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdAdminflair = function (user, msg, meta) {
|
||||||
|
if (user.account.globalRank < 255) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift();
|
||||||
|
|
||||||
|
var superadminflair = {
|
||||||
|
labelclass: "label-danger",
|
||||||
|
icon: "glyphicon-globe"
|
||||||
|
};
|
||||||
|
|
||||||
|
var cargs = [];
|
||||||
|
args.forEach(function (a) {
|
||||||
|
if (a.indexOf("!icon-") === 0) {
|
||||||
|
superadminflair.icon = "glyph" + a.substring(1);
|
||||||
|
} else if (a.indexOf("!label-") === 0) {
|
||||||
|
superadminflair.labelclass = a.substring(1);
|
||||||
|
} else {
|
||||||
|
cargs.push(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
meta.superadminflair = superadminflair;
|
||||||
|
meta.forceShowName = true;
|
||||||
|
|
||||||
|
this.processChatMsg(user, { msg: cargs.join(" "), meta: meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdAfk = function (user, _msg, _meta) {
|
||||||
|
user.setAFK(!user.is(Flags.U_AFK));
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdMute = function (user, msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canMute(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var muteperm = this.channel.modules.permissions.permissions.mute;
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /mute */
|
||||||
|
|
||||||
|
var name = args.shift();
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/mute requires a target name"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
name = name.toLowerCase();
|
||||||
|
|
||||||
|
var target;
|
||||||
|
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === name) {
|
||||||
|
target = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/mute target " + name + " not present in channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.account.effectiveRank >= user.account.effectiveRank
|
||||||
|
|| target.account.globalRank > user.account.globalRank) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/mute failed - " + target.getName() + " has equal or higher rank " +
|
||||||
|
"than you."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.setFlag(Flags.U_MUTED);
|
||||||
|
this.muted.add(name);
|
||||||
|
this.channel.sendUserMeta(this.channel.users, target, -1);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " muted " + target.getName());
|
||||||
|
this.sendModMessage(user.getName() + " muted " + target.getName(), muteperm);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdSMute = function (user, msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canMute(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var muteperm = this.channel.modules.permissions.permissions.mute;
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /smute */
|
||||||
|
|
||||||
|
var name = args.shift();
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/smute requires a target name"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
name = name.toLowerCase();
|
||||||
|
|
||||||
|
var target;
|
||||||
|
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === name) {
|
||||||
|
target = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/smute target " + name + " not present in channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.account.effectiveRank >= user.account.effectiveRank
|
||||||
|
|| target.account.globalRank > user.account.globalRank) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/smute failed - " + target.getName() + " has equal or higher rank " +
|
||||||
|
"than you."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.setFlag(Flags.U_MUTED | Flags.U_SMUTED);
|
||||||
|
this.muted.add(name);
|
||||||
|
this.muted.add(SHADOW_TAG + name);
|
||||||
|
this.channel.sendUserMeta(this.channel.users, target, muteperm);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " shadowmuted " + target.getName());
|
||||||
|
this.sendModMessage(user.getName() + " shadowmuted " + target.getName(), muteperm);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatModule.prototype.handleCmdUnmute = function (user, msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canMute(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var muteperm = this.channel.modules.permissions.permissions.mute;
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /mute */
|
||||||
|
|
||||||
|
var name = args.shift();
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "/unmute requires a target name"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
name = name.toLowerCase();
|
||||||
|
|
||||||
|
if (name === user.getName().toLowerCase()) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "You are not allowed to unmute yourself"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isMuted(name)) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: name + " is not muted."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.muted.delete(name);
|
||||||
|
this.muted.delete(SHADOW_TAG + name);
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + name);
|
||||||
|
this.sendModMessage(user.getName() + " unmuted " + name, muteperm);
|
||||||
|
|
||||||
|
var target;
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === name) {
|
||||||
|
target = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.clearFlag(Flags.U_MUTED | Flags.U_SMUTED);
|
||||||
|
this.channel.sendUserMeta(this.channel.users, target, -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ChatModule;
|
||||||
157
src/channel/customization.js
Normal file
157
src/channel/customization.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
const ChannelModule = require("./module");
|
||||||
|
const XSS = require("../xss");
|
||||||
|
const { hash } = require('../util/hash');
|
||||||
|
|
||||||
|
const TYPE_SETCSS = {
|
||||||
|
css: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_SETJS = {
|
||||||
|
js: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_SETMOTD = {
|
||||||
|
motd: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
function CustomizationModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.css = "";
|
||||||
|
this.js = "";
|
||||||
|
this.motd = "";
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomizationModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
Object.defineProperty(CustomizationModule.prototype, 'css', {
|
||||||
|
get() {
|
||||||
|
return this._css;
|
||||||
|
},
|
||||||
|
|
||||||
|
set(val) {
|
||||||
|
this._css = val;
|
||||||
|
this.cssHash = hash('md5', val, 'base64');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(CustomizationModule.prototype, 'js', {
|
||||||
|
get() {
|
||||||
|
return this._js;
|
||||||
|
},
|
||||||
|
|
||||||
|
set(val) {
|
||||||
|
this._js = val;
|
||||||
|
this.jsHash = hash('md5', val, 'base64');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomizationModule.prototype.load = function (data) {
|
||||||
|
if ("css" in data) {
|
||||||
|
this.css = data.css;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("js" in data) {
|
||||||
|
this.js = data.js;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("motd" in data) {
|
||||||
|
if (typeof data.motd === "object" && data.motd.motd) {
|
||||||
|
// Old style MOTD, convert to new
|
||||||
|
this.motd = XSS.sanitizeHTML(data.motd.motd).replace(
|
||||||
|
/\n/g, "<br>\n");
|
||||||
|
} else if (typeof data.motd === "string") {
|
||||||
|
// The MOTD is filtered before it is saved, however it is also
|
||||||
|
// re-filtered on load in case the filtering rules change
|
||||||
|
this.motd = XSS.sanitizeHTML(data.motd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.save = function (data) {
|
||||||
|
data.css = this.css;
|
||||||
|
data.js = this.js;
|
||||||
|
data.motd = this.motd;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.setMotd = function (motd) {
|
||||||
|
this.motd = XSS.sanitizeHTML(motd);
|
||||||
|
this.sendMotd(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
this.sendCSSJS([user]);
|
||||||
|
this.sendMotd([user]);
|
||||||
|
user.socket.typecheckedOn("setChannelCSS", TYPE_SETCSS, this.handleSetCSS.bind(this, user));
|
||||||
|
user.socket.typecheckedOn("setChannelJS", TYPE_SETJS, this.handleSetJS.bind(this, user));
|
||||||
|
user.socket.typecheckedOn("setMotd", TYPE_SETMOTD, this.handleSetMotd.bind(this, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.sendCSSJS = function (users) {
|
||||||
|
var data = {
|
||||||
|
css: this.css,
|
||||||
|
cssHash: this.cssHash,
|
||||||
|
js: this.js,
|
||||||
|
jsHash: this.jsHash
|
||||||
|
};
|
||||||
|
users.forEach(function (u) {
|
||||||
|
u.socket.emit("channelCSSJS", data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.sendMotd = function (users) {
|
||||||
|
var data = this.motd;
|
||||||
|
users.forEach(function (u) {
|
||||||
|
u.socket.emit("setMotd", data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.handleSetCSS = function (user, data) {
|
||||||
|
if (!this.channel.modules.permissions.canSetCSS(user)) {
|
||||||
|
user.kick("Attempted setChannelCSS as non-admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldHash = this.cssHash;
|
||||||
|
// TODO: consider sending back an error instead of silently truncating
|
||||||
|
this.css = data.css.substring(0, 20000);
|
||||||
|
|
||||||
|
if (oldHash !== this.cssHash) {
|
||||||
|
this.dirty = true;
|
||||||
|
this.sendCSSJS(this.channel.users);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " updated the channel CSS");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.handleSetJS = function (user, data) {
|
||||||
|
if (!this.channel.modules.permissions.canSetJS(user)) {
|
||||||
|
user.kick("Attempted setChannelJS as non-admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldHash = this.jsHash;
|
||||||
|
this.js = data.js.substring(0, 20000);
|
||||||
|
|
||||||
|
if (oldHash !== this.jsHash) {
|
||||||
|
this.dirty = true;
|
||||||
|
this.sendCSSJS(this.channel.users);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " updated the channel JS");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomizationModule.prototype.handleSetMotd = function (user, data) {
|
||||||
|
if (!this.channel.modules.permissions.canEditMotd(user)) {
|
||||||
|
user.kick("Attempted setMotd with insufficient permission");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var motd = data.motd.substring(0, 20000);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
this.setMotd(motd);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " updated the MOTD");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = CustomizationModule;
|
||||||
60
src/channel/drink.js
Normal file
60
src/channel/drink.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// TODO: figure out what to do with this module
|
||||||
|
// it serves a very niche use case and is only a core module because of
|
||||||
|
// legacy reasons (early channels requested it before I had criteria
|
||||||
|
// around what to include in core)
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
|
||||||
|
function DrinkModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.drinks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrinkModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
DrinkModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.emit("drinkCount", this.drinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
DrinkModule.prototype.onUserPreChat = function (user, data, cb) {
|
||||||
|
var msg = data.msg;
|
||||||
|
var perms = this.channel.modules.permissions;
|
||||||
|
if (msg.match(/^\/d-?[0-9]*/) && perms.canCallDrink(user)) {
|
||||||
|
msg = msg.substring(2);
|
||||||
|
var m = msg.match(/^(-?[0-9]+)/);
|
||||||
|
var count;
|
||||||
|
if (m) {
|
||||||
|
count = parseInt(m[1]);
|
||||||
|
if (isNaN(count) || count < -10000 || count > 10000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = msg.replace(m[1], "").trim();
|
||||||
|
if (msg || count > 0) {
|
||||||
|
msg += " drink! (x" + count + ")";
|
||||||
|
} else {
|
||||||
|
this.drinks += count;
|
||||||
|
this.channel.broadcastAll("drinkCount", this.drinks);
|
||||||
|
return cb(null, ChannelModule.DENY);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg = msg.trim() + " drink!";
|
||||||
|
count = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drinks += count;
|
||||||
|
this.channel.broadcastAll("drinkCount", this.drinks);
|
||||||
|
data.msg = msg;
|
||||||
|
data.meta.addClass = "drink";
|
||||||
|
data.meta.forceShowName = true;
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
} else {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DrinkModule.prototype.onMediaChange = function () {
|
||||||
|
this.drinks = 0;
|
||||||
|
this.channel.broadcastAll("drinkCount", 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = DrinkModule;
|
||||||
304
src/channel/emotes.js
Normal file
304
src/channel/emotes.js
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var XSS = require("../xss");
|
||||||
|
|
||||||
|
function EmoteList(defaults) {
|
||||||
|
if (!defaults) {
|
||||||
|
defaults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emotes = defaults.map(validateEmote).filter(function (f) {
|
||||||
|
return f !== false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EmoteList.prototype = {
|
||||||
|
pack: function () {
|
||||||
|
return Array.prototype.slice.call(this.emotes);
|
||||||
|
},
|
||||||
|
|
||||||
|
importList: function (emotes) {
|
||||||
|
this.emotes = Array.prototype.slice.call(emotes);
|
||||||
|
},
|
||||||
|
|
||||||
|
emoteExists: function (emote){
|
||||||
|
for (let i = 0; i < this.emotes.length; i++) {
|
||||||
|
if (this.emotes[i].name === emote.name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
renameEmote: function (emote) {
|
||||||
|
var found = false;
|
||||||
|
for (var i = 0; i < this.emotes.length; i++) {
|
||||||
|
if (this.emotes[i].name === emote.old) {
|
||||||
|
found = true;
|
||||||
|
this.emotes[i] = emote;
|
||||||
|
delete this.emotes[i].old;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(found){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEmote: function (emote) {
|
||||||
|
var found = false;
|
||||||
|
for (var i = 0; i < this.emotes.length; i++) {
|
||||||
|
if (this.emotes[i].name === emote.name) {
|
||||||
|
found = true;
|
||||||
|
this.emotes[i] = emote;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If no emote was updated, add a new one */
|
||||||
|
if (!found) {
|
||||||
|
this.emotes.push(emote);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEmote: function (emote) {
|
||||||
|
for (var i = 0; i < this.emotes.length; i++) {
|
||||||
|
if (this.emotes[i].name === emote.name) {
|
||||||
|
this.emotes.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
moveEmote: function (from, to) {
|
||||||
|
if (from < 0 || to < 0 ||
|
||||||
|
from >= this.emotes.length || to >= this.emotes.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var f = this.emotes[from];
|
||||||
|
/* Offset from/to indexes to account for the fact that removing
|
||||||
|
an element changes the position of one of them.
|
||||||
|
|
||||||
|
I could have just done a swap, but it's already implemented this way
|
||||||
|
and it works. */
|
||||||
|
to = to > from ? to + 1 : to;
|
||||||
|
from = to > from ? from : from + 1;
|
||||||
|
|
||||||
|
this.emotes.splice(to, 0, f);
|
||||||
|
this.emotes.splice(from, 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateEmote(f) {
|
||||||
|
if (typeof f.name !== "string" || typeof f.image !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.image = f.image.substring(0, 1000);
|
||||||
|
f.image = XSS.sanitizeText(f.image);
|
||||||
|
|
||||||
|
var s = XSS.looseSanitizeText(f.name).replace(/([\\.?+*$^|()[\]{}])/g, "\\$1");
|
||||||
|
s = "(^|\\s)" + s + "(?!\\S)";
|
||||||
|
f.source = s;
|
||||||
|
|
||||||
|
if (!f.image || !f.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new RegExp(f.source, "gi");
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmoteModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.emotes = new EmoteList();
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmoteModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
EmoteModule.prototype.load = function (data) {
|
||||||
|
if ("emotes" in data) {
|
||||||
|
this.emotes = new EmoteList(data.emotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.save = function (data) {
|
||||||
|
data.emotes = this.emotes.pack();
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.packInfo = function (data, isAdmin) {
|
||||||
|
if (isAdmin) {
|
||||||
|
data.emoteCount = this.emotes.emotes.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.on("renameEmote", this.handleRenameEmote.bind(this, user));
|
||||||
|
user.socket.on("updateEmote", this.handleUpdateEmote.bind(this, user));
|
||||||
|
user.socket.on("importEmotes", this.handleImportEmotes.bind(this, user));
|
||||||
|
user.socket.on("moveEmote", this.handleMoveEmote.bind(this, user));
|
||||||
|
user.socket.on("removeEmote", this.handleRemoveEmote.bind(this, user));
|
||||||
|
this.sendEmotes([user]);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.sendEmotes = function (users) {
|
||||||
|
var f = this.emotes.pack();
|
||||||
|
users.forEach(function (u) {
|
||||||
|
u.socket.emit("emoteList", f);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.handleRenameEmote = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
** This shouldn't be able to happen,
|
||||||
|
** but we have idiots that like to send handcrafted frames to fuck with shit
|
||||||
|
*/
|
||||||
|
if (typeof data.old !== "string"){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditEmotes(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var e = this.emotes.emoteExists(data);
|
||||||
|
var f = validateEmote(data);
|
||||||
|
if (!f || e) {
|
||||||
|
var message = "Unable to rename emote '" + JSON.stringify(data) + "'. " +
|
||||||
|
"Please contact an administrator for assistance.";
|
||||||
|
if (!data.image || !data.name) {
|
||||||
|
message = "Emote names and images must not be blank.";
|
||||||
|
}
|
||||||
|
if (e) {
|
||||||
|
message = "Emote already exists.";
|
||||||
|
}
|
||||||
|
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// See comment above
|
||||||
|
var success = this.emotes.renameEmote(Object.assign({}, f));
|
||||||
|
if(!success){ return; }
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
chan.broadcastAll("renameEmote", f);
|
||||||
|
chan.logger.log(`[mod] ${user.getName()} renamed emote: ${f.old} -> ${f.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.handleUpdateEmote = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditEmotes(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var f = validateEmote(data);
|
||||||
|
if (!f) {
|
||||||
|
var message = "Unable to update emote '" + JSON.stringify(data) + "'. " +
|
||||||
|
"Please contact an administrator for assistance.";
|
||||||
|
if (!data.image || !data.name) {
|
||||||
|
message = "Emote names and images must not be blank.";
|
||||||
|
}
|
||||||
|
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emotes.updateEmote(f);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
chan.broadcastAll("updateEmote", f);
|
||||||
|
|
||||||
|
chan.logger.log("[mod] " + user.getName() + " updated emote: " + f.name + " -> " +
|
||||||
|
f.image);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.handleImportEmotes = function (user, data) {
|
||||||
|
if (!(data instanceof Array)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Note: importing requires a different permission node than simply
|
||||||
|
updating/removing */
|
||||||
|
if (!this.channel.modules.permissions.canImportEmotes(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emotes.importList(data.map(validateEmote).filter(function (f) {
|
||||||
|
return f !== false;
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
this.sendEmotes(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.handleRemoveEmote = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditEmotes(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.name !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emotes.removeEmote(data);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " removed emote: " + data.name);
|
||||||
|
this.channel.broadcastAll("removeEmote", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
EmoteModule.prototype.handleMoveEmote = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditEmotes(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.to !== "number" || typeof data.from !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emotes.moveEmote(data.from, data.to);
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = EmoteModule;
|
||||||
313
src/channel/filters.js
Normal file
313
src/channel/filters.js
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
var FilterList = require("cytubefilters");
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('filters');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Converts JavaScript-style replacements ($1, $2, etc.) with
|
||||||
|
* PCRE-style (\1, \2, etc.)
|
||||||
|
*/
|
||||||
|
function fixReplace(replace) {
|
||||||
|
return replace.replace(/\$(\d)/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFilter(f) {
|
||||||
|
if (typeof f.source !== "string" || typeof f.flags !== "string" ||
|
||||||
|
typeof f.replace !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof f.name !== "string") {
|
||||||
|
f.name = f.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.replace = fixReplace(f.replace.substring(0, 1000));
|
||||||
|
f.flags = f.flags.substring(0, 4);
|
||||||
|
|
||||||
|
try {
|
||||||
|
FilterList.checkValidRegex(f.source);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = {
|
||||||
|
name: f.name,
|
||||||
|
source: f.source,
|
||||||
|
replace: fixReplace(f.replace),
|
||||||
|
flags: f.flags,
|
||||||
|
active: !!f.active,
|
||||||
|
filterlinks: !!f.filterlinks
|
||||||
|
};
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaultFilter(name, source, flags, replace) {
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
source: source,
|
||||||
|
flags: flags,
|
||||||
|
replace: replace,
|
||||||
|
active: true,
|
||||||
|
filterlinks: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = [
|
||||||
|
makeDefaultFilter("monospace", "`(.+?)`", "g", "<code>\\1</code>"),
|
||||||
|
makeDefaultFilter("bold", "\\*(.+?)\\*", "g", "<strong>\\1</strong>"),
|
||||||
|
makeDefaultFilter("italic", "_(.+?)_", "g", "<em>\\1</em>"),
|
||||||
|
makeDefaultFilter("strike", "~~(.+?)~~", "g", "<s>\\1</s>"),
|
||||||
|
makeDefaultFilter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig",
|
||||||
|
"<span class=\"spoiler\">\\1</span>")
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChatFilterModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.filters = new FilterList();
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatFilterModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.load = function (data) {
|
||||||
|
if ("filters" in data) {
|
||||||
|
var filters = data.filters.map(validateFilter).filter(function (f) {
|
||||||
|
return f !== null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
this.filters = new FilterList(filters);
|
||||||
|
} catch (e) {
|
||||||
|
LOGGER.error("Filter load failed: " + e + " (channel:" +
|
||||||
|
this.channel.name);
|
||||||
|
this.channel.logger.log("Failed to load filters: " + e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.filters = new FilterList(DEFAULT_FILTERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.save = function (data) {
|
||||||
|
data.filters = this.filters.pack();
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.packInfo = function (data, isAdmin) {
|
||||||
|
if (isAdmin) {
|
||||||
|
data.chatFilterCount = this.filters.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.on("addFilter", this.handleAddFilter.bind(this, user));
|
||||||
|
user.socket.on("updateFilter", this.handleUpdateFilter.bind(this, user));
|
||||||
|
user.socket.on("importFilters", this.handleImportFilters.bind(this, user));
|
||||||
|
user.socket.on("moveFilter", this.handleMoveFilter.bind(this, user));
|
||||||
|
user.socket.on("removeFilter", this.handleRemoveFilter.bind(this, user));
|
||||||
|
user.socket.on("requestChatFilters", this.handleRequestChatFilters.bind(this, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.sendChatFilters = function (users) {
|
||||||
|
var f = this.filters.pack();
|
||||||
|
var chan = this.channel;
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (chan.modules.permissions.canEditFilters(u)) {
|
||||||
|
u.socket.emit("chatFilters", f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleAddFilter = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditFilters(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FilterList.checkValidRegex(data.source);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Invalid regex: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = validateFilter(data);
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.filters.addFilter(data);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Filter add failed: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
user.socket.emit("addFilterSuccess");
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
chan.users.forEach(function (u) {
|
||||||
|
if (chan.modules.permissions.canEditFilters(u)) {
|
||||||
|
u.socket.emit("updateChatFilter", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.logger.log("[mod] " + user.getName() + " added filter: " + data.name + " -> " +
|
||||||
|
"s/" + data.source + "/" + data.replace + "/" + data.flags +
|
||||||
|
" active: " + data.active + ", filterlinks: " + data.filterlinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleUpdateFilter = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditFilters(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FilterList.checkValidRegex(data.source);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Invalid regex: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = validateFilter(data);
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.filters.updateFilter(data);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Filter update failed: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
chan.users.forEach(function (u) {
|
||||||
|
if (chan.modules.permissions.canEditFilters(u)) {
|
||||||
|
u.socket.emit("updateChatFilter", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.logger.log("[mod] " + user.getName() + " updated filter: " + data.name + " -> " +
|
||||||
|
"s/" + data.source + "/" + data.replace + "/" + data.flags +
|
||||||
|
" active: " + data.active + ", filterlinks: " + data.filterlinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleImportFilters = function (user, data) {
|
||||||
|
if (!(data instanceof Array)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Note: importing requires a different permission node than simply
|
||||||
|
updating/removing */
|
||||||
|
if (!this.channel.modules.permissions.canImportFilters(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.filters = new FilterList(data.map(validateFilter).filter(function (f) {
|
||||||
|
return f !== null;
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Filter import failed: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " imported the filter list");
|
||||||
|
this.sendChatFilters(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleRemoveFilter = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditFilters(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.name !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.filters.removeFilter(data);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Filter removal failed: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
chan.users.forEach(function (u) {
|
||||||
|
if (chan.modules.permissions.canEditFilters(u)) {
|
||||||
|
u.socket.emit("deleteChatFilter", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " removed filter: " + data.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleMoveFilter = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canEditFilters(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.to !== "number" || typeof data.from !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.filters.moveFilter(data.from, data.to);
|
||||||
|
} catch (e) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "Filter move failed: " + e.message,
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
|
||||||
|
this.sendChatFilters([user]);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ChatFilterModule;
|
||||||
452
src/channel/kickban.js
Normal file
452
src/channel/kickban.js
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var db = require("../database");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
var util = require("../utilities");
|
||||||
|
var Account = require("../account");
|
||||||
|
import Promise from 'bluebird';
|
||||||
|
|
||||||
|
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
|
||||||
|
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
|
||||||
|
const dbAddBan = Promise.promisify(db.channels.ban);
|
||||||
|
const dbGetIPs = Promise.promisify(db.getIPs);
|
||||||
|
|
||||||
|
const TYPE_UNBAN = {
|
||||||
|
id: "number",
|
||||||
|
name: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
function KickBanModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
|
||||||
|
if (this.channel.modules.chat) {
|
||||||
|
this.channel.modules.chat.registerCommand("/kick", this.handleCmdKick.bind(this));
|
||||||
|
this.channel.modules.chat.registerCommand("/kickanons", this.handleCmdKickAnons.bind(this));
|
||||||
|
this.channel.modules.chat.registerCommand("/ban", this.handleCmdBan.bind(this));
|
||||||
|
this.channel.modules.chat.registerCommand("/ipban", this.handleCmdIPBan.bind(this));
|
||||||
|
this.channel.modules.chat.registerCommand("/banip", this.handleCmdIPBan.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KickBanModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
function checkIPBan(cname, ip, cb) {
|
||||||
|
db.channels.isIPBanned(cname, ip, function (err, banned) {
|
||||||
|
if (err) {
|
||||||
|
cb(false);
|
||||||
|
} else {
|
||||||
|
cb(banned);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBan(cname, ip, name, cb) {
|
||||||
|
db.channels.isBanned(cname, ip, name, function (err, banned) {
|
||||||
|
if (err) {
|
||||||
|
cb(false);
|
||||||
|
} else {
|
||||||
|
cb(banned);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
KickBanModule.prototype.onUserPreJoin = function (user, data, cb) {
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
return cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cname = this.channel.name;
|
||||||
|
function callback(banned) {
|
||||||
|
if (banned) {
|
||||||
|
cb(null, ChannelModule.DENY);
|
||||||
|
user.kick("You are banned from this channel.");
|
||||||
|
} else {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getName() !== '') {
|
||||||
|
checkBan(cname, user.realip, user.getName(), callback);
|
||||||
|
} else {
|
||||||
|
checkIPBan(cname, user.realip, callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chan = this.channel;
|
||||||
|
const refCaller = "KickBanModule::onUserPostJoin";
|
||||||
|
user.waitFlag(Flags.U_LOGGED_IN, function () {
|
||||||
|
chan.refCounter.ref(refCaller);
|
||||||
|
db.channels.isNameBanned(chan.name, user.getName(), function (err, banned) {
|
||||||
|
if (!err && banned) {
|
||||||
|
user.kick("You are banned from this channel.");
|
||||||
|
if (chan.modules.chat) {
|
||||||
|
chan.modules.chat.sendModMessage(user.getName() + " was kicked (" +
|
||||||
|
"name is banned)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chan.refCounter.unref(refCaller);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
user.socket.on("requestBanlist", function () { self.sendBanlist([user]); });
|
||||||
|
user.socket.typecheckedOn("unban", TYPE_UNBAN, this.handleUnban.bind(this, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.sendBanlist = function (users) {
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var perms = this.channel.modules.permissions;
|
||||||
|
|
||||||
|
var bans = [];
|
||||||
|
var unmaskedbans = [];
|
||||||
|
db.channels.listBans(this.channel.name, function (err, banlist) {
|
||||||
|
if (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < banlist.length; i++) {
|
||||||
|
bans.push({
|
||||||
|
id: banlist[i].id,
|
||||||
|
ip: banlist[i].ip === "*" ? "*" : util.cloakIP(banlist[i].ip),
|
||||||
|
name: banlist[i].name,
|
||||||
|
reason: banlist[i].reason,
|
||||||
|
bannedby: banlist[i].bannedby
|
||||||
|
});
|
||||||
|
unmaskedbans.push({
|
||||||
|
id: banlist[i].id,
|
||||||
|
ip: banlist[i].ip,
|
||||||
|
name: banlist[i].name,
|
||||||
|
reason: banlist[i].reason,
|
||||||
|
bannedby: banlist[i].bannedby
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (!perms.canBan(u)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (u.account.effectiveRank >= 255) {
|
||||||
|
u.socket.emit("banlist", unmaskedbans);
|
||||||
|
} else {
|
||||||
|
u.socket.emit("banlist", bans);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.sendUnban = function (users, data) {
|
||||||
|
var perms = this.channel.modules.permissions;
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (perms.canBan(u)) {
|
||||||
|
u.socket.emit("banlistRemove", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.handleCmdKick = function (user, msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canKick(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /kick */
|
||||||
|
if (args.length === 0 || args[0].trim() === "") {
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: "No kick target specified. If you're trying to kick " +
|
||||||
|
"anonymous users, use /kickanons"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var name = args.shift().toLowerCase();
|
||||||
|
var reason = args.join(" ");
|
||||||
|
var target = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === name) {
|
||||||
|
target = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.account.effectiveRank >= user.account.effectiveRank
|
||||||
|
|| target.account.globalRank > user.account.globalRank) {
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: "You do not have permission to kick " + target.getName()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
target.kick(reason);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " kicked " + target.getName() +
|
||||||
|
" (" + reason + ")");
|
||||||
|
if (this.channel.modules.chat) {
|
||||||
|
this.channel.modules.chat.sendModMessage(user.getName() + " kicked " +
|
||||||
|
target.getName());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.handleCmdKickAnons = function (user, _msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canKick(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = Array.prototype.slice.call(this.channel.users);
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (!u.is(Flags.U_LOGGED_IN)) {
|
||||||
|
u.kick("anonymous user");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " kicked anonymous users.");
|
||||||
|
if (this.channel.modules.chat) {
|
||||||
|
this.channel.modules.chat.sendModMessage(user.getName() + " kicked anonymous " +
|
||||||
|
"users");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* /ban - name bans */
|
||||||
|
KickBanModule.prototype.handleCmdBan = function (user, msg, _meta) {
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /ban */
|
||||||
|
if (args.length === 0 || args[0].trim() === "") {
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: "No ban target specified."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var name = args.shift().toLowerCase();
|
||||||
|
var reason = args.join(" ");
|
||||||
|
|
||||||
|
const chan = this.channel;
|
||||||
|
chan.refCounter.ref("KickBanModule::handleCmdBan");
|
||||||
|
|
||||||
|
this.banName(user, name, reason).catch(error => {
|
||||||
|
const message = error.message || error;
|
||||||
|
user.socket.emit("errorMsg", { msg: message });
|
||||||
|
}).then(() => {
|
||||||
|
chan.refCounter.unref("KickBanModule::handleCmdBan");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* /ipban - bans name and IP addresses associated with it */
|
||||||
|
KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /ipban */
|
||||||
|
if (args.length === 0 || args[0].trim() === "") {
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: "No ban target specified."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var name = args.shift().toLowerCase();
|
||||||
|
var range = false;
|
||||||
|
if (args[0] === "range") {
|
||||||
|
range = "range";
|
||||||
|
args.shift();
|
||||||
|
} else if (args[0] === "wrange") {
|
||||||
|
range = "wrange";
|
||||||
|
args.shift();
|
||||||
|
}
|
||||||
|
var reason = args.join(" ");
|
||||||
|
|
||||||
|
const chan = this.channel;
|
||||||
|
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
|
||||||
|
|
||||||
|
this.banAll(user, name, range, reason).catch(error => {
|
||||||
|
//console.log('!!!', error.stack);
|
||||||
|
const message = error.message || error;
|
||||||
|
user.socket.emit("errorMsg", { msg: message });
|
||||||
|
}).then(() => {
|
||||||
|
chan.refCounter.unref("KickBanModule::handleCmdIPBan");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
|
||||||
|
if (!this.channel || this.channel.dead) {
|
||||||
|
throw new Error("Channel not live");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.banName = async function banName(actor, name, reason) {
|
||||||
|
reason = reason.substring(0, 255);
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
|
||||||
|
if (!chan.modules.permissions.canBan(actor)) {
|
||||||
|
throw new Error("You do not have ban permissions on this channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
name = name.toLowerCase();
|
||||||
|
if (name === actor.getLowerName()) {
|
||||||
|
actor.socket.emit("costanza", {
|
||||||
|
msg: "You can't ban yourself"
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error("You cannot ban yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = await Account.rankForName(name, chan.name);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
if (rank >= actor.account.effectiveRank) {
|
||||||
|
throw new Error("You don't have permission to ban " + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBanned = await dbIsNameBanned(chan.name, name);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
if (isBanned) {
|
||||||
|
throw new Error(name + " is already banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbAddBan(chan.name, "*", name, reason, actor.getName());
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
chan.logger.log("[mod] " + actor.getName() + " namebanned " + name);
|
||||||
|
|
||||||
|
if (chan.modules.chat) {
|
||||||
|
chan.modules.chat.sendModMessage(
|
||||||
|
actor.getName() + " namebanned " + name,
|
||||||
|
chan.modules.permissions.permissions.ban
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.kickBanTarget(name, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.banIP = async function banIP(actor, ip, name, reason) {
|
||||||
|
reason = reason.substring(0, 255);
|
||||||
|
var masked = util.cloakIP(ip);
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
|
||||||
|
if (!chan.modules.permissions.canBan(actor)) {
|
||||||
|
throw new Error("You do not have ban permissions on this channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = await Account.rankForIP(ip, chan.name);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
if (rank >= actor.account.effectiveRank) {
|
||||||
|
// TODO: this message should be made friendlier
|
||||||
|
throw new Error("You don't have permission to ban IP " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBanned = await dbIsIPBanned(chan.name, ip);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
if (isBanned) {
|
||||||
|
// TODO: this message should be made friendlier
|
||||||
|
throw new Error(masked + " is already banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbAddBan(chan.name, ip, name, reason, actor.getName());
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
var cloaked = util.cloakIP(ip);
|
||||||
|
chan.logger.log(
|
||||||
|
"[mod] " + actor.getName() + " banned " + cloaked +
|
||||||
|
" (" + name + ")"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chan.modules.chat) {
|
||||||
|
chan.modules.chat.sendModMessage(
|
||||||
|
actor.getName() + " banned " + cloaked + " (" + name + ")",
|
||||||
|
chan.modules.permissions.permissions.ban
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.kickBanTarget(name, ip);
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.banAll = async function banAll(
|
||||||
|
actor,
|
||||||
|
name,
|
||||||
|
range,
|
||||||
|
reason
|
||||||
|
) {
|
||||||
|
reason = reason.substring(0, 255);
|
||||||
|
|
||||||
|
var chan = this.channel;
|
||||||
|
|
||||||
|
if (!chan.modules.permissions.canBan(actor)) {
|
||||||
|
throw new Error("You do not have ban permissions on this channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ips = await dbGetIPs(name);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
|
||||||
|
const toBan = new Set();
|
||||||
|
for (let ip of ips) {
|
||||||
|
switch (range) {
|
||||||
|
case "range":
|
||||||
|
toBan.add(util.getIPRange(ip));
|
||||||
|
break;
|
||||||
|
case "wrange":
|
||||||
|
toBan.add(util.getWideIPRange(ip));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toBan.add(ip);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = Array.from(toBan).map(ip =>
|
||||||
|
this.banIP(actor, ip, name, reason)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!await dbIsNameBanned(chan.name, name)) {
|
||||||
|
promises.push(this.banName(actor, name, reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
this.checkChannelAlive();
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.kickBanTarget = function (name, ip) {
|
||||||
|
name = name.toLowerCase();
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === name ||
|
||||||
|
this.channel.users[i].realip === ip) {
|
||||||
|
this.channel.users[i].kick("You're banned!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
KickBanModule.prototype.handleUnban = function (user, data) {
|
||||||
|
if (!this.channel.modules.permissions.canBan(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.channel.refCounter.ref("KickBanModule::handleUnban");
|
||||||
|
db.channels.unbanId(this.channel.name, data.id, function (err) {
|
||||||
|
if (err) {
|
||||||
|
self.channel.refCounter.unref("KickBanModule::handleUnban");
|
||||||
|
return user.socket.emit("errorMsg", {
|
||||||
|
msg: err
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sendUnban(self.channel.users, data);
|
||||||
|
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
|
||||||
|
if (self.channel.modules.chat) {
|
||||||
|
var banperm = self.channel.modules.permissions.permissions.ban;
|
||||||
|
self.channel.modules.chat.sendModMessage(
|
||||||
|
user.getName() + " unbanned " + data.name,
|
||||||
|
banperm
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.channel.refCounter.unref("KickBanModule::handleUnban");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = KickBanModule;
|
||||||
131
src/channel/library.js
Normal file
131
src/channel/library.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
var util = require("../utilities");
|
||||||
|
var InfoGetter = require("../get-info");
|
||||||
|
var db = require("../database");
|
||||||
|
import { Counter, Summary } from 'prom-client';
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('channel/library');
|
||||||
|
|
||||||
|
const TYPE_UNCACHE = {
|
||||||
|
id: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_SEARCH_MEDIA = {
|
||||||
|
source: "string,optional",
|
||||||
|
query: "string"
|
||||||
|
};
|
||||||
|
|
||||||
|
function LibraryModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
LibraryModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
LibraryModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.typecheckedOn("uncache", TYPE_UNCACHE, this.handleUncache.bind(this, user));
|
||||||
|
user.socket.typecheckedOn("searchMedia", TYPE_SEARCH_MEDIA, this.handleSearchMedia.bind(this, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
LibraryModule.prototype.cacheMedia = function (media) {
|
||||||
|
if (this.channel.is(Flags.C_REGISTERED) && !util.isLive(media.type)) {
|
||||||
|
db.channels.addToLibrary(this.channel.name, media);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
LibraryModule.prototype.cacheMediaList = function (list) {
|
||||||
|
if (this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
LOGGER.info(
|
||||||
|
'Saving %d items to library for %s',
|
||||||
|
list.length,
|
||||||
|
this.channel.name
|
||||||
|
);
|
||||||
|
db.channels.addListToLibrary(this.channel.name, list).catch(error => {
|
||||||
|
LOGGER.error('Failed to add list to library: %s', error.stack);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
LibraryModule.prototype.handleUncache = function (user, data) {
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canUncache(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chan = this.channel;
|
||||||
|
chan.refCounter.ref("LibraryModule::handleUncache");
|
||||||
|
db.channels.deleteFromLibrary(chan.name, data.id, function (err, _res) {
|
||||||
|
if (chan.dead) {
|
||||||
|
return;
|
||||||
|
} else if (err) {
|
||||||
|
chan.refCounter.unref("LibraryModule::handleUncache");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chan.logger.log("[library] " + user.getName() + " deleted " + data.id +
|
||||||
|
"from the library");
|
||||||
|
chan.refCounter.unref("LibraryModule::handleUncache");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const librarySearchQueryCount = new Counter({
|
||||||
|
name: 'cytube_library_search_query_count',
|
||||||
|
help: 'Counter for number of channel library searches',
|
||||||
|
labelNames: ['source']
|
||||||
|
});
|
||||||
|
const librarySearchResultSize = new Summary({
|
||||||
|
name: 'cytube_library_search_results_size',
|
||||||
|
help: 'Summary for number of channel library results returned',
|
||||||
|
labelNames: ['source']
|
||||||
|
});
|
||||||
|
LibraryModule.prototype.handleSearchMedia = function (user, data) {
|
||||||
|
var query = data.query.substring(0, 100);
|
||||||
|
var searchYT = function () {
|
||||||
|
librarySearchQueryCount.labels('yt').inc(1, new Date());
|
||||||
|
InfoGetter.Getters.ytSearch(query, function (e, vids) {
|
||||||
|
if (!e) {
|
||||||
|
librarySearchResultSize.labels('yt')
|
||||||
|
.observe(vids.length, new Date());
|
||||||
|
user.socket.emit("searchResults", {
|
||||||
|
source: "yt",
|
||||||
|
results: vids
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.source === "yt" || !this.channel.is(Flags.C_REGISTERED) ||
|
||||||
|
!this.channel.modules.permissions.canSeePlaylist(user)) {
|
||||||
|
searchYT();
|
||||||
|
} else {
|
||||||
|
librarySearchQueryCount.labels('library').inc(1, new Date());
|
||||||
|
|
||||||
|
db.channels.searchLibrary(this.channel.name, query, function (err, res) {
|
||||||
|
if (err) {
|
||||||
|
res = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
librarySearchResultSize.labels('library')
|
||||||
|
.observe(res.length, new Date());
|
||||||
|
|
||||||
|
res.sort(function (a, b) {
|
||||||
|
var x = a.title.toLowerCase();
|
||||||
|
var y = b.title.toLowerCase();
|
||||||
|
return (x === y) ? 0 : (x < y ? -1 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.forEach(function (r) {
|
||||||
|
r.duration = util.formatTime(r.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
user.socket.emit("searchResults", {
|
||||||
|
source: "library",
|
||||||
|
results: res
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = LibraryModule;
|
||||||
72
src/channel/mediarefresher.js
Normal file
72
src/channel/mediarefresher.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
var Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Config = require("../config");
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('mediarefresher');
|
||||||
|
|
||||||
|
function MediaRefresherModule(channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this._interval = false;
|
||||||
|
this._media = null;
|
||||||
|
this._playlist = channel.modules.playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaRefresherModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
MediaRefresherModule.prototype.onPreMediaChange = function (data, cb) {
|
||||||
|
if (this._interval) clearInterval(this._interval);
|
||||||
|
|
||||||
|
this._media = data;
|
||||||
|
var pl = this._playlist;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case "vi":
|
||||||
|
pl._refreshing = true;
|
||||||
|
return this.initVimeo(data, function () {
|
||||||
|
pl._refreshing = false;
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaRefresherModule.prototype.unload = function () {
|
||||||
|
try {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
this._interval = null;
|
||||||
|
} catch (error) {
|
||||||
|
LOGGER.error(error.stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaRefresherModule.prototype.initVimeo = function (data, cb) {
|
||||||
|
if (!Config.get("vimeo-workaround")) {
|
||||||
|
if (cb) cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
self.channel.refCounter.ref("MediaRefresherModule::initVimeo");
|
||||||
|
Vimeo.extract(data.id).then(function (direct) {
|
||||||
|
if (self.dead || self.channel.dead) {
|
||||||
|
self.unload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self._media === data) {
|
||||||
|
data.meta.direct = direct;
|
||||||
|
self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " +
|
||||||
|
data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cb) cb();
|
||||||
|
}).catch(function (err) {
|
||||||
|
LOGGER.error("Unexpected vimeo::extract() fail: " + err.stack);
|
||||||
|
if (cb) cb();
|
||||||
|
}).finally(() => {
|
||||||
|
self.channel.refCounter.unref("MediaRefresherModule::initVimeo");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = MediaRefresherModule;
|
||||||
83
src/channel/module.js
Normal file
83
src/channel/module.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
function ChannelModule(channel) {
|
||||||
|
this.channel = channel;
|
||||||
|
this.dirty = false;
|
||||||
|
this.supportsDirtyCheck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelModule.prototype = {
|
||||||
|
/**
|
||||||
|
* Called when the channel is loading its data from a JSON object.
|
||||||
|
*/
|
||||||
|
load: function (_data) {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the channel is saving its state to a JSON object.
|
||||||
|
*/
|
||||||
|
save: function (_data) {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the channel is being unloaded
|
||||||
|
*/
|
||||||
|
unload: function () {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to pack info, e.g. for channel detail view
|
||||||
|
*/
|
||||||
|
packInfo: function (_data, _isAdmin) {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a user is attempting to join a channel.
|
||||||
|
*
|
||||||
|
* data is the data sent by the client with the joinChannel
|
||||||
|
* packet.
|
||||||
|
*/
|
||||||
|
onUserPreJoin: function (_user, _data, cb) {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a user has been accepted to the channel.
|
||||||
|
*/
|
||||||
|
onUserPostJoin: function (_user) {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a user has been disconnected from the channel.
|
||||||
|
*/
|
||||||
|
onUserPart: function (_user) {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a chatMsg event is received
|
||||||
|
*/
|
||||||
|
onUserPreChat: function (_user, _data, cb) {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before a new video begins playing
|
||||||
|
*/
|
||||||
|
onPreMediaChange: function (_data, cb) {
|
||||||
|
cb(null, ChannelModule.PASSTHROUGH);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a new video begins playing
|
||||||
|
*/
|
||||||
|
onMediaChange: function (_data) {
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Channel module callback return codes */
|
||||||
|
ChannelModule.ERROR = -1;
|
||||||
|
ChannelModule.PASSTHROUGH = 0;
|
||||||
|
ChannelModule.DENY = 1;
|
||||||
|
|
||||||
|
module.exports = ChannelModule;
|
||||||
397
src/channel/opts.js
Normal file
397
src/channel/opts.js
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Config = require("../config");
|
||||||
|
var Utilities = require("../utilities");
|
||||||
|
var url = require("url");
|
||||||
|
|
||||||
|
function realTypeOf(thing) {
|
||||||
|
return thing === null ? 'null' : typeof thing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionsModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.opts = {
|
||||||
|
allow_voteskip: true, // Allow users to voteskip
|
||||||
|
voteskip_ratio: 0.5, // Ratio of skip votes:non-afk users needed to skip the video
|
||||||
|
afk_timeout: 600, // Number of seconds before a user is automatically marked afk
|
||||||
|
pagetitle: this.channel.name, // Title of the browser tab
|
||||||
|
maxlength: 0, // Maximum length (in seconds) of a video queued
|
||||||
|
externalcss: "", // Link to external stylesheet
|
||||||
|
externaljs: "", // Link to external script
|
||||||
|
chat_antiflood: false, // Throttle chat messages
|
||||||
|
chat_antiflood_params: {
|
||||||
|
burst: 4, // Number of messages to allow with no throttling
|
||||||
|
sustained: 1, // Throttle rate (messages/second)
|
||||||
|
cooldown: 4 // Number of seconds with no messages before burst is reset
|
||||||
|
},
|
||||||
|
show_public: false, // List the channel on the index page
|
||||||
|
enable_link_regex: true, // Use the built-in link filter
|
||||||
|
password: false, // Channel password (false -> no password required for entry)
|
||||||
|
allow_dupes: false, // Allow duplicate videos on the playlist
|
||||||
|
torbanned: false, // Block connections from Tor exit nodes
|
||||||
|
block_anonymous_users: false, //Only allow connections from registered users.
|
||||||
|
allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f)
|
||||||
|
playlist_max_per_user: 0, // Maximum number of playlist items per user
|
||||||
|
new_user_chat_delay: 0, // Minimum account/IP age to chat
|
||||||
|
new_user_chat_link_delay: 0, // Minimum account/IP age to post links
|
||||||
|
playlist_max_duration_per_user: 0 // Maximum total playlist time per user
|
||||||
|
};
|
||||||
|
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
OptionsModule.prototype.load = function (data) {
|
||||||
|
if ("opts" in data) {
|
||||||
|
for (var key in this.opts) {
|
||||||
|
if (key in data.opts) {
|
||||||
|
this.opts[key] = data.opts[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.opts.pagetitle = unzalgo(this.opts.pagetitle);
|
||||||
|
this.opts.chat_antiflood_params.burst = Math.min(
|
||||||
|
20,
|
||||||
|
this.opts.chat_antiflood_params.burst
|
||||||
|
);
|
||||||
|
this.opts.chat_antiflood_params.sustained = Math.min(
|
||||||
|
10,
|
||||||
|
this.opts.chat_antiflood_params.sustained
|
||||||
|
);
|
||||||
|
this.opts.afk_timeout = Math.min(86400 /* one day */, this.opts.afk_timeout);
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.save = function (data) {
|
||||||
|
data.opts = this.opts;
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.packInfo = function (data, isAdmin) {
|
||||||
|
data.pagetitle = this.opts.pagetitle;
|
||||||
|
data.public = this.opts.show_public;
|
||||||
|
if (isAdmin) {
|
||||||
|
data.hasPassword = this.opts.password !== false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.get = function (key) {
|
||||||
|
return this.opts[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.set = function (key, value) {
|
||||||
|
this.opts[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.on("setOptions", this.handleSetOptions.bind(this, user));
|
||||||
|
|
||||||
|
this.sendOpts([user]);
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.sendOpts = function (users) {
|
||||||
|
var opts = this.opts;
|
||||||
|
|
||||||
|
if (users === this.channel.users) {
|
||||||
|
this.channel.broadcastAll("channelOpts", opts);
|
||||||
|
} else {
|
||||||
|
users.forEach(function (user) {
|
||||||
|
user.socket.emit("channelOpts", opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.getPermissions = function () {
|
||||||
|
return this.channel.modules.permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
OptionsModule.prototype.handleSetOptions = function (user, data) {
|
||||||
|
if (typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.getPermissions().canSetOptions(user)) {
|
||||||
|
user.kick("Attempted setOptions as a non-moderator");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendUpdate = false;
|
||||||
|
|
||||||
|
if ("allow_voteskip" in data) {
|
||||||
|
this.opts.allow_voteskip = Boolean(data.allow_voteskip);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("voteskip_ratio" in data) {
|
||||||
|
var ratio = parseFloat(data.voteskip_ratio);
|
||||||
|
if (isNaN(ratio) || ratio < 0) {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-voteskip_ratio",
|
||||||
|
message: `Input must be a number 0 or greater, not "${data.voteskip_ratio}"`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.opts.voteskip_ratio = ratio;
|
||||||
|
sendUpdate = true;
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-voteskip_ratio"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("afk_timeout" in data) {
|
||||||
|
var tm = parseInt(data.afk_timeout);
|
||||||
|
if (isNaN(tm) || tm < 0 || tm > 86400 /* one day */) {
|
||||||
|
tm = 0;
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-afk_timeout",
|
||||||
|
message: "AFK timeout must be between 1 and 86400 seconds (or 0 to disable)"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-afk_timeout",
|
||||||
|
});
|
||||||
|
|
||||||
|
var same = tm === this.opts.afk_timeout;
|
||||||
|
this.opts.afk_timeout = tm;
|
||||||
|
if (!same) {
|
||||||
|
this.channel.users.forEach(function (u) {
|
||||||
|
u.autoAFK();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("pagetitle" in data && user.account.effectiveRank >= 3) {
|
||||||
|
var title = unzalgo((""+data.pagetitle).substring(0, 100));
|
||||||
|
if (!title.trim().match(Config.get("reserved-names.pagetitles"))) {
|
||||||
|
this.opts.pagetitle = title;
|
||||||
|
sendUpdate = true;
|
||||||
|
} else {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: "That pagetitle is reserved",
|
||||||
|
alert: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("maxlength" in data) {
|
||||||
|
var ml = 0;
|
||||||
|
if (typeof data.maxlength !== "number") {
|
||||||
|
ml = Utilities.parseTime(data.maxlength);
|
||||||
|
} else {
|
||||||
|
ml = parseInt(data.maxlength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(ml) || ml < 0) {
|
||||||
|
ml = 0;
|
||||||
|
}
|
||||||
|
this.opts.maxlength = ml;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("playlist_max_duration_per_user" in data) {
|
||||||
|
const max = data.playlist_max_duration_per_user;
|
||||||
|
if (typeof max !== "number" || isNaN(max) || max < 0) {
|
||||||
|
user.socket.emit("errorMsg", {
|
||||||
|
msg: `Expected number for playlist_max_duration_per_user, not "${max}"`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.opts.playlist_max_duration_per_user = max;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("externalcss" in data && user.account.effectiveRank >= 3) {
|
||||||
|
var prefix = "Invalid URL for external CSS: ";
|
||||||
|
if (typeof data.externalcss !== "string") {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externalcss",
|
||||||
|
message: prefix + "URL must be a string, not "
|
||||||
|
+ realTypeOf(data.externalcss)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = data.externalcss.substring(0, 255).trim();
|
||||||
|
if (!link) {
|
||||||
|
sendUpdate = (this.opts.externalcss !== "");
|
||||||
|
this.opts.externalcss = "";
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-externalcss"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var urldata = url.parse(link);
|
||||||
|
if (!urldata.protocol || urldata.protocol !== 'https:') {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externalcss",
|
||||||
|
message: prefix + " URL must begin with 'https://'"
|
||||||
|
});
|
||||||
|
} else if (!urldata.host) {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externalcss",
|
||||||
|
message: prefix + "missing hostname"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-externalcss"
|
||||||
|
});
|
||||||
|
this.opts.externalcss = urldata.href;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("externaljs" in data && user.account.effectiveRank >= 3) {
|
||||||
|
const prefix = "Invalid URL for external JS: ";
|
||||||
|
if (typeof data.externaljs !== "string") {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externaljs",
|
||||||
|
message: prefix + "URL must be a string, not "
|
||||||
|
+ realTypeOf(data.externaljs)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = data.externaljs.substring(0, 255).trim();
|
||||||
|
if (!link) {
|
||||||
|
sendUpdate = (this.opts.externaljs !== "");
|
||||||
|
this.opts.externaljs = "";
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-externaljs"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const urldata = url.parse(link);
|
||||||
|
if (!urldata.protocol || urldata.protocol !== 'https:') {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externaljs",
|
||||||
|
message: prefix + " URL must begin with 'https://'"
|
||||||
|
});
|
||||||
|
} else if (!urldata.host) {
|
||||||
|
user.socket.emit("validationError", {
|
||||||
|
target: "#cs-externaljs",
|
||||||
|
message: prefix + "missing hostname"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user.socket.emit("validationPassed", {
|
||||||
|
target: "#cs-externaljs"
|
||||||
|
});
|
||||||
|
this.opts.externaljs = urldata.href;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("chat_antiflood" in data) {
|
||||||
|
this.opts.chat_antiflood = Boolean(data.chat_antiflood);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("chat_antiflood_params" in data) {
|
||||||
|
if (typeof data.chat_antiflood_params !== "object") {
|
||||||
|
data.chat_antiflood_params = {
|
||||||
|
burst: 4,
|
||||||
|
sustained: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var b = parseInt(data.chat_antiflood_params.burst);
|
||||||
|
if (isNaN(b) || b < 0) {
|
||||||
|
b = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
b = Math.min(20, b);
|
||||||
|
|
||||||
|
var s = parseFloat(data.chat_antiflood_params.sustained);
|
||||||
|
if (isNaN(s) || s <= 0) {
|
||||||
|
s = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
s = Math.min(10, s);
|
||||||
|
|
||||||
|
var c = b / s;
|
||||||
|
this.opts.chat_antiflood_params = {
|
||||||
|
burst: b,
|
||||||
|
sustained: s,
|
||||||
|
cooldown: c
|
||||||
|
};
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("show_public" in data && user.account.effectiveRank >= 3) {
|
||||||
|
this.opts.show_public = Boolean(data.show_public);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("enable_link_regex" in data) {
|
||||||
|
this.opts.enable_link_regex = Boolean(data.enable_link_regex);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("password" in data && user.account.effectiveRank >= 3) {
|
||||||
|
var pw = data.password + "";
|
||||||
|
pw = pw === "" ? false : pw.substring(0, 100);
|
||||||
|
this.opts.password = pw;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("allow_dupes" in data) {
|
||||||
|
this.opts.allow_dupes = Boolean(data.allow_dupes);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("torbanned" in data && user.account.effectiveRank >= 3) {
|
||||||
|
this.opts.torbanned = Boolean(data.torbanned);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if("block_anonymous_users" in data && user.account.effectiveRank >=3){
|
||||||
|
this.opts.block_anonymous_users = Boolean(data.block_anonymous_users);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("allow_ascii_control" in data && user.account.effectiveRank >= 3) {
|
||||||
|
this.opts.allow_ascii_control = Boolean(data.allow_ascii_control);
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("playlist_max_per_user" in data && user.account.effectiveRank >= 3) {
|
||||||
|
var max = parseInt(data.playlist_max_per_user);
|
||||||
|
if (!isNaN(max) && max >= 0) {
|
||||||
|
this.opts.playlist_max_per_user = max;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("new_user_chat_delay" in data) {
|
||||||
|
const delay = data.new_user_chat_delay;
|
||||||
|
if (!isNaN(delay) && delay >= 0) {
|
||||||
|
this.opts.new_user_chat_delay = delay;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("new_user_chat_link_delay" in data) {
|
||||||
|
const delay = data.new_user_chat_link_delay;
|
||||||
|
if (!isNaN(delay) && delay >= 0) {
|
||||||
|
this.opts.new_user_chat_link_delay = delay;
|
||||||
|
sendUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
|
||||||
|
if (sendUpdate) {
|
||||||
|
this.dirty = true;
|
||||||
|
this.sendOpts(this.channel.users);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forgive me
|
||||||
|
const combiners = /[\u0300-\u036f\u0483-\u0487\u0591-\u05bd\u05bf-\u05bf\u05c1-\u05c2\u05c4-\u05c5\u05c7-\u05c7\u0610-\u061a\u064b-\u065f\u0670-\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7-\u06e8\u06ea-\u06ed\u0711-\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0859-\u085b\u08e4-\u0902\u093a-\u093a\u093c-\u093c\u0941-\u0948\u094d-\u094d\u0951-\u0957\u0962-\u0963\u0981-\u0981\u09bc-\u09bc\u09c1-\u09c4\u09cd-\u09cd\u09e2-\u09e3\u0a01-\u0a02\u0a3c-\u0a3c\u0a41-\u0a42\u0a47-\u0a48\u0a4b-\u0a4d\u0a51-\u0a51\u0a70-\u0a71\u0a75-\u0a75\u0a81-\u0a82\u0abc-\u0abc\u0ac1-\u0ac5\u0ac7-\u0ac8\u0acd-\u0acd\u0ae2-\u0ae3\u0b01-\u0b01\u0b3c-\u0b3c\u0b3f-\u0b3f\u0b41-\u0b44\u0b4d-\u0b4d\u0b56-\u0b56\u0b62-\u0b63\u0b82-\u0b82\u0bc0-\u0bc0\u0bcd-\u0bcd\u0c00-\u0c00\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55-\u0c56\u0c62-\u0c63\u0c81-\u0c81\u0cbc-\u0cbc\u0cbf-\u0cbf\u0cc6-\u0cc6\u0ccc-\u0ccd\u0ce2-\u0ce3\u0d01-\u0d01\u0d41-\u0d44\u0d4d-\u0d4d\u0d62-\u0d63\u0dca-\u0dca\u0dd2-\u0dd4\u0dd6-\u0dd6\u0e31-\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1-\u0eb1\u0eb4-\u0eb9\u0ebb-\u0ebc\u0ec8-\u0ecd\u0f18-\u0f19\u0f35-\u0f35\u0f37-\u0f37\u0f39-\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86-\u0f87\u0f8d-\u0f97\u0f99-\u0fbc\u0fc6-\u0fc6\u102d-\u1030\u1032-\u1037\u1039-\u103a\u103d-\u103e\u1058-\u1059\u105e-\u1060\u1071-\u1074\u1082-\u1082\u1085-\u1086\u108d-\u108d\u109d-\u109d\u135d-\u135f\u1712-\u1714\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17b4-\u17b5\u17b7-\u17bd\u17c6-\u17c6\u17c9-\u17d3\u17dd-\u17dd\u180b-\u180d\u18a9-\u18a9\u1920-\u1922\u1927-\u1928\u1932-\u1932\u1939-\u193b\u1a17-\u1a18\u1a1b-\u1a1b\u1a56-\u1a56\u1a58-\u1a5e\u1a60-\u1a60\u1a62-\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f-\u1a7f\u1ab0-\u1abd\u1b00-\u1b03\u1b34-\u1b34\u1b36-\u1b3a\u1b3c-\u1b3c\u1b42-\u1b42\u1b6b-\u1b73\u1b80-\u1b81\u1ba2-\u1ba5\u1ba8-\u1ba9\u1bab-\u1bad\u1be6-\u1be6\u1be8-\u1be9\u1bed-\u1bed\u1bef-\u1bf1\u1c2c-\u1c33\u1c36-\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced-\u1ced\u1cf4-\u1cf4\u1cf8-\u1cf9\u1dc0-\u1df5\u1dfc-\u1dff\u20d0-\u20dc\u20e1-\u20e1\u20e5-\u20f0\u2cef-\u2cf1\u2d7f-\u2d7f\u2de0-\u2dff\u302a-\u302d\u3099-\u309a\ua66f-\ua66f\ua674-\ua67d\ua69f-\ua69f\ua6f0-\ua6f1\ua802-\ua802\ua806-\ua806\ua80b-\ua80b\ua825-\ua826\ua8c4-\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3-\ua9b3\ua9b6-\ua9b9\ua9bc-\ua9bc\ua9e5-\ua9e5\uaa29-\uaa2e\uaa31-\uaa32\uaa35-\uaa36\uaa43-\uaa43\uaa4c-\uaa4c\uaa7c-\uaa7c\uaab0-\uaab0\uaab2-\uaab4\uaab7-\uaab8\uaabe-\uaabf\uaac1-\uaac1\uaaec-\uaaed\uaaf6-\uaaf6\uabe5-\uabe5\uabe8-\uabe8\uabed-\uabed\ufb1e-\ufb1e\ufe00-\ufe0f\ufe20-\ufe2d]/g;
|
||||||
|
|
||||||
|
function unzalgo(text) {
|
||||||
|
// TODO: consider only removing stacked combiners so that legitimate
|
||||||
|
// single combining characters can be used.
|
||||||
|
|
||||||
|
return text.replace(combiners, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OptionsModule;
|
||||||
400
src/channel/permissions.js
Normal file
400
src/channel/permissions.js
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var User = require("../user");
|
||||||
|
|
||||||
|
const DEFAULT_PERMISSIONS = {
|
||||||
|
seeplaylist: -1, // See the playlist
|
||||||
|
playlistadd: 1.5, // Add video to the playlist
|
||||||
|
playlistnext: 1.5, // Add a video next on the playlist
|
||||||
|
playlistmove: 1.5, // Move a video on the playlist
|
||||||
|
playlistdelete: 2, // Delete a video from the playlist
|
||||||
|
playlistjump: 1.5, // Start a different video on the playlist
|
||||||
|
playlistaddlist: 1.5, // Add a list of videos to the playlist
|
||||||
|
oplaylistadd: -1, // Same as above, but for open (unlocked) playlist
|
||||||
|
oplaylistnext: 1.5,
|
||||||
|
oplaylistmove: 1.5,
|
||||||
|
oplaylistdelete: 2,
|
||||||
|
oplaylistjump: 1.5,
|
||||||
|
oplaylistaddlist: 1.5,
|
||||||
|
playlistaddcustom: 3, // Add custom embed to the playlist
|
||||||
|
playlistaddrawfile: 2, // Add raw file to the playlist
|
||||||
|
playlistaddlive: 1.5, // Add a livestream to the playlist
|
||||||
|
exceedmaxlength: 2, // Add a video longer than the maximum length set
|
||||||
|
addnontemp: 2, // Add a permanent video to the playlist
|
||||||
|
settemp: 2, // Toggle temporary status of a playlist item
|
||||||
|
playlistshuffle: 2, // Shuffle the playlist
|
||||||
|
playlistclear: 2, // Clear the playlist
|
||||||
|
pollctl: 1.5, // Open/close polls
|
||||||
|
pollvote: -1, // Vote in polls
|
||||||
|
viewhiddenpoll: 1.5, // View results of hidden polls
|
||||||
|
voteskip: -1, // Vote to skip the current video
|
||||||
|
viewvoteskip: 1.5, // View voteskip results
|
||||||
|
mute: 1.5, // Mute other users
|
||||||
|
kick: 1.5, // Kick other users
|
||||||
|
ban: 2, // Ban other users
|
||||||
|
motdedit: 3, // Edit the MOTD
|
||||||
|
filteredit: 3, // Control chat filters
|
||||||
|
filterimport: 3, // Import chat filter list
|
||||||
|
emoteedit: 3, // Control emotes
|
||||||
|
emoteimport: 3, // Import emote list
|
||||||
|
playlistlock: 2, // Lock/unlock the playlist
|
||||||
|
leaderctl: 2, // Give/take leader
|
||||||
|
drink: 1.5, // Use the /d command
|
||||||
|
chat: 0, // Send chat messages
|
||||||
|
chatclear: 2, // Use the /clear command
|
||||||
|
exceedmaxitems: 2, // Exceed maximum items per user limit
|
||||||
|
deletefromchannellib: 2, // Delete channel library items
|
||||||
|
exceedmaxdurationperuser: 2 // Exceed maximum total playlist length per user
|
||||||
|
};
|
||||||
|
|
||||||
|
function PermissionsModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
this.permissions = {};
|
||||||
|
this.openPlaylist = false;
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PermissionsModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
PermissionsModule.prototype.load = function (data) {
|
||||||
|
this.permissions = {};
|
||||||
|
var preset = "permissions" in data ? data.permissions : {};
|
||||||
|
for (var key in DEFAULT_PERMISSIONS) {
|
||||||
|
if (key in preset) {
|
||||||
|
this.permissions[key] = preset[key];
|
||||||
|
} else {
|
||||||
|
this.permissions[key] = DEFAULT_PERMISSIONS[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("openPlaylist" in data) {
|
||||||
|
this.openPlaylist = data.openPlaylist;
|
||||||
|
} else if ("playlistLock" in data) {
|
||||||
|
this.openPlaylist = !data.playlistLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.save = function (data) {
|
||||||
|
data.permissions = this.permissions;
|
||||||
|
data.openPlaylist = this.openPlaylist;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.hasPermission = function (account, node) {
|
||||||
|
if (account instanceof User) {
|
||||||
|
account = account.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.indexOf("playlist") === 0 && this.openPlaylist &&
|
||||||
|
account.effectiveRank >= this.permissions["o"+node]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.effectiveRank >= this.permissions[node];
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.sendPermissions = function (users) {
|
||||||
|
var perms = this.permissions;
|
||||||
|
if (users === this.channel.users) {
|
||||||
|
this.channel.broadcastAll("setPermissions", perms);
|
||||||
|
} else {
|
||||||
|
users.forEach(function (u) {
|
||||||
|
u.socket.emit("setPermissions", perms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.sendPlaylistLock = function (users) {
|
||||||
|
if (users === this.channel.users) {
|
||||||
|
this.channel.broadcastAll("setPlaylistLocked", !this.openPlaylist);
|
||||||
|
} else {
|
||||||
|
var locked = !this.openPlaylist;
|
||||||
|
users.forEach(function (u) {
|
||||||
|
u.socket.emit("setPlaylistLocked", locked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.on("setPermissions", this.handleSetPermissions.bind(this, user));
|
||||||
|
user.socket.on("togglePlaylistLock", this.handleTogglePlaylistLock.bind(this, user));
|
||||||
|
this.sendPermissions([user]);
|
||||||
|
this.sendPlaylistLock([user]);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.handleTogglePlaylistLock = function (user) {
|
||||||
|
if (!this.hasPermission(user, "playlistlock")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
this.openPlaylist = !this.openPlaylist;
|
||||||
|
if (this.openPlaylist) {
|
||||||
|
this.channel.logger.log("[playlist] " + user.getName() + " unlocked the playlist");
|
||||||
|
} else {
|
||||||
|
this.channel.logger.log("[playlist] " + user.getName() + " locked the playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendPlaylistLock(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.handleSetPermissions = function (user, perms) {
|
||||||
|
if (typeof perms !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.canSetPermissions(user)) {
|
||||||
|
user.kick("Attempted setPermissions as a non-admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in perms) {
|
||||||
|
if (typeof perms[key] !== "number") {
|
||||||
|
perms[key] = parseFloat(perms[key]);
|
||||||
|
if (isNaN(perms[key])) {
|
||||||
|
delete perms[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in perms) {
|
||||||
|
if (key in this.permissions) {
|
||||||
|
this.permissions[key] = perms[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("seeplaylist" in perms) {
|
||||||
|
if (this.channel.modules.playlist) {
|
||||||
|
this.channel.modules.playlist.sendPlaylist(this.channel.users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = true;
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " updated permissions");
|
||||||
|
this.sendPermissions(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddVideo = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistadd");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSetTemp = function (account) {
|
||||||
|
return this.hasPermission(account, "settemp");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSeePlaylist = function (account) {
|
||||||
|
return this.hasPermission(account, "seeplaylist");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddList = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistaddlist");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddNonTemp = function (account) {
|
||||||
|
return this.hasPermission(account, "addnontemp");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddNext = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistnext");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddLive = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistaddlive");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddCustom = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistaddcustom");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddRawFile = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistaddrawfile");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canMoveVideo = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistmove");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canDeleteVideo = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistdelete");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSkipVideo = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistjump");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canToggleTemporary = function (account) {
|
||||||
|
return this.hasPermission(account, "settemp");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canExceedMaxLength = function (account) {
|
||||||
|
return this.hasPermission(account, "exceedmaxlength");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canExceedMaxDurationPerUser = function (account) {
|
||||||
|
return this.hasPermission(account, "exceedmaxdurationperuser");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canShufflePlaylist = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistshuffle");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canClearPlaylist = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistclear");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canLockPlaylist = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistlock");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAssignLeader = function (account) {
|
||||||
|
return this.hasPermission(account, "leaderctl");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canControlPoll = function (account) {
|
||||||
|
return this.hasPermission(account, "pollctl");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canVote = function (account) {
|
||||||
|
return this.hasPermission(account, "pollvote");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canViewHiddenPoll = function (account) {
|
||||||
|
return this.hasPermission(account, "viewhiddenpoll");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canVoteskip = function (account) {
|
||||||
|
return this.hasPermission(account, "voteskip");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSeeVoteskipResults = function (actor) {
|
||||||
|
return this.hasPermission(actor, "viewvoteskip");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canMute = function (actor) {
|
||||||
|
return this.hasPermission(actor, "mute");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canKick = function (actor) {
|
||||||
|
return this.hasPermission(actor, "kick");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canBan = function (actor) {
|
||||||
|
return this.hasPermission(actor, "ban");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canEditMotd = function (actor) {
|
||||||
|
return this.hasPermission(actor, "motdedit");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canEditFilters = function (actor) {
|
||||||
|
return this.hasPermission(actor, "filteredit");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canImportFilters = function (actor) {
|
||||||
|
return this.hasPermission(actor, "filterimport");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canEditEmotes = function (actor) {
|
||||||
|
return this.hasPermission(actor, "emoteedit");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canImportEmotes = function (actor) {
|
||||||
|
return this.hasPermission(actor, "emoteimport");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canCallDrink = function (actor) {
|
||||||
|
return this.hasPermission(actor, "drink");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canChat = function (actor) {
|
||||||
|
return this.hasPermission(actor, "chat");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canClearChat = function (actor) {
|
||||||
|
return this.hasPermission(actor, "chatclear");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSetOptions = function (actor) {
|
||||||
|
if (actor instanceof User) {
|
||||||
|
actor = actor.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.effectiveRank >= 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSetCSS = function (actor) {
|
||||||
|
if (actor instanceof User) {
|
||||||
|
actor = actor.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.effectiveRank >= 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSetJS = function (actor) {
|
||||||
|
if (actor instanceof User) {
|
||||||
|
actor = actor.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.effectiveRank >= 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canSetPermissions = function (actor) {
|
||||||
|
if (actor instanceof User) {
|
||||||
|
actor = actor.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.effectiveRank >= 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canUncache = function (actor) {
|
||||||
|
return this.hasPermission(actor, "deletefromchannellib");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canExceedMaxItemsPerUser = function (actor) {
|
||||||
|
return this.hasPermission(actor, "exceedmaxitems");
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.loadUnregistered = function () {
|
||||||
|
var perms = {
|
||||||
|
seeplaylist: -1,
|
||||||
|
playlistadd: -1, // Add video to the playlist
|
||||||
|
playlistnext: 0,
|
||||||
|
playlistmove: 0, // Move a video on the playlist
|
||||||
|
playlistdelete: 0, // Delete a video from the playlist
|
||||||
|
playlistjump: 0, // Start a different video on the playlist
|
||||||
|
playlistaddlist: 0, // Add a list of videos to the playlist
|
||||||
|
oplaylistadd: -1, // Same as above, but for open (unlocked) playlist
|
||||||
|
oplaylistnext: 0,
|
||||||
|
oplaylistmove: 0,
|
||||||
|
oplaylistdelete: 0,
|
||||||
|
oplaylistjump: 0,
|
||||||
|
oplaylistaddlist: 0,
|
||||||
|
playlistaddcustom: 0, // Add custom embed to the playlist
|
||||||
|
playlistaddlive: 0, // Add a livestream to the playlist
|
||||||
|
exceedmaxlength: 0, // Add a video longer than the maximum length set
|
||||||
|
addnontemp: 0, // Add a permanent video to the playlist
|
||||||
|
settemp: 0, // Toggle temporary status of a playlist item
|
||||||
|
playlistshuffle: 0, // Shuffle the playlist
|
||||||
|
playlistclear: 0, // Clear the playlist
|
||||||
|
pollctl: 0, // Open/close polls
|
||||||
|
pollvote: -1, // Vote in polls
|
||||||
|
viewhiddenpoll: 1.5, // View results of hidden polls
|
||||||
|
voteskip: -1, // Vote to skip the current video
|
||||||
|
viewvoteskip: 1.5, // View voteskip results
|
||||||
|
playlistlock: 2, // Lock/unlock the playlist
|
||||||
|
leaderctl: 0, // Give/take leader
|
||||||
|
drink: 0, // Use the /d command
|
||||||
|
chat: 0, // Send chat messages
|
||||||
|
chatclear: 2, // Use the /clear command
|
||||||
|
exceedmaxitems: 2, // Exceed max items per user
|
||||||
|
deletefromchannellib: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var key in perms) {
|
||||||
|
this.permissions[key] = perms[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openPlaylist = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PermissionsModule;
|
||||||
1425
src/channel/playlist.js
Normal file
1425
src/channel/playlist.js
Normal file
File diff suppressed because it is too large
Load diff
278
src/channel/poll.js
Normal file
278
src/channel/poll.js
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Poll = require("../poll").Poll;
|
||||||
|
import { ValidationError } from '../errors';
|
||||||
|
import Config from '../config';
|
||||||
|
import { ackOrErrorMsg } from '../util/ack';
|
||||||
|
|
||||||
|
const TYPE_NEW_POLL = {
|
||||||
|
title: "string",
|
||||||
|
timeout: "number,optional",
|
||||||
|
obscured: "boolean",
|
||||||
|
retainVotes: "boolean,optional",
|
||||||
|
opts: "array"
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_VOTE = {
|
||||||
|
option: "number"
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOM_VIEW_HIDDEN = ":viewHidden";
|
||||||
|
const ROOM_NO_VIEW_HIDDEN = ":noViewHidden";
|
||||||
|
|
||||||
|
function PollModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
|
||||||
|
this.poll = null;
|
||||||
|
this.roomViewHidden = this.channel.uniqueName + ROOM_VIEW_HIDDEN;
|
||||||
|
this.roomNoViewHidden = this.channel.uniqueName + ROOM_NO_VIEW_HIDDEN;
|
||||||
|
if (this.channel.modules.chat) {
|
||||||
|
this.channel.modules.chat.registerCommand("poll", this.handlePollCmd.bind(this, false));
|
||||||
|
this.channel.modules.chat.registerCommand("hpoll", this.handlePollCmd.bind(this, true));
|
||||||
|
}
|
||||||
|
this.supportsDirtyCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PollModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
PollModule.prototype.unload = function () {
|
||||||
|
if (this.poll && this.poll.timer) {
|
||||||
|
clearTimeout(this.poll.timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.load = function (data) {
|
||||||
|
if ("poll" in data) {
|
||||||
|
if (data.poll !== null) {
|
||||||
|
this.poll = Poll.fromChannelData(data.poll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.save = function (data) {
|
||||||
|
if (this.poll === null) {
|
||||||
|
data.poll = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.poll = this.poll.toChannelData();
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
this.sendPoll(user);
|
||||||
|
user.socket.typecheckedOn("newPoll", TYPE_NEW_POLL, this.handleNewPoll.bind(this, user));
|
||||||
|
user.socket.typecheckedOn("vote", TYPE_VOTE, this.handleVote.bind(this, user));
|
||||||
|
user.socket.on("closePoll", this.handleClosePoll.bind(this, user));
|
||||||
|
this.addUserToPollRoom(user);
|
||||||
|
const self = this;
|
||||||
|
user.on("effectiveRankChange", () => {
|
||||||
|
if (self.channel && !self.channel.dead) {
|
||||||
|
self.addUserToPollRoom(user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.addUserToPollRoom = function (user) {
|
||||||
|
const perms = this.channel.modules.permissions;
|
||||||
|
if (perms.canViewHiddenPoll(user)) {
|
||||||
|
user.socket.leave(this.roomNoViewHidden);
|
||||||
|
user.socket.join(this.roomViewHidden);
|
||||||
|
} else {
|
||||||
|
user.socket.leave(this.roomViewHidden);
|
||||||
|
user.socket.join(this.roomNoViewHidden);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.onUserPart = function(user) {
|
||||||
|
if (this.poll && !this.poll.retainVotes && this.poll.uncountVote(user.realip)) {
|
||||||
|
this.broadcastPoll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.sendPoll = function (user) {
|
||||||
|
if (!this.poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var perms = this.channel.modules.permissions;
|
||||||
|
|
||||||
|
if (perms.canViewHiddenPoll(user)) {
|
||||||
|
var unobscured = this.poll.toUpdateFrame(true);
|
||||||
|
user.socket.emit("newPoll", unobscured);
|
||||||
|
} else {
|
||||||
|
var obscured = this.poll.toUpdateFrame(false);
|
||||||
|
user.socket.emit("newPoll", obscured);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.broadcastPoll = function (isNewPoll) {
|
||||||
|
if (!this.poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var obscured = this.poll.toUpdateFrame(false);
|
||||||
|
var unobscured = this.poll.toUpdateFrame(true);
|
||||||
|
|
||||||
|
const event = isNewPoll ? "newPoll" : "updatePoll";
|
||||||
|
|
||||||
|
this.channel.broadcastToRoom(event, unobscured, this.roomViewHidden);
|
||||||
|
this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden);
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.validatePollInput = function validatePollInput(title, options) {
|
||||||
|
if (typeof title !== 'string') {
|
||||||
|
throw new ValidationError('Poll title must be a string.');
|
||||||
|
}
|
||||||
|
if (title.length > 255) {
|
||||||
|
throw new ValidationError('Poll title must be no more than 255 characters long.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(options)) {
|
||||||
|
throw new ValidationError('Poll options must be an array.');
|
||||||
|
}
|
||||||
|
if (options.length > Config.get('poll.max-options')) {
|
||||||
|
throw new ValidationError(`Polls are limited to a maximum of ${Config.get('poll.max-options')} options.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < options.length; i++) {
|
||||||
|
if (typeof options[i] !== 'string') {
|
||||||
|
throw new ValidationError('Poll options must be strings.');
|
||||||
|
}
|
||||||
|
if (options[i].length === 0 || options[i].length > 255) {
|
||||||
|
throw new ValidationError('Poll options must be 1-255 characters long.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.handleNewPoll = function (user, data, ack) {
|
||||||
|
if (!this.channel.modules.permissions.canControlPoll(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure any existing poll is closed
|
||||||
|
this.handleClosePoll(user);
|
||||||
|
|
||||||
|
ack = ackOrErrorMsg(ack, user);
|
||||||
|
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
ack({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid data received for poll creation.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.validatePollInput(data.title, data.opts);
|
||||||
|
} catch (error) {
|
||||||
|
ack({
|
||||||
|
error: {
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.hasOwnProperty("timeout") &&
|
||||||
|
(isNaN(data.timeout) || data.timeout < 1 || data.timeout > 86400)) {
|
||||||
|
ack({
|
||||||
|
error: {
|
||||||
|
message: "Poll timeout must be between 1 and 86400 seconds"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var poll = Poll.create(
|
||||||
|
user.getName(),
|
||||||
|
data.title,
|
||||||
|
data.opts,
|
||||||
|
{
|
||||||
|
hideVotes: data.obscured,
|
||||||
|
retainVotes: data.retainVotes === undefined ? false : data.retainVotes
|
||||||
|
}
|
||||||
|
);
|
||||||
|
var self = this;
|
||||||
|
if (data.hasOwnProperty("timeout")) {
|
||||||
|
poll.timer = setTimeout(function () {
|
||||||
|
if (self.poll === poll) {
|
||||||
|
self.handleClosePoll({
|
||||||
|
getName: function () { return "[poll timer]"; },
|
||||||
|
effectiveRank: 255
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, data.timeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.poll = poll;
|
||||||
|
this.dirty = true;
|
||||||
|
this.broadcastPoll(true);
|
||||||
|
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
|
||||||
|
ack({});
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.handleVote = function (user, data) {
|
||||||
|
if (!this.channel.modules.permissions.canVote(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.poll) {
|
||||||
|
if (this.poll.countVote(user.realip, data.option)) {
|
||||||
|
this.dirty = true;
|
||||||
|
this.broadcastPoll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.handleClosePoll = function (user) {
|
||||||
|
if (!this.channel.modules.permissions.canControlPoll(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.poll) {
|
||||||
|
if (this.poll.hideVotes) {
|
||||||
|
this.poll.hideVotes = false;
|
||||||
|
this.channel.broadcastAll("updatePoll", this.poll.toUpdateFrame(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.poll.timer) {
|
||||||
|
clearTimeout(this.poll.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channel.broadcastAll("closePoll");
|
||||||
|
this.channel.logger.log("[poll] " + user.getName() + " closed the active poll");
|
||||||
|
this.poll = null;
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
|
||||||
|
if (!this.channel.modules.permissions.canControlPoll(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure any existing poll is closed
|
||||||
|
this.handleClosePoll(user);
|
||||||
|
|
||||||
|
msg = msg.replace(/^\/h?poll/, "");
|
||||||
|
|
||||||
|
var args = msg.split(",");
|
||||||
|
var title = args.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.validatePollInput(title, args);
|
||||||
|
} catch (error) {
|
||||||
|
user.socket.emit('errorMsg', {
|
||||||
|
msg: 'Error creating poll: ' + error.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var poll = Poll.create(user.getName(), title, args, { hideVotes: obscured });
|
||||||
|
this.poll = poll;
|
||||||
|
this.dirty = true;
|
||||||
|
this.broadcastPoll(true);
|
||||||
|
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PollModule;
|
||||||
200
src/channel/ranks.js
Normal file
200
src/channel/ranks.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
var Account = require("../account");
|
||||||
|
var db = require("../database");
|
||||||
|
import Promise from 'bluebird';
|
||||||
|
|
||||||
|
const dbSetChannelRank = Promise.promisify(db.channels.setRank);
|
||||||
|
|
||||||
|
const TYPE_SET_CHANNEL_RANK = {
|
||||||
|
name: "string",
|
||||||
|
rank: "number"
|
||||||
|
};
|
||||||
|
|
||||||
|
function RankModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
|
||||||
|
if (this.channel.modules.chat) {
|
||||||
|
this.channel.modules.chat.registerCommand("/rank", this.handleCmdRank.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RankModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
RankModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.typecheckedOn("setChannelRank", TYPE_SET_CHANNEL_RANK, this.handleRankChange.bind(this, user));
|
||||||
|
var self = this;
|
||||||
|
user.socket.on("requestChannelRanks", function () {
|
||||||
|
self.sendChannelRanks([user]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
RankModule.prototype.sendChannelRanks = function (users) {
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.channels.allRanks(this.channel.name, function (err, ranks) {
|
||||||
|
if (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (u.account.effectiveRank >= 3) {
|
||||||
|
u.socket.emit("channelRanks", ranks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
RankModule.prototype.handleCmdRank = function (user, msg, _meta) {
|
||||||
|
var args = msg.split(" ");
|
||||||
|
args.shift(); /* shift off /rank */
|
||||||
|
var name = args.shift();
|
||||||
|
var rank = parseInt(args.shift());
|
||||||
|
|
||||||
|
if (!name || isNaN(rank)) {
|
||||||
|
user.socket.emit("noflood", {
|
||||||
|
action: "/rank",
|
||||||
|
msg: "Syntax: /rank <username> <rank>. <rank> must be a positive integer > 1"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleRankChange(user, { name: name, rank: rank });
|
||||||
|
};
|
||||||
|
|
||||||
|
RankModule.prototype.handleRankChange = function (user, data) {
|
||||||
|
if (user.account.effectiveRank < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rank = data.rank;
|
||||||
|
var userrank = user.account.effectiveRank;
|
||||||
|
var name = data.name.substring(0, 20).toLowerCase();
|
||||||
|
|
||||||
|
if (!name.match(/^[a-zA-Z0-9_-]{1,20}$/)) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Invalid target name " + data.name
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(rank) || rank < 1 || (rank >= userrank && !(userrank === 4 && rank === 4))) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Updating user rank failed: You can't promote someone to a rank equal " +
|
||||||
|
"or higher than yourself, or demote them to below rank 1."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var receiver;
|
||||||
|
var lowerName = name.toLowerCase();
|
||||||
|
for (var i = 0; i < this.channel.users.length; i++) {
|
||||||
|
if (this.channel.users[i].getLowerName() === lowerName) {
|
||||||
|
receiver = this.channel.users[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === user.getLowerName()) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Updating user rank failed: You can't promote or demote yourself."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Updating user rank failed: in an unregistered channel, a user must " +
|
||||||
|
"be online in the channel in order to have their rank changed."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiver) {
|
||||||
|
var current = Math.max(receiver.account.globalRank, receiver.account.channelRank);
|
||||||
|
if (current >= userrank && !(userrank === 4 && current === 4)) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Updating user rank failed: You can't promote or demote "+
|
||||||
|
"someone who has equal or higher rank than yourself"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRank = receiver.account.effectiveRank;
|
||||||
|
receiver.account.channelRank = rank;
|
||||||
|
receiver.account.effectiveRank = Math.max(receiver.account.globalRank, rank);
|
||||||
|
receiver.emit("effectiveRankChange", receiver.account.effectiveRank, oldRank);
|
||||||
|
receiver.socket.emit("rank", receiver.account.effectiveRank);
|
||||||
|
this.channel.logger.log("[mod] " + user.getName() + " set " + name + "'s rank " +
|
||||||
|
"to " + rank);
|
||||||
|
this.channel.broadcastAll("setUserRank", data);
|
||||||
|
|
||||||
|
if (!this.channel.is(Flags.C_REGISTERED)) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "This channel is not registered. Any rank changes are temporary " +
|
||||||
|
"and not stored in the database."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!receiver.is(Flags.U_REGISTERED)) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "The user you promoted is not a registered account. " +
|
||||||
|
"Any rank changes are temporary and not stored in the database."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.userrank = userrank;
|
||||||
|
|
||||||
|
this.updateDatabase(data, function (err) {
|
||||||
|
if (err) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Database failure when updating rank"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
data.userrank = userrank;
|
||||||
|
var self = this;
|
||||||
|
this.updateDatabase(data, function (err) {
|
||||||
|
if (err) {
|
||||||
|
user.socket.emit("channelRankFail", {
|
||||||
|
msg: "Updating user rank failed: " + err
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.channel.logger.log("[mod] " + user.getName() + " set " + data.name +
|
||||||
|
"'s rank to " + rank);
|
||||||
|
self.channel.broadcastAll("setUserRank", data);
|
||||||
|
if (self.channel.modules.chat) {
|
||||||
|
self.channel.modules.chat.sendModMessage(
|
||||||
|
user.getName() + " set " + data.name + "'s rank to " + rank,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RankModule.prototype.updateDatabase = function (data, cb) {
|
||||||
|
var chan = this.channel;
|
||||||
|
Account.rankForName(data.name, this.channel.name).then(rank => {
|
||||||
|
if (rank >= data.userrank && !(rank === 4 && data.userrank === 4)) {
|
||||||
|
throw new Error(
|
||||||
|
"You can't promote or demote someone" +
|
||||||
|
" with equal or higher rank than you."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbSetChannelRank(chan.name, data.name, data.rank);
|
||||||
|
}).then(() => {
|
||||||
|
process.nextTick(cb);
|
||||||
|
}).catch(error => {
|
||||||
|
process.nextTick(cb, error.message || error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = RankModule;
|
||||||
160
src/channel/voteskip.js
Normal file
160
src/channel/voteskip.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
var ChannelModule = require("./module");
|
||||||
|
var Flags = require("../flags");
|
||||||
|
var Poll = require("../poll").Poll;
|
||||||
|
|
||||||
|
function VoteskipModule(_channel) {
|
||||||
|
ChannelModule.apply(this, arguments);
|
||||||
|
|
||||||
|
this.poll = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VoteskipModule.prototype = Object.create(ChannelModule.prototype);
|
||||||
|
|
||||||
|
VoteskipModule.prototype.onUserPostJoin = function (user) {
|
||||||
|
user.socket.on("voteskip", this.handleVoteskip.bind(this, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.onUserPart = function(user) {
|
||||||
|
if (!this.poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unvote(user.realip);
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.handleVoteskip = function (user) {
|
||||||
|
if (!this.channel.modules.options.get("allow_voteskip")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.playlist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channel.modules.permissions.canVoteskip(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.poll) {
|
||||||
|
this.poll = Poll.create("[server]", "voteskip", ["skip"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.poll.countVote(user.realip, 0)) {
|
||||||
|
// Vote was already recorded for this IP, no update needed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = "";
|
||||||
|
if (this.channel.modules.playlist.current) {
|
||||||
|
title = " " + this.channel.modules.playlist.current.media.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = user.getName() || "(anonymous)";
|
||||||
|
|
||||||
|
this.channel.logger.log("[playlist] " + name + " voteskipped " + title);
|
||||||
|
user.setAFK(false);
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.unvote = function(ip) {
|
||||||
|
if (!this.poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.poll.uncountVote(ip);
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.update = function () {
|
||||||
|
if (!this.channel.modules.options.get("allow_voteskip")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.channel.modules.playlist.meta.count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { counts } = this.poll.toUpdateFrame(false);
|
||||||
|
const { total, eligible, noPermission, afk } = this.calcUsercounts();
|
||||||
|
const need = Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"));
|
||||||
|
if (counts[0] >= need) {
|
||||||
|
const info = `${counts[0]}/${eligible} skipped; ` +
|
||||||
|
`eligible voters: ${eligible} = total (${total}) - AFK (${afk}) ` +
|
||||||
|
`- no permission (${noPermission}); ` +
|
||||||
|
`ratio = ${this.channel.modules.options.get("voteskip_ratio")}`;
|
||||||
|
this.channel.logger.log(`[playlist] Voteskip passed: ${info}`);
|
||||||
|
this.channel.broadcastAll(
|
||||||
|
'chatMsg',
|
||||||
|
{
|
||||||
|
username: "[voteskip]",
|
||||||
|
msg: `Voteskip passed: ${info}`,
|
||||||
|
meta: {
|
||||||
|
addClass: "server-whisper",
|
||||||
|
addClassToNameAndTimestamp: true
|
||||||
|
},
|
||||||
|
time: Date.now()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.reset();
|
||||||
|
this.channel.modules.playlist._playNext();
|
||||||
|
} else {
|
||||||
|
this.sendVoteskipData(this.channel.users);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.sendVoteskipData = function (users) {
|
||||||
|
const { eligible } = this.calcUsercounts();
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (this.poll) {
|
||||||
|
const { counts } = this.poll.toUpdateFrame(false);
|
||||||
|
data = {
|
||||||
|
count: counts[0],
|
||||||
|
need: Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
data = {
|
||||||
|
count: 0,
|
||||||
|
need: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var perms = this.channel.modules.permissions;
|
||||||
|
|
||||||
|
users.forEach(function (u) {
|
||||||
|
if (perms.canSeeVoteskipResults(u)) {
|
||||||
|
u.socket.emit("voteskip", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.calcUsercounts = function () {
|
||||||
|
const perms = this.channel.modules.permissions;
|
||||||
|
const counts = { total: 0, noPermission: 0, afk: 0 };
|
||||||
|
|
||||||
|
this.channel.users.forEach(u => {
|
||||||
|
counts.total++;
|
||||||
|
|
||||||
|
if (!perms.canVoteskip(u)) counts.noPermission++;
|
||||||
|
else if (u.is(Flags.U_AFK)) counts.afk++;
|
||||||
|
});
|
||||||
|
|
||||||
|
counts.eligible = counts.total - (counts.noPermission + counts.afk);
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.reset = function reset() {
|
||||||
|
this.poll = false;
|
||||||
|
this.sendVoteskipData(this.channel.users);
|
||||||
|
};
|
||||||
|
|
||||||
|
VoteskipModule.prototype.onMediaChange = function (_data) {
|
||||||
|
this.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = VoteskipModule;
|
||||||
501
src/config.js
Normal file
501
src/config.js
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
var path = require("path");
|
||||||
|
var net = require("net");
|
||||||
|
var YAML = require("yamljs");
|
||||||
|
|
||||||
|
import { loadFromToml } from './configuration/configloader';
|
||||||
|
import { CamoConfig } from './configuration/camoconfig';
|
||||||
|
import { PrometheusConfig } from './configuration/prometheusconfig';
|
||||||
|
import { EmailConfig } from './configuration/emailconfig';
|
||||||
|
import { CaptchaConfig } from './configuration/captchaconfig';
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('config');
|
||||||
|
|
||||||
|
var defaults = {
|
||||||
|
mysql: {
|
||||||
|
server: "localhost",
|
||||||
|
port: 3306,
|
||||||
|
database: "cytube3",
|
||||||
|
user: "cytube3",
|
||||||
|
password: "",
|
||||||
|
"pool-size": 10
|
||||||
|
},
|
||||||
|
listen: [
|
||||||
|
{
|
||||||
|
ip: "0.0.0.0",
|
||||||
|
port: 8080,
|
||||||
|
http: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ip: "0.0.0.0",
|
||||||
|
port: 1337,
|
||||||
|
io: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
http: {
|
||||||
|
"default-port": 8080,
|
||||||
|
"root-domain": "localhost",
|
||||||
|
"alt-domains": ["127.0.0.1"],
|
||||||
|
minify: false,
|
||||||
|
"max-age": "7d",
|
||||||
|
gzip: true,
|
||||||
|
"gzip-threshold": 1024,
|
||||||
|
"cookie-secret": "change-me",
|
||||||
|
index: {
|
||||||
|
"max-entries": 50
|
||||||
|
},
|
||||||
|
"trust-proxies": [
|
||||||
|
"loopback"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
https: {
|
||||||
|
enabled: false,
|
||||||
|
domain: "https://localhost",
|
||||||
|
"default-port": 8443,
|
||||||
|
keyfile: "localhost.key",
|
||||||
|
passphrase: "",
|
||||||
|
certfile: "localhost.cert",
|
||||||
|
cafile: "",
|
||||||
|
ciphers: "HIGH:!DSS:!aNULL@STRENGTH"
|
||||||
|
},
|
||||||
|
io: {
|
||||||
|
domain: "http://localhost",
|
||||||
|
"default-port": 1337,
|
||||||
|
"ip-connection-limit": 10,
|
||||||
|
cors: {
|
||||||
|
"allowed-origins": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"youtube-v3-key": "",
|
||||||
|
"channel-blacklist": [],
|
||||||
|
"channel-path": "r",
|
||||||
|
"channel-save-interval": 5,
|
||||||
|
"max-channels-per-user": 5,
|
||||||
|
"max-accounts-per-ip": 5,
|
||||||
|
"guest-login-delay": 60,
|
||||||
|
aliases: {
|
||||||
|
"purge-interval": 3600000,
|
||||||
|
"max-age": 2592000000
|
||||||
|
},
|
||||||
|
"vimeo-workaround": false,
|
||||||
|
"html-template": {
|
||||||
|
title: "CyTube Beta", description: "Free, open source synchtube"
|
||||||
|
},
|
||||||
|
"reserved-names": {
|
||||||
|
usernames: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
|
||||||
|
channels: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
|
||||||
|
pagetitles: []
|
||||||
|
},
|
||||||
|
"contacts": [],
|
||||||
|
"aggressive-gc": false,
|
||||||
|
playlist: {
|
||||||
|
"max-items": 4000,
|
||||||
|
"update-interval": 5
|
||||||
|
},
|
||||||
|
ffmpeg: {
|
||||||
|
enabled: false,
|
||||||
|
"ffprobe-exec": "ffprobe"
|
||||||
|
},
|
||||||
|
"link-domain-blacklist": [],
|
||||||
|
setuid: {
|
||||||
|
enabled: false,
|
||||||
|
"group": "users",
|
||||||
|
"user": "nobody",
|
||||||
|
"timeout": 15
|
||||||
|
},
|
||||||
|
"service-socket": {
|
||||||
|
enabled: false,
|
||||||
|
socket: "service.sock"
|
||||||
|
},
|
||||||
|
"twitch-client-id": null,
|
||||||
|
poll: {
|
||||||
|
"max-options": 50
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a config object with the defaults, warning about missing keys
|
||||||
|
*/
|
||||||
|
function merge(obj, def, path) {
|
||||||
|
for (var key in def) {
|
||||||
|
if (key in obj) {
|
||||||
|
if (typeof obj[key] === "object") {
|
||||||
|
merge(obj[key], def[key], path + "." + key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOGGER.warn("Missing config key " + (path + "." + key) +
|
||||||
|
"; using default: " + JSON.stringify(def[key]));
|
||||||
|
obj[key] = def[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg = defaults;
|
||||||
|
let camoConfig = new CamoConfig();
|
||||||
|
let prometheusConfig = new PrometheusConfig();
|
||||||
|
let emailConfig = new EmailConfig();
|
||||||
|
let captchaConfig = new CaptchaConfig();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the configuration from the given YAML file
|
||||||
|
*/
|
||||||
|
exports.load = function (file) {
|
||||||
|
let absPath = path.join(__dirname, "..", file);
|
||||||
|
try {
|
||||||
|
cfg = YAML.load(absPath);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === "ENOENT") {
|
||||||
|
throw new Error(`No such file: ${absPath}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid config file ${absPath}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg == null) {
|
||||||
|
throw new Error("Configuration parser returned null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.mail) {
|
||||||
|
LOGGER.error(
|
||||||
|
'Old style mail configuration found in config.yaml. ' +
|
||||||
|
'Email will not be delivered unless you copy conf/example/email.toml ' +
|
||||||
|
'to conf/email.toml and edit it to your liking. ' +
|
||||||
|
'To remove this warning, delete the "mail:" block in config.yaml.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
merge(cfg, defaults, "config");
|
||||||
|
|
||||||
|
preprocessConfig(cfg);
|
||||||
|
LOGGER.info("Loaded configuration from " + file);
|
||||||
|
|
||||||
|
loadCamoConfig();
|
||||||
|
loadPrometheusConfig();
|
||||||
|
loadEmailConfig();
|
||||||
|
loadCaptchaConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkLoadConfig(configClass, filename) {
|
||||||
|
try {
|
||||||
|
return loadFromToml(
|
||||||
|
configClass,
|
||||||
|
path.resolve(__dirname, '..', 'conf', filename)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error.line !== 'undefined') {
|
||||||
|
LOGGER.error(`Error in conf/${filename}: ${error} (line ${error.line})`);
|
||||||
|
} else {
|
||||||
|
LOGGER.error(`Error loading conf/${filename}: ${error.stack}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCamoConfig() {
|
||||||
|
const conf = checkLoadConfig(CamoConfig, 'camo.toml');
|
||||||
|
|
||||||
|
if (conf === null) {
|
||||||
|
LOGGER.info('No camo configuration found, chat images will not be proxied.');
|
||||||
|
camoConfig = new CamoConfig();
|
||||||
|
} else {
|
||||||
|
camoConfig = conf;
|
||||||
|
const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED';
|
||||||
|
LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrometheusConfig() {
|
||||||
|
const conf = checkLoadConfig(PrometheusConfig, 'prometheus.toml');
|
||||||
|
|
||||||
|
if (conf === null) {
|
||||||
|
LOGGER.info('No prometheus configuration found, defaulting to disabled');
|
||||||
|
prometheusConfig = new PrometheusConfig();
|
||||||
|
} else {
|
||||||
|
prometheusConfig = conf;
|
||||||
|
const enabled = prometheusConfig.isEnabled() ? 'ENABLED' : 'DISABLED';
|
||||||
|
LOGGER.info(
|
||||||
|
'Loaded prometheus configuration from conf/prometheus.toml. ' +
|
||||||
|
`Prometheus listener is ${enabled}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEmailConfig() {
|
||||||
|
const conf = checkLoadConfig(EmailConfig, 'email.toml');
|
||||||
|
|
||||||
|
if (conf === null) {
|
||||||
|
LOGGER.info('No email configuration found, defaulting to disabled');
|
||||||
|
emailConfig = new EmailConfig();
|
||||||
|
} else {
|
||||||
|
emailConfig = conf;
|
||||||
|
LOGGER.info('Loaded email configuration from conf/email.toml.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCaptchaConfig() {
|
||||||
|
const conf = checkLoadConfig(Object, 'captcha.toml');
|
||||||
|
|
||||||
|
if (conf === null) {
|
||||||
|
LOGGER.info('No captcha configuration found, defaulting to disabled');
|
||||||
|
captchaConfig.load();
|
||||||
|
} else {
|
||||||
|
captchaConfig.load(conf);
|
||||||
|
LOGGER.info('Loaded captcha configuration from conf/captcha.toml.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'm sorry
|
||||||
|
function preprocessConfig(cfg) {
|
||||||
|
// Root domain should start with a . for cookies
|
||||||
|
var root = cfg.http["root-domain"];
|
||||||
|
if (/127\.0\.0\.1|localhost/.test(root)) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"Detected 127.0.0.1 or localhost in root-domain '%s'. This server " +
|
||||||
|
"will not work from other computers! Set root-domain to the domain " +
|
||||||
|
"the website will be accessed from (e.g. example.com)",
|
||||||
|
root
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/^http/.test(root)) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"root-domain '%s' should not contain http:// or https://, removing it",
|
||||||
|
root
|
||||||
|
);
|
||||||
|
root = root.replace(/^https?:\/\//, "");
|
||||||
|
}
|
||||||
|
if (/:\d+$/.test(root)) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"root-domain '%s' should not contain a trailing port, removing it",
|
||||||
|
root
|
||||||
|
);
|
||||||
|
root = root.replace(/:\d+$/, "");
|
||||||
|
}
|
||||||
|
root = root.replace(/^\.*/, "");
|
||||||
|
cfg.http["root-domain"] = root;
|
||||||
|
if (root.indexOf(".") !== -1 && !net.isIP(root)) {
|
||||||
|
root = "." + root;
|
||||||
|
}
|
||||||
|
cfg.http["root-domain-dotted"] = root;
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
if (process.env.DEBUG === "1" || process.env.DEBUG === "true") {
|
||||||
|
cfg.debug = true;
|
||||||
|
} else {
|
||||||
|
cfg.debug = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip trailing slashes from domains
|
||||||
|
cfg.https.domain = cfg.https.domain.replace(/\/*$/, "");
|
||||||
|
|
||||||
|
// Socket.IO URLs
|
||||||
|
cfg.io["ipv4-nossl"] = "";
|
||||||
|
cfg.io["ipv4-ssl"] = "";
|
||||||
|
cfg.io["ipv6-nossl"] = "";
|
||||||
|
cfg.io["ipv6-ssl"] = "";
|
||||||
|
for (var i = 0; i < cfg.listen.length; i++) {
|
||||||
|
var srv = cfg.listen[i];
|
||||||
|
if (!srv.ip) {
|
||||||
|
srv.ip = "0.0.0.0";
|
||||||
|
}
|
||||||
|
if (!srv.io) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srv.ip === "") {
|
||||||
|
if (srv.port === cfg.io["default-port"]) {
|
||||||
|
cfg.io["ipv4-nossl"] = cfg.io["domain"] + ":" + cfg.io["default-port"];
|
||||||
|
} else if (srv.port === cfg.https["default-port"]) {
|
||||||
|
cfg.io["ipv4-ssl"] = cfg.https["domain"] + ":" + cfg.https["default-port"];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIPv4(srv.ip) || srv.ip === "::") {
|
||||||
|
if (srv.https && !cfg.io["ipv4-ssl"]) {
|
||||||
|
if (srv.url) {
|
||||||
|
cfg.io["ipv4-ssl"] = srv.url;
|
||||||
|
} else {
|
||||||
|
cfg.io["ipv4-ssl"] = cfg.https["domain"] + ":" + srv.port;
|
||||||
|
}
|
||||||
|
} else if (!cfg.io["ipv4-nossl"]) {
|
||||||
|
if (srv.url) {
|
||||||
|
cfg.io["ipv4-nossl"] = srv.url;
|
||||||
|
} else {
|
||||||
|
cfg.io["ipv4-nossl"] = cfg.io["domain"] + ":" + srv.port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (net.isIPv6(srv.ip) || srv.ip === "::") {
|
||||||
|
if (srv.https && !cfg.io["ipv6-ssl"]) {
|
||||||
|
if (!srv.url) {
|
||||||
|
LOGGER.error("Config Error: no URL defined for IPv6 " +
|
||||||
|
"Socket.IO listener! Ignoring this listener " +
|
||||||
|
"because the Socket.IO client cannot connect to " +
|
||||||
|
"a raw IPv6 address.");
|
||||||
|
LOGGER.error("(Listener was: " + JSON.stringify(srv) + ")");
|
||||||
|
} else {
|
||||||
|
cfg.io["ipv6-ssl"] = srv.url;
|
||||||
|
}
|
||||||
|
} else if (!cfg.io["ipv6-nossl"]) {
|
||||||
|
if (!srv.url) {
|
||||||
|
LOGGER.error("Config Error: no URL defined for IPv6 " +
|
||||||
|
"Socket.IO listener! Ignoring this listener " +
|
||||||
|
"because the Socket.IO client cannot connect to " +
|
||||||
|
"a raw IPv6 address.");
|
||||||
|
LOGGER.error("(Listener was: " + JSON.stringify(srv) + ")");
|
||||||
|
} else {
|
||||||
|
cfg.io["ipv6-nossl"] = srv.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.io["ipv4-default"] = cfg.io["ipv4-ssl"] || cfg.io["ipv4-nossl"];
|
||||||
|
cfg.io["ipv6-default"] = cfg.io["ipv6-ssl"] || cfg.io["ipv6-nossl"];
|
||||||
|
|
||||||
|
if (/127\.0\.0\.1|localhost/.test(cfg.io["ipv4-default"])) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"socket.io is bound to localhost, this server will be inaccessible " +
|
||||||
|
"from other computers!"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate RegExps for reserved names
|
||||||
|
var reserved = cfg["reserved-names"];
|
||||||
|
for (var key in reserved) {
|
||||||
|
if (reserved[key] && reserved[key].length > 0) {
|
||||||
|
reserved[key] = new RegExp(reserved[key].join("|"), "i");
|
||||||
|
} else {
|
||||||
|
reserved[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convert channel blacklist to a hashtable */
|
||||||
|
var tbl = {};
|
||||||
|
cfg["channel-blacklist"].forEach(function (c) {
|
||||||
|
tbl[c.toLowerCase()] = true;
|
||||||
|
});
|
||||||
|
cfg["channel-blacklist"] = tbl;
|
||||||
|
|
||||||
|
/* Check channel path */
|
||||||
|
if(!/^[-\w]+$/.test(cfg["channel-path"])){
|
||||||
|
LOGGER.error("Channel paths may only use the same characters as usernames and channel names.");
|
||||||
|
process.exit(78); // sysexits.h for bad config
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg["link-domain-blacklist"].length > 0) {
|
||||||
|
cfg["link-domain-blacklist-regex"] = new RegExp(
|
||||||
|
cfg["link-domain-blacklist"].join("|").replace(/\./g, "\\."), "gi");
|
||||||
|
} else {
|
||||||
|
// Match nothing
|
||||||
|
cfg["link-domain-blacklist-regex"] = new RegExp("$x^", "gi");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg["youtube-v3-key"]) {
|
||||||
|
require("@cytube/mediaquery/lib/provider/youtube").setApiKey(
|
||||||
|
cfg["youtube-v3-key"]);
|
||||||
|
} else {
|
||||||
|
LOGGER.warn("No YouTube v3 API key set. YouTube links will " +
|
||||||
|
"not work. See youtube-v3-key in config.template.yaml and " +
|
||||||
|
"https://developers.google.com/youtube/registering_an_application for " +
|
||||||
|
"information on registering an API key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg["twitch-client-id"]) {
|
||||||
|
require("@cytube/mediaquery/lib/provider/twitch-vod").setClientID(
|
||||||
|
cfg["twitch-client-id"]);
|
||||||
|
require("@cytube/mediaquery/lib/provider/twitch-clip").setClientID(
|
||||||
|
cfg["twitch-client-id"]);
|
||||||
|
} else {
|
||||||
|
LOGGER.warn("No Twitch Client ID set. Twitch VOD links will " +
|
||||||
|
"not work. See twitch-client-id in config.template.yaml and " +
|
||||||
|
"https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup" +
|
||||||
|
"for more information on registering a client ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove calzoneman from contact config (old default)
|
||||||
|
cfg.contacts = cfg.contacts.filter(contact => {
|
||||||
|
return contact.name !== 'calzoneman';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cfg.io.throttle) {
|
||||||
|
cfg.io.throttle = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.io.throttle = Object.assign({
|
||||||
|
'in-rate-limit': Infinity
|
||||||
|
}, cfg.io.throttle);
|
||||||
|
cfg.io.throttle = Object.assign({
|
||||||
|
'bucket-capacity': cfg.io.throttle['in-rate-limit']
|
||||||
|
}, cfg.io.throttle);
|
||||||
|
|
||||||
|
if (!cfg['channel-storage']) {
|
||||||
|
cfg['channel-storage'] = { type: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a configuration value with the given key
|
||||||
|
*
|
||||||
|
* Accepts a dot-separated key for nested values, e.g. "http.port"
|
||||||
|
* Throws an error if a nonexistant key is requested
|
||||||
|
*/
|
||||||
|
exports.get = function (key) {
|
||||||
|
var obj = cfg;
|
||||||
|
var keylist = key.split(".");
|
||||||
|
var current = keylist.shift();
|
||||||
|
var path = current;
|
||||||
|
while (keylist.length > 0) {
|
||||||
|
if (!(current in obj)) {
|
||||||
|
throw new Error("Nonexistant config key '" + path + "." + current + "'");
|
||||||
|
}
|
||||||
|
obj = obj[current];
|
||||||
|
current = keylist.shift();
|
||||||
|
path += "." + current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj[current];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a configuration value with the given key
|
||||||
|
*
|
||||||
|
* Accepts a dot-separated key for nested values, e.g. "http.port"
|
||||||
|
* Throws an error if a nonexistant key is requested
|
||||||
|
*/
|
||||||
|
exports.set = function (key, value) {
|
||||||
|
var obj = cfg;
|
||||||
|
var keylist = key.split(".");
|
||||||
|
var current = keylist.shift();
|
||||||
|
var path = current;
|
||||||
|
while (keylist.length > 0) {
|
||||||
|
if (!(current in obj)) {
|
||||||
|
throw new Error("Nonexistant config key '" + path + "." + current + "'");
|
||||||
|
}
|
||||||
|
obj = obj[current];
|
||||||
|
current = keylist.shift();
|
||||||
|
path += "." + current;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj[current] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCamoConfig = function getCamoConfig() {
|
||||||
|
return camoConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getPrometheusConfig = function getPrometheusConfig() {
|
||||||
|
return prometheusConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getEmailConfig = function getEmailConfig() {
|
||||||
|
return emailConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCaptchaConfig = function getCaptchaConfig() {
|
||||||
|
return captchaConfig;
|
||||||
|
};
|
||||||
51
src/configuration/camoconfig.js
Normal file
51
src/configuration/camoconfig.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
const SPECIALCHARS = /([\\.?+*$^|()[\]{}])/g;
|
||||||
|
|
||||||
|
class CamoConfig {
|
||||||
|
constructor(config = { camo: { enabled: false } }) {
|
||||||
|
this.config = config.camo;
|
||||||
|
if (this.config.server) {
|
||||||
|
this.config.server = this.config.server.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
validate() {
|
||||||
|
if (this.config.encoding
|
||||||
|
&& !~['url', 'hex'].indexOf(this.config.encoding)) {
|
||||||
|
throw new Error(`Value for key 'encoding' must be either 'url' or 'hex', not '${this.config.encoding}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey() {
|
||||||
|
return this.config.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer() {
|
||||||
|
return this.config.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWhitelistedDomains() {
|
||||||
|
return this.config['whitelisted-domains'] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getWhitelistedDomainsRegexp() {
|
||||||
|
const domains = this.getWhitelistedDomains()
|
||||||
|
.map(d => '\\.' + d.replace(SPECIALCHARS, '\\$1') + '$');
|
||||||
|
if (domains.length === 0) {
|
||||||
|
// If no whitelist, match nothing
|
||||||
|
return new RegExp('$^');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(domains.join('|'), 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEncoding() {
|
||||||
|
return this.config.encoding || 'url';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CamoConfig };
|
||||||
30
src/configuration/captchaconfig.js
Normal file
30
src/configuration/captchaconfig.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
class CaptchaConfig {
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(config = { hcaptcha: {}, register: { enabled: false } }) {
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
const hcaptcha = config.hcaptcha;
|
||||||
|
this._hcaptcha = {
|
||||||
|
getSiteKey() {
|
||||||
|
return hcaptcha['site-key'];
|
||||||
|
},
|
||||||
|
|
||||||
|
getSecret() {
|
||||||
|
return hcaptcha.secret;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getHcaptcha() {
|
||||||
|
return this._hcaptcha;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this.config.register.enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CaptchaConfig };
|
||||||
19
src/configuration/configloader.js
Normal file
19
src/configuration/configloader.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import toml from 'toml';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
/** @module cytube-common/configuration/configloader */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a toml file and pass the results to a configuration
|
||||||
|
* constructor.
|
||||||
|
*
|
||||||
|
* @param {function} constructor Constructor to call with the loaded data
|
||||||
|
* @param {string} filename Path to the toml file to load
|
||||||
|
* @returns {Object} Configuration object constructed from the provided constructor
|
||||||
|
* @throws {SyntaxError} Errors propagated from toml.parse()
|
||||||
|
*/
|
||||||
|
export function loadFromToml(constructor, filename) {
|
||||||
|
const rawContents = fs.readFileSync(filename).toString('utf8');
|
||||||
|
const configData = toml.parse(rawContents);
|
||||||
|
return new (constructor)(configData);
|
||||||
|
}
|
||||||
88
src/configuration/emailconfig.js
Normal file
88
src/configuration/emailconfig.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
class EmailConfig {
|
||||||
|
constructor(config = { 'password-reset': { enabled: false }, smtp: {} }) {
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
const smtp = config.smtp;
|
||||||
|
this._smtp = {
|
||||||
|
getHost() {
|
||||||
|
return smtp.host;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPort() {
|
||||||
|
return smtp.port;
|
||||||
|
},
|
||||||
|
|
||||||
|
isSecure() {
|
||||||
|
return smtp.secure;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser() {
|
||||||
|
return smtp.user;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPassword() {
|
||||||
|
return smtp.password;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = config['password-reset'];
|
||||||
|
this._reset = {
|
||||||
|
isEnabled() {
|
||||||
|
return reset.enabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
getHTML() {
|
||||||
|
return reset['html-template'];
|
||||||
|
},
|
||||||
|
|
||||||
|
getText() {
|
||||||
|
return reset['text-template'];
|
||||||
|
},
|
||||||
|
|
||||||
|
getFrom() {
|
||||||
|
return reset.from;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubject() {
|
||||||
|
return reset.subject;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = config['delete-account'];
|
||||||
|
this._delete = {
|
||||||
|
isEnabled() {
|
||||||
|
return deleteAccount != null && deleteAccount.enabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
getHTML() {
|
||||||
|
return deleteAccount['html-template'];
|
||||||
|
},
|
||||||
|
|
||||||
|
getText() {
|
||||||
|
return deleteAccount['text-template'];
|
||||||
|
},
|
||||||
|
|
||||||
|
getFrom() {
|
||||||
|
return deleteAccount.from;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubject() {
|
||||||
|
return deleteAccount.subject;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSmtp() {
|
||||||
|
return this._smtp;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPasswordReset() {
|
||||||
|
return this._reset;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeleteAccount() {
|
||||||
|
return this._delete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EmailConfig };
|
||||||
47
src/configuration/ioconfig.js
Normal file
47
src/configuration/ioconfig.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
export default class IOConfiguration {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocketEndpoints() {
|
||||||
|
return this.config.endpoints.slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IOConfiguration.fromOldConfig = function (oldConfig) {
|
||||||
|
const config = {
|
||||||
|
endpoints: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (oldConfig.get('io.ipv4-ssl')) {
|
||||||
|
config.endpoints.push({
|
||||||
|
url: oldConfig.get('io.ipv4-ssl'),
|
||||||
|
secure: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldConfig.get('io.ipv4-nossl')) {
|
||||||
|
config.endpoints.push({
|
||||||
|
url: oldConfig.get('io.ipv4-nossl'),
|
||||||
|
secure: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldConfig.get('io.ipv6-ssl')) {
|
||||||
|
config.endpoints.push({
|
||||||
|
url: oldConfig.get('io.ipv4-ssl'),
|
||||||
|
secure: true,
|
||||||
|
ipv6: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldConfig.get('io.ipv6-nossl')) {
|
||||||
|
config.endpoints.push({
|
||||||
|
url: oldConfig.get('io.ipv4-nossl'),
|
||||||
|
secure: false,
|
||||||
|
ipv6: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IOConfiguration(config);
|
||||||
|
};
|
||||||
23
src/configuration/prometheusconfig.js
Normal file
23
src/configuration/prometheusconfig.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
class PrometheusConfig {
|
||||||
|
constructor(config = { prometheus: { enabled: false } }) {
|
||||||
|
this.config = config.prometheus;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPort() {
|
||||||
|
return this.config.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHost() {
|
||||||
|
return this.config.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPath() {
|
||||||
|
return this.config.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PrometheusConfig };
|
||||||
77
src/configuration/webconfig.js
Normal file
77
src/configuration/webconfig.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import clone from 'clone';
|
||||||
|
|
||||||
|
export default class WebConfiguration {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmailContacts() {
|
||||||
|
return clone(this.config.contacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrustedProxies() {
|
||||||
|
return this.config.trustProxies;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieSecret() {
|
||||||
|
return this.config.authCookie.cookieSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieDomain() {
|
||||||
|
return this.config.authCookie.cookieDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnableGzip() {
|
||||||
|
return this.config.gzip.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGzipThreshold() {
|
||||||
|
return this.config.gzip.threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnableMinification() {
|
||||||
|
return this.config.enableMinification;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheTTL() {
|
||||||
|
return this.config.cacheTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxIndexEntries() {
|
||||||
|
return this.config.maxIndexEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WebConfiguration.fromOldConfig = function (oldConfig) {
|
||||||
|
const config = {
|
||||||
|
contacts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
oldConfig.get('contacts').forEach(contact => {
|
||||||
|
config.contacts.push({
|
||||||
|
name: contact.name,
|
||||||
|
email: contact.email,
|
||||||
|
title: contact.title
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
config.gzip = {
|
||||||
|
enabled: oldConfig.get('http.gzip'),
|
||||||
|
threshold: oldConfig.get('http.gzip-threshold')
|
||||||
|
};
|
||||||
|
|
||||||
|
config.authCookie = {
|
||||||
|
cookieSecret: oldConfig.get('http.cookie-secret'),
|
||||||
|
cookieDomain: oldConfig.get('http.root-domain-dotted')
|
||||||
|
};
|
||||||
|
|
||||||
|
config.enableMinification = oldConfig.get('http.minify');
|
||||||
|
|
||||||
|
config.cacheTTL = oldConfig.get('http.max-age');
|
||||||
|
|
||||||
|
config.maxIndexEntries = oldConfig.get('http.index.max-entries');
|
||||||
|
|
||||||
|
config.trustProxies = oldConfig.get('http.trust-proxies');
|
||||||
|
|
||||||
|
return new WebConfiguration(config);
|
||||||
|
};
|
||||||
102
src/controller/captcha.js
Normal file
102
src/controller/captcha.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
const https = require('https');
|
||||||
|
const querystring = require('querystring');
|
||||||
|
const { Counter } = require('prom-client');
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('captcha-controller');
|
||||||
|
|
||||||
|
const captchaCount = new Counter({
|
||||||
|
name: 'cytube_captcha_count',
|
||||||
|
help: 'Count of captcha checks'
|
||||||
|
});
|
||||||
|
const captchaFailCount = new Counter({
|
||||||
|
name: 'cytube_captcha_failed_count',
|
||||||
|
help: 'Count of rejected captcha responses'
|
||||||
|
});
|
||||||
|
|
||||||
|
class CaptchaController {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyToken(token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let params = querystring.stringify({
|
||||||
|
secret: this.config.getHcaptcha().getSecret(),
|
||||||
|
response: token
|
||||||
|
});
|
||||||
|
let req = https.request(
|
||||||
|
'https://hcaptcha.com/siteverify',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': params.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
const error = new Error('Request timed out.');
|
||||||
|
error.code = 'ETIMEDOUT';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('response', res => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
req.abort();
|
||||||
|
|
||||||
|
reject(new Error(
|
||||||
|
`HTTP ${res.statusCode} ${res.statusMessage}`
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
|
||||||
|
res.on('data', data => {
|
||||||
|
buffer += data;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve(buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(params);
|
||||||
|
req.end();
|
||||||
|
}).then(body => {
|
||||||
|
captchaCount.inc(1);
|
||||||
|
let res = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
captchaFailCount.inc(1);
|
||||||
|
if (res['error-codes'].length > 0) {
|
||||||
|
switch (res['error-codes'][0]) {
|
||||||
|
case 'missing-input-secret':
|
||||||
|
throw new Error('hCaptcha is misconfigured: missing secret');
|
||||||
|
case 'invalid-input-secret':
|
||||||
|
throw new Error('hCaptcha is misconfigured: invalid secret');
|
||||||
|
case 'sitekey-secret-mismatch':
|
||||||
|
throw new Error('hCaptcha is misconfigured: secret does not match site-key');
|
||||||
|
case 'invalid-input-response':
|
||||||
|
case 'invalid-or-already-seen-response':
|
||||||
|
throw new Error('Invalid captcha response');
|
||||||
|
default:
|
||||||
|
LOGGER.error('Unknown hCaptcha error; response: %j', res);
|
||||||
|
throw new Error('Unknown hCaptcha error: ' + res['error-codes'][0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Captcha verification failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CaptchaController };
|
||||||
52
src/controller/email.js
Normal file
52
src/controller/email.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
class EmailController {
|
||||||
|
constructor(mailer, config) {
|
||||||
|
this.mailer = mailer;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPasswordReset(params = {}) {
|
||||||
|
const { address, username, url } = params;
|
||||||
|
|
||||||
|
const resetConfig = this.config.getPasswordReset();
|
||||||
|
|
||||||
|
const html = resetConfig.getHTML()
|
||||||
|
.replace(/\$user\$/g, username)
|
||||||
|
.replace(/\$url\$/g, url);
|
||||||
|
const text = resetConfig.getText()
|
||||||
|
.replace(/\$user\$/g, username)
|
||||||
|
.replace(/\$url\$/g, url);
|
||||||
|
|
||||||
|
const result = await this.mailer.sendMail({
|
||||||
|
from: resetConfig.getFrom(),
|
||||||
|
to: `${username} <${address}>`,
|
||||||
|
subject: resetConfig.getSubject(),
|
||||||
|
html,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAccountDeletion(params = {}) {
|
||||||
|
const { address, username } = params;
|
||||||
|
|
||||||
|
const deleteConfig = this.config.getDeleteAccount();
|
||||||
|
|
||||||
|
const html = deleteConfig.getHTML()
|
||||||
|
.replace(/\$user\$/g, username);
|
||||||
|
const text = deleteConfig.getText()
|
||||||
|
.replace(/\$user\$/g, username);
|
||||||
|
|
||||||
|
const result = await this.mailer.sendMail({
|
||||||
|
from: deleteConfig.getFrom(),
|
||||||
|
to: `${username} <${address}>`,
|
||||||
|
subject: deleteConfig.getSubject(),
|
||||||
|
html,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EmailController };
|
||||||
267
src/custom-media.js
Normal file
267
src/custom-media.js
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { ValidationError } from './errors';
|
||||||
|
import { parse as urlParse } from 'url';
|
||||||
|
import net from 'net';
|
||||||
|
import Media from './media';
|
||||||
|
import { get as httpGet } from 'http';
|
||||||
|
import { get as httpsGet } from 'https';
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('custom-media');
|
||||||
|
|
||||||
|
const SOURCE_QUALITIES = new Set([
|
||||||
|
240,
|
||||||
|
360,
|
||||||
|
480,
|
||||||
|
540,
|
||||||
|
720,
|
||||||
|
1080,
|
||||||
|
1440,
|
||||||
|
2160
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SOURCE_CONTENT_TYPES = new Set([
|
||||||
|
'application/dash+xml',
|
||||||
|
'application/x-mpegURL',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/opus',
|
||||||
|
'video/mp4',
|
||||||
|
'video/ogg',
|
||||||
|
'video/webm'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LIVE_ONLY_CONTENT_TYPES = new Set([
|
||||||
|
'application/dash+xml'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function lookup(url, opts) {
|
||||||
|
if (!opts) opts = {};
|
||||||
|
if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(options, parseURL(url));
|
||||||
|
|
||||||
|
if (!/^https?:$/.test(options.protocol)) {
|
||||||
|
reject(new ValidationError(
|
||||||
|
`Unacceptable protocol "${options.protocol}". Custom metadata must be`
|
||||||
|
+ ' retrieved by HTTP or HTTPS'
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.info('Looking up %s', url);
|
||||||
|
|
||||||
|
// this is fucking stupid
|
||||||
|
const get = options.protocol === 'https:' ? httpsGet : httpGet;
|
||||||
|
const req = get(options);
|
||||||
|
|
||||||
|
req.setTimeout(opts.timeout, () => {
|
||||||
|
const error = new Error('Request timed out');
|
||||||
|
error.code = 'ETIMEDOUT';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', error => {
|
||||||
|
LOGGER.warn('Request for %s failed: %s', url, error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('response', res => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
req.abort();
|
||||||
|
|
||||||
|
reject(new Error(
|
||||||
|
`Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}`
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^application\/json/.test(res.headers['content-type'])) {
|
||||||
|
req.abort();
|
||||||
|
|
||||||
|
reject(new Error(
|
||||||
|
`Expected content-type application/json, not ${res.headers['content-type']}`
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
|
||||||
|
res.on('data', data => {
|
||||||
|
buffer += data;
|
||||||
|
|
||||||
|
if (buffer.length > 100 * 1024) {
|
||||||
|
req.abort();
|
||||||
|
reject(new Error('Response size exceeds 100KB'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve(buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).then(body => {
|
||||||
|
return convert(url, JSON.parse(body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convert(id, data) {
|
||||||
|
validate(data);
|
||||||
|
|
||||||
|
if (data.live) data.duration = 0;
|
||||||
|
|
||||||
|
const sources = {};
|
||||||
|
|
||||||
|
for (let source of data.sources) {
|
||||||
|
if (!sources.hasOwnProperty(source.quality))
|
||||||
|
sources[source.quality] = [];
|
||||||
|
|
||||||
|
sources[source.quality].push({
|
||||||
|
link: source.url,
|
||||||
|
contentType: source.contentType,
|
||||||
|
quality: source.quality
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
direct: sources,
|
||||||
|
textTracks: data.textTracks,
|
||||||
|
thumbnail: data.thumbnail, // Currently ignored by Media
|
||||||
|
live: !!data.live // Currently ignored by Media
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Media(id, data.title, data.duration, 'cm', meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(data) {
|
||||||
|
if (typeof data.title !== 'string')
|
||||||
|
throw new ValidationError('title must be a string');
|
||||||
|
if (!data.title)
|
||||||
|
throw new ValidationError('title must not be blank');
|
||||||
|
|
||||||
|
if (typeof data.duration !== 'number')
|
||||||
|
throw new ValidationError('duration must be a number');
|
||||||
|
if (!isFinite(data.duration) || data.duration < 0)
|
||||||
|
throw new ValidationError('duration must be a non-negative finite number');
|
||||||
|
|
||||||
|
if (data.hasOwnProperty('live') && typeof data.live !== 'boolean')
|
||||||
|
throw new ValidationError('live must be a boolean');
|
||||||
|
|
||||||
|
if (data.hasOwnProperty('thumbnail')) {
|
||||||
|
if (typeof data.thumbnail !== 'string')
|
||||||
|
throw new ValidationError('thumbnail must be a string');
|
||||||
|
validateURL(data.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSources(data.sources, data);
|
||||||
|
validateTextTracks(data.textTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSources(sources, data) {
|
||||||
|
if (!Array.isArray(sources))
|
||||||
|
throw new ValidationError('sources must be a list');
|
||||||
|
if (sources.length === 0)
|
||||||
|
throw new ValidationError('source list must be nonempty');
|
||||||
|
|
||||||
|
for (let source of sources) {
|
||||||
|
if (typeof source.url !== 'string')
|
||||||
|
throw new ValidationError('source URL must be a string');
|
||||||
|
validateURL(source.url);
|
||||||
|
|
||||||
|
if (!SOURCE_CONTENT_TYPES.has(source.contentType))
|
||||||
|
throw new ValidationError(
|
||||||
|
`unacceptable source contentType "${source.contentType}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live)
|
||||||
|
throw new ValidationError(
|
||||||
|
`contentType "${source.contentType}" requires live: true`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!SOURCE_QUALITIES.has(source.quality))
|
||||||
|
throw new ValidationError(`unacceptable source quality "${source.quality}"`);
|
||||||
|
|
||||||
|
if (source.hasOwnProperty('bitrate')) {
|
||||||
|
if (typeof source.bitrate !== 'number')
|
||||||
|
throw new ValidationError('source bitrate must be a number');
|
||||||
|
if (!isFinite(source.bitrate) || source.bitrate < 0)
|
||||||
|
throw new ValidationError(
|
||||||
|
'source bitrate must be a non-negative finite number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTextTracks(textTracks) {
|
||||||
|
if (typeof textTracks === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(textTracks))
|
||||||
|
throw new ValidationError('textTracks must be a list');
|
||||||
|
|
||||||
|
let default_count = 0;
|
||||||
|
for (let track of textTracks) {
|
||||||
|
if (typeof track.url !== 'string')
|
||||||
|
throw new ValidationError('text track URL must be a string');
|
||||||
|
validateURL(track.url);
|
||||||
|
|
||||||
|
if (track.contentType !== 'text/vtt')
|
||||||
|
throw new ValidationError(
|
||||||
|
`unacceptable text track contentType "${track.contentType}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof track.name !== 'string')
|
||||||
|
throw new ValidationError('text track name must be a string');
|
||||||
|
if (!track.name)
|
||||||
|
throw new ValidationError('text track name must be nonempty');
|
||||||
|
|
||||||
|
if (typeof track.default !== 'undefined') {
|
||||||
|
if (default_count > 0)
|
||||||
|
throw new ValidationError('only one default text track is allowed');
|
||||||
|
else if (typeof track.default !== 'boolean' || track.default !== true)
|
||||||
|
throw new ValidationError('text default attribute must be set to boolean true');
|
||||||
|
else
|
||||||
|
default_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseURL(urlstring) {
|
||||||
|
const url = urlParse(urlstring);
|
||||||
|
|
||||||
|
// legacy url.parse doesn't check this
|
||||||
|
if (url.protocol == null || url.host == null) {
|
||||||
|
throw new Error(`Invalid URL "${urlstring}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateURL(urlstring) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = parseURL(urlstring);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ValidationError(`invalid URL "${urlstring}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== 'https:')
|
||||||
|
throw new ValidationError(`URL protocol must be HTTPS (invalid: "${urlstring}")`);
|
||||||
|
|
||||||
|
if (net.isIP(url.hostname))
|
||||||
|
throw new ValidationError(
|
||||||
|
'URL hostname must be a domain name, not an IP address'
|
||||||
|
+ ` (invalid: "${urlstring}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/customembed.js
Normal file
46
src/customembed.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
var cheerio = require("cheerio");
|
||||||
|
var crypto = require("crypto");
|
||||||
|
var Media = require("./media");
|
||||||
|
|
||||||
|
function sha256(input) {
|
||||||
|
var hash = crypto.createHash("sha256");
|
||||||
|
hash.update(input);
|
||||||
|
return hash.digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter(input) {
|
||||||
|
var $ = cheerio.load(input, {
|
||||||
|
lowerCaseTags: true,
|
||||||
|
lowerCaseAttributeNames: true
|
||||||
|
});
|
||||||
|
var meta = getMeta($);
|
||||||
|
var id = "cu:" + sha256(input);
|
||||||
|
|
||||||
|
return new Media(id, "Custom Media", "--:--", "cu", meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeta($) {
|
||||||
|
let tag = $("iframe");
|
||||||
|
if (tag.length !== 0) {
|
||||||
|
return filterIframe(tag[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid embed. Input must be an <iframe> tag");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterIframe(tag) {
|
||||||
|
if (!/^https:/.test(tag.attribs.src)) {
|
||||||
|
throw new Error("Invalid embed. Embed source must be HTTPS, plain HTTP is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = {
|
||||||
|
embed: {
|
||||||
|
tag: "iframe",
|
||||||
|
src: tag.attribs.src
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.filter = filter;
|
||||||
446
src/database.js
Normal file
446
src/database.js
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
var Config = require("./config");
|
||||||
|
var tables = require("./database/tables");
|
||||||
|
import knex from 'knex';
|
||||||
|
import { GlobalBanDB } from './db/globalban';
|
||||||
|
import { MetadataCacheDB } from './database/metadata_cache';
|
||||||
|
import { Summary, Counter } from 'prom-client';
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('database');
|
||||||
|
const queryLatency = new Summary({
|
||||||
|
name: 'cytube_db_query_duration_seconds',
|
||||||
|
help: 'DB query latency (including time spent acquiring connections)'
|
||||||
|
});
|
||||||
|
const queryCount = new Counter({
|
||||||
|
name: 'cytube_db_queries_total',
|
||||||
|
help: 'DB query count'
|
||||||
|
});
|
||||||
|
const queryErrorCount = new Counter({
|
||||||
|
name: 'cytube_db_query_errors_total',
|
||||||
|
help: 'DB query error count'
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
queryLatency.reset();
|
||||||
|
}, 5 * 60 * 1000).unref();
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
let globalBanDB = null;
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
constructor(knexConfig = null) {
|
||||||
|
if (knexConfig === null) {
|
||||||
|
knexConfig = {
|
||||||
|
client: 'mysql',
|
||||||
|
connection: {
|
||||||
|
host: Config.get('mysql.server'),
|
||||||
|
port: Config.get('mysql.port'),
|
||||||
|
user: Config.get('mysql.user'),
|
||||||
|
password: Config.get('mysql.password'),
|
||||||
|
database: Config.get('mysql.database'),
|
||||||
|
multipleStatements: true, // Legacy thing
|
||||||
|
charset: 'utf8mb4'
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
min: Config.get('mysql.pool-size'),
|
||||||
|
max: Config.get('mysql.pool-size')
|
||||||
|
},
|
||||||
|
debug: !!process.env.KNEX_DEBUG
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.knex = knex(knexConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTransaction(fn) {
|
||||||
|
const end = queryLatency.startTimer();
|
||||||
|
return this.knex.transaction(fn).catch(error => {
|
||||||
|
queryErrorCount.inc(1);
|
||||||
|
throw error;
|
||||||
|
}).finally(() => {
|
||||||
|
end();
|
||||||
|
queryCount.inc(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.Database = Database;
|
||||||
|
module.exports.users = require("./database/accounts");
|
||||||
|
module.exports.channels = require("./database/channels");
|
||||||
|
|
||||||
|
module.exports.init = function (newDB) {
|
||||||
|
if (newDB) {
|
||||||
|
db = newDB;
|
||||||
|
} else {
|
||||||
|
db = new Database();
|
||||||
|
}
|
||||||
|
db.knex.raw('select 1 from dual')
|
||||||
|
.catch(error => {
|
||||||
|
LOGGER.error('Initial database connection failed: %s', error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.then(() => tables.initTables())
|
||||||
|
.then(() => {
|
||||||
|
require('./database/update').checkVersion();
|
||||||
|
module.exports.loadAnnouncement();
|
||||||
|
require('@cytube/mediaquery/lib/provider/youtube').setCache(
|
||||||
|
new MetadataCacheDB(db)
|
||||||
|
);
|
||||||
|
}).catch(error => {
|
||||||
|
LOGGER.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.getDB = function getDB() {
|
||||||
|
return db;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.getGlobalBanDB = function getGlobalBanDB() {
|
||||||
|
if (globalBanDB === null) {
|
||||||
|
globalBanDB = new GlobalBanDB(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalBanDB;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a database query
|
||||||
|
*/
|
||||||
|
module.exports.query = function (query, sub, callback) {
|
||||||
|
// 2nd argument is optional
|
||||||
|
if (typeof sub === "function") {
|
||||||
|
callback = sub;
|
||||||
|
sub = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SHOW_SQL) {
|
||||||
|
LOGGER.debug('%s', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = queryLatency.startTimer();
|
||||||
|
db.knex.raw(query, sub)
|
||||||
|
.then(res => {
|
||||||
|
process.nextTick(callback, null, res[0]);
|
||||||
|
}).catch(error => {
|
||||||
|
queryErrorCount.inc(1);
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
sub = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let subs = JSON.stringify(sub);
|
||||||
|
if (subs.length > 100) {
|
||||||
|
subs = subs.substring(0, 100) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to strip off the beginning of the message which
|
||||||
|
// contains the entire substituted SQL query (followed by an
|
||||||
|
// error code)
|
||||||
|
// Thanks MySQL/MariaDB...
|
||||||
|
error.message = error.message.replace(/^.* - ER/, 'ER');
|
||||||
|
|
||||||
|
LOGGER.error(
|
||||||
|
'Legacy DB query failed. Query: %s, Substitutions: %s, ' +
|
||||||
|
'Error: %s',
|
||||||
|
query,
|
||||||
|
subs,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
process.nextTick(callback, 'Database failure', null);
|
||||||
|
}).finally(() => {
|
||||||
|
end();
|
||||||
|
queryCount.inc(1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy function to be used as a callback when none is provided
|
||||||
|
*/
|
||||||
|
function blackHole() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* password recovery */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes recovery rows older than the given time
|
||||||
|
*/
|
||||||
|
module.exports.cleanOldPasswordResets = function (callback) {
|
||||||
|
if (typeof callback === "undefined") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "DELETE FROM password_reset WHERE expire < ?";
|
||||||
|
module.exports.query(query, [Date.now() - 24*60*60*1000], callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.addPasswordReset = function (data, cb) {
|
||||||
|
if (typeof cb !== "function") {
|
||||||
|
cb = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ip = data.ip || "";
|
||||||
|
var name = data.name;
|
||||||
|
var email = data.email;
|
||||||
|
var hash = data.hash;
|
||||||
|
var expire = data.expire;
|
||||||
|
|
||||||
|
if (!name || !hash) {
|
||||||
|
cb("Internal error: Must provide name and hash to insert a new password reset", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.query("INSERT INTO `password_reset` (`ip`, `name`, `email`, `hash`, `expire`) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE ip=?, hash=?, email=?, expire=?",
|
||||||
|
[ip, name, email, hash, expire, ip, hash, email, expire], cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.lookupPasswordReset = function (hash, cb) {
|
||||||
|
if (typeof cb !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.query("SELECT * FROM `password_reset` WHERE hash=?", [hash],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
cb(err, null);
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
cb("Invalid password reset link", null);
|
||||||
|
} else {
|
||||||
|
cb(null, rows[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.deletePasswordReset = function (hash) {
|
||||||
|
module.exports.query("DELETE FROM `password_reset` WHERE hash=?", [hash]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* user playlists */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all of a user's playlists
|
||||||
|
*/
|
||||||
|
module.exports.listUserPlaylists = function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "SELECT name, count, duration FROM user_playlists WHERE user=?";
|
||||||
|
module.exports.query(query, [name], callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a user playlist by (user, name) pair
|
||||||
|
*/
|
||||||
|
module.exports.getUserPlaylist = function (username, plname, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "SELECT contents FROM user_playlists WHERE " +
|
||||||
|
"user=? AND name=?";
|
||||||
|
|
||||||
|
module.exports.query(query, [username, plname], function (err, res) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.length == 0) {
|
||||||
|
callback("Playlist does not exist", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pl = null;
|
||||||
|
try {
|
||||||
|
pl = JSON.parse(res[0].contents);
|
||||||
|
} catch(e) {
|
||||||
|
callback("Malformed playlist JSON", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, pl);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a user playlist. Overwrites if the playlist keyed by
|
||||||
|
* (user, name) already exists
|
||||||
|
*/
|
||||||
|
module.exports.saveUserPlaylist = function (pl, username, plname, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmp = [], time = 0;
|
||||||
|
for(var i in pl) {
|
||||||
|
var e = {
|
||||||
|
id: pl[i].media.id,
|
||||||
|
title: pl[i].media.title,
|
||||||
|
seconds: pl[i].media.seconds || 0,
|
||||||
|
type: pl[i].media.type,
|
||||||
|
meta: {
|
||||||
|
codec: pl[i].media.meta.codec,
|
||||||
|
bitrate: pl[i].media.meta.bitrate,
|
||||||
|
scuri: pl[i].media.meta.scuri,
|
||||||
|
embed: pl[i].media.meta.embed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
time += pl[i].media.seconds || 0;
|
||||||
|
tmp.push(e);
|
||||||
|
}
|
||||||
|
var count = tmp.length;
|
||||||
|
var plText = JSON.stringify(tmp);
|
||||||
|
|
||||||
|
var query = "INSERT INTO user_playlists VALUES (?, ?, ?, ?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE contents=?, count=?, duration=?";
|
||||||
|
|
||||||
|
var params = [username, plname, plText, count, time,
|
||||||
|
plText, count, time];
|
||||||
|
|
||||||
|
module.exports.query(query, params, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user playlist
|
||||||
|
*/
|
||||||
|
module.exports.deleteUserPlaylist = function (username, plname, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "DELETE FROM user_playlists WHERE user=? AND name=?";
|
||||||
|
module.exports.query(query, [username, plname], callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* aliases */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a user or guest login in the aliases table
|
||||||
|
*/
|
||||||
|
module.exports.recordVisit = function (ip, name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var time = Date.now();
|
||||||
|
var query = "DELETE FROM aliases WHERE ip=? AND name=?;" +
|
||||||
|
"INSERT INTO aliases VALUES (NULL, ?, ?, ?)";
|
||||||
|
|
||||||
|
module.exports.query(query, [ip, name, ip, name, time], callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes alias rows older than the given time
|
||||||
|
*/
|
||||||
|
module.exports.cleanOldAliases = function (expiration, callback) {
|
||||||
|
if (typeof callback === "undefined") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "DELETE FROM aliases WHERE time < ?";
|
||||||
|
module.exports.query(query, [Date.now() - expiration], callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of aliases for an IP address
|
||||||
|
*/
|
||||||
|
module.exports.getAliases = function (ip, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "SELECT name,time FROM aliases WHERE ip";
|
||||||
|
// if the ip parameter is a /24 range, we want to match accordingly
|
||||||
|
if (ip.match(/^\d+\.\d+\.\d+$/) || ip.match(/^\d+\.\d+$/)) {
|
||||||
|
query += " LIKE ?";
|
||||||
|
ip += ".%";
|
||||||
|
} else if (ip.match(/^(?:[0-9a-f]{4}:){3}[0-9a-f]{4}$/) ||
|
||||||
|
ip.match(/^(?:[0-9a-f]{4}:){2}[0-9a-f]{4}$/)) {
|
||||||
|
query += " LIKE ?";
|
||||||
|
ip += ":%";
|
||||||
|
} else {
|
||||||
|
query += "=?";
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY time DESC LIMIT 5";
|
||||||
|
|
||||||
|
module.exports.query(query, [ip], function (err, res) {
|
||||||
|
var names = null;
|
||||||
|
if(!err) {
|
||||||
|
names = res.map(function (row) { return row.name; });
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(err, names);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of IPs that a name as logged in from
|
||||||
|
*/
|
||||||
|
module.exports.getIPs = function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = "SELECT ip FROM aliases WHERE name=?";
|
||||||
|
module.exports.query(query, [name], function (err, res) {
|
||||||
|
var ips = null;
|
||||||
|
if(!err) {
|
||||||
|
ips = res.map(function (row) { return row.ip; });
|
||||||
|
}
|
||||||
|
callback(err, ips);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* END REGION */
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
module.exports.loadAnnouncement = function () {
|
||||||
|
var query = "SELECT * FROM `meta` WHERE `key`='announcement'";
|
||||||
|
module.exports.query(query, function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var announcement = rows[0].value;
|
||||||
|
try {
|
||||||
|
announcement = JSON.parse(announcement);
|
||||||
|
} catch (e) {
|
||||||
|
LOGGER.error("Invalid announcement data in database: " +
|
||||||
|
announcement.value);
|
||||||
|
module.exports.clearAnnouncement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var Server = require("./server");
|
||||||
|
if (!Server.getServer || !Server.getServer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sv = Server.getServer();
|
||||||
|
sv.announcement = announcement;
|
||||||
|
for (var id in sv.ioServers) {
|
||||||
|
sv.ioServers[id].emit("announcement", announcement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.setAnnouncement = function (data) {
|
||||||
|
var query = "INSERT INTO `meta` (`key`, `value`) VALUES ('announcement', ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE `value`=?";
|
||||||
|
var repl = JSON.stringify(data);
|
||||||
|
module.exports.query(query, [repl, repl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.clearAnnouncement = function () {
|
||||||
|
module.exports.query("DELETE FROM `meta` WHERE `key`='announcement'");
|
||||||
|
};
|
||||||
588
src/database/accounts.js
Normal file
588
src/database/accounts.js
Normal file
|
|
@ -0,0 +1,588 @@
|
||||||
|
var $util = require("../utilities");
|
||||||
|
var bcrypt = require("bcrypt");
|
||||||
|
var db = require("../database");
|
||||||
|
var Config = require("../config");
|
||||||
|
import { promisify } from "bluebird";
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('database/accounts');
|
||||||
|
|
||||||
|
var registrationLock = {};
|
||||||
|
var blackHole = function () { };
|
||||||
|
|
||||||
|
function parseProfile(data) {
|
||||||
|
try {
|
||||||
|
var profile = JSON.parse(data.profile);
|
||||||
|
profile.image = profile.image || "";
|
||||||
|
profile.text = profile.text || "";
|
||||||
|
data.profile = profile;
|
||||||
|
} catch (error) {
|
||||||
|
data.profile = { image: "", text: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init: function () {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a username for deduplication purposes.
|
||||||
|
* Collapses visibily similar characters into a single character.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
dedupeUsername: function dedupeUsername(name) {
|
||||||
|
return name.replace(/[Il1]/ig, '1')
|
||||||
|
.replace(/[o0]/ig, '0')
|
||||||
|
.replace(/[_-]/g, '_');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a username is taken
|
||||||
|
*/
|
||||||
|
isUsernameTaken: function (name, callback) {
|
||||||
|
db.query("SELECT name FROM `users` WHERE name = ? or name_dedupe = ?",
|
||||||
|
[name, module.exports.dedupeUsername(name)],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched = null;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.name === name) {
|
||||||
|
matched = name;
|
||||||
|
} else if (matched === null) {
|
||||||
|
matched = row.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(
|
||||||
|
null,
|
||||||
|
rows.length > 0,
|
||||||
|
matched
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a user by any field
|
||||||
|
*/
|
||||||
|
search: function (where, like, fields, callback) {
|
||||||
|
// Don't allow search to return password hashes
|
||||||
|
if (fields.indexOf("password") !== -1) {
|
||||||
|
fields.splice(fields.indexOf("password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query(`SELECT ${fields.join(",")} FROM \`users\` WHERE ${where} LIKE ?`,
|
||||||
|
["%"+like+"%"],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, rows);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `users` WHERE name = ? AND inactive = FALSE",
|
||||||
|
[name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length !== 1) {
|
||||||
|
return callback("User does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
parseProfile(rows[0]);
|
||||||
|
callback(null, rows[0]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user account
|
||||||
|
*/
|
||||||
|
register: function (name, pw, email, ip, callback) {
|
||||||
|
// Start off with a boatload of error checking
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof pw !== "string") {
|
||||||
|
callback("You must provide a nonempty username and password", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var lname = name.toLowerCase();
|
||||||
|
|
||||||
|
if (registrationLock[lname]) {
|
||||||
|
callback("There is already a registration in progress for "+name,
|
||||||
|
null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$util.isValidUserName(name)) {
|
||||||
|
callback("Invalid username. Usernames may consist of 1-20 " +
|
||||||
|
"characters a-z, A-Z, 0-9, -, _, and accented letters.",
|
||||||
|
null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof email !== "string") {
|
||||||
|
email = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ip !== "string") {
|
||||||
|
ip = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// From this point forward, actual registration happens
|
||||||
|
// registrationLock prevents concurrent database activity
|
||||||
|
// on the same user account
|
||||||
|
registrationLock[lname] = true;
|
||||||
|
|
||||||
|
this.getAccounts(ip, function (err, accts) {
|
||||||
|
if (err) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accts.length >= Config.get("max-accounts-per-ip")) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
callback("You have registered too many accounts from this "+
|
||||||
|
"computer.", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.isUsernameTaken(name, function (err, taken, matched) {
|
||||||
|
if (err) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taken) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
|
||||||
|
if (matched === name) {
|
||||||
|
callback(
|
||||||
|
`Please choose a different username: "${name}" ` +
|
||||||
|
`is already registered.`,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
callback(
|
||||||
|
`Please choose a different username: "${name}" ` +
|
||||||
|
`too closely matches an existing name. ` +
|
||||||
|
`For example, "Joe" (lowercase 'o'), and ` +
|
||||||
|
`"j0e" (zero) are not considered unique.`,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt.hash(pw, 10, function (err, hash) {
|
||||||
|
if (err) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("INSERT INTO `users` " +
|
||||||
|
"(`name`, `password`, `global_rank`, `email`, `profile`, `ip`, `time`, `name_dedupe`)" +
|
||||||
|
" VALUES " +
|
||||||
|
"(?, ?, ?, ?, '', ?, ?, ?)",
|
||||||
|
[name, hash, 1, email, ip, Date.now(), module.exports.dedupeUsername(name)],
|
||||||
|
function (err, _res) {
|
||||||
|
delete registrationLock[lname];
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else {
|
||||||
|
callback(null, {
|
||||||
|
name: name,
|
||||||
|
hash: hash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a username/password pair
|
||||||
|
*/
|
||||||
|
verifyLogin: function (name, pw, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof pw !== "string") {
|
||||||
|
callback("Invalid username/password combination", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$util.isValidUserName(name)) {
|
||||||
|
callback(`Invalid username "${name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Passwords are capped at 100 characters to prevent a potential
|
||||||
|
denial of service vector through causing the server to hash
|
||||||
|
ridiculously long strings.
|
||||||
|
*/
|
||||||
|
pw = pw.substring(0, 100);
|
||||||
|
|
||||||
|
/* Note: rather than hash the password and then query based on name and
|
||||||
|
password, I query by name, then use bcrypt.compare() to check that
|
||||||
|
the hashes match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `users` WHERE name=? AND inactive = FALSE",
|
||||||
|
[name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
callback("User does not exist", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt.compare(pw, rows[0].password, function (err, match) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else if (!match) {
|
||||||
|
callback("Invalid username/password combination", null);
|
||||||
|
} else {
|
||||||
|
parseProfile(rows[0]);
|
||||||
|
callback(null, rows[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a user's password
|
||||||
|
*/
|
||||||
|
setPassword: function (name, pw, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof pw !== "string") {
|
||||||
|
callback("Invalid username/password combination", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Passwords are capped at 100 characters to prevent a potential
|
||||||
|
denial of service vector through causing the server to hash
|
||||||
|
ridiculously long strings.
|
||||||
|
*/
|
||||||
|
pw = pw.substring(0, 100);
|
||||||
|
|
||||||
|
bcrypt.hash(pw, 10, function (err, hash) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE `users` SET password=? WHERE name=?",
|
||||||
|
[hash, name],
|
||||||
|
function (err, _result) {
|
||||||
|
callback(err, err ? null : true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a user's global rank
|
||||||
|
*/
|
||||||
|
getGlobalRank: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
callback(null, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT global_rank FROM `users` WHERE name=?", [name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
callback(null, 0);
|
||||||
|
} else {
|
||||||
|
callback(null, rows[0].global_rank);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a user's global rank
|
||||||
|
*/
|
||||||
|
setGlobalRank: function (name, rank, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rank !== "number") {
|
||||||
|
callback("Invalid rank", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE `users` SET global_rank=? WHERE name=?", [rank, name],
|
||||||
|
function (err, _result) {
|
||||||
|
callback(err, err ? null : true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup multiple users' global rank in one query
|
||||||
|
*/
|
||||||
|
getGlobalRanks: function (names, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(names instanceof Array)) {
|
||||||
|
callback("Expected array of names, got " + typeof names, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
return callback(null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = "(" + names.map(function () { return "?";}).join(",") + ")";
|
||||||
|
|
||||||
|
db.query("SELECT global_rank FROM `users` WHERE name IN " + list, names,
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
callback(null, []);
|
||||||
|
} else {
|
||||||
|
callback(null, rows.map(function (x) { return x.global_rank; }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a user's email
|
||||||
|
*/
|
||||||
|
getEmail: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT email FROM `users` WHERE name=? AND inactive = FALSE", [name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
callback("User does not exist", null);
|
||||||
|
} else {
|
||||||
|
callback(null, rows[0].email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a user's email
|
||||||
|
*/
|
||||||
|
setEmail: function (name, email, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof email !== "string") {
|
||||||
|
callback("Invalid email", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE `users` SET email=? WHERE name=?", [email, name],
|
||||||
|
function (err, _result) {
|
||||||
|
callback(err, err ? null : true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a user's profile
|
||||||
|
*/
|
||||||
|
getProfile: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT profile FROM `users` WHERE name=?", [name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
callback("User does not exist", null);
|
||||||
|
} else {
|
||||||
|
var userprof = {
|
||||||
|
image: "",
|
||||||
|
text: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rows[0].profile === "") {
|
||||||
|
callback(null, userprof);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var profile = JSON.parse(rows[0].profile);
|
||||||
|
userprof.image = profile.image || "";
|
||||||
|
userprof.text = profile.text || "";
|
||||||
|
callback(null, userprof);
|
||||||
|
} catch (e) {
|
||||||
|
LOGGER.error("Corrupt profile: " + rows[0].profile +
|
||||||
|
" (user: " + name + ")");
|
||||||
|
callback(null, userprof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a user's profile
|
||||||
|
*/
|
||||||
|
setProfile: function (name, profile, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
callback("Invalid username", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof profile !== "object") {
|
||||||
|
callback("Invalid profile", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast to string to guarantee string type
|
||||||
|
profile.image += "";
|
||||||
|
profile.text += "";
|
||||||
|
|
||||||
|
// Limit size
|
||||||
|
profile.image = profile.image.substring(0, 255);
|
||||||
|
profile.text = profile.text.substring(0, 255);
|
||||||
|
|
||||||
|
// Stringify the literal to guarantee I only get the keys I want
|
||||||
|
var profilejson = JSON.stringify({
|
||||||
|
image: profile.image,
|
||||||
|
text: profile.text
|
||||||
|
});
|
||||||
|
|
||||||
|
db.query("UPDATE `users` SET profile=? WHERE name=?", [profilejson, name],
|
||||||
|
function (err, _result) {
|
||||||
|
callback(err, err ? null : true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all names registered from a given IP
|
||||||
|
*/
|
||||||
|
getAccounts: function (ip, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT name,global_rank FROM `users` WHERE `ip`=?", [ip],
|
||||||
|
callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
requestAccountDeletion: id => {
|
||||||
|
return db.getDB().runTransaction(async tx => {
|
||||||
|
try {
|
||||||
|
let user = await tx.table('users').where({ id }).first();
|
||||||
|
await tx.table('user_deletion_requests')
|
||||||
|
.insert({
|
||||||
|
user_id: id
|
||||||
|
});
|
||||||
|
await tx.table('users')
|
||||||
|
.where({ id })
|
||||||
|
.update({ password: '', inactive: true });
|
||||||
|
|
||||||
|
// TODO: ideally password reset should be by user_id and not name
|
||||||
|
// For now, we need to make sure to clear it
|
||||||
|
await tx.table('password_reset')
|
||||||
|
.where({ name: user.name })
|
||||||
|
.delete();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore unique violation -- probably caused by a duplicate request
|
||||||
|
if (error.code !== 'ER_DUP_ENTRY') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
findAccountsPendingDeletion: () => {
|
||||||
|
return db.getDB().runTransaction(tx => {
|
||||||
|
let lastWeek = new Date(Date.now() - 7 * 24 * 3600 * 1000);
|
||||||
|
return tx.table('user_deletion_requests')
|
||||||
|
.where('user_deletion_requests.created_at', '<', lastWeek)
|
||||||
|
.join('users', 'user_deletion_requests.user_id', '=', 'users.id')
|
||||||
|
.select('users.id', 'users.name');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
purgeAccount: id => {
|
||||||
|
return db.getDB().runTransaction(async tx => {
|
||||||
|
let user = await tx.table('users').where({ id }).first();
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.table('channel_ranks').where({ name: user.name }).delete();
|
||||||
|
await tx.table('user_playlists').where({ user: user.name }).delete();
|
||||||
|
await tx.table('users').where({ id }).delete();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.verifyLoginAsync = promisify(module.exports.verifyLogin);
|
||||||
708
src/database/channels.js
Normal file
708
src/database/channels.js
Normal file
|
|
@ -0,0 +1,708 @@
|
||||||
|
var db = require("../database");
|
||||||
|
var valid = require("../utilities").isValidChannelName;
|
||||||
|
var Flags = require("../flags");
|
||||||
|
var util = require("../utilities");
|
||||||
|
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
|
||||||
|
import Config from '../config';
|
||||||
|
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('database/channels');
|
||||||
|
|
||||||
|
var blackHole = function () { };
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init: function () {
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given channel name is registered
|
||||||
|
*/
|
||||||
|
isChannelTaken: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(name)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT name FROM `channels` WHERE name=?",
|
||||||
|
[name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, rows.length > 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a channel
|
||||||
|
*/
|
||||||
|
lookup: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(name)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channels` WHERE name=?",
|
||||||
|
[name],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
callback("No such channel", null);
|
||||||
|
} else {
|
||||||
|
callback(null, rows[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for a channel
|
||||||
|
*/
|
||||||
|
search: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channels` WHERE name LIKE ?",
|
||||||
|
["%" + name + "%"],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, rows);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for a channel by owner
|
||||||
|
*/
|
||||||
|
searchOwner: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channels` WHERE owner LIKE ?",
|
||||||
|
["%" + name + "%"],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, rows);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and registers a new channel
|
||||||
|
*/
|
||||||
|
register: function (name, owner, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof owner !== "string") {
|
||||||
|
callback("Name and owner are required for channel registration", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(name)) {
|
||||||
|
callback("Invalid channel name. Channel names may consist of 1-30 " +
|
||||||
|
"characters a-z, A-Z, 0-9, -, and _", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.isChannelTaken(name, function (err, taken) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taken) {
|
||||||
|
callback("Channel name " + name + " is already taken", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("INSERT INTO `channels` " +
|
||||||
|
"(`name`, `owner`, `time`, `last_loaded`) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, owner, Date.now(), new Date()],
|
||||||
|
function (err, _res) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.users.getGlobalRank(owner, function (err, rank) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rank = Math.max(rank, 5);
|
||||||
|
|
||||||
|
module.exports.setRank(name, owner, rank, function (err) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, { name: name });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a channel
|
||||||
|
*/
|
||||||
|
drop: function (name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(name)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channels` WHERE name=?", [name], function (err) {
|
||||||
|
|
||||||
|
module.exports.deleteBans(name, function (err) {
|
||||||
|
if (err) {
|
||||||
|
LOGGER.error("Failed to delete bans for " + name + ": " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.deleteLibrary(name, function (err) {
|
||||||
|
if (err) {
|
||||||
|
LOGGER.error("Failed to delete library for " + name + ": " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.deleteAllRanks(name, function (err) {
|
||||||
|
if (err) {
|
||||||
|
LOGGER.error("Failed to delete ranks for " + name + ": " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(err, !err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up channels registered by a given user
|
||||||
|
*/
|
||||||
|
listUserChannels: function (owner, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channels` WHERE owner=?", [owner],
|
||||||
|
function (err, res) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(err, res);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
listUserChannelsAsync: owner => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
module.exports.listUserChannels(owner, (error, rows) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the channel from the database
|
||||||
|
*/
|
||||||
|
load: function (chan, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan.name)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channels` WHERE name=?", chan.name, function (err, res) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.length === 0) {
|
||||||
|
callback("Channel is not registered", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chan.dead) {
|
||||||
|
callback("Channel is dead", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that before this line, chan.name might have a different capitalization
|
||||||
|
// than the database has stored. Update accordingly.
|
||||||
|
chan.name = res[0].name;
|
||||||
|
chan.uniqueName = chan.name.toLowerCase();
|
||||||
|
chan.id = res[0].id;
|
||||||
|
chan.ownerName = typeof res[0].owner === 'string' ? res[0].owner.toLowerCase() : null;
|
||||||
|
chan.setFlag(Flags.C_REGISTERED);
|
||||||
|
chan.logger.log("[init] Loaded channel from database");
|
||||||
|
callback(null, true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a user's rank
|
||||||
|
*/
|
||||||
|
getRank: function (chan, name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_ranks` WHERE name=? AND channel=?",
|
||||||
|
[name, chan],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
callback(null, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, rows[0].rank);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up multiple users' ranks at once
|
||||||
|
*/
|
||||||
|
getRanks: function (chan, names, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var replace = "(" + names.map(function () { return "?"; }).join(",") + ")";
|
||||||
|
|
||||||
|
/* Last substitution is the channel to select ranks for */
|
||||||
|
const sub = names.concat([chan]);
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_ranks` WHERE name IN " +
|
||||||
|
replace + " AND channel=?", sub,
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, rows.map(function (r) { return r.rank; }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query all user ranks at once
|
||||||
|
*/
|
||||||
|
allRanks: function (chan, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_ranks` WHERE channel=?", [chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a user's rank
|
||||||
|
*/
|
||||||
|
setRank: function (chan, name, rank, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rank < 2) {
|
||||||
|
module.exports.deleteRank(chan, name, callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("INSERT INTO `channel_ranks` VALUES (?, ?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE `rank`=?",
|
||||||
|
[name, rank, chan, rank], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user's rank entry
|
||||||
|
*/
|
||||||
|
deleteRank: function (chan, name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_ranks` WHERE name=? AND channel=?", [name, chan],
|
||||||
|
callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all ranks for a channel
|
||||||
|
*/
|
||||||
|
deleteAllRanks: function (chan, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_ranks` WHERE channel=?", [chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a media item to the library
|
||||||
|
*/
|
||||||
|
addToLibrary: function (chan, media, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = JSON.stringify({
|
||||||
|
bitrate: media.meta.bitrate,
|
||||||
|
codec: media.meta.codec,
|
||||||
|
scuri: media.meta.scuri,
|
||||||
|
embed: media.meta.embed,
|
||||||
|
direct: media.meta.direct
|
||||||
|
});
|
||||||
|
|
||||||
|
db.query("INSERT INTO `channel_libraries` " +
|
||||||
|
"(id, title, seconds, type, meta, channel) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE id=id",
|
||||||
|
[media.id, media.title, media.seconds, media.type, meta, chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of media items to the library
|
||||||
|
*/
|
||||||
|
addListToLibrary: async function addListToLibrary(chan, list) {
|
||||||
|
if (!valid(chan)) {
|
||||||
|
throw new Error("Invalid channel name");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length > Config.get("playlist.max-items")) {
|
||||||
|
throw new Error("Cannot save list to library: exceeds max-items");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = list.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
seconds: item.seconds,
|
||||||
|
type: item.type,
|
||||||
|
meta: JSON.stringify({
|
||||||
|
bitrate: item.meta.bitrate,
|
||||||
|
codec: item.meta.codec,
|
||||||
|
scuri: item.meta.scuri,
|
||||||
|
embed: item.meta.embed,
|
||||||
|
direct: item.meta.direct
|
||||||
|
}),
|
||||||
|
channel: chan
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.getDB().runTransaction(tx => {
|
||||||
|
const insert = tx.table('channel_libraries')
|
||||||
|
.insert(items);
|
||||||
|
|
||||||
|
const update = tx.raw(createMySQLDuplicateKeyUpdate(
|
||||||
|
['title', 'seconds', 'meta']
|
||||||
|
));
|
||||||
|
|
||||||
|
return tx.raw(insert.toString() + update.toString());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a media item from the library by id
|
||||||
|
*/
|
||||||
|
getLibraryItem: function (chan, id, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_libraries` WHERE id=? AND channel=?", [id, chan],
|
||||||
|
function (err, rows) {
|
||||||
|
if (err) {
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
callback("Item not in library", null);
|
||||||
|
} else {
|
||||||
|
callback(null, rows[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the library by title
|
||||||
|
*/
|
||||||
|
searchLibrary: function (chan, search, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_libraries` WHERE title LIKE ? AND channel=?",
|
||||||
|
["%" + search + "%", chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a media item from the library
|
||||||
|
*/
|
||||||
|
deleteFromLibrary: function (chan, id, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_libraries` WHERE id=? AND channel=?",
|
||||||
|
[id, chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all library entries for a channel
|
||||||
|
*/
|
||||||
|
deleteLibrary: function (chan, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_libraries` WHERE channel=?", [chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a ban to the banlist
|
||||||
|
*/
|
||||||
|
ban: function (chan, ip, name, note, bannedby, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("INSERT INTO `channel_bans` (ip, name, reason, bannedby, channel) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[ip, name, note, bannedby, chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP address or range is banned
|
||||||
|
*/
|
||||||
|
isIPBanned: function (chan, ip, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = util.getIPRange(ip);
|
||||||
|
var wrange = util.getWideIPRange(ip);
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_bans` WHERE ip IN (?, ?, ?) AND channel=?",
|
||||||
|
[ip, range, wrange, chan],
|
||||||
|
function (err, rows) {
|
||||||
|
callback(err, err ? false : rows.length > 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a username is banned
|
||||||
|
*/
|
||||||
|
isNameBanned: function (chan, name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_bans` WHERE name=? AND channel=?", [name, chan],
|
||||||
|
function (err, rows) {
|
||||||
|
callback(err, err ? false : rows.length > 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user's name or IP is banned
|
||||||
|
*/
|
||||||
|
isBanned: function (chan, ip, name, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = util.getIPRange(ip);
|
||||||
|
var wrange = util.getWideIPRange(ip);
|
||||||
|
|
||||||
|
db.query("SELECT COUNT(1) AS count FROM `channel_bans` WHERE (ip IN (?, ?, ?) OR name=?) AND channel=?",
|
||||||
|
[ip, range, wrange, name, chan],
|
||||||
|
function (err, rows) {
|
||||||
|
callback(err, err ? false : rows.length > 0 && rows[0].count > 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all bans
|
||||||
|
*/
|
||||||
|
listBans: function (chan, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("SELECT * FROM `channel_bans` WHERE channel=?", [chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a ban from the banlist
|
||||||
|
*/
|
||||||
|
unbanId: function (chan, id, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_bans` WHERE id=? AND channel=?",
|
||||||
|
[id, chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all bans from a channel
|
||||||
|
*/
|
||||||
|
deleteBans: function (chan, id, callback) {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
callback = blackHole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid(chan)) {
|
||||||
|
callback("Invalid channel name", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("DELETE FROM `channel_bans` WHERE channel=?", [chan], callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the `last_loaded` column to be the current timestamp
|
||||||
|
*/
|
||||||
|
updateLastLoaded: function updateLastLoaded(channelId) {
|
||||||
|
if (channelId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE channels SET last_loaded = ? WHERE id = ?", [new Date(), channelId], error => {
|
||||||
|
if (error) {
|
||||||
|
LOGGER.error(`Failed to update last_loaded column for channel ID ${channelId}: ${error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the `owner_last_seen` column to be the current timestamp
|
||||||
|
*/
|
||||||
|
updateOwnerLastSeen: function updateOwnerLastSeen(channelId) {
|
||||||
|
if (channelId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE channels SET owner_last_seen = ? WHERE id = ?", [new Date(), channelId], error => {
|
||||||
|
if (error) {
|
||||||
|
LOGGER.error(`Failed to update owner_last_seen column for channel ID ${channelId}: ${error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
81
src/database/metadata_cache.js
Normal file
81
src/database/metadata_cache.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Summary } from 'prom-client';
|
||||||
|
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
|
||||||
|
|
||||||
|
const Media = require('@cytube/mediaquery/lib/media');
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('metadata-cache');
|
||||||
|
|
||||||
|
// TODO: these fullname-vs-shortcode hacks really need to be abolished
|
||||||
|
function mediaquery2cytube(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'youtube':
|
||||||
|
return 'yt';
|
||||||
|
default:
|
||||||
|
throw new Error(`mediaquery2cytube: no mapping for ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cytube2mediaquery(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'yt':
|
||||||
|
return 'youtube';
|
||||||
|
default:
|
||||||
|
throw new Error(`cytube2mediaquery: no mapping for ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedResultAge = new Summary({
|
||||||
|
name: 'cytube_yt_cache_result_age_seconds',
|
||||||
|
help: 'Age (in seconds) of cached record'
|
||||||
|
});
|
||||||
|
|
||||||
|
class MetadataCacheDB {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(media) {
|
||||||
|
media = new Media(media);
|
||||||
|
media.type = mediaquery2cytube(media.type);
|
||||||
|
return this.db.runTransaction(async tx => {
|
||||||
|
let insert = tx.table('media_metadata_cache')
|
||||||
|
.insert({
|
||||||
|
id: media.id,
|
||||||
|
type: media.type,
|
||||||
|
metadata: JSON.stringify(media),
|
||||||
|
updated_at: tx.raw('CURRENT_TIMESTAMP')
|
||||||
|
});
|
||||||
|
let update = tx.raw(createMySQLDuplicateKeyUpdate(
|
||||||
|
['metadata', 'updated_at']
|
||||||
|
));
|
||||||
|
|
||||||
|
return tx.raw(insert.toString() + update.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id, type) {
|
||||||
|
return this.db.runTransaction(async tx => {
|
||||||
|
let row = await tx.table('media_metadata_cache')
|
||||||
|
.where({ id, type })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (row === undefined || row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let age = (Date.now() - row.updated_at.getTime())/1000;
|
||||||
|
if (age > 0) {
|
||||||
|
cachedResultAge.observe(age);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
LOGGER.error('Failed to record cachedResultAge metric: %s', error.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = JSON.parse(row.metadata);
|
||||||
|
metadata.type = cytube2mediaquery(metadata.type);
|
||||||
|
return new Media(metadata);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MetadataCacheDB };
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue