Compare commits
2 commits
postgreSQL
...
account-se
Author | SHA1 | Date | |
---|---|---|---|
76328fed07 | |||
c5c88264f7 |
|
@ -1,6 +0,0 @@
|
|||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_size = 4
|
||||
indent_style = space
|
45
.eslintrc.js
45
.eslintrc.js
|
@ -1,45 +0,0 @@
|
|||
/* ESLint Config */
|
||||
module.exports = {
|
||||
env: {
|
||||
'es2017': true,
|
||||
// others envs defined by cascading .eslintrc files
|
||||
},
|
||||
extends: 'eslint:recommended',
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
'sourceType': 'module',
|
||||
},
|
||||
rules: {
|
||||
'brace-style': ['error','1tbs',{ 'allowSingleLine': true }],
|
||||
'indent': [
|
||||
'off', // temporary... a lot of stuff needs to be reformatted | 2020-08-21: I guess it's not so temporary...
|
||||
4,
|
||||
{ 'SwitchCase': 1 }
|
||||
],
|
||||
'linebreak-style': ['error','unix'],
|
||||
'no-control-regex': ['off'],
|
||||
'no-prototype-builtins': ['off'], // should consider cleaning up the code and turning this back on at some point
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-unused-vars': [
|
||||
'error', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_|^Promise$'
|
||||
}
|
||||
],
|
||||
'semi': ['error','always'],
|
||||
'quotes': ['off'] // Old code uses double quotes, new code uses single / template
|
||||
},
|
||||
ignorePatterns: [
|
||||
// These are not ours
|
||||
'www/js/dash.all.min.js',
|
||||
'www/js/jquery-1.12.4.min.js',
|
||||
'www/js/jquery-ui.js',
|
||||
'www/js/peertube.js',
|
||||
'www/js/playerjs-0.0.12.js',
|
||||
'www/js/sc.js',
|
||||
'www/js/video.js',
|
||||
'www/js/videojs-contrib-hls.min.js',
|
||||
'www/js/videojs-dash.js',
|
||||
'www/js/videojs-resolution-switcher.js',
|
||||
],
|
||||
}
|
33
.eslintrc.yml
Normal file
33
.eslintrc.yml
Normal file
|
@ -0,0 +1,33 @@
|
|||
env:
|
||||
es6: true
|
||||
node: true
|
||||
extends: 'eslint:recommended'
|
||||
parser: 'babel-eslint'
|
||||
parserOptions:
|
||||
sourceType: module
|
||||
ecmaVersion: 2017 # For async/await
|
||||
rules:
|
||||
brace-style:
|
||||
- error
|
||||
- 1tbs
|
||||
- allowSingleLine: true
|
||||
indent:
|
||||
- off # temporary... a lot of stuff needs to be reformatted
|
||||
- 4
|
||||
- SwitchCase: 1
|
||||
linebreak-style:
|
||||
- error
|
||||
- unix
|
||||
no-control-regex:
|
||||
- off
|
||||
no-trailing-spaces:
|
||||
- error
|
||||
no-unused-vars:
|
||||
- error
|
||||
- argsIgnorePattern: ^_
|
||||
varsIgnorePattern: ^_|^Promise$
|
||||
semi:
|
||||
- error
|
||||
- always
|
||||
quotes:
|
||||
- off # Old code uses double quotes, new code uses single / template
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -18,4 +18,3 @@ www/js/cytube-google-drive.user.js
|
|||
www/js/cytube-google-drive.meta.js
|
||||
www/js/player.js
|
||||
tor-exit-list.json
|
||||
*.patch
|
||||
|
|
12
.travis.yml
12
.travis.yml
|
@ -4,11 +4,11 @@ addons:
|
|||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- gcc-9
|
||||
- g++-9
|
||||
- gcc-4.8
|
||||
- g++-4.8
|
||||
env:
|
||||
- CXX="g++-9"
|
||||
- CXX="g++-4.8"
|
||||
node_js:
|
||||
- "15"
|
||||
- "14"
|
||||
- "12"
|
||||
- "11"
|
||||
- "10"
|
||||
- "8"
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2013-2022 Calvin Montgomery and contributors
|
||||
Copyright (c) 2013-2018 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:
|
||||
|
||||
|
|
139
NEWS.md
139
NEWS.md
|
@ -1,142 +1,3 @@
|
|||
2022-09-21
|
||||
==========
|
||||
|
||||
**Upgrade intervention required**
|
||||
|
||||
This release adds a feature to ban channels, replacing the earlier (hastily
|
||||
added) configuration-based `channel-blacklist`. If you have any entries in
|
||||
`channel-blacklist` in your `config.yaml`, you will need to migrate them to the
|
||||
new bans table by using a command after upgrading (the ACP web interface hasn't
|
||||
been updated for this feature):
|
||||
|
||||
./bin/admin.js ban-channel <channel-name> <external-reason> <internal-reason>
|
||||
|
||||
The external reason will be displayed when users attempt to join the banned
|
||||
channel, while the internal reason is only displayed when using the
|
||||
`show-channel-ban` command.
|
||||
|
||||
You can later use `unban-channel` to remove a ban. The owner of the banned
|
||||
channel can still delete it, but the banned state will persist, so the channel
|
||||
cannot be re-registered later.
|
||||
|
||||
2022-08-28
|
||||
==========
|
||||
|
||||
This release integrates Xaekai's added support for Bandcamp, BitChute, Odysee,
|
||||
and Nicovideo playback support into the main repository. The updated support
|
||||
for custom fonts and audio tracks in custom media manifests is also included,
|
||||
but does not work out of the box -- it requires a separate channel script; this
|
||||
may be addressed in the future.
|
||||
|
||||
2021-08-14
|
||||
==========
|
||||
|
||||
CyTube has been upgraded to socket.io v4 (from v2).
|
||||
|
||||
**Breaking change:** Newer versions of socket.io require CORS to validate the
|
||||
origin initiating the socket connection. CyTube allows the origins specified in
|
||||
the `io.domain` and `https.domain` configuration keys by default, which should
|
||||
work for many use cases, however, if you host your website on a different domain
|
||||
than the socket connection, you will need to configure the allowed origins (see
|
||||
config.template.yaml under `io.cors`).
|
||||
|
||||
CyTube enables the `allowEIO3` configuration in socket.io by default, which
|
||||
means that existing clients and bots using socket.io-client v2 should continue
|
||||
to work.
|
||||
|
||||
2021-08-12
|
||||
==========
|
||||
|
||||
The legacy metrics recorder (`counters.log` file) has been removed. For over 4
|
||||
years now, CyTube has integrated with [Prometheus](https://prometheus.io/),
|
||||
which provides a superior way to monitor the application. Copy
|
||||
`conf/example/prometheus.toml` to `conf/prometheus.toml` and edit it to
|
||||
configure CyTube's Prometheus support.
|
||||
|
||||
2021-08-12
|
||||
==========
|
||||
|
||||
Due to changes in Soundcloud's authorization scheme, support has been dropped
|
||||
from core due to requiring each server owner to register an API key (which is
|
||||
currently impossible as they have not accepted new API key registrations for
|
||||
*years*).
|
||||
|
||||
If you happen to already have an API key registered, or if Soundcloud reopens
|
||||
registration at some point in the future, feel free to reach out to me for
|
||||
patches to reintroduce support for it.
|
||||
|
||||
2020-08-21
|
||||
==========
|
||||
|
||||
Some of CyTube's dependencies depends on features in newer versions of node.js.
|
||||
Accordingly, node 10 is no longer supported. Administrators are recommended to
|
||||
use node 12 (the active LTS), or node 14 (the current version).
|
||||
|
||||
2020-06-22
|
||||
==========
|
||||
|
||||
Twitch has [updated their embed
|
||||
player](https://discuss.dev.twitch.tv/t/twitch-embedded-player-migration-timeline-update/25588),
|
||||
which adds new requirements for embedding Twitch:
|
||||
|
||||
1. The origin website must be served over HTTPS
|
||||
2. The origin website must be served over the default port (i.e., the hostname
|
||||
cannot include a port; https://example.com:8443 won't work)
|
||||
|
||||
Additionally, third-party cookies must be enabled for whatever internal
|
||||
subdomains Twitch is using.
|
||||
|
||||
CyTube now sets the parameters expected by Twitch, and displays an error message
|
||||
if it detects (1) or (2) above are not met.
|
||||
|
||||
2020-02-15
|
||||
==========
|
||||
|
||||
Old versions of CyTube defaulted to storing channel state in flatfiles located
|
||||
in the `chandump` directory. The default was changed a while ago, and the
|
||||
flatfile storage mechanism has now been removed.
|
||||
|
||||
Admins who have not already migrated their installation to the "database"
|
||||
channel storage type can do so by following these instructions:
|
||||
|
||||
1. Run `git checkout e3a9915b454b32e49d3871c94c839899f809520a` to temporarily
|
||||
switch to temporarily revert to the previous version of the code that
|
||||
supports the "file" channel storage type
|
||||
2. Run `npm run build-server` to build the old version
|
||||
3. Run `node lib/channel-storage/migrator.js |& tee migration.log` to migrate
|
||||
channel state from files to the database
|
||||
4. Inspect the output of the migration tool for errors
|
||||
5. Set `channel-storage`/`type` to `"database"` in `config.yaml` and start the
|
||||
server. Load a channel to verify the migration worked as expected
|
||||
6. Upgrade back to the latest version with `git checkout 3.0` and `npm run
|
||||
build-server`
|
||||
7. Remove the `channel-storage` block from `config.yaml` and remove the
|
||||
`chandump` directory since it is no longer needed (you may wish to archive
|
||||
it somewhere in case you later discover the migration didn't work as
|
||||
expected).
|
||||
|
||||
If you encounter any errors during the process, please file an issue on GitHub
|
||||
and attach the output of the migration tool (which if you use the above commands
|
||||
will be written to `migration.log`).
|
||||
|
||||
2019-12-01
|
||||
==========
|
||||
|
||||
In accordance with node v8 LTS becoming end-of-life on 2019-12-31, CyTube no
|
||||
longer supports v8.
|
||||
|
||||
Please upgrade to v10 or v12 (active LTS); refer to
|
||||
https://nodejs.org/en/about/releases/ for the node.js support timelines.
|
||||
|
||||
2018-12-07
|
||||
==========
|
||||
|
||||
Users can now self-service request their account to be deleted, and it will be
|
||||
automatically purged after 7 days. In order to send a notification email to
|
||||
the user about the request, copy the [email
|
||||
configuration](https://github.com/calzoneman/sync/blob/3.0/conf/example/email.toml#L43)
|
||||
to `conf/email.toml` (the same file used for password reset emails).
|
||||
|
||||
2018-10-21
|
||||
==========
|
||||
|
||||
|
|
176
bin/admin.js
176
bin/admin.js
|
@ -1,176 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const Config = require('../lib/config');
|
||||
Config.load('config.yaml');
|
||||
|
||||
if (!Config.get('service-socket.enabled')){
|
||||
console.error('The Service Socket is not enabled.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const readline = require('node:readline/promises');
|
||||
|
||||
const socketPath = path.resolve(__dirname, '..', Config.get('service-socket.socket'));
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function doCommand(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = net.createConnection(socketPath);
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(JSON.stringify(params) + '\n');
|
||||
});
|
||||
|
||||
client.on('data', data => {
|
||||
client.end();
|
||||
resolve(JSON.parse(data));
|
||||
});
|
||||
|
||||
client.on('error', error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let commands = [
|
||||
{
|
||||
command: 'ban-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 3) {
|
||||
console.log('Usage: ban-channel <name> <externalReason> <internalReason>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name, externalReason, internalReason] = args;
|
||||
let answer = await rl.question(`Ban ${name} with external reason "${externalReason}" and internal reason "${internalReason}"? `);
|
||||
|
||||
if (!/^[yY]$/.test(answer)) {
|
||||
console.log('Aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'ban-channel',
|
||||
name,
|
||||
externalReason,
|
||||
internalReason
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
console.log('Ban succeeded.');
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
command: 'unban-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 1) {
|
||||
console.log('Usage: unban-channel <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name] = args;
|
||||
let answer = await rl.question(`Unban ${name}? `);
|
||||
|
||||
if (!/^[yY]$/.test(answer)) {
|
||||
console.log('Aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'unban-channel',
|
||||
name
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
console.log('Unban succeeded.');
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
command: 'show-banned-channel',
|
||||
handler: async args => {
|
||||
if (args.length !== 1) {
|
||||
console.log('Usage: show-banned-channel <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [name] = args;
|
||||
|
||||
let res = await doCommand({
|
||||
command: 'show-banned-channel',
|
||||
name
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 'error':
|
||||
console.log('Error:', res.error);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'success':
|
||||
if (res.ban != null) {
|
||||
console.log(`Channel: ${name}`);
|
||||
console.log(`Ban issued: ${res.ban.createdAt}`);
|
||||
console.log(`Banned by: ${res.ban.bannedBy}`);
|
||||
console.log(`External reason:\n${res.ban.externalReason}`);
|
||||
console.log(`Internal reason:\n${res.ban.internalReason}`);
|
||||
} else {
|
||||
console.log(`Channel ${name} is not banned.`);
|
||||
}
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown result: ${res.status}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
let found = false;
|
||||
commands.forEach(cmd => {
|
||||
if (cmd.command === process.argv[2]) {
|
||||
found = true;
|
||||
cmd.handler(process.argv.slice(3)).then(() => {
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.log('Error in command:', error.stack);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
console.log('Available commands:');
|
||||
commands.forEach(cmd => {
|
||||
console.log(` * ${cmd.command}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
|
@ -6,35 +6,26 @@ var path = require('path');
|
|||
|
||||
var order = [
|
||||
'base.coffee',
|
||||
|
||||
'dailymotion.coffee',
|
||||
'niconico.coffee',
|
||||
'peertube.coffee',
|
||||
'soundcloud.coffee',
|
||||
'twitch.coffee',
|
||||
'vimeo.coffee',
|
||||
'youtube.coffee',
|
||||
|
||||
// playerjs-based players
|
||||
'playerjs.coffee',
|
||||
'iframechild.coffee',
|
||||
'odysee.coffee',
|
||||
'streamable.coffee',
|
||||
|
||||
// iframe embed-based players
|
||||
'embed.coffee',
|
||||
'custom-embed.coffee',
|
||||
'livestream.com.coffee',
|
||||
'twitchclip.coffee',
|
||||
|
||||
// video.js-based players
|
||||
'dailymotion.coffee',
|
||||
'videojs.coffee',
|
||||
'playerjs.coffee',
|
||||
'streamable.coffee',
|
||||
'gdrive-player.coffee',
|
||||
'hls.coffee',
|
||||
'raw-file.coffee',
|
||||
'soundcloud.coffee',
|
||||
'embed.coffee',
|
||||
'twitch.coffee',
|
||||
'livestream.com.coffee',
|
||||
'custom-embed.coffee',
|
||||
'rtmp.coffee',
|
||||
|
||||
// mediaUpdate handler
|
||||
'smashcast.coffee',
|
||||
'ustream.coffee',
|
||||
'imgur.coffee',
|
||||
'gdrive-youtube.coffee',
|
||||
'hls.coffee',
|
||||
'mixer.coffee',
|
||||
'update.coffee'
|
||||
];
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
[hcaptcha]
|
||||
# Site key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||
site-key = "10000000-ffff-ffff-ffff-000000000001"
|
||||
# Secret key from hCaptcha. The value here by default is the dummy test key for local testing
|
||||
secret = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
[register]
|
||||
# Whether to require a captcha for registration
|
||||
enabled = true
|
|
@ -107,20 +107,17 @@ io:
|
|||
default-port: 1337
|
||||
# limit the number of concurrent socket connections per IP address
|
||||
ip-connection-limit: 10
|
||||
cors:
|
||||
# Additional origins to allow socket connections from (io.domain and
|
||||
# https.domain are included implicitly).
|
||||
allowed-origins: []
|
||||
|
||||
# YouTube v3 API key
|
||||
# 1. Go to https://console.developers.google.com/, create a new "project" (or choose an existing one)
|
||||
# 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"
|
||||
# See https://developers.google.com/youtube/registering_an_application
|
||||
# YouTube links will not work without this!
|
||||
# Instructions:
|
||||
# 1. Go to https://console.developers.google.com/project
|
||||
# 2. Create a new API project
|
||||
# 3. On the left sidebar, click "Credentials" under "APIs & auth"
|
||||
# 4. Click "Create new Key" under "Public API access"
|
||||
# 5. Click "Server key"
|
||||
# 6. Under "APIs & auth" click "YouTube Data API" and then click "Enable API"
|
||||
youtube-v3-key: ''
|
||||
# Limit for the number of channels a user can register
|
||||
max-channels-per-user: 5
|
||||
|
@ -128,8 +125,6 @@ max-channels-per-user: 5
|
|||
max-accounts-per-ip: 5
|
||||
# Minimum number of seconds between guest logins from the same IP
|
||||
guest-login-delay: 60
|
||||
# Maximum character length of a chat message, capped at 1000 characters
|
||||
max-chat-message-length: 320
|
||||
|
||||
# Allows you to customize the path divider. The /r/ in http://localhost/r/yourchannel
|
||||
# Acceptable characters are a-z A-Z 0-9 _ and -
|
||||
|
@ -139,6 +134,9 @@ channel-path: 'r'
|
|||
channel-blacklist: []
|
||||
# Minutes between saving channel state to disk
|
||||
channel-save-interval: 5
|
||||
# Determines channel data storage mechanism.
|
||||
channel-storage:
|
||||
type: 'database'
|
||||
|
||||
# Configure periodic clearing of old alias data
|
||||
aliases:
|
||||
|
@ -213,5 +211,8 @@ service-socket:
|
|||
# https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup
|
||||
twitch-client-id: null
|
||||
|
||||
# Mixer Client ID (https://mixer.com/lab)
|
||||
mixer-client-id: null
|
||||
|
||||
poll:
|
||||
max-options: 50
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
CyTube Custom Content Metadata
|
||||
==============================
|
||||
|
||||
*Last updated: 2022-02-12*
|
||||
|
||||
## Purpose ##
|
||||
|
||||
CyTube currently supports adding custom audio/video content by allowing the user
|
||||
|
@ -26,22 +24,6 @@ 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:
|
||||
|
@ -61,8 +43,6 @@ To add custom content, the user provides a JSON object with the following keys:
|
|||
playlist, but this functionality may be offered in the future.
|
||||
* `sources`: A nonempty list of playable sources for the content. The format
|
||||
is described below.
|
||||
* `audioTracks`: An optional list of audio tracks for using demuxed audio
|
||||
and providing multiple audio selections. The format is described below.
|
||||
* `textTracks`: An optional list of text tracks for subtitles or closed
|
||||
captioning. The format is described below.
|
||||
|
||||
|
@ -93,6 +73,7 @@ The following MIME types are accepted for the `contentType` field:
|
|||
* `application/x-mpegURL` (HLS streams)
|
||||
- HLS is only tested with livestreams. VODs are accepted, but I do not test
|
||||
this functionality.
|
||||
but without `live: true` will be rejected.
|
||||
* `application/dash+xml` (DASH streams)
|
||||
- Support for DASH is experimental
|
||||
* ~~`rtmp/flv`~~
|
||||
|
@ -101,46 +82,19 @@ The following MIME types are accepted for the `contentType` field:
|
|||
RTMP streams are only supported through the existing `rt:` media
|
||||
type.
|
||||
* `audio/aac`
|
||||
* `audio/mp4`
|
||||
* `audio/mpeg`
|
||||
* `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).
|
||||
|
||||
### Audio Track Format ###
|
||||
|
||||
Each audio track entry is a JSON object with the following keys:
|
||||
|
||||
* `label`: A label for the audio track. This is displayed in the menu for the
|
||||
viewer to select a text track.
|
||||
* `language`: A two or three letter IETF BCP 47 subtype code indicating the
|
||||
language of the audio track.
|
||||
* `url`: A valid URL that browsers can use to retrieve the track. The URL
|
||||
must resolve to a publicly-routed IP address, and must use the `https:` scheme.
|
||||
* `contentType`: A string representing the MIME type of the track at `url`.
|
||||
Any type starting with `audio` from the list above is acceptable. However
|
||||
the usage of audio/aac is known to cause audio syncrhonization problems
|
||||
for some users. It is recommended to use an m4a file to wrap aac streams.
|
||||
|
||||
**Important note regarding audio tracks:**
|
||||
|
||||
Because of browsers trying to be too smart for their own good, you should
|
||||
include a silent audio stream in the video sources when using separate audio
|
||||
tracks. If you do not, the browser will automatically pause the video whenever
|
||||
the browser detects the page as not visible. There is no way to instruct it to
|
||||
not do so. You can readily accomplish the inclusion of a silent audio track
|
||||
with ffmpeg using the anullsrc filter like so:
|
||||
`ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 -i input.mp4 -c:v copy -c:a aac -shortest output.mp4`
|
||||
It is recommended to match the sample rate and codec you intend to use in your
|
||||
audioTracks in your silent track.
|
||||
|
||||
### Text Track Format ###
|
||||
|
||||
Each text track entry is a JSON object with the following keys:
|
||||
|
||||
|
||||
* `url`: A valid URL that browsers can use to retrieve the track. The URL
|
||||
must resolve to a publicly-routed IP address, and must the `https:` scheme.
|
||||
* `contentType`: A string representing the MIME type of the track at `url`.
|
||||
|
@ -148,8 +102,6 @@ Each text track entry is a JSON object with the following keys:
|
|||
[`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:**
|
||||
|
||||
|
@ -179,8 +131,7 @@ for more information about setting this header.
|
|||
{
|
||||
"url": "https://example.com/subtitles.vtt",
|
||||
"contentType": "text/vtt",
|
||||
"name": "English Subtitles",
|
||||
"default": true
|
||||
"name": "English Subtitles"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -206,5 +157,3 @@ non-exhaustive.
|
|||
to the browser.
|
||||
* The manifest includes source URLs or text track URLs with expiration times,
|
||||
session IDs, etc. in the URL querystring.
|
||||
* The manifest provides source URLs with non-silent audio as well as a list
|
||||
of audioTracks.
|
||||
|
|
|
@ -12,4 +12,4 @@ This user guide is a work in progress rewrite of the old user guide. If you not
|
|||
## I need help! ##
|
||||
|
||||
1. Please read the [FAQ](https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions) and check whether that answers your question.
|
||||
2. If not, you can contact someone for help. IRC support is provided on `irc.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.
|
||||
2. If not, you can contact someone for help. IRC support is provided on `irc.6irc.net #cytube` ([webchat](https://webchat.6irc.net/?channels=cytube) available) for http://cytu.be, http://synchtu.be, and general questions about using the software. If nobody is available on IRC, or you want to speak privately, email one of the contacts on https://cytu.be/contact.
|
||||
|
|
|
@ -29,9 +29,7 @@ natively. Accordingly, CyTube only supports a few codecs:
|
|||
|
||||
**Video**
|
||||
|
||||
* MP4 (AV1)
|
||||
* MP4 (H.264)
|
||||
* WebM (AV1)
|
||||
* WebM (VP8)
|
||||
* WebM (VP9)
|
||||
* Ogg/Theora
|
||||
|
|
|
@ -21,6 +21,7 @@ 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.
|
||||
|
|
4
index.js
4
index.js
|
@ -2,10 +2,10 @@
|
|||
|
||||
const ver = process.version.match(/v(\d+)\.\d+\.\d+/);
|
||||
|
||||
if (parseInt(ver[1], 10) < 12) {
|
||||
if (parseInt(ver[1], 10) < 6) {
|
||||
console.error(
|
||||
`node.js ${process.version} is not supported. ` +
|
||||
'CyTube requires node v12 or later.'
|
||||
'CyTube requires node v6 or later.'
|
||||
)
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
@ -110,25 +110,6 @@ describe('KickbanModule', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects if the username is invalid', done => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
assert.strictEqual(
|
||||
obj.msg,
|
||||
'Invalid username'
|
||||
);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
kickban.handleCmdBan(
|
||||
mockUser,
|
||||
'/ban test_user<>%$# because reasons',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if the user does not have ban permission', done => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
if (frame === 'errorMsg') {
|
||||
|
@ -247,8 +228,7 @@ describe('KickbanModule', () => {
|
|||
await tx.table('aliases')
|
||||
.insert([{
|
||||
name: 'test_user',
|
||||
ip: '1.2.3.4',
|
||||
time: Date.now()
|
||||
ip: '1.2.3.4'
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
@ -405,8 +385,7 @@ describe('KickbanModule', () => {
|
|||
await tx.table('aliases')
|
||||
.insert({
|
||||
name: 'test_user',
|
||||
ip: longIP,
|
||||
time: Date.now()
|
||||
ip: longIP
|
||||
});
|
||||
}).then(() => {
|
||||
kickban.handleCmdIPBan(
|
||||
|
@ -510,8 +489,7 @@ describe('KickbanModule', () => {
|
|||
await tx.table('aliases')
|
||||
.insert({
|
||||
name: 'another_user',
|
||||
ip: '1.2.3.3', // different IP, same /24 range
|
||||
time: Date.now()
|
||||
ip: '1.2.3.3' // different IP, same /24 range
|
||||
});
|
||||
}).then(() => {
|
||||
mockUser.socket.emit = (frame, obj) => {
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
const assert = require('assert');
|
||||
const { BannedChannelsController } = require('../../lib/controller/banned-channels');
|
||||
const dbChannels = require('../../lib/database/channels');
|
||||
const testDB = require('../testutil/db').testDB;
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
require('../../lib/database').init(testDB);
|
||||
|
||||
const testBan = {
|
||||
name: 'ban_test_1',
|
||||
externalReason: 'because I said so',
|
||||
internalReason: 'illegal content',
|
||||
bannedBy: 'admin'
|
||||
};
|
||||
|
||||
async function cleanupTestBan() {
|
||||
return dbChannels.removeBannedChannel(testBan.name);
|
||||
}
|
||||
|
||||
describe('BannedChannelsController', () => {
|
||||
let controller;
|
||||
let messages;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestBan();
|
||||
messages = new EventEmitter();
|
||||
controller = new BannedChannelsController(
|
||||
dbChannels,
|
||||
messages
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBan();
|
||||
});
|
||||
|
||||
it('bans a channel', async () => {
|
||||
assert.strictEqual(await controller.getBannedChannel(testBan.name), null);
|
||||
|
||||
let received = null;
|
||||
messages.once('ChannelBanned', cb => {
|
||||
received = cb;
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
let info = await controller.getBannedChannel(testBan.name);
|
||||
for (let field of Object.keys(testBan)) {
|
||||
// Consider renaming parameter to avoid this branch
|
||||
if (field === 'name') {
|
||||
assert.strictEqual(info.channelName, testBan.name);
|
||||
} else {
|
||||
assert.strictEqual(info[field], testBan[field]);
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(received, null);
|
||||
assert.strictEqual(received.channel, testBan.name);
|
||||
assert.strictEqual(received.externalReason, testBan.externalReason);
|
||||
});
|
||||
|
||||
it('updates an existing ban', async () => {
|
||||
let received = [];
|
||||
messages.on('ChannelBanned', cb => {
|
||||
received.push(cb);
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
|
||||
let testBan2 = { ...testBan, externalReason: 'because of reasons' };
|
||||
await controller.banChannel(testBan2);
|
||||
|
||||
let info = await controller.getBannedChannel(testBan2.name);
|
||||
for (let field of Object.keys(testBan2)) {
|
||||
// Consider renaming parameter to avoid this branch
|
||||
if (field === 'name') {
|
||||
assert.strictEqual(info.channelName, testBan2.name);
|
||||
} else {
|
||||
assert.strictEqual(info[field], testBan2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(received, [
|
||||
{
|
||||
channel: testBan.name,
|
||||
externalReason: testBan.externalReason
|
||||
},
|
||||
{
|
||||
channel: testBan2.name,
|
||||
externalReason: testBan2.externalReason
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('unbans a channel', async () => {
|
||||
let received = null;
|
||||
messages.once('ChannelUnbanned', cb => {
|
||||
received = cb;
|
||||
});
|
||||
|
||||
await controller.banChannel(testBan);
|
||||
await controller.unbanChannel(testBan.name, testBan.bannedBy);
|
||||
|
||||
let info = await controller.getBannedChannel(testBan.name);
|
||||
assert.strictEqual(info, null);
|
||||
|
||||
assert.notEqual(received, null);
|
||||
assert.strictEqual(received.channel, testBan.name);
|
||||
});
|
||||
});
|
|
@ -1,88 +0,0 @@
|
|||
const assert = require('assert');
|
||||
const { testDB } = require('../testutil/db');
|
||||
const accounts = require('../../lib/database/accounts');
|
||||
|
||||
require('../../lib/database').init(testDB);
|
||||
|
||||
describe('AccountsDatabase', () => {
|
||||
describe('#verifyLogin', () => {
|
||||
let ip = '169.254.111.111';
|
||||
let user;
|
||||
let password;
|
||||
|
||||
beforeEach(async () => {
|
||||
return testDB.knex.table('users')
|
||||
.where({ ip })
|
||||
.delete();
|
||||
});
|
||||
|
||||
beforeEach(done => {
|
||||
user = `u${Math.random().toString(31).substring(2)}`;
|
||||
password = 'int!gration_Test';
|
||||
|
||||
accounts.register(
|
||||
user,
|
||||
password,
|
||||
'',
|
||||
ip,
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Created test user ${user}`);
|
||||
done();
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
it('verifies a correct login', done => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
password,
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.strictEqual(res.name, user);
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies a correct login with an older hash', done => {
|
||||
testDB.knex.table('users')
|
||||
.where({ name: user })
|
||||
.update({
|
||||
// 'test' hashed with old version of bcrypt module
|
||||
password: '$2b$10$2oCG7O9FFqie7T8O33yQDugFPS0NqkgbQjtThTs7Jr8E1QOzdRruK'
|
||||
})
|
||||
.then(() => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
'test',
|
||||
(error, res) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.strictEqual(res.name, user);
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an incorrect login', done => {
|
||||
accounts.verifyLogin(
|
||||
user,
|
||||
'not the right password',
|
||||
(error, res) => {
|
||||
assert.strictEqual(error, 'Invalid username/password combination');
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -21,9 +21,9 @@ 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() }
|
||||
{ ip: testIPs[0], name: testNames[0] },
|
||||
{ ip: testIPs[0], name: testNames[1] },
|
||||
{ ip: testIPs[1], name: testNames[1] }
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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' };
|
||||
|
@ -36,7 +35,7 @@ describe('GlobalBanDB', () => {
|
|||
assert.deepStrictEqual([{
|
||||
ip: '8.8.8.8',
|
||||
reason: 'test'
|
||||
}], bans.map(o));
|
||||
}], bans);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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);
|
||||
|
||||
|
@ -28,7 +27,7 @@ describe('PasswordResetDB', () => {
|
|||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), params);
|
||||
assert.deepStrictEqual(rows[0], params);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -46,7 +45,7 @@ describe('PasswordResetDB', () => {
|
|||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), params);
|
||||
assert.deepStrictEqual(rows[0], params);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -66,7 +65,7 @@ describe('PasswordResetDB', () => {
|
|||
|
||||
it('gets a password reset by hash', () => {
|
||||
return passwordResetDB.get(reset.hash).then(result => {
|
||||
assert.deepStrictEqual(o(result), reset);
|
||||
assert.deepStrictEqual(result, reset);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -137,7 +136,7 @@ describe('PasswordResetDB', () => {
|
|||
.select();
|
||||
}).then(rows => {
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(o(rows[0]), reset2);
|
||||
assert.deepStrictEqual(rows[0], reset2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
exports.o = function o(obj) {
|
||||
// Workaround for knex returning RowDataPacket and failing assertions
|
||||
return Object.assign({}, obj);
|
||||
}
|
7261
package-lock.json
generated
7261
package-lock.json
generated
File diff suppressed because it is too large
Load diff
87
package.json
87
package.json
|
@ -2,82 +2,85 @@
|
|||
"author": "Calvin Montgomery",
|
||||
"name": "CyTube",
|
||||
"description": "Online media synchronizer and chat",
|
||||
"version": "3.86.0",
|
||||
"version": "3.63.0",
|
||||
"repository": {
|
||||
"url": "http://github.com/calzoneman/sync"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@calzoneman/express-babel-decorators": "^1.0.0",
|
||||
"@calzoneman/jsli": "^2.0.1",
|
||||
"@cytube/mediaquery": "github:CyTube/mediaquery#564d0c4615e80f72722b0f68ac81f837a4c5fc81",
|
||||
"bcrypt": "^5.0.1",
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.20.1",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"clone": "^2.1.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"bcrypt": "^2.0.1",
|
||||
"bluebird": "^3.5.1",
|
||||
"body-parser": "^1.18.2",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"clone": "^2.1.1",
|
||||
"compression": "^1.5.2",
|
||||
"cookie-parser": "^1.4.0",
|
||||
"create-error": "^0.3.1",
|
||||
"csrf": "^3.1.0",
|
||||
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
|
||||
"express": "^4.18.2",
|
||||
"csrf": "^3.0.0",
|
||||
"cytube-mediaquery": "git://github.com/CyTube/mediaquery",
|
||||
"cytubefilters": "git://github.com/calzoneman/cytubefilters.git#ffdbce83c6cf806f9ae0983cc735230fefcfdb5a",
|
||||
"express": "^4.16.2",
|
||||
"express-minify": "^1.0.0",
|
||||
"graceful-fs": "^4.1.2",
|
||||
"json-typecheck": "^0.1.3",
|
||||
"knex": "^2.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^6.6.1",
|
||||
"pg": "^8.11.3",
|
||||
"pg-native": "^3.0.1",
|
||||
"prom-client": "^13.1.0",
|
||||
"proxy-addr": "^2.0.6",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^3.1.1",
|
||||
"sanitize-html": "^2.7.0",
|
||||
"serve-static": "^1.15.0",
|
||||
"socket.io": "^4.5.4",
|
||||
"source-map-support": "^0.5.19",
|
||||
"toml": "^3.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"knex": "^0.14.3",
|
||||
"lodash": "^4.17.5",
|
||||
"morgan": "^1.6.1",
|
||||
"mysql": "^2.9.0",
|
||||
"nodemailer": "^4.4.2",
|
||||
"prom-client": "^10.0.2",
|
||||
"proxy-addr": "^2.0.2",
|
||||
"pug": "^2.0.0-beta3",
|
||||
"redis": "^2.4.2",
|
||||
"sanitize-html": "^1.14.1",
|
||||
"serve-static": "^1.13.2",
|
||||
"socket.io": "^2.0.3",
|
||||
"source-map-support": "^0.5.3",
|
||||
"sprintf-js": "^1.0.3",
|
||||
"toml": "^2.3.0",
|
||||
"uuid": "^3.2.1",
|
||||
"yamljs": "^0.2.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build-player": "./bin/build-player.js",
|
||||
"build-server": "babel -D --source-maps --out-dir lib/ src/",
|
||||
"build-server": "babel -D --source-maps --loose es6.destructuring,es6.forOf --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/",
|
||||
"server-dev": "babel -D --watch --source-maps --loose es6.destructuring,es6.forOf --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",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-eslint": "^8.2.2",
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||
"babel-preset-env": "^1.5.2",
|
||||
"coffeescript": "^1.9.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-no-jquery": "^2.7.0",
|
||||
"mocha": "^9.2.2",
|
||||
"sinon": "^10.0.0"
|
||||
"eslint": "^4.19.1",
|
||||
"mocha": "^5.2.0",
|
||||
"sinon": "^2.3.2"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
[
|
||||
"@babel/env",
|
||||
"env",
|
||||
{
|
||||
"targets": {
|
||||
"node": "12"
|
||||
"node": "8"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports"
|
||||
"add-module-exports",
|
||||
"transform-decorators-legacy"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,24 +15,13 @@ window.CustomEmbedPlayer = class CustomEmbedPlayer extends EmbedPlayer
|
|||
return
|
||||
|
||||
embedSrc = data.meta.embed.src
|
||||
|
||||
link = document.createElement('a')
|
||||
link.href = embedSrc
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferer'
|
||||
|
||||
strong = document.createElement('strong')
|
||||
strong.textContent = embedSrc
|
||||
link.appendChild(strong)
|
||||
|
||||
# TODO: Ideally makeAlert() would allow optionally providing a DOM
|
||||
# element instead of requiring HTML text
|
||||
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link.outerHTML),
|
||||
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')
|
||||
.on('click', =>
|
||||
.click(=>
|
||||
super(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
|
|
|
@ -5,13 +5,13 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
|
||||
@setMediaProperties(data)
|
||||
@initialVolumeSet = false
|
||||
@playbackReadyCb = null
|
||||
|
||||
waitUntilDefined(window, 'DM', =>
|
||||
removeOld()
|
||||
|
||||
params =
|
||||
autoplay: 1
|
||||
wmode: if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||
logo: 0
|
||||
|
||||
quality = @mapQuality(USEROPTS.default_quality)
|
||||
|
@ -26,7 +26,7 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
)
|
||||
|
||||
@dm.addEventListener('apiready', =>
|
||||
@dmReady = true
|
||||
@dm.ready = true
|
||||
@dm.addEventListener('ended', ->
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
|
@ -47,64 +47,43 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
@setVolume(VOLUME)
|
||||
@initialVolumeSet = true
|
||||
)
|
||||
|
||||
# Once the video stops, the internal state of the player
|
||||
# becomes unusable and attempting to load() will corrupt it and
|
||||
# crash the player with an error. As a short–medium term
|
||||
# workaround, mark the player as "not ready" until the next
|
||||
# playback_ready event
|
||||
@dm.addEventListener('video_end', =>
|
||||
@dmReady = false
|
||||
)
|
||||
@dm.addEventListener('playback_ready', =>
|
||||
@dmReady = true
|
||||
if @playbackReadyCb
|
||||
@playbackReadyCb()
|
||||
@playbackReadyCb = null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
@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')
|
||||
console.error('WTF? DailymotionPlayer::load() called but dm is not ready')
|
||||
|
||||
pause: ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
@paused = true
|
||||
@dm.pause()
|
||||
|
||||
play: ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
@paused = false
|
||||
@dm.play()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
@dm.seek(time)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
@dm.setVolume(volume)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
cb(@dm.currentTime)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @dm and @dmReady
|
||||
if @dm and @dm.ready
|
||||
if @dm.muted
|
||||
cb(0)
|
||||
else
|
||||
|
@ -124,7 +103,3 @@ window.DailymotionPlayer = class DailymotionPlayer extends Player
|
|||
when '360' then '380'
|
||||
when 'best' then '1080'
|
||||
else 'auto'
|
||||
|
||||
destroy: ->
|
||||
if @dm
|
||||
@dm.destroy('ytapiplayer')
|
||||
|
|
|
@ -24,10 +24,27 @@ window.EmbedPlayer = class EmbedPlayer extends Player
|
|||
console.error('EmbedPlayer::load(): missing meta.embed')
|
||||
return
|
||||
|
||||
@player = @loadIframe(embed)
|
||||
if embed.tag == 'object'
|
||||
@player = @loadObject(embed)
|
||||
else
|
||||
@player = @loadIframe(embed)
|
||||
|
||||
removeOld(@player)
|
||||
|
||||
loadObject: (embed) ->
|
||||
object = $('<object/>').attr(
|
||||
type: 'application/x-shockwave-flash'
|
||||
data: embed.src
|
||||
wmode: 'opaque'
|
||||
)
|
||||
genParam('allowfullscreen', 'true').appendTo(object)
|
||||
genParam('allowscriptaccess', 'always').appendTo(object)
|
||||
|
||||
for key, value of embed.params
|
||||
genParam(key, value).appendTo(object)
|
||||
|
||||
return object
|
||||
|
||||
loadIframe: (embed) ->
|
||||
if embed.src.indexOf('http:') == 0 and location.protocol == 'https:'
|
||||
if @__proto__.mixedContentError?
|
||||
|
@ -42,7 +59,6 @@ window.EmbedPlayer = class EmbedPlayer extends Player
|
|||
iframe = $('<iframe/>').attr(
|
||||
src: embed.src
|
||||
frameborder: '0'
|
||||
allow: 'autoplay'
|
||||
allowfullscreen: '1'
|
||||
)
|
||||
|
||||
|
|
|
@ -31,56 +31,3 @@ window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
|
|||
jitter: 500
|
||||
})
|
||||
, Math.random() * 1000)
|
||||
|
||||
window.promptToInstallDriveUserscript = ->
|
||||
if document.getElementById('prompt-install-drive-userscript')
|
||||
return
|
||||
alertBox = document.createElement('div')
|
||||
alertBox.id = 'prompt-install-drive-userscript'
|
||||
alertBox.className = 'alert alert-info'
|
||||
alertBox.innerHTML = """
|
||||
Due to continual breaking changes making it increasingly difficult to
|
||||
maintain Google Drive support, Google Drive now requires installing
|
||||
a userscript in order to play the video."""
|
||||
alertBox.appendChild(document.createElement('br'))
|
||||
infoLink = document.createElement('a')
|
||||
infoLink.className = 'btn btn-info'
|
||||
infoLink.href = '/google_drive_userscript'
|
||||
infoLink.textContent = 'Click here for details'
|
||||
infoLink.target = '_blank'
|
||||
alertBox.appendChild(infoLink)
|
||||
|
||||
closeButton = document.createElement('button')
|
||||
closeButton.className = 'close pull-right'
|
||||
closeButton.innerHTML = '×'
|
||||
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))
|
||||
|
|
131
player/gdrive-youtube.coffee
Normal file
131
player/gdrive-youtube.coffee
Normal file
|
@ -0,0 +1,131 @@
|
|||
window.GoogleDriveYouTubePlayer = class GoogleDriveYouTubePlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof GoogleDriveYouTubePlayer)
|
||||
return new GoogleDriveYouTubePlayer(data)
|
||||
|
||||
@setMediaProperties(data)
|
||||
@init(data)
|
||||
|
||||
init: (data) ->
|
||||
window.promptToInstallDriveUserscript()
|
||||
embed = $('<embed />').attr(
|
||||
type: 'application/x-shockwave-flash'
|
||||
src: "https://www.youtube.com/get_player?docid=#{data.id}&ps=docs\
|
||||
&partnerid=30&enablejsapi=1&cc_load_policy=1\
|
||||
&auth_timeout=86400000000"
|
||||
flashvars: 'autoplay=1&playerapiid=uniquePlayerId'
|
||||
wmode: 'opaque'
|
||||
allowscriptaccess: 'always'
|
||||
)
|
||||
removeOld(embed)
|
||||
|
||||
window.onYouTubePlayerReady = =>
|
||||
if PLAYER != this
|
||||
return
|
||||
|
||||
@yt = embed[0]
|
||||
window.gdriveStateChange = @onStateChange.bind(this)
|
||||
@yt.addEventListener('onStateChange', 'gdriveStateChange')
|
||||
@onReady()
|
||||
|
||||
load: (data) ->
|
||||
@yt = null
|
||||
@setMediaProperties(data)
|
||||
@init(data)
|
||||
|
||||
onReady: ->
|
||||
@yt.ready = true
|
||||
@setVolume(VOLUME)
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
|
||||
onStateChange: (ev) ->
|
||||
if PLAYER != this
|
||||
return
|
||||
|
||||
if (ev == YT.PlayerState.PAUSED and not @paused) or
|
||||
(ev == YT.PlayerState.PLAYING and @paused)
|
||||
@paused = (ev == YT.PlayerState.PAUSED)
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
|
||||
if ev == YT.PlayerState.ENDED and CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @yt and @yt.ready
|
||||
@yt.playVideo()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @yt and @yt.ready
|
||||
@yt.pauseVideo()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @yt and @yt.ready
|
||||
@yt.seekTo(time, true)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @yt and @yt.ready
|
||||
if volume > 0
|
||||
# If the player is muted, even if the volume is set,
|
||||
# the player remains muted
|
||||
@yt.unMute()
|
||||
@yt.setVolume(volume * 100)
|
||||
|
||||
setQuality: (quality) ->
|
||||
if not @yt or not @yt.ready
|
||||
return
|
||||
|
||||
ytQuality = switch String(quality)
|
||||
when '240' then 'small'
|
||||
when '360' then 'medium'
|
||||
when '480' then 'large'
|
||||
when '720' then 'hd720'
|
||||
when '1080' then 'hd1080'
|
||||
when 'best' then 'highres'
|
||||
else 'auto'
|
||||
|
||||
if ytQuality != 'auto'
|
||||
@yt.setPlaybackQuality(ytQuality)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
cb(@yt.getCurrentTime())
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
if @yt.isMuted()
|
||||
cb(0)
|
||||
else
|
||||
cb(@yt.getVolume() / 100)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
||||
window.promptToInstallDriveUserscript = ->
|
||||
if document.getElementById('prompt-install-drive-userscript')
|
||||
return
|
||||
alertBox = document.createElement('div')
|
||||
alertBox.id = 'prompt-install-drive-userscript'
|
||||
alertBox.className = 'alert alert-info'
|
||||
alertBox.innerHTML = """
|
||||
Due to continual breaking changes making it increasingly difficult to
|
||||
maintain Google Drive support, Google Drive now requires installing
|
||||
a userscript in order to play the video."""
|
||||
alertBox.appendChild(document.createElement('br'))
|
||||
infoLink = document.createElement('a')
|
||||
infoLink.className = 'btn btn-info'
|
||||
infoLink.href = '/google_drive_userscript'
|
||||
infoLink.textContent = 'Click here for details'
|
||||
infoLink.target = '_blank'
|
||||
alertBox.appendChild(infoLink)
|
||||
|
||||
closeButton = document.createElement('button')
|
||||
closeButton.className = 'close pull-right'
|
||||
closeButton.innerHTML = '×'
|
||||
closeButton.onclick = ->
|
||||
alertBox.parentNode.removeChild(alertBox)
|
||||
alertBox.insertBefore(closeButton, alertBox.firstChild)
|
||||
removeOld($('<div/>').append(alertBox))
|
|
@ -1,33 +0,0 @@
|
|||
window.IframeChild = class IframeChild extends PlayerJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof IframeChild)
|
||||
return new IframeChild(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
@ready = false
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: '/iframe'
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupFrame(iframe[0], data)
|
||||
@setupPlayer(iframe[0])
|
||||
)
|
||||
|
||||
setupFrame: (iframe, data) ->
|
||||
iframe.addEventListener('load', =>
|
||||
# TODO: ideally, communication with the child frame should use postMessage()
|
||||
iframe.contentWindow.VOLUME = VOLUME
|
||||
iframe.contentWindow.loadMediaPlayer(Object.assign({}, data, { type: 'cm' } ))
|
||||
iframe.contentWindow.document.querySelector('#ytapiplayer').classList.add('vjs-16-9')
|
||||
adapter = iframe.contentWindow.playerjs.VideoJSAdapter(iframe.contentWindow.PLAYER.player)
|
||||
adapter.ready()
|
||||
typeof data?.meta?.thumbnail == 'string' and iframe.contentWindow.PLAYER.player.poster(data.meta.thumbnail)
|
||||
)
|
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)
|
|
@ -6,12 +6,18 @@ window.LivestreamPlayer = class LivestreamPlayer extends EmbedPlayer
|
|||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
[ account, event ] = data.id.split(';')
|
||||
data.meta.embed =
|
||||
src: "https://livestream.com/accounts/#{account}/events/#{event}/player?\
|
||||
enableInfoAndActivity=false&\
|
||||
defaultDrawer=&\
|
||||
autoPlay=true&\
|
||||
mute=false"
|
||||
tag: 'iframe'
|
||||
if LIVESTREAM_CHROMELESS
|
||||
data.meta.embed =
|
||||
src: 'https://cdn.livestream.com/chromelessPlayer/v20/playerapi.swf'
|
||||
tag: 'object'
|
||||
params:
|
||||
flashvars: "channel=#{data.id}"
|
||||
else
|
||||
data.meta.embed =
|
||||
src: "https://cdn.livestream.com/embed/#{data.id}?\
|
||||
layout=4&\
|
||||
color=0x000000&\
|
||||
iconColorOver=0xe7e7e7&\
|
||||
iconColor=0xcccccc"
|
||||
tag: 'iframe'
|
||||
super(data)
|
||||
|
|
12
player/mixer.coffee
Normal file
12
player/mixer.coffee
Normal file
|
@ -0,0 +1,12 @@
|
|||
window.MixerPlayer = class MixerPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof MixerPlayer)
|
||||
return new MixerPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
data.meta.embed =
|
||||
src: "https://mixer.com/embed/player/#{data.meta.mixer.channelToken}"
|
||||
tag: 'iframe'
|
||||
super(data)
|
|
@ -1,66 +0,0 @@
|
|||
window.NicoPlayer = class NicoPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof NicoPlayer)
|
||||
return new NicoPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'NicovideoEmbed', =>
|
||||
@nico = new NicovideoEmbed({ playerId: 'ytapiplayer', videoId: data.id })
|
||||
removeOld($(@nico.iframe))
|
||||
|
||||
@nico.on('ended', =>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
|
||||
@nico.on('pause', =>
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@nico.on('play', =>
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@nico.on('ready', =>
|
||||
@play()
|
||||
@setVolume(VOLUME)
|
||||
)
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @nico
|
||||
@nico.play()
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @nico
|
||||
@nico.pause()
|
||||
|
||||
seekTo: (time) ->
|
||||
if @nico
|
||||
@nico.seek(time * 1000)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @nico
|
||||
@nico.volumeChange(volume)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @nico
|
||||
cb(parseFloat(@nico.state.currentTime / 1000))
|
||||
else
|
||||
cb(0)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @nico
|
||||
cb(parseFloat(@nico.state.volume))
|
||||
else
|
||||
cb(VOLUME)
|
|
@ -1,21 +0,0 @@
|
|||
window.OdyseePlayer = class OdyseePlayer extends PlayerJSPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof OdyseePlayer)
|
||||
return new OdyseePlayer(data)
|
||||
|
||||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@ready = false
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: data.meta.embed.src
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0], data)
|
||||
)
|
|
@ -1,122 +0,0 @@
|
|||
PEERTUBE_EMBED_WARNING = 'This channel is embedding PeerTube content from %link%.
|
||||
PeerTube instances may use P2P technology that will expose your IP address to third parties, including but not
|
||||
limited to other users in this channel. It is also conceivable that if the content in question is in violation of
|
||||
copyright laws your IP address could be potentially be observed by legal authorities monitoring the tracker of
|
||||
this PeerTube instance. The operators of %site% are not responsible for the data sent by the embedded player to
|
||||
third parties on your behalf.<br><br> If you understand the risks, wish to assume all liability, and continue to
|
||||
the content, click "Embed" below to allow the content to be embedded.<hr>'
|
||||
|
||||
PEERTUBE_RISK = false
|
||||
|
||||
window.PeerPlayer = class PeerPlayer extends Player
|
||||
constructor: (data) ->
|
||||
if not (this instanceof PeerPlayer)
|
||||
return new PeerPlayer(data)
|
||||
|
||||
@warn(data)
|
||||
|
||||
warn: (data) ->
|
||||
if USEROPTS.peertube_risk or PEERTUBE_RISK
|
||||
return @load(data)
|
||||
|
||||
site = new URL(document.URL).hostname
|
||||
embedSrc = data.meta.embed.domain
|
||||
link = "<a href=\"http://#{embedSrc}\" target=\"_blank\" rel=\"noopener noreferer\"><strong>#{embedSrc}</strong></a>"
|
||||
alert = makeAlert('Privacy Advisory', PEERTUBE_EMBED_WARNING.replace('%link%', link).replace('%site%', site),
|
||||
'alert-warning')
|
||||
.removeClass('col-md-12')
|
||||
$('<button/>').addClass('btn btn-default')
|
||||
.text('Embed')
|
||||
.on('click', =>
|
||||
@load(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
$('<button/>').addClass('btn btn-default pull-right')
|
||||
.text('Embed and dont ask again for this session')
|
||||
.on('click', =>
|
||||
PEERTUBE_RISK = true
|
||||
@load(data)
|
||||
)
|
||||
.appendTo(alert.find('.alert'))
|
||||
removeOld(alert)
|
||||
|
||||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
|
||||
waitUntilDefined(window, 'PeerTubePlayer', =>
|
||||
video = $('<iframe/>')
|
||||
removeOld(video)
|
||||
video.attr(
|
||||
src: "https://#{data.meta.embed.domain}/videos/embed/#{data.meta.embed.uuid}?api=1"
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
@peertube = new PeerTubePlayer(video[0])
|
||||
|
||||
@peertube.addEventListener('playbackStatusChange', (status) =>
|
||||
@paused = status == 'paused'
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
|
||||
@peertube.addEventListener('playbackStatusUpdate', (status) =>
|
||||
@peertube.currentTime = status.position
|
||||
|
||||
if status.playbackState == "ended" and CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
|
||||
@peertube.addEventListener('volumeChange', (volume) =>
|
||||
VOLUME = volume
|
||||
setOpt("volume", VOLUME)
|
||||
)
|
||||
|
||||
@play()
|
||||
@setVolume(VOLUME)
|
||||
)
|
||||
|
||||
play: ->
|
||||
@paused = false
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.play().catch((error) ->
|
||||
console.error('PeerTube::play():', error)
|
||||
)
|
||||
|
||||
pause: ->
|
||||
@paused = true
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.pause().catch((error) ->
|
||||
console.error('PeerTube::pause():', error)
|
||||
)
|
||||
|
||||
seekTo: (time) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.seek(time)
|
||||
|
||||
getVolume: (cb) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.getVolume().then((volume) ->
|
||||
cb(parseFloat(volume))
|
||||
).catch((error) ->
|
||||
console.error('PeerTube::getVolume():', error)
|
||||
)
|
||||
else
|
||||
cb(VOLUME)
|
||||
|
||||
setVolume: (volume) ->
|
||||
if @peertube and @peertube.ready
|
||||
@peertube.setVolume(volume).catch((error) ->
|
||||
console.error('PeerTube::setVolume():', error)
|
||||
)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @peertube and @peertube.ready
|
||||
cb(@peertube.currentTime)
|
||||
else
|
||||
cb(0)
|
||||
|
||||
setQuality: (quality) ->
|
||||
# USEROPTS.default_quality
|
||||
# @peertube.getResolutions()
|
||||
# @peertube.setResolution(resolutionId : number)
|
||||
|
|
@ -8,48 +8,55 @@ window.PlayerJSPlayer = class PlayerJSPlayer extends Player
|
|||
load: (data) ->
|
||||
@setMediaProperties(data)
|
||||
@ready = false
|
||||
@finishing = false
|
||||
|
||||
if not data.meta.playerjs
|
||||
throw new Error('Invalid input: missing meta.playerjs')
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: data.meta.playerjs.src
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
.attr(src: data.meta.playerjs.src)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0])
|
||||
)
|
||||
|
||||
setupPlayer: (iframe) ->
|
||||
@player = new playerjs.Player(iframe)
|
||||
@player.on('ready', =>
|
||||
@player.on('error', (error) =>
|
||||
console.error('PlayerJS error', error.stack)
|
||||
)
|
||||
@player.on('ended', ->
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
)
|
||||
@player.on('play', ->
|
||||
@paused = false
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
@player.on('pause', ->
|
||||
@paused = true
|
||||
if CLIENT.leader
|
||||
sendVideoUpdate()
|
||||
)
|
||||
@player = 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)
|
||||
@player.setVolume(VOLUME * 100)
|
||||
|
||||
if not @paused
|
||||
@player.play()
|
||||
if not @paused
|
||||
@player.play()
|
||||
|
||||
@ready = true
|
||||
@ready = true
|
||||
)
|
||||
)
|
||||
|
||||
play: ->
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
codecToMimeType = (codec) ->
|
||||
switch codec
|
||||
when 'mov/h264', 'mov/av1' then 'video/mp4'
|
||||
when 'mov/h264' then 'video/mp4'
|
||||
when 'flv/h264' then 'video/flv'
|
||||
when 'matroska/vp8', 'matroska/vp9', 'matroska/av1' then 'video/webm'
|
||||
when 'matroska/vp8', 'matroska/vp9' 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
|
||||
|
|
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)
|
|
@ -6,30 +6,7 @@ window.StreamablePlayer = class StreamablePlayer extends PlayerJSPlayer
|
|||
super(data)
|
||||
|
||||
load: (data) ->
|
||||
@ready = false
|
||||
@finishing = false
|
||||
@setMediaProperties(data)
|
||||
data.meta.playerjs =
|
||||
src: "https://streamable.com/e/#{data.id}"
|
||||
|
||||
waitUntilDefined(window, 'playerjs', =>
|
||||
iframe = $('<iframe/>')
|
||||
.attr(
|
||||
src: "https://streamable.com/e/#{data.id}"
|
||||
allow: 'autoplay; fullscreen'
|
||||
)
|
||||
|
||||
removeOld(iframe)
|
||||
@setupPlayer(iframe[0])
|
||||
@player.on('ready', =>
|
||||
# Streamable does not implement ended event since it loops
|
||||
# gotta use a timeupdate hack
|
||||
@player.on('timeupdate', (time) =>
|
||||
if time.duration - time.seconds < 1 and not @finishing
|
||||
setTimeout(=>
|
||||
if CLIENT.leader
|
||||
socket.emit('playNext')
|
||||
@pause()
|
||||
, (time.duration - time.seconds) * 1000)
|
||||
@finishing = true
|
||||
)
|
||||
)
|
||||
)
|
||||
super(data)
|
||||
|
|
|
@ -1,10 +1,3 @@
|
|||
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)
|
||||
|
@ -19,29 +12,14 @@ window.TwitchPlayer = class TwitchPlayer extends Player
|
|||
|
||||
init: (data) ->
|
||||
removeOld()
|
||||
|
||||
if location.hostname != location.host or location.protocol != 'https:'
|
||||
alert = makeAlert(
|
||||
'Twitch API Parameters',
|
||||
window.TWITCH_PARAMS_ERROR,
|
||||
'alert-danger'
|
||||
).removeClass('col-md-12')
|
||||
removeOld(alert)
|
||||
@twitch = null
|
||||
return
|
||||
|
||||
options =
|
||||
parent: [location.hostname]
|
||||
width: $('#ytapiplayer').width()
|
||||
height: $('#ytapiplayer').height()
|
||||
|
||||
if data.type is 'tv'
|
||||
# VOD
|
||||
options.video = data.id
|
||||
options =
|
||||
video: data.id
|
||||
else
|
||||
# Livestream
|
||||
options.channel = data.id
|
||||
|
||||
options =
|
||||
channel: data.id
|
||||
@twitch = new Twitch.Player('ytapiplayer', options)
|
||||
@twitch.addEventListener(Twitch.Player.READY, =>
|
||||
@setVolume(VOLUME)
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
window.TwitchClipPlayer = class TwitchClipPlayer extends EmbedPlayer
|
||||
constructor: (data) ->
|
||||
if not (this instanceof TwitchClipPlayer)
|
||||
return new TwitchClipPlayer(data)
|
||||
|
||||
@load(data)
|
||||
|
||||
load: (data) ->
|
||||
if location.hostname != location.host or location.protocol != 'https:'
|
||||
alert = makeAlert(
|
||||
'Twitch API Parameters',
|
||||
window.TWITCH_PARAMS_ERROR,
|
||||
'alert-danger'
|
||||
).removeClass('col-md-12')
|
||||
removeOld(alert)
|
||||
return
|
||||
|
||||
data.meta.embed =
|
||||
tag: 'iframe'
|
||||
src: "https://clips.twitch.tv/embed?clip=#{data.id}&parent=#{location.host}"
|
||||
super(data)
|
|
@ -3,22 +3,24 @@ TYPE_MAP =
|
|||
vi: VimeoPlayer
|
||||
dm: DailymotionPlayer
|
||||
gd: GoogleDrivePlayer
|
||||
gp: VideoJSPlayer
|
||||
fi: FilePlayer
|
||||
jw: FilePlayer
|
||||
sc: SoundCloudPlayer
|
||||
li: LivestreamPlayer
|
||||
tw: TwitchPlayer
|
||||
tv: TwitchPlayer
|
||||
cu: CustomEmbedPlayer
|
||||
rt: RTMPPlayer
|
||||
hb: SmashcastPlayer
|
||||
us: UstreamPlayer
|
||||
im: ImgurPlayer
|
||||
vm: VideoJSPlayer
|
||||
hl: HLSPlayer
|
||||
sb: StreamablePlayer
|
||||
tc: TwitchClipPlayer
|
||||
tc: VideoJSPlayer
|
||||
cm: VideoJSPlayer
|
||||
pt: PeerPlayer
|
||||
bc: IframeChild
|
||||
bn: IframeChild
|
||||
od: OdyseePlayer
|
||||
nv: NicoPlayer
|
||||
mx: MixerPlayer
|
||||
|
||||
window.loadMediaPlayer = (data) ->
|
||||
try
|
||||
|
@ -110,8 +112,7 @@ window.removeOld = (replace) ->
|
|||
$('#soundcloud-volume-holder').remove()
|
||||
replace ?= $('<div/>').addClass('embed-responsive-item')
|
||||
old = $('#ytapiplayer')
|
||||
old.attr('id', 'ytapiplayer-old')
|
||||
replace.attr('id', 'ytapiplayer')
|
||||
replace.insertBefore(old)
|
||||
old.remove()
|
||||
replace.attr('id', 'ytapiplayer')
|
||||
return replace
|
||||
|
|
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)
|
|
@ -42,13 +42,9 @@ getSourceLabel = (source) ->
|
|||
else
|
||||
return "#{source.quality}p #{source.contentType.split('/')[1]}"
|
||||
|
||||
hasAnyTextTracks = (data) ->
|
||||
ntracks = data?.meta?.textTracks?.length ? 0
|
||||
return ntracks > 0
|
||||
|
||||
hasAnyAudioTracks = (data) ->
|
||||
ntracks = data?.meta?.audioTracks?.length ? 0
|
||||
return ntracks > 0
|
||||
waitUntilDefined(window, 'videojs', =>
|
||||
videojs.options.flash.swf = '/video-js.swf'
|
||||
)
|
||||
|
||||
window.VideoJSPlayer = class VideoJSPlayer extends Player
|
||||
constructor: (data) ->
|
||||
|
@ -63,7 +59,7 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
|||
width: '100%'
|
||||
height: '100%'
|
||||
|
||||
if @mediaType == 'cm' and hasAnyTextTracks(data)
|
||||
if @mediaType == 'cm' and data.meta.textTracks
|
||||
attrs.crossorigin = 'anonymous'
|
||||
|
||||
video = $('<video/>')
|
||||
|
@ -100,38 +96,25 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
|||
if data.meta.textTracks
|
||||
data.meta.textTracks.forEach((track) ->
|
||||
label = track.name
|
||||
attrs =
|
||||
$('<track/>').attr(
|
||||
src: track.url
|
||||
kind: 'subtitles'
|
||||
type: track.type
|
||||
label: label
|
||||
|
||||
if track.default? and track.default
|
||||
attrs.default = ''
|
||||
|
||||
$('<track/>').attr(attrs).appendTo(video)
|
||||
).appendTo(video)
|
||||
)
|
||||
|
||||
pluginData =
|
||||
videoJsResolutionSwitcher:
|
||||
default: @sources[0].res
|
||||
|
||||
if hasAnyAudioTracks(data)
|
||||
pluginData.audioSwitch =
|
||||
audioTracks: data.meta.audioTracks,
|
||||
volume: VOLUME
|
||||
|
||||
@player = videojs(video[0],
|
||||
# https://github.com/Dash-Industry-Forum/dash.js/issues/2184
|
||||
autoplay: @sources[0].type != 'application/dash+xml',
|
||||
controls: true,
|
||||
plugins: pluginData
|
||||
|
||||
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.poster(data.meta.thumbnail)
|
||||
@player.updateSrc(@sources)
|
||||
@player.on('error', =>
|
||||
err = @player.error()
|
||||
|
@ -143,11 +126,8 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
|||
@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()
|
||||
if @mediaType is 'gd' and not window.hasDriveUserscript
|
||||
window.promptToInstallDriveUserscript()
|
||||
)
|
||||
@setVolume(VOLUME)
|
||||
@player.on('ended', ->
|
||||
|
|
|
@ -13,9 +13,14 @@ window.VimeoPlayer = class VimeoPlayer extends Player
|
|||
removeOld(video)
|
||||
video.attr(
|
||||
src: "https://player.vimeo.com/video/#{data.id}"
|
||||
allow: 'autoplay; fullscreen'
|
||||
webkitallowfullscreen: true
|
||||
mozallowfullscreen: true
|
||||
allowfullscreen: true
|
||||
)
|
||||
|
||||
if USEROPTS.wmode_transparent
|
||||
video.attr('wmode', 'transparent')
|
||||
|
||||
@vimeo = new Vimeo.Player(video[0])
|
||||
|
||||
@vimeo.on('ended', =>
|
||||
|
|
|
@ -4,6 +4,7 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
return new YouTubePlayer(data)
|
||||
|
||||
@setMediaProperties(data)
|
||||
@qualityRaceCondition = true
|
||||
@pauseSeekRaceCondition = false
|
||||
|
||||
waitUntilDefined(window, 'YT', =>
|
||||
|
@ -12,6 +13,7 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
waitUntilDefined(YT, 'Player', =>
|
||||
removeOld()
|
||||
|
||||
wmode = if USEROPTS.wmode_transparent then 'transparent' else 'opaque'
|
||||
@yt = new YT.Player('ytapiplayer',
|
||||
videoId: data.id
|
||||
playerVars:
|
||||
|
@ -20,6 +22,7 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
controls: 1
|
||||
iv_load_policy: 3 # iv_load_policy 3 indicates no annotations
|
||||
rel: 0
|
||||
wmode: wmode
|
||||
events:
|
||||
onReady: @onReady.bind(this)
|
||||
onStateChange: @onStateChange.bind(this)
|
||||
|
@ -31,6 +34,9 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@setMediaProperties(data)
|
||||
if @yt and @yt.ready
|
||||
@yt.loadVideoById(data.id, data.currentTime)
|
||||
@qualityRaceCondition = true
|
||||
if USEROPTS.default_quality
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
else
|
||||
console.error('WTF? YouTubePlayer::load() called but yt is not ready')
|
||||
|
||||
|
@ -39,9 +45,15 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@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).
|
||||
# For some reason setting the quality doesn't work
|
||||
# until the first event has fired.
|
||||
if @qualityRaceCondition
|
||||
@qualityRaceCondition = false
|
||||
if USEROPTS.default_quality
|
||||
@setQuality(USEROPTS.default_quality)
|
||||
|
||||
# Similar to above, if you pause the video before the first PLAYING
|
||||
# event is emitted, weird things happen.
|
||||
if ev.data == YT.PlayerState.PLAYING and @pauseSeekRaceCondition
|
||||
@pause()
|
||||
@pauseSeekRaceCondition = false
|
||||
|
@ -78,7 +90,20 @@ window.YouTubePlayer = class YouTubePlayer extends Player
|
|||
@yt.setVolume(volume * 100)
|
||||
|
||||
setQuality: (quality) ->
|
||||
# https://github.com/calzoneman/sync/issues/726
|
||||
if not @yt or not @yt.ready
|
||||
return
|
||||
|
||||
ytQuality = switch String(quality)
|
||||
when '240' then 'small'
|
||||
when '360' then 'medium'
|
||||
when '480' then 'large'
|
||||
when '720' then 'hd720'
|
||||
when '1080' then 'hd1080'
|
||||
when 'best' then 'highres'
|
||||
else 'auto'
|
||||
|
||||
if ytQuality != 'auto'
|
||||
@yt.setPlaybackQuality(ytQuality)
|
||||
|
||||
getTime: (cb) ->
|
||||
if @yt and @yt.ready
|
||||
|
|
|
@ -2,13 +2,8 @@
|
|||
|
||||
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
|
||||
$npm_package_scripts_build_server
|
||||
echo "Building from player/ to www/js/player.js"
|
||||
npm run build-player
|
||||
$npm_package_scripts_build_player
|
||||
echo "Done"
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
{ "env": { "node": true } }
|
|
@ -1,3 +1,4 @@
|
|||
import { FileStore } from './filestore';
|
||||
import { DatabaseStore } from './dbstore';
|
||||
import Config from '../config';
|
||||
import Promise from 'bluebird';
|
||||
|
@ -25,12 +26,11 @@ export function 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.'
|
||||
);
|
||||
switch (Config.get('channel-storage.type')) {
|
||||
case 'database':
|
||||
return new DatabaseStore();
|
||||
case 'file':
|
||||
default:
|
||||
return new FileStore();
|
||||
}
|
||||
|
||||
return new DatabaseStore();
|
||||
}
|
||||
|
|
78
src/channel-storage/filestore.js
Normal file
78
src/channel-storage/filestore.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
import Promise from 'bluebird';
|
||||
import { stat } from 'fs';
|
||||
import * as fs from 'graceful-fs';
|
||||
import path from 'path';
|
||||
import { ChannelStateSizeError } from '../errors';
|
||||
|
||||
const readFileAsync = Promise.promisify(fs.readFile);
|
||||
const writeFileAsync = Promise.promisify(fs.writeFile);
|
||||
const readdirAsync = Promise.promisify(fs.readdir);
|
||||
const statAsync = Promise.promisify(stat);
|
||||
const SIZE_LIMIT = 1048576;
|
||||
const CHANDUMP_DIR = path.resolve(__dirname, '..', '..', 'chandump');
|
||||
|
||||
export class FileStore {
|
||||
filenameForChannel(channelName) {
|
||||
return path.join(CHANDUMP_DIR, channelName);
|
||||
}
|
||||
|
||||
load(id, channelName) {
|
||||
const filename = this.filenameForChannel(channelName);
|
||||
return statAsync(filename).then(stats => {
|
||||
if (stats.size > SIZE_LIMIT) {
|
||||
return Promise.reject(
|
||||
new ChannelStateSizeError(
|
||||
'Channel state file is too large',
|
||||
{
|
||||
limit: SIZE_LIMIT,
|
||||
actual: stats.size
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return readFileAsync(filename);
|
||||
}
|
||||
}).then(fileContents => {
|
||||
try {
|
||||
return JSON.parse(fileContents);
|
||||
} catch (e) {
|
||||
return Promise.reject(new Error('Channel state file is not valid JSON: ' + e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async save(id, channelName, data) {
|
||||
let original;
|
||||
try {
|
||||
original = await this.load(id, channelName);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
} else {
|
||||
original = {};
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
original[key] = data[key];
|
||||
});
|
||||
|
||||
const filename = this.filenameForChannel(channelName);
|
||||
const fileContents = new Buffer(JSON.stringify(original), 'utf8');
|
||||
if (fileContents.length > SIZE_LIMIT) {
|
||||
throw new ChannelStateSizeError(
|
||||
'Channel state size is too large',
|
||||
{
|
||||
limit: SIZE_LIMIT,
|
||||
actual: fileContents.length
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return await writeFileAsync(filename, fileContents);
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return readdirAsync(CHANDUMP_DIR);
|
||||
}
|
||||
}
|
181
src/channel-storage/migrator.js
Normal file
181
src/channel-storage/migrator.js
Normal file
|
@ -0,0 +1,181 @@
|
|||
import Config from '../config';
|
||||
import Promise from 'bluebird';
|
||||
import db from '../database';
|
||||
import { FileStore } from './filestore';
|
||||
import { DatabaseStore } from './dbstore';
|
||||
import { sanitizeHTML } from '../xss';
|
||||
import { ChannelNotFoundError } from '../errors';
|
||||
|
||||
/* eslint no-console: off */
|
||||
|
||||
const EXPECTED_KEYS = [
|
||||
'chatbuffer',
|
||||
'chatmuted',
|
||||
'css',
|
||||
'emotes',
|
||||
'filters',
|
||||
'js',
|
||||
'motd',
|
||||
'openPlaylist',
|
||||
'opts',
|
||||
'permissions',
|
||||
'playlist',
|
||||
'poll'
|
||||
];
|
||||
|
||||
function fixOldChandump(data) {
|
||||
const converted = {};
|
||||
EXPECTED_KEYS.forEach(key => {
|
||||
converted[key] = data[key];
|
||||
});
|
||||
|
||||
if (data.queue) {
|
||||
converted.playlist = {
|
||||
pl: data.queue.map(item => {
|
||||
return {
|
||||
media: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
seconds: item.seconds,
|
||||
duration: item.duration,
|
||||
type: item.type,
|
||||
meta: {}
|
||||
},
|
||||
queueby: item.queueby,
|
||||
temp: item.temp
|
||||
};
|
||||
}),
|
||||
pos: data.position,
|
||||
time: data.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('openqueue')) {
|
||||
converted.openPlaylist = data.openqueue;
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('playlistLock')) {
|
||||
converted.openPlaylist = !data.playlistLock;
|
||||
}
|
||||
|
||||
if (data.chatbuffer) {
|
||||
converted.chatbuffer = data.chatbuffer.map(entry => {
|
||||
return {
|
||||
username: entry.username,
|
||||
msg: entry.msg,
|
||||
meta: entry.meta || {
|
||||
addClass: entry.msgclass ? entry.msgclass : undefined
|
||||
},
|
||||
time: entry.time
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (data.motd && data.motd.motd) {
|
||||
converted.motd = sanitizeHTML(data.motd.motd).replace(/\n/g, '<br>\n');
|
||||
}
|
||||
|
||||
if (data.opts && data.opts.customcss) {
|
||||
converted.opts.externalcss = data.opts.customcss;
|
||||
}
|
||||
|
||||
if (data.opts && data.opts.customjs) {
|
||||
converted.opts.externaljs = data.opts.customjs;
|
||||
}
|
||||
|
||||
if (data.filters && data.filters.length > 0 && Array.isArray(data.filters[0])) {
|
||||
converted.filters = data.filters.map(filter => {
|
||||
let [source, replace, active] = filter;
|
||||
return {
|
||||
source: source,
|
||||
replace: replace,
|
||||
flags: 'g',
|
||||
active: active,
|
||||
filterlinks: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
function migrate(src, dest, opts) {
|
||||
const chanPath = Config.get('channel-path');
|
||||
|
||||
return src.listChannels().then(names => {
|
||||
return Promise.reduce(names, (_, name) => {
|
||||
// A long time ago there was a bug where CyTube would save a different
|
||||
// chandump depending on the capitalization of the channel name in the URL.
|
||||
// This was fixed, but there are still some really old chandumps with
|
||||
// uppercase letters in the name.
|
||||
//
|
||||
// If another chandump exists which is all lowercase, then that one is
|
||||
// canonical. Otherwise, it's safe to load the existing capitalization,
|
||||
// convert it, and save.
|
||||
if (name !== name.toLowerCase()) {
|
||||
if (names.indexOf(name.toLowerCase()) >= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
return src.load(name).then(data => {
|
||||
data = fixOldChandump(data);
|
||||
Object.keys(data).forEach(key => {
|
||||
if (opts.keyWhitelist.length > 0 &&
|
||||
opts.keyWhitelist.indexOf(key) < 0) {
|
||||
delete data[key];
|
||||
} else if (opts.keyBlacklist.length > 0 &&
|
||||
opts.keyBlacklist.indexOf(key) >= 0) {
|
||||
delete data[key];
|
||||
}
|
||||
});
|
||||
return dest.save(name, data);
|
||||
}).then(() => {
|
||||
console.log(`Migrated /${chanPath}/${name}`);
|
||||
}).catch(ChannelNotFoundError, _err => {
|
||||
console.log(`Skipping /${chanPath}/${name} (not present in the database)`);
|
||||
}).catch(err => {
|
||||
console.error(`Failed to migrate /${chanPath}/${name}: ${err.stack}`);
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function loadOpts(argv) {
|
||||
const opts = {
|
||||
keyWhitelist: [],
|
||||
keyBlacklist: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '-w') {
|
||||
opts.keyWhitelist = (argv[i+1] || '').split(',');
|
||||
i++;
|
||||
} else if (argv[i] === '-b') {
|
||||
opts.keyBlacklist = (argv[i+1] || '').split(',');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
Config.load('config.yaml');
|
||||
db.init();
|
||||
const src = new FileStore();
|
||||
const dest = new DatabaseStore();
|
||||
const opts = loadOpts(process.argv.slice(2));
|
||||
|
||||
Promise.delay(1000).then(() => {
|
||||
return migrate(src, dest, opts);
|
||||
}).then(() => {
|
||||
console.log('Migration complete');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error(`Migration failed: ${err.stack}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
|
@ -1,6 +1,6 @@
|
|||
var ChannelModule = require("./module");
|
||||
var Flags = require("../flags");
|
||||
var fs = require("fs");
|
||||
var fs = require("graceful-fs");
|
||||
var path = require("path");
|
||||
var sio = require("socket.io");
|
||||
var db = require("../database");
|
||||
|
@ -94,10 +94,7 @@ function Channel(name) {
|
|||
}, USERCOUNT_THROTTLE);
|
||||
const self = this;
|
||||
db.channels.load(this, function (err) {
|
||||
if (err && err.code === 'EBANNED') {
|
||||
self.emit("loadFail", err.message);
|
||||
self.setFlag(Flags.C_ERROR);
|
||||
} else if (err && err !== "Channel is not registered") {
|
||||
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 {
|
||||
|
@ -177,6 +174,23 @@ Channel.prototype.initModules = function () {
|
|||
self.logger.log("[init] Loaded modules: " + inited.join(", "));
|
||||
};
|
||||
|
||||
Channel.prototype.getDiskSize = function (cb) {
|
||||
if (this._getDiskSizeTimeout > Date.now()) {
|
||||
return cb(null, this._cachedDiskSize);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
|
||||
fs.stat(file, function (err, stats) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
self._cachedDiskSize = stats.size;
|
||||
cb(null, self._cachedDiskSize);
|
||||
});
|
||||
};
|
||||
|
||||
Channel.prototype.loadState = function () {
|
||||
/* Don't load from disk if not registered */
|
||||
if (!this.is(Flags.C_REGISTERED)) {
|
||||
|
@ -447,13 +461,6 @@ Channel.prototype.acceptUser = function (user) {
|
|||
});
|
||||
|
||||
this.sendUserlist([user]);
|
||||
|
||||
// Managing this from here is not great, but due to the sequencing involved
|
||||
// and the limitations of the existing design, it'll have to do.
|
||||
if (this.modules.playlist.leader !== null) {
|
||||
user.socket.emit("setLeader", this.modules.playlist.leader.getName());
|
||||
}
|
||||
|
||||
this.broadcastUsercount();
|
||||
if (!this.is(Flags.C_REGISTERED)) {
|
||||
user.socket.emit("channelNotRegistered");
|
||||
|
@ -557,7 +564,7 @@ Channel.prototype.sendUserMeta = function (users, user, minrank) {
|
|||
var self = this;
|
||||
var userdata = self.packUserData(user);
|
||||
users.filter(function (u) {
|
||||
return typeof minrank !== "number" || u.account.effectiveRank >= minrank;
|
||||
return typeof minrank !== "number" || u.account.effectiveRank > minrank;
|
||||
}).forEach(function (u) {
|
||||
if (u.account.globalRank >= 255) {
|
||||
u.socket.emit("setUserMeta", {
|
||||
|
|
|
@ -3,6 +3,7 @@ var XSS = require("../xss");
|
|||
var ChannelModule = require("./module");
|
||||
var util = require("../utilities");
|
||||
var Flags = require("../flags");
|
||||
var counters = require("../counters");
|
||||
import { transformImgTags } from '../camo';
|
||||
import { Counter } from 'prom-client';
|
||||
|
||||
|
@ -156,13 +157,14 @@ const chatIncomingCount = new Counter({
|
|||
});
|
||||
ChatModule.prototype.handleChatMsg = function (user, data) {
|
||||
var self = this;
|
||||
counters.add("chat:incoming");
|
||||
chatIncomingCount.inc(1, new Date());
|
||||
|
||||
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
|
||||
data.msg = data.msg.substring(0, 320);
|
||||
|
||||
// Restrict new accounts/IPs from chatting and posting links
|
||||
if (this.restrictNewAccount(user, data)) {
|
||||
|
@ -248,7 +250,7 @@ ChatModule.prototype.handlePm = function (user, data) {
|
|||
}
|
||||
|
||||
|
||||
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
|
||||
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) {
|
||||
|
@ -356,6 +358,7 @@ ChatModule.prototype.processChatMsg = function (user, data) {
|
|||
return;
|
||||
}
|
||||
this.sendMessage(msgobj);
|
||||
counters.add("chat:sent");
|
||||
chatSentCount.inc(1, new Date());
|
||||
};
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ var Flags = require("../flags");
|
|||
var util = require("../utilities");
|
||||
var Account = require("../account");
|
||||
import Promise from 'bluebird';
|
||||
const XSS = require("../xss");
|
||||
|
||||
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
|
||||
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
|
||||
|
@ -262,6 +261,7 @@ KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
|
|||
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
|
||||
|
||||
this.banAll(user, name, range, reason).catch(error => {
|
||||
//console.log('!!!', error.stack);
|
||||
const message = error.message || error;
|
||||
user.socket.emit("errorMsg", { msg: message });
|
||||
}).then(() => {
|
||||
|
@ -276,10 +276,6 @@ KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
|
|||
};
|
||||
|
||||
KickBanModule.prototype.banName = async function banName(actor, name, reason) {
|
||||
if (!util.isValidUserName(name)) {
|
||||
throw new Error("Invalid username");
|
||||
}
|
||||
|
||||
reason = reason.substring(0, 255);
|
||||
|
||||
var chan = this.channel;
|
||||
|
@ -327,9 +323,6 @@ KickBanModule.prototype.banName = async function banName(actor, name, reason) {
|
|||
};
|
||||
|
||||
KickBanModule.prototype.banIP = async function banIP(actor, ip, name, reason) {
|
||||
if (!util.isValidUserName(name)) {
|
||||
throw new Error("Invalid username");
|
||||
}
|
||||
reason = reason.substring(0, 255);
|
||||
var masked = util.cloakIP(ip);
|
||||
|
||||
|
@ -411,12 +404,7 @@ KickBanModule.prototype.banAll = async function banAll(
|
|||
);
|
||||
|
||||
if (!await dbIsNameBanned(chan.name, name)) {
|
||||
promises.push(this.banName(actor, name, reason).catch(error => {
|
||||
// TODO: banning should be made idempotent, not throw an error
|
||||
if (!/already banned/.test(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
promises.push(this.banName(actor, name, reason));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
@ -452,9 +440,8 @@ KickBanModule.prototype.handleUnban = function (user, data) {
|
|||
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
|
||||
if (self.channel.modules.chat) {
|
||||
var banperm = self.channel.modules.permissions.permissions.ban;
|
||||
// TODO: quick fix, shouldn't trust name from unban frame.
|
||||
self.channel.modules.chat.sendModMessage(
|
||||
user.getName() + " unbanned " + XSS.sanitizeText(data.name),
|
||||
user.getName() + " unbanned " + data.name,
|
||||
banperm
|
||||
);
|
||||
}
|
||||
|
|
|
@ -110,6 +110,10 @@ LibraryModule.prototype.handleSearchMedia = function (user, data) {
|
|||
librarySearchResultSize.labels('library')
|
||||
.observe(res.length, new Date());
|
||||
|
||||
if (res.length === 0) {
|
||||
return searchYT();
|
||||
}
|
||||
|
||||
res.sort(function (a, b) {
|
||||
var x = a.title.toLowerCase();
|
||||
var y = b.title.toLowerCase();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
var Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
|
||||
var Vimeo = require("cytube-mediaquery/lib/provider/vimeo");
|
||||
var ChannelModule = require("./module");
|
||||
var Config = require("../config");
|
||||
|
||||
|
|
|
@ -59,7 +59,6 @@ OptionsModule.prototype.load = function (data) {
|
|||
10,
|
||||
this.opts.chat_antiflood_params.sustained
|
||||
);
|
||||
this.opts.afk_timeout = Math.min(86400 /* one day */, this.opts.afk_timeout);
|
||||
this.dirty = false;
|
||||
};
|
||||
|
||||
|
@ -140,26 +139,18 @@ OptionsModule.prototype.handleSetOptions = function (user, data) {
|
|||
|
||||
if ("afk_timeout" in data) {
|
||||
var tm = parseInt(data.afk_timeout);
|
||||
if (isNaN(tm) || tm < 0 || tm > 86400 /* one day */) {
|
||||
if (isNaN(tm) || tm < 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -8,7 +8,9 @@ var Flags = require("../flags");
|
|||
var db = require("../database");
|
||||
var CustomEmbedFilter = require("../customembed").filter;
|
||||
var XSS = require("../xss");
|
||||
import counters from '../counters';
|
||||
import { Counter } from 'prom-client';
|
||||
import * as Switches from '../switches';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('playlist');
|
||||
|
||||
|
@ -116,7 +118,7 @@ PlaylistModule.prototype = Object.create(ChannelModule.prototype);
|
|||
|
||||
Object.defineProperty(PlaylistModule.prototype, "dirty", {
|
||||
get() {
|
||||
return this._positionDirty || this._listDirty;
|
||||
return this._positionDirty || this._listDirty || !Switches.isActive("plDirtyCheck");
|
||||
},
|
||||
|
||||
set(val) {
|
||||
|
@ -158,22 +160,14 @@ PlaylistModule.prototype.load = function (data) {
|
|||
}
|
||||
} else if (item.media.type === "gd") {
|
||||
delete item.media.meta.gpdirect;
|
||||
} else if (["vm", "jw", "mx", "im", "gp", "us", "hb"].includes(item.media.type)) {
|
||||
} else if (["vm", "jw"].includes(item.media.type)) {
|
||||
// JW has been deprecated for a long time
|
||||
// VM shut down in December 2017
|
||||
// Mixer shut down in July 2020
|
||||
// Imgur replaced albums with a feature called galleries in 2019
|
||||
// Picasa shut down in 2016
|
||||
// Ustream was sunset by IBM in September 2018
|
||||
// SmashCast (Hitbox) seemed to just vanish November 2020
|
||||
LOGGER.warn(
|
||||
"Dropping playlist item with deprecated type %s",
|
||||
item.media.type
|
||||
);
|
||||
return;
|
||||
} else if (item.media.meta.embed && item.media.meta.embed.tag !== 'iframe') {
|
||||
LOGGER.warn("Dropping playlist item with flash embed");
|
||||
return;
|
||||
}
|
||||
|
||||
var m = new Media(item.media.id, item.media.title, item.media.seconds,
|
||||
|
@ -220,13 +214,17 @@ PlaylistModule.prototype.save = function (data) {
|
|||
time = this.current.media.currentTime;
|
||||
}
|
||||
|
||||
data.playlistPosition = {
|
||||
index: pos,
|
||||
time
|
||||
};
|
||||
if (Switches.isActive("plDirtyCheck")) {
|
||||
data.playlistPosition = {
|
||||
index: pos,
|
||||
time
|
||||
};
|
||||
|
||||
if (this._listDirty) {
|
||||
data.playlist = { pl: arr, pos, time, externalPosition: true };
|
||||
if (this._listDirty) {
|
||||
data.playlist = { pl: arr, pos, time, externalPosition: true };
|
||||
}
|
||||
} else {
|
||||
data.playlist = { pl: arr, pos, time };
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -518,6 +516,7 @@ PlaylistModule.prototype.queueStandard = function (user, data) {
|
|||
|
||||
const self = this;
|
||||
this.channel.refCounter.ref("PlaylistModule::queueStandard");
|
||||
counters.add("playlist:queue:count", 1);
|
||||
this.semaphore.queue(function (lock) {
|
||||
InfoGetter.getMedia(data.id, data.type, function (err, media) {
|
||||
if (err) {
|
||||
|
@ -749,8 +748,6 @@ PlaylistModule.prototype.handleShuffle = function (user) {
|
|||
this.channel.logger.log("[playlist] " + user.getName() + " shuffled the playlist");
|
||||
|
||||
var pl = this.items.toArray(false);
|
||||
let currentUid = this.current ? this.current.uid : null;
|
||||
let currentTime = this.current ? this.current.media.currentTime : undefined;
|
||||
this.items.clear();
|
||||
this.semaphore.reset();
|
||||
while (pl.length > 0) {
|
||||
|
@ -761,12 +758,7 @@ PlaylistModule.prototype.handleShuffle = function (user) {
|
|||
queueby: pl[i].queueby
|
||||
});
|
||||
|
||||
if (pl[i].uid === currentUid) {
|
||||
this.items.prepend(item);
|
||||
} else {
|
||||
this.items.append(item);
|
||||
}
|
||||
|
||||
this.items.append(item);
|
||||
pl.splice(i, 1);
|
||||
}
|
||||
this._listDirty = true;
|
||||
|
@ -780,7 +772,7 @@ PlaylistModule.prototype.handleShuffle = function (user) {
|
|||
u.socket.emit("playlist", pl);
|
||||
}
|
||||
});
|
||||
this.startPlayback(currentTime);
|
||||
this.startPlayback();
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -943,11 +935,6 @@ PlaylistModule.prototype._addItem = function (media, data, user, cb) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isNaN(media.seconds)) {
|
||||
LOGGER.warn("Detected NaN duration for %j", media);
|
||||
return qfail("Internal error: could not determine media duration");
|
||||
}
|
||||
|
||||
if (data.maxlength > 0 && media.seconds > data.maxlength) {
|
||||
return qfail("Video exceeds the maximum length set by the channel admin: " +
|
||||
data.maxlength + " seconds");
|
||||
|
@ -987,10 +974,6 @@ PlaylistModule.prototype._addItem = function (media, data, user, cb) {
|
|||
}
|
||||
}
|
||||
|
||||
if (media.meta.ytRating === "ytAgeRestricted") {
|
||||
return qfail("Cannot add age restricted videos. See: https://github.com/calzoneman/sync/wiki/Frequently-Asked-Questions#why-dont-age-restricted-youtube-videos-work");
|
||||
}
|
||||
|
||||
/* Warn about blocked countries */
|
||||
if (media.meta.restricted) {
|
||||
user.socket.emit("queueWarn", {
|
||||
|
|
|
@ -8,7 +8,6 @@ const TYPE_NEW_POLL = {
|
|||
title: "string",
|
||||
timeout: "number,optional",
|
||||
obscured: "boolean",
|
||||
retainVotes: "boolean,optional",
|
||||
opts: "array"
|
||||
};
|
||||
|
||||
|
@ -43,7 +42,12 @@ PollModule.prototype.unload = function () {
|
|||
PollModule.prototype.load = function (data) {
|
||||
if ("poll" in data) {
|
||||
if (data.poll !== null) {
|
||||
this.poll = Poll.fromChannelData(data.poll);
|
||||
this.poll = new Poll(data.poll.initiator, "", [], data.poll.obscured);
|
||||
this.poll.title = data.poll.title;
|
||||
this.poll.options = data.poll.options;
|
||||
this.poll.counts = data.poll.counts;
|
||||
this.poll.votes = data.poll.votes;
|
||||
this.poll.timestamp = data.poll.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,7 +60,15 @@ PollModule.prototype.save = function (data) {
|
|||
return;
|
||||
}
|
||||
|
||||
data.poll = this.poll.toChannelData();
|
||||
data.poll = {
|
||||
title: this.poll.title,
|
||||
initiator: this.poll.initiator,
|
||||
options: this.poll.options,
|
||||
counts: this.poll.counts,
|
||||
votes: this.poll.votes,
|
||||
obscured: this.poll.obscured,
|
||||
timestamp: this.poll.timestamp
|
||||
};
|
||||
};
|
||||
|
||||
PollModule.prototype.onUserPostJoin = function (user) {
|
||||
|
@ -85,7 +97,8 @@ PollModule.prototype.addUserToPollRoom = function (user) {
|
|||
};
|
||||
|
||||
PollModule.prototype.onUserPart = function(user) {
|
||||
if (this.poll && !this.poll.retainVotes && this.poll.uncountVote(user.realip)) {
|
||||
if (this.poll) {
|
||||
this.poll.unvote(user.realip);
|
||||
this.broadcastPoll(false);
|
||||
}
|
||||
};
|
||||
|
@ -97,11 +110,12 @@ PollModule.prototype.sendPoll = function (user) {
|
|||
|
||||
var perms = this.channel.modules.permissions;
|
||||
|
||||
user.socket.emit("closePoll");
|
||||
if (perms.canViewHiddenPoll(user)) {
|
||||
var unobscured = this.poll.toUpdateFrame(true);
|
||||
var unobscured = this.poll.packUpdate(true);
|
||||
user.socket.emit("newPoll", unobscured);
|
||||
} else {
|
||||
var obscured = this.poll.toUpdateFrame(false);
|
||||
var obscured = this.poll.packUpdate(false);
|
||||
user.socket.emit("newPoll", obscured);
|
||||
}
|
||||
};
|
||||
|
@ -111,10 +125,13 @@ PollModule.prototype.broadcastPoll = function (isNewPoll) {
|
|||
return;
|
||||
}
|
||||
|
||||
var obscured = this.poll.toUpdateFrame(false);
|
||||
var unobscured = this.poll.toUpdateFrame(true);
|
||||
var obscured = this.poll.packUpdate(false);
|
||||
var unobscured = this.poll.packUpdate(true);
|
||||
|
||||
const event = isNewPoll ? "newPoll" : "updatePoll";
|
||||
if (isNewPoll) {
|
||||
this.channel.broadcastAll("closePoll");
|
||||
}
|
||||
|
||||
this.channel.broadcastToRoom(event, unobscured, this.roomViewHidden);
|
||||
this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden);
|
||||
|
@ -148,9 +165,6 @@ PollModule.prototype.handleNewPoll = function (user, data, ack) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Ensure any existing poll is closed
|
||||
this.handleClosePoll(user);
|
||||
|
||||
ack = ackOrErrorMsg(ack, user);
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
|
@ -173,27 +187,9 @@ PollModule.prototype.handleNewPoll = function (user, data, ack) {
|
|||
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 poll = new Poll(user.getName(), data.title, data.opts, data.obscured);
|
||||
var self = this;
|
||||
if (data.hasOwnProperty("timeout")) {
|
||||
if (data.hasOwnProperty("timeout") && !isNaN(data.timeout) && data.timeout > 0) {
|
||||
poll.timer = setTimeout(function () {
|
||||
if (self.poll === poll) {
|
||||
self.handleClosePoll({
|
||||
|
@ -217,10 +213,9 @@ PollModule.prototype.handleVote = function (user, data) {
|
|||
}
|
||||
|
||||
if (this.poll) {
|
||||
if (this.poll.countVote(user.realip, data.option)) {
|
||||
this.dirty = true;
|
||||
this.broadcastPoll(false);
|
||||
}
|
||||
this.poll.vote(user.realip, data.option);
|
||||
this.dirty = true;
|
||||
this.broadcastPoll(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -230,9 +225,9 @@ PollModule.prototype.handleClosePoll = function (user) {
|
|||
}
|
||||
|
||||
if (this.poll) {
|
||||
if (this.poll.hideVotes) {
|
||||
this.poll.hideVotes = false;
|
||||
this.channel.broadcastAll("updatePoll", this.poll.toUpdateFrame(true));
|
||||
if (this.poll.obscured) {
|
||||
this.poll.obscured = false;
|
||||
this.channel.broadcastAll("updatePoll", this.poll.packUpdate(true));
|
||||
}
|
||||
|
||||
if (this.poll.timer) {
|
||||
|
@ -251,9 +246,6 @@ PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Ensure any existing poll is closed
|
||||
this.handleClosePoll(user);
|
||||
|
||||
msg = msg.replace(/^\/h?poll/, "");
|
||||
|
||||
var args = msg.split(",");
|
||||
|
@ -268,7 +260,7 @@ PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
|
|||
return;
|
||||
}
|
||||
|
||||
var poll = Poll.create(user.getName(), title, args, { hideVotes: obscured });
|
||||
var poll = new Poll(user.getName(), title, args, obscured);
|
||||
this.poll = poll;
|
||||
this.dirty = true;
|
||||
this.broadcastPoll(true);
|
||||
|
|
|
@ -164,7 +164,6 @@ RankModule.prototype.handleRankChange = function (user, data) {
|
|||
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);
|
||||
|
|
|
@ -37,10 +37,10 @@ VoteskipModule.prototype.handleVoteskip = function (user) {
|
|||
}
|
||||
|
||||
if (!this.poll) {
|
||||
this.poll = Poll.create("[server]", "voteskip", ["skip"]);
|
||||
this.poll = new Poll("[server]", "voteskip", ["skip"], false);
|
||||
}
|
||||
|
||||
if (!this.poll.countVote(user.realip, 0)) {
|
||||
if (!this.poll.vote(user.realip, 0)) {
|
||||
// Vote was already recorded for this IP, no update needed
|
||||
return;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ VoteskipModule.prototype.unvote = function(ip) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.poll.uncountVote(ip);
|
||||
this.poll.unvote(ip);
|
||||
};
|
||||
|
||||
VoteskipModule.prototype.update = function () {
|
||||
|
@ -78,30 +78,14 @@ VoteskipModule.prototype.update = function () {
|
|||
return;
|
||||
}
|
||||
|
||||
const { counts } = this.poll.toUpdateFrame(false);
|
||||
const { total, eligible, noPermission, afk } = this.calcUsercounts();
|
||||
const need = Math.max(
|
||||
1, // Require at least one vote, see #944
|
||||
Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
|
||||
);
|
||||
if (counts[0] >= need) {
|
||||
const info = `${counts[0]}/${eligible} skipped; ` +
|
||||
const need = Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"));
|
||||
if (this.poll.counts[0] >= need) {
|
||||
const info = `${this.poll.counts[0]}/${eligible} skipped; ` +
|
||||
`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 {
|
||||
|
@ -111,20 +95,11 @@ VoteskipModule.prototype.update = function () {
|
|||
|
||||
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 data = {
|
||||
count: this.poll ? this.poll.counts[0] : 0,
|
||||
need: this.poll ? Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
|
||||
: 0
|
||||
};
|
||||
|
||||
var perms = this.channel.modules.permissions;
|
||||
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import Server from '../server';
|
||||
|
||||
export async function handleBanChannel({ name, externalReason, internalReason }) {
|
||||
await Server.getServer().bannedChannelsController.banChannel({
|
||||
name,
|
||||
externalReason,
|
||||
internalReason,
|
||||
bannedBy: '[console]'
|
||||
});
|
||||
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
export async function handleUnbanChannel({ name }) {
|
||||
await Server.getServer().bannedChannelsController.unbanChannel(name, '[console]');
|
||||
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
export async function handleShowBannedChannel({ name }) {
|
||||
let banInfo = await Server.getServer().bannedChannelsController.getBannedChannel(name);
|
||||
|
||||
return { status: 'success', ban: banInfo };
|
||||
}
|
113
src/config.js
113
src/config.js
|
@ -6,13 +6,11 @@ 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 = {
|
||||
database: {
|
||||
client: "mysql",
|
||||
mysql: {
|
||||
server: "localhost",
|
||||
port: 3306,
|
||||
database: "cytube3",
|
||||
|
@ -61,18 +59,18 @@ var defaults = {
|
|||
io: {
|
||||
domain: "http://localhost",
|
||||
"default-port": 1337,
|
||||
"ip-connection-limit": 10,
|
||||
cors: {
|
||||
"allowed-origins": []
|
||||
}
|
||||
"ip-connection-limit": 10
|
||||
},
|
||||
"youtube-v3-key": "",
|
||||
"channel-blacklist": [],
|
||||
"channel-path": "r",
|
||||
"channel-save-interval": 5,
|
||||
"channel-storage": {
|
||||
type: "file"
|
||||
},
|
||||
"max-channels-per-user": 5,
|
||||
"max-accounts-per-ip": 5,
|
||||
"guest-login-delay": 60,
|
||||
"max-chat-message-length": 320,
|
||||
aliases: {
|
||||
"purge-interval": 3600000,
|
||||
"max-age": 2592000000
|
||||
|
@ -134,25 +132,34 @@ 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);
|
||||
cfg = YAML.load(path.join(__dirname, "..", file));
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
throw new Error(`No such file: ${absPath}`);
|
||||
LOGGER.info(file + " does not exist, assuming default configuration");
|
||||
cfg = defaults;
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Invalid config file ${absPath}: ${e}`);
|
||||
LOGGER.error("Error loading config file " + file + ": ");
|
||||
LOGGER.error(e);
|
||||
if (e.stack) {
|
||||
LOGGER.error(e.stack);
|
||||
}
|
||||
cfg = defaults;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg == null) {
|
||||
throw new Error("Configuration parser returned null");
|
||||
LOGGER.info(file + " is an Invalid configuration file, " +
|
||||
"assuming default configuration");
|
||||
cfg = defaults;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cfg.mail) {
|
||||
|
@ -172,7 +179,6 @@ exports.load = function (file) {
|
|||
loadCamoConfig();
|
||||
loadPrometheusConfig();
|
||||
loadEmailConfig();
|
||||
loadCaptchaConfig();
|
||||
};
|
||||
|
||||
function checkLoadConfig(configClass, filename) {
|
||||
|
@ -235,44 +241,10 @@ function loadEmailConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
@ -356,13 +328,6 @@ function preprocessConfig(cfg) {
|
|||
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) {
|
||||
|
@ -373,6 +338,13 @@ function preprocessConfig(cfg) {
|
|||
}
|
||||
}
|
||||
|
||||
/* Convert channel blacklist to a hashtable */
|
||||
var tbl = {};
|
||||
cfg["channel-blacklist"].forEach(function (c) {
|
||||
tbl[c.toLowerCase()] = true;
|
||||
});
|
||||
cfg["channel-blacklist"] = tbl;
|
||||
|
||||
/* Check channel path */
|
||||
if(!/^[-\w]+$/.test(cfg["channel-path"])){
|
||||
LOGGER.error("Channel paths may only use the same characters as usernames and channel names.");
|
||||
|
@ -388,7 +360,7 @@ function preprocessConfig(cfg) {
|
|||
}
|
||||
|
||||
if (cfg["youtube-v3-key"]) {
|
||||
require("@cytube/mediaquery/lib/provider/youtube").setApiKey(
|
||||
require("cytube-mediaquery/lib/provider/youtube").setApiKey(
|
||||
cfg["youtube-v3-key"]);
|
||||
} else {
|
||||
LOGGER.warn("No YouTube v3 API key set. YouTube links will " +
|
||||
|
@ -398,9 +370,9 @@ function preprocessConfig(cfg) {
|
|||
}
|
||||
|
||||
if (cfg["twitch-client-id"]) {
|
||||
require("@cytube/mediaquery/lib/provider/twitch-vod").setClientID(
|
||||
require("cytube-mediaquery/lib/provider/twitch-vod").setClientID(
|
||||
cfg["twitch-client-id"]);
|
||||
require("@cytube/mediaquery/lib/provider/twitch-clip").setClientID(
|
||||
require("cytube-mediaquery/lib/provider/twitch-clip").setClientID(
|
||||
cfg["twitch-client-id"]);
|
||||
} else {
|
||||
LOGGER.warn("No Twitch Client ID set. Twitch VOD links will " +
|
||||
|
@ -409,6 +381,16 @@ function preprocessConfig(cfg) {
|
|||
"for more information on registering a client ID");
|
||||
}
|
||||
|
||||
if (cfg["mixer-client-id"]) {
|
||||
require("cytube-mediaquery/lib/provider/mixer").setClientID(
|
||||
cfg["mixer-client-id"]
|
||||
);
|
||||
} else {
|
||||
LOGGER.warn("No Mixer Client ID set. Mixer.com links will " +
|
||||
"not work. See mixer-client-id in config.template.yaml " +
|
||||
"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';
|
||||
|
@ -425,15 +407,6 @@ function preprocessConfig(cfg) {
|
|||
'bucket-capacity': cfg.io.throttle['in-rate-limit']
|
||||
}, cfg.io.throttle);
|
||||
|
||||
if (!cfg['channel-storage']) {
|
||||
cfg['channel-storage'] = { type: undefined };
|
||||
}
|
||||
|
||||
if (cfg["max-chat-message-length"] > 1000) {
|
||||
LOGGER.warn("Max chat message length was greater than 1000. Setting to 1000.");
|
||||
cfg["max-chat-message-length"] = 1000;
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
|
@ -494,7 +467,3 @@ exports.getPrometheusConfig = function getPrometheusConfig() {
|
|||
exports.getEmailConfig = function getEmailConfig() {
|
||||
return emailConfig;
|
||||
};
|
||||
|
||||
exports.getCaptchaConfig = function getCaptchaConfig() {
|
||||
return captchaConfig;
|
||||
};
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
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 };
|
|
@ -51,7 +51,7 @@ class EmailConfig {
|
|||
const deleteAccount = config['delete-account'];
|
||||
this._delete = {
|
||||
isEnabled() {
|
||||
return deleteAccount != null && deleteAccount.enabled;
|
||||
return deleteAccount !== null && deleteAccount.enabled;
|
||||
},
|
||||
|
||||
getHTML() {
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import { eventlog } from '../logger';
|
||||
import { SimpleCache } from '../util/simple-cache';
|
||||
const LOGGER = require('@calzoneman/jsli')('BannedChannelsController');
|
||||
|
||||
export class BannedChannelsController {
|
||||
constructor(dbChannels, globalMessageBus) {
|
||||
this.dbChannels = dbChannels;
|
||||
this.globalMessageBus = globalMessageBus;
|
||||
this.cache = new SimpleCache({
|
||||
maxElem: 1000,
|
||||
maxAge: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO: add an audit log to the database
|
||||
*/
|
||||
|
||||
async banChannel({ name, externalReason, internalReason, bannedBy }) {
|
||||
LOGGER.info(`Banning channel ${name} (banned by ${bannedBy})`);
|
||||
eventlog.log(`[acp] ${bannedBy} banned channel ${name}`);
|
||||
|
||||
let banInfo = await this.dbChannels.getBannedChannel(name);
|
||||
if (banInfo !== null) {
|
||||
LOGGER.warn(`Channel ${name} is already banned, updating ban reason`);
|
||||
}
|
||||
|
||||
this.cache.delete(name);
|
||||
|
||||
await this.dbChannels.putBannedChannel({
|
||||
name,
|
||||
externalReason,
|
||||
internalReason,
|
||||
bannedBy
|
||||
});
|
||||
|
||||
this.globalMessageBus.emit(
|
||||
'ChannelBanned',
|
||||
{ channel: name, externalReason }
|
||||
);
|
||||
}
|
||||
|
||||
async unbanChannel(name, unbannedBy) {
|
||||
LOGGER.info(`Unbanning channel ${name}`);
|
||||
eventlog.log(`[acp] ${unbannedBy} unbanned channel ${name}`);
|
||||
this.cache.delete(name);
|
||||
|
||||
this.globalMessageBus.emit(
|
||||
'ChannelUnbanned',
|
||||
{ channel: name }
|
||||
);
|
||||
|
||||
await this.dbChannels.removeBannedChannel(name);
|
||||
}
|
||||
|
||||
async getBannedChannel(name) {
|
||||
name = name.toLowerCase();
|
||||
|
||||
let info = this.cache.get(name);
|
||||
if (info === null) {
|
||||
info = await this.dbChannels.getBannedChannel(name);
|
||||
this.cache.put(name, info);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
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 };
|
55
src/counters.js
Normal file
55
src/counters.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import io from 'socket.io';
|
||||
import Socket from 'socket.io/lib/socket';
|
||||
import * as Metrics from './metrics/metrics';
|
||||
import { JSONFileMetricsReporter } from './metrics/jsonfilemetricsreporter';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('counters');
|
||||
|
||||
var server = null;
|
||||
|
||||
exports.add = Metrics.incCounter;
|
||||
|
||||
Socket.prototype._packet = Socket.prototype.packet;
|
||||
Socket.prototype.packet = function () {
|
||||
this._packet.apply(this, arguments);
|
||||
exports.add('socket.io:packet');
|
||||
};
|
||||
|
||||
function getConnectedSockets() {
|
||||
var sockets = io.instance.sockets.sockets;
|
||||
if (typeof sockets.length === 'number') {
|
||||
return sockets.length;
|
||||
} else {
|
||||
return Object.keys(sockets).length;
|
||||
}
|
||||
}
|
||||
|
||||
function setChannelCounts(metrics) {
|
||||
if (server === null) {
|
||||
server = require('./server').getServer();
|
||||
}
|
||||
|
||||
try {
|
||||
var publicCount = 0;
|
||||
var allCount = 0;
|
||||
server.channels.forEach(function (c) {
|
||||
allCount++;
|
||||
if (c.modules.options && c.modules.options.get("show_public")) {
|
||||
publicCount++;
|
||||
}
|
||||
});
|
||||
|
||||
metrics.addProperty('channelCount:all', allCount);
|
||||
metrics.addProperty('channelCount:public', publicCount);
|
||||
} catch (error) {
|
||||
LOGGER.error(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
const reporter = new JSONFileMetricsReporter('counters.log');
|
||||
Metrics.setReporter(reporter);
|
||||
Metrics.setReportInterval(60000);
|
||||
Metrics.addReportHook((metrics) => {
|
||||
metrics.addProperty('socket.io:count', getConnectedSockets());
|
||||
setChannelCounts(metrics);
|
||||
});
|
|
@ -22,21 +22,15 @@ const SOURCE_CONTENT_TYPES = new Set([
|
|||
'application/dash+xml',
|
||||
'application/x-mpegURL',
|
||||
'audio/aac',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/opus',
|
||||
'audio/mpeg',
|
||||
'video/mp4',
|
||||
'video/ogg',
|
||||
'video/webm'
|
||||
]);
|
||||
|
||||
const AUDIO_ONLY_CONTENT_TYPES = new Set([
|
||||
'audio/aac',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/opus'
|
||||
const LIVE_ONLY_CONTENT_TYPES = new Set([
|
||||
'application/dash+xml'
|
||||
]);
|
||||
|
||||
export function lookup(url, opts) {
|
||||
|
@ -140,7 +134,6 @@ export function convert(id, data) {
|
|||
|
||||
const meta = {
|
||||
direct: sources,
|
||||
audioTracks: data.audioTracks,
|
||||
textTracks: data.textTracks,
|
||||
thumbnail: data.thumbnail, // Currently ignored by Media
|
||||
live: !!data.live // Currently ignored by Media
|
||||
|
@ -169,20 +162,11 @@ export function validate(data) {
|
|||
validateURL(data.thumbnail);
|
||||
}
|
||||
|
||||
validateSources(data.sources);
|
||||
validateAudioTracks(data.audioTracks);
|
||||
validateSources(data.sources, data);
|
||||
validateTextTracks(data.textTracks);
|
||||
/*
|
||||
* TODO: Xaekai's Octopus subtitle support uses a separate subTracks array
|
||||
* in a slightly different format than textTracks. That currently requires
|
||||
* a channel script to use, but if that is integrated in core then it needs
|
||||
* to be validated here (and ideally merged with textTracks so there is only
|
||||
* one array).
|
||||
*/
|
||||
validateFonts(data.fonts);
|
||||
}
|
||||
|
||||
function validateSources(sources) {
|
||||
function validateSources(sources, data) {
|
||||
if (!Array.isArray(sources))
|
||||
throw new ValidationError('sources must be a list');
|
||||
if (sources.length === 0)
|
||||
|
@ -198,8 +182,12 @@ function validateSources(sources) {
|
|||
`unacceptable source contentType "${source.contentType}"`
|
||||
);
|
||||
|
||||
// TODO (Xaekai): This should be allowed
|
||||
if (/*!AUDIO_ONLY_CONTENT_TYPES.has(source.contentType) && */!SOURCE_QUALITIES.has(source.quality))
|
||||
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')) {
|
||||
|
@ -213,45 +201,6 @@ function validateSources(sources) {
|
|||
}
|
||||
}
|
||||
|
||||
function validateAudioTracks(audioTracks) {
|
||||
if (typeof audioTracks === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(audioTracks)){
|
||||
throw new ValidationError('audioTracks must be a list');
|
||||
}
|
||||
|
||||
for (let track of audioTracks) {
|
||||
if (typeof track.url !== 'string'){
|
||||
throw new ValidationError('audio track URL must be a string');
|
||||
}
|
||||
validateURL(track.url);
|
||||
|
||||
if (!AUDIO_ONLY_CONTENT_TYPES.has(track.contentType)){
|
||||
throw new ValidationError(
|
||||
`unacceptable audio track contentType "${track.contentType}"`
|
||||
);
|
||||
}
|
||||
if (typeof track.label !== 'string'){
|
||||
throw new ValidationError('audio track label must be a string');
|
||||
}
|
||||
if (!track.label){
|
||||
throw new ValidationError('audio track label must be nonempty');
|
||||
}
|
||||
|
||||
if (typeof track.language !== 'string'){
|
||||
throw new ValidationError('audio track language must be a string');
|
||||
}
|
||||
if (!track.language){
|
||||
throw new ValidationError('audio track language must be nonempty');
|
||||
}
|
||||
if (!/^[a-z]{2,3}$/.test(track.language)){
|
||||
throw new ValidationError('audio track language must be a two or three letter IETF BCP 47 subtag');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTextTracks(textTracks) {
|
||||
if (typeof textTracks === 'undefined') {
|
||||
return;
|
||||
|
@ -260,7 +209,6 @@ function validateTextTracks(textTracks) {
|
|||
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');
|
||||
|
@ -275,29 +223,6 @@ function validateTextTracks(textTracks) {
|
|||
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 validateFonts(fonts) {
|
||||
if (typeof textTracks === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(fonts))
|
||||
throw new ValidationError('fonts must be a list of URLs');
|
||||
|
||||
for (let f of fonts) {
|
||||
if (typeof f !== 'string')
|
||||
throw new ValidationError('fonts must be a list of URLs');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,12 +20,77 @@ function filter(input) {
|
|||
}
|
||||
|
||||
function getMeta($) {
|
||||
let tag = $("iframe");
|
||||
var tag = $("embed");
|
||||
if (tag.length !== 0) {
|
||||
return filterEmbed(tag[0]);
|
||||
}
|
||||
tag = $("object");
|
||||
if (tag.length !== 0) {
|
||||
return filterObject(tag[0]);
|
||||
}
|
||||
tag = $("iframe");
|
||||
if (tag.length !== 0) {
|
||||
return filterIframe(tag[0]);
|
||||
}
|
||||
|
||||
throw new Error("Invalid embed. Input must be an <iframe> tag");
|
||||
throw new Error("Invalid embed. Input must be an <iframe>, <object>, or " +
|
||||
"<embed> tag.");
|
||||
}
|
||||
|
||||
const ALLOWED_PARAMS = /^(flashvars|bgcolor|movie)$/i;
|
||||
function filterEmbed(tag) {
|
||||
if (tag.attribs.type && tag.attribs.type !== "application/x-shockwave-flash") {
|
||||
throw new Error("Invalid embed. Only type 'application/x-shockwave-flash' " +
|
||||
"is allowed for <embed> tags.");
|
||||
}
|
||||
|
||||
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: "object",
|
||||
src: tag.attribs.src,
|
||||
params: {}
|
||||
}
|
||||
};
|
||||
|
||||
for (var key in tag.attribs) {
|
||||
if (ALLOWED_PARAMS.test(key)) {
|
||||
meta.embed.params[key] = tag.attribs[key];
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function filterObject(tag) {
|
||||
if (tag.attribs.type && tag.attribs.type !== "application/x-shockwave-flash") {
|
||||
throw new Error("Invalid embed. Only type 'application/x-shockwave-flash' " +
|
||||
"is allowed for <object> tags.");
|
||||
}
|
||||
|
||||
if (!/^https:/.test(tag.attribs.data)) {
|
||||
throw new Error("Invalid embed. Embed source must be HTTPS, plain HTTP is not supported.");
|
||||
}
|
||||
|
||||
var meta = {
|
||||
embed: {
|
||||
tag: "object",
|
||||
src: tag.attribs.data,
|
||||
params: {}
|
||||
}
|
||||
};
|
||||
|
||||
tag.children.forEach(function (child) {
|
||||
if (child.name !== "param") return;
|
||||
if (!ALLOWED_PARAMS.test(child.attribs.name)) return;
|
||||
|
||||
meta.embed.params[child.attribs.name] = child.attribs.value;
|
||||
});
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function filterIframe(tag) {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
var Config = require("./config");
|
||||
var tables = require("./database/tables");
|
||||
import * as Metrics from './metrics/metrics';
|
||||
import knex from 'knex';
|
||||
import { GlobalBanDB } from './db/globalban';
|
||||
import { MetadataCacheDB } from './database/metadata_cache';
|
||||
import { Summary, Counter } from 'prom-client';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('database');
|
||||
|
@ -30,19 +30,20 @@ class Database {
|
|||
constructor(knexConfig = null) {
|
||||
if (knexConfig === null) {
|
||||
knexConfig = {
|
||||
client: Config.get('database.client'),
|
||||
client: 'mysql',
|
||||
connection: {
|
||||
host: Config.get('database.server'),
|
||||
port: Config.get('database.port'),
|
||||
user: Config.get('database.user'),
|
||||
password: Config.get('database.password'),
|
||||
database: Config.get('database.database'),
|
||||
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('database.pool-size'),
|
||||
max: Config.get('database.pool-size')
|
||||
min: Config.get('mysql.pool-size'),
|
||||
max: Config.get('mysql.pool-size'),
|
||||
refreshIdle: false
|
||||
},
|
||||
debug: !!process.env.KNEX_DEBUG
|
||||
};
|
||||
|
@ -52,12 +53,14 @@ class Database {
|
|||
}
|
||||
|
||||
runTransaction(fn) {
|
||||
const timer = Metrics.startTimer('db:queryTime');
|
||||
const end = queryLatency.startTimer();
|
||||
return this.knex.transaction(fn).catch(error => {
|
||||
queryErrorCount.inc(1);
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
end();
|
||||
Metrics.stopTimer(timer);
|
||||
queryCount.inc(1);
|
||||
});
|
||||
}
|
||||
|
@ -73,8 +76,6 @@ module.exports.init = function (newDB) {
|
|||
} else {
|
||||
db = new Database();
|
||||
}
|
||||
// FIXME Initial database connection failed: error: select 1 from dual
|
||||
// relation "dual" does not exist
|
||||
db.knex.raw('select 1 from dual')
|
||||
.catch(error => {
|
||||
LOGGER.error('Initial database connection failed: %s', error.stack);
|
||||
|
@ -84,12 +85,6 @@ module.exports.init = function (newDB) {
|
|||
.then(() => {
|
||||
require('./database/update').checkVersion();
|
||||
module.exports.loadAnnouncement();
|
||||
require('@cytube/mediaquery/lib/provider/youtube').setCache(
|
||||
new MetadataCacheDB(db)
|
||||
);
|
||||
require('@cytube/mediaquery/lib/provider/bitchute').setCache(
|
||||
new MetadataCacheDB(db)
|
||||
);
|
||||
}).catch(error => {
|
||||
LOGGER.error(error.stack);
|
||||
process.exit(1);
|
||||
|
@ -112,6 +107,7 @@ module.exports.getGlobalBanDB = function getGlobalBanDB() {
|
|||
* Execute a database query
|
||||
*/
|
||||
module.exports.query = function (query, sub, callback) {
|
||||
const timer = Metrics.startTimer('db:queryTime');
|
||||
// 2nd argument is optional
|
||||
if (typeof sub === "function") {
|
||||
callback = sub;
|
||||
|
@ -158,6 +154,7 @@ module.exports.query = function (query, sub, callback) {
|
|||
process.nextTick(callback, 'Database failure', null);
|
||||
}).finally(() => {
|
||||
end();
|
||||
Metrics.stopTimer(timer);
|
||||
queryCount.inc(1);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
var db = require("../database");
|
||||
var valid = require("../utilities").isValidChannelName;
|
||||
var fs = require("fs");
|
||||
var path = require("path");
|
||||
var Flags = require("../flags");
|
||||
var util = require("../utilities");
|
||||
// TODO: I think newer knex has native support for this
|
||||
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
|
||||
import Config from '../config';
|
||||
|
||||
|
@ -198,6 +199,14 @@ module.exports = {
|
|||
}
|
||||
});
|
||||
|
||||
fs.unlink(path.join(__dirname, "..", "..", "chandump", name),
|
||||
function (err) {
|
||||
if (err && err.code !== "ENOENT") {
|
||||
LOGGER.error("Deleting chandump failed:");
|
||||
LOGGER.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
callback(err, !err);
|
||||
});
|
||||
},
|
||||
|
@ -210,9 +219,7 @@ module.exports = {
|
|||
return;
|
||||
}
|
||||
|
||||
db.query("SELECT c.*, bc.external_reason as banReason " +
|
||||
"FROM channels c LEFT OUTER JOIN banned_channels bc " +
|
||||
"ON bc.channel_name = c.name WHERE c.owner=?", [owner],
|
||||
db.query("SELECT * FROM `channels` WHERE owner=?", [owner],
|
||||
function (err, res) {
|
||||
if (err) {
|
||||
callback(err, []);
|
||||
|
@ -248,28 +255,13 @@ module.exports = {
|
|||
return;
|
||||
}
|
||||
|
||||
db.query("SELECT c.*, bc.external_reason as banReason " +
|
||||
"FROM channels c LEFT OUTER JOIN banned_channels bc " +
|
||||
"ON bc.channel_name = c.name WHERE c.name=? " +
|
||||
"UNION " +
|
||||
"SELECT c.*, bc.external_reason as banReason " +
|
||||
"FROM channels c RIGHT OUTER JOIN banned_channels bc " +
|
||||
"ON bc.channel_name = c.name WHERE bc.channel_name=? ",
|
||||
[chan.name, chan.name],
|
||||
function (err, res) {
|
||||
db.query("SELECT * FROM `channels` WHERE name=?", chan.name, function (err, res) {
|
||||
if (err) {
|
||||
callback(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.length > 0 && res[0].banReason !== null) {
|
||||
let banError = new Error(`Channel is banned: ${res[0].banReason}`);
|
||||
banError.code = 'EBANNED';
|
||||
callback(banError, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.length === 0 || res[0].id === null) {
|
||||
if (res.length === 0) {
|
||||
callback("Channel is not registered", null);
|
||||
return;
|
||||
}
|
||||
|
@ -386,7 +378,7 @@ module.exports = {
|
|||
}
|
||||
|
||||
db.query("INSERT INTO `channel_ranks` VALUES (?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE `rank`=?",
|
||||
"ON DUPLICATE KEY UPDATE rank=?",
|
||||
[name, rank, chan, rank], callback);
|
||||
},
|
||||
|
||||
|
@ -722,63 +714,5 @@ module.exports = {
|
|||
LOGGER.error(`Failed to update owner_last_seen column for channel ID ${channelId}: ${error}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getBannedChannel: async function getBannedChannel(name) {
|
||||
if (!valid(name)) {
|
||||
throw new Error("Invalid channel name");
|
||||
}
|
||||
|
||||
return await db.getDB().runTransaction(async tx => {
|
||||
let rows = await tx.table('banned_channels')
|
||||
.where({ channel_name: name })
|
||||
.select();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelName: rows[0].channel_name,
|
||||
externalReason: rows[0].external_reason,
|
||||
internalReason: rows[0].internal_reason,
|
||||
bannedBy: rows[0].banned_by,
|
||||
createdAt: rows[0].created_at,
|
||||
updatedAt: rows[0].updated_at
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
putBannedChannel: async function putBannedChannel({ name, externalReason, internalReason, bannedBy }) {
|
||||
if (!valid(name)) {
|
||||
throw new Error("Invalid channel name");
|
||||
}
|
||||
|
||||
return await db.getDB().runTransaction(async tx => {
|
||||
let insert = tx.table('banned_channels')
|
||||
.insert({
|
||||
channel_name: name,
|
||||
external_reason: externalReason,
|
||||
internal_reason: internalReason,
|
||||
banned_by: bannedBy
|
||||
});
|
||||
let update = tx.raw(createMySQLDuplicateKeyUpdate(
|
||||
['external_reason', 'internal_reason', 'banned_by']
|
||||
));
|
||||
|
||||
return tx.raw(insert.toString() + update.toString());
|
||||
});
|
||||
},
|
||||
|
||||
removeBannedChannel: async function removeBannedChannel(name) {
|
||||
if (!valid(name)) {
|
||||
throw new Error("Invalid channel name");
|
||||
}
|
||||
|
||||
return await db.getDB().runTransaction(async tx => {
|
||||
await tx.table('banned_channels')
|
||||
.where({ channel_name: name })
|
||||
.delete();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,88 +0,0 @@
|
|||
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';
|
||||
case 'bitchute':
|
||||
return 'bc';
|
||||
default:
|
||||
throw new Error(`mediaquery2cytube: no mapping for ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function cytube2mediaquery(type) {
|
||||
switch (type) {
|
||||
case 'yt':
|
||||
return 'youtube';
|
||||
case 'bc':
|
||||
return 'bitchute';
|
||||
default:
|
||||
throw new Error(`cytube2mediaquery: no mapping for ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cachedResultAge = new Summary({
|
||||
name: 'cytube_media_cache_result_age_seconds',
|
||||
help: 'Age (in seconds) of cached record',
|
||||
labelNames: ['source']
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let age = 0;
|
||||
try {
|
||||
age = (Date.now() - row.updated_at.getTime())/1000;
|
||||
if (age > 0) {
|
||||
cachedResultAge.labels(type).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);
|
||||
metadata.meta.cacheAge = age;
|
||||
return new Media(metadata);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { MetadataCacheDB };
|
|
@ -29,7 +29,6 @@ export async function initTables() {
|
|||
// Registration time, TODO convert to timestamp
|
||||
t.bigint('time').notNullable();
|
||||
t.string('name_dedupe', 20).defaultTo(null);
|
||||
t.boolean('inactive').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureTable('channels', t => {
|
||||
|
@ -142,29 +141,4 @@ export async function initTables() {
|
|||
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
|
||||
t.index('created_at');
|
||||
});
|
||||
|
||||
await ensureTable('media_metadata_cache', t => {
|
||||
// The types of id and type are chosen for compatibility
|
||||
// with the existing channel_libraries table.
|
||||
// TODO in the future schema, revisit the ID layout for different media types.
|
||||
t.charset('utf8');
|
||||
t.string('id', 255).notNullable();
|
||||
t.string('type', 2).notNullable();
|
||||
t.text('metadata').notNullable();
|
||||
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
|
||||
|
||||
t.primary(['type', 'id']);
|
||||
t.index('updated_at');
|
||||
});
|
||||
|
||||
await ensureTable('banned_channels', t => {
|
||||
t.charset('utf8mb4');
|
||||
t.string('channel_name', 30)
|
||||
.notNullable()
|
||||
.unique();
|
||||
t.text('external_reason').notNullable();
|
||||
t.text('internal_reason').notNullable();
|
||||
t.string('banned_by', 20).notNullable();
|
||||
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ var path = require("path");
|
|||
|
||||
import { callOnce } from './util/call-once';
|
||||
|
||||
const CYTUBE_VERSION = require('../package.json').version;
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('ffmpeg');
|
||||
const ECODE_MESSAGES = {
|
||||
ENOTFOUND: e => (
|
||||
|
@ -34,10 +32,6 @@ const ECODE_MESSAGES = {
|
|||
"The remote server is unreachable from this server. " +
|
||||
"Please contact the video server's administrator for assistance."
|
||||
),
|
||||
ENOMEM: _e => (
|
||||
"An out of memory error caused the request to fail. Please contact an " +
|
||||
"administrator for assistance."
|
||||
),
|
||||
|
||||
DEPTH_ZERO_SELF_SIGNED_CERT: _e => (
|
||||
'The remote server provided an invalid ' +
|
||||
|
@ -45,12 +39,6 @@ const ECODE_MESSAGES = {
|
|||
'trusted certificate. See https://letsencrypt.org/ to get ' +
|
||||
'a free, trusted certificate.'
|
||||
),
|
||||
SELF_SIGNED_CERT_IN_CHAIN: _e => (
|
||||
'The remote server provided an invalid ' +
|
||||
'(self-signed) SSL certificate. Raw file support requires a ' +
|
||||
'trusted certificate. See https://letsencrypt.org/ to get ' +
|
||||
'a free, trusted certificate.'
|
||||
),
|
||||
UNABLE_TO_VERIFY_LEAF_SIGNATURE: _e => (
|
||||
"The remote server's SSL certificate chain could not be validated. " +
|
||||
"Please contact the administrator of the server to correct their " +
|
||||
|
@ -59,16 +47,6 @@ const ECODE_MESSAGES = {
|
|||
CERT_HAS_EXPIRED: _e => (
|
||||
"The remote server's SSL certificate has expired. Please contact " +
|
||||
"the administrator of the server to renew the certificate."
|
||||
),
|
||||
ERR_TLS_CERT_ALTNAME_INVALID: _e => (
|
||||
"The remote server's SSL connection is misconfigured and has served " +
|
||||
"a certificate invalid for the given link."
|
||||
),
|
||||
|
||||
// node's http parser barfs when careless servers ignore RFC 2616 and send a
|
||||
// response body in reply to a HEAD request
|
||||
HPE_INVALID_CONSTANT: _e => (
|
||||
"The remote server for this link is misconfigured."
|
||||
)
|
||||
};
|
||||
|
||||
|
@ -80,16 +58,13 @@ var acceptedCodecs = {
|
|||
"flv/h264": true,
|
||||
"matroska/vp8": true,
|
||||
"matroska/vp9": true,
|
||||
"ogg/theora": true,
|
||||
"mov/av1": true,
|
||||
"matroska/av1": true
|
||||
"ogg/theora": true
|
||||
};
|
||||
|
||||
var acceptedAudioCodecs = {
|
||||
"mp3": true,
|
||||
"vorbis": true,
|
||||
"aac": true,
|
||||
"opus": true
|
||||
"aac": true
|
||||
};
|
||||
|
||||
var audioOnlyContainers = {
|
||||
|
@ -109,9 +84,7 @@ function initFFLog() {
|
|||
}
|
||||
|
||||
function fixRedirectIfNeeded(urldata, redirect) {
|
||||
let parsedRedirect = urlparse.parse(redirect);
|
||||
if (parsedRedirect.host === null) {
|
||||
// Relative path, munge it to absolute
|
||||
if (!/^https:/.test(redirect)) {
|
||||
redirect = urldata.protocol + "//" + urldata.host + redirect;
|
||||
}
|
||||
|
||||
|
@ -162,13 +135,6 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
|
|||
const { redirCount, cookie } = params;
|
||||
var data = urlparse.parse(url);
|
||||
if (!/https:/.test(data.protocol)) {
|
||||
if (redirCount > 0) {
|
||||
// If the original URL redirected, the user is probably not aware
|
||||
// that the link they entered (which was HTTPS) is redirecting to a
|
||||
// non-HTTPS endpoint
|
||||
return cb(`Unexpected redirect to a non-HTTPS link: ${url}`);
|
||||
}
|
||||
|
||||
return cb("Only links starting with 'https://' are supported " +
|
||||
"for raw audio/video support");
|
||||
}
|
||||
|
@ -180,11 +146,8 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
|
|||
|
||||
var transport = (data.protocol === "https:") ? https : http;
|
||||
data.method = "HEAD";
|
||||
data.headers = {
|
||||
'User-Agent': `CyTube/${CYTUBE_VERSION}`
|
||||
};
|
||||
if (cookie) {
|
||||
data.headers['Cookie'] = cookie;
|
||||
data.headers = { 'Cookie': cookie };
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -198,7 +161,6 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
|
|||
"on the website hosting the link. For best results, use " +
|
||||
"a direct link. See https://git.io/vrE75 for details.");
|
||||
}
|
||||
|
||||
const nextParams = {
|
||||
redirCount: redirCount + 1,
|
||||
cookie: cookie + getCookie(res)
|
||||
|
@ -212,10 +174,11 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
|
|||
}
|
||||
|
||||
if (!/^audio|^video/.test(res.headers["content-type"])) {
|
||||
cb("Could not detect a supported audio/video type. See " +
|
||||
"https://git.io/fjtOK for a list of supported providers. " +
|
||||
"(Content-Type was: '" + res.headers["content-type"] + "')");
|
||||
return;
|
||||
return cb("Expected a content-type starting with 'audio' or 'video', but " +
|
||||
"got '" + res.headers["content-type"] + "'. Only direct links " +
|
||||
"to video and audio files are accepted, and the website hosting " +
|
||||
"the file must be configured to send the correct MIME type. " +
|
||||
"See https://git.io/vrE75 for details.");
|
||||
}
|
||||
|
||||
cb();
|
||||
|
@ -231,19 +194,22 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
|
|||
return;
|
||||
}
|
||||
|
||||
LOGGER.error(
|
||||
"Error sending preflight request: %s (code=%s) (link: %s)",
|
||||
err.message,
|
||||
err.code,
|
||||
url
|
||||
);
|
||||
// HPE_INVALID_CONSTANT comes from node's HTTP parser because
|
||||
// facebook's CDN violates RFC 2616 by sending a body even though
|
||||
// the request uses the HEAD method.
|
||||
// Avoid logging this because it's a known issue.
|
||||
if (!(err.code === 'HPE_INVALID_CONSTANT' && /fbcdn/.test(url))) {
|
||||
LOGGER.error(
|
||||
"Error sending preflight request: %s (code=%s) (link: %s)",
|
||||
err.message,
|
||||
err.code,
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
cb("An unexpected error occurred while trying to process the link. " +
|
||||
"If this link is hosted on a server you own, it is likely " +
|
||||
"misconfigured and you can join community support for assistance. " +
|
||||
"If you are attempting to add links from third party websites, the " +
|
||||
"developers do not provide support for this." +
|
||||
(err.code ? (" Error code: " + err.code) : ""));
|
||||
"Try again, and contact support for further troubleshooting if the " +
|
||||
"problem continues." + (err.code ? (" Error code: " + err.code) : ""));
|
||||
});
|
||||
|
||||
req.end();
|
||||
|
@ -371,14 +337,7 @@ exports.ffprobe = function ffprobe(filename, cb) {
|
|||
var childErr;
|
||||
var args = ["-show_streams", "-show_format", filename];
|
||||
if (USE_JSON) args = ["-of", "json"].concat(args);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(Config.get("ffmpeg.ffprobe-exec"), args);
|
||||
} catch (error) {
|
||||
LOGGER.error("Unable to spawn() ffprobe process: %s", error.stack);
|
||||
cb(error);
|
||||
return;
|
||||
}
|
||||
var child = spawn(Config.get("ffmpeg.ffprobe-exec"), args);
|
||||
var stdout = "";
|
||||
var stderr = "";
|
||||
var timer = setTimeout(function () {
|
||||
|
|
281
src/get-info.js
281
src/get-info.js
|
@ -3,17 +3,13 @@ const Media = require("./media");
|
|||
const CustomEmbedFilter = require("./customembed").filter;
|
||||
const Config = require("./config");
|
||||
const ffmpeg = require("./ffmpeg");
|
||||
const mediaquery = require("@cytube/mediaquery");
|
||||
const YouTube = require("@cytube/mediaquery/lib/provider/youtube");
|
||||
const Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
|
||||
const Odysee = require("@cytube/mediaquery/lib/provider/odysee");
|
||||
const PeerTube = require("@cytube/mediaquery/lib/provider/peertube");
|
||||
const BitChute = require("@cytube/mediaquery/lib/provider/bitchute");
|
||||
const BandCamp = require("@cytube/mediaquery/lib/provider/bandcamp");
|
||||
const Nicovideo = require("@cytube/mediaquery/lib/provider/nicovideo");
|
||||
const Streamable = require("@cytube/mediaquery/lib/provider/streamable");
|
||||
const TwitchVOD = require("@cytube/mediaquery/lib/provider/twitch-vod");
|
||||
const TwitchClip = require("@cytube/mediaquery/lib/provider/twitch-clip");
|
||||
const mediaquery = require("cytube-mediaquery");
|
||||
const YouTube = require("cytube-mediaquery/lib/provider/youtube");
|
||||
const Vimeo = require("cytube-mediaquery/lib/provider/vimeo");
|
||||
const Streamable = require("cytube-mediaquery/lib/provider/streamable");
|
||||
const TwitchVOD = require("cytube-mediaquery/lib/provider/twitch-vod");
|
||||
const TwitchClip = require("cytube-mediaquery/lib/provider/twitch-clip");
|
||||
const Mixer = require("cytube-mediaquery/lib/provider/mixer");
|
||||
import { Counter } from 'prom-client';
|
||||
import { lookup as lookupCustomMetadata } from './custom-media';
|
||||
|
||||
|
@ -76,9 +72,6 @@ var Getters = {
|
|||
if (video.meta.blocked) {
|
||||
meta.restricted = video.meta.blocked;
|
||||
}
|
||||
if (video.meta.ytRating) {
|
||||
meta.ytRating = video.meta.ytRating;
|
||||
}
|
||||
|
||||
var media = new Media(video.id, video.title, video.duration, "yt", meta);
|
||||
callback(false, media);
|
||||
|
@ -209,22 +202,114 @@ var Getters = {
|
|||
});
|
||||
},
|
||||
|
||||
/* soundcloud.com - see https://github.com/calzoneman/sync/issues/916 */
|
||||
/* soundcloud.com */
|
||||
sc: function (id, callback) {
|
||||
callback(
|
||||
"Soundcloud is not supported anymore due to requiring OAuth but not " +
|
||||
"accepting new API key registrations."
|
||||
);
|
||||
},
|
||||
/* TODO: require server owners to register their own API key, put in config */
|
||||
const SC_CLIENT = "2e0c82ab5a020f3a7509318146128abd";
|
||||
|
||||
/* livestream.com */
|
||||
li: function (id, callback) {
|
||||
if (!id.match(/^\d+;\d+$/)) {
|
||||
var m = id.match(/([\w-/.:]+)/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
} else {
|
||||
callback("Invalid ID", null);
|
||||
return;
|
||||
}
|
||||
|
||||
var title = "Livestream.com";
|
||||
var options = {
|
||||
host: "api.soundcloud.com",
|
||||
port: 443,
|
||||
path: "/resolve.json?url=" + id + "&client_id=" + SC_CLIENT,
|
||||
method: "GET",
|
||||
dataType: "jsonp",
|
||||
timeout: 1000
|
||||
};
|
||||
|
||||
urlRetrieve(https, options, function (status, data) {
|
||||
switch (status) {
|
||||
case 200:
|
||||
case 302:
|
||||
break; /* Request is OK, skip to handling data */
|
||||
case 400:
|
||||
return callback("Invalid request", null);
|
||||
case 403:
|
||||
return callback("Private sound", null);
|
||||
case 404:
|
||||
return callback("Sound not found", null);
|
||||
case 500:
|
||||
case 503:
|
||||
return callback("Service unavailable", null);
|
||||
default:
|
||||
return callback("HTTP " + status, null);
|
||||
}
|
||||
|
||||
var track = null;
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
track = data.location;
|
||||
} catch(e) {
|
||||
callback(e, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var options2 = {
|
||||
host: "api.soundcloud.com",
|
||||
port: 443,
|
||||
path: track,
|
||||
method: "GET",
|
||||
dataType: "jsonp",
|
||||
timeout: 1000
|
||||
};
|
||||
|
||||
/**
|
||||
* There has got to be a way to directly get the data I want without
|
||||
* making two requests to Soundcloud...right?
|
||||
* ...right?
|
||||
*/
|
||||
urlRetrieve(https, options2, function (status, data) {
|
||||
switch (status) {
|
||||
case 200:
|
||||
break; /* Request is OK, skip to handling data */
|
||||
case 400:
|
||||
return callback("Invalid request", null);
|
||||
case 403:
|
||||
return callback("Private sound", null);
|
||||
case 404:
|
||||
return callback("Sound not found", null);
|
||||
case 500:
|
||||
case 503:
|
||||
return callback("Service unavailable", null);
|
||||
default:
|
||||
return callback("HTTP " + status, null);
|
||||
}
|
||||
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
var seconds = data.duration / 1000;
|
||||
var title = data.title;
|
||||
var meta = {};
|
||||
if (data.sharing === "private" && data.embeddable_by === "all") {
|
||||
meta.scuri = data.uri;
|
||||
}
|
||||
var media = new Media(id, title, seconds, "sc", meta);
|
||||
callback(false, media);
|
||||
} catch(e) {
|
||||
callback(e, null);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
/* livestream.com */
|
||||
li: function (id, callback) {
|
||||
var m = id.match(/([\w-]+)/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
} else {
|
||||
callback("Invalid ID", null);
|
||||
return;
|
||||
}
|
||||
var title = "Livestream.com - " + id;
|
||||
var media = new Media(id, title, "--:--", "li");
|
||||
callback(false, media);
|
||||
},
|
||||
|
@ -281,6 +366,47 @@ var Getters = {
|
|||
});
|
||||
},
|
||||
|
||||
/* ustream.tv */
|
||||
us: function (id, callback) {
|
||||
var m = id.match(/(channel\/[^?&#]+)/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
} else {
|
||||
callback("Invalid ID", null);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = {
|
||||
host: "www.ustream.tv",
|
||||
port: 443,
|
||||
path: "/" + id,
|
||||
method: "GET",
|
||||
timeout: 1000
|
||||
};
|
||||
|
||||
urlRetrieve(https, options, function (status, data) {
|
||||
if(status !== 200) {
|
||||
callback("Ustream HTTP " + status, null);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Yes, regexing this information out of the HTML sucks.
|
||||
* No, there is not a better solution -- it seems IBM
|
||||
* deprecated the old API (or at least replaced with an
|
||||
* enterprise API marked "Contact sales") so fuck it.
|
||||
*/
|
||||
var m = data.match(/https:\/\/www\.ustream\.tv\/embed\/(\d+)/);
|
||||
if (m) {
|
||||
var title = "Ustream.tv - " + id;
|
||||
var media = new Media(m[1], title, "--:--", "us");
|
||||
callback(false, media);
|
||||
} else {
|
||||
callback("Channel ID not found", null);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* rtmp stream */
|
||||
rt: function (id, callback) {
|
||||
var title = "Livestream";
|
||||
|
@ -290,18 +416,28 @@ var Getters = {
|
|||
|
||||
/* HLS stream */
|
||||
hl: function (id, callback) {
|
||||
if (!/^https/.test(id)) {
|
||||
callback(
|
||||
"HLS links must start with HTTPS due to browser security " +
|
||||
"policy. See https://git.io/vpDLK for details."
|
||||
);
|
||||
return;
|
||||
}
|
||||
var title = "Livestream";
|
||||
var media = new Media(id, title, "--:--", "hl");
|
||||
callback(false, media);
|
||||
},
|
||||
|
||||
/* imgur.com albums */
|
||||
im: function (id, callback) {
|
||||
/**
|
||||
* TODO: Consider deprecating this in favor of custom embeds
|
||||
*/
|
||||
var m = id.match(/([\w-]+)/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
} else {
|
||||
callback("Invalid ID", null);
|
||||
return;
|
||||
}
|
||||
var title = "Imgur Album - " + id;
|
||||
var media = new Media(id, title, "--:--", "im");
|
||||
callback(false, media);
|
||||
},
|
||||
|
||||
/* custom embed */
|
||||
cu: function (id, callback) {
|
||||
var media;
|
||||
|
@ -353,6 +489,28 @@ var Getters = {
|
|||
});
|
||||
},
|
||||
|
||||
/* hitbox.tv / smashcast.tv */
|
||||
hb: function (id, callback) {
|
||||
var m = id.match(/([\w-]+)/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
} else {
|
||||
callback("Invalid ID", null);
|
||||
return;
|
||||
}
|
||||
var title = "Smashcast - " + id;
|
||||
var media = new Media(id, title, "--:--", "hb");
|
||||
callback(false, media);
|
||||
},
|
||||
|
||||
/* vid.me */
|
||||
vm: function (id, callback) {
|
||||
process.nextTick(
|
||||
callback,
|
||||
"As of December 2017, vid.me is no longer in service."
|
||||
);
|
||||
},
|
||||
|
||||
/* streamable */
|
||||
sb: function (id, callback) {
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
|
@ -369,16 +527,6 @@ var Getters = {
|
|||
});
|
||||
},
|
||||
|
||||
/* PeerTube network */
|
||||
pt: function (id, callback) {
|
||||
PeerTube.lookup(id).then(video => {
|
||||
video = new Media(video.id, video.title, video.duration, "pt", video.meta);
|
||||
callback(null, video);
|
||||
}).catch(error => {
|
||||
callback(error.message || error);
|
||||
});
|
||||
},
|
||||
|
||||
/* custom media - https://github.com/calzoneman/sync/issues/655 */
|
||||
cm: async function (id, callback) {
|
||||
try {
|
||||
|
@ -389,43 +537,24 @@ var Getters = {
|
|||
}
|
||||
},
|
||||
|
||||
/* BitChute */
|
||||
bc: function (id, callback) {
|
||||
BitChute.lookup(id).then(video => {
|
||||
video = new Media(video.id, video.title, video.duration, "bc", video.meta);
|
||||
callback(null, video);
|
||||
}).catch(error => {
|
||||
callback(error.message || error);
|
||||
});
|
||||
},
|
||||
/* mixer.com */
|
||||
mx: function (id, callback) {
|
||||
let m = id.match(/^[\w-]+$/);
|
||||
if (!m) {
|
||||
process.nextTick(callback, "Invalid mixer.com ID");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Odysee */
|
||||
od: function (id, callback) {
|
||||
Odysee.lookup(id).then(video => {
|
||||
video = new Media(video.id, video.title, video.duration, "od", video.meta);
|
||||
callback(null, video);
|
||||
Mixer.lookup(id).then(stream => {
|
||||
process.nextTick(callback, null, new Media(
|
||||
stream.id,
|
||||
stream.title,
|
||||
"--:--",
|
||||
"mx",
|
||||
stream.meta
|
||||
));
|
||||
}).catch(error => {
|
||||
callback(error.message || error);
|
||||
});
|
||||
},
|
||||
|
||||
/* BandCamp */
|
||||
bn: function (id, callback) {
|
||||
BandCamp.lookup(id).then(video => {
|
||||
video = new Media(video.id, video.title, video.duration, "bn", video.meta);
|
||||
callback(null, video);
|
||||
}).catch(error => {
|
||||
callback(error.message || error);
|
||||
});
|
||||
},
|
||||
|
||||
/* Niconico */
|
||||
nv: function (id, callback) {
|
||||
Nicovideo.lookup(id).then(video => {
|
||||
video = new Media(video.id, video.title, video.duration, "nv", video.meta);
|
||||
callback(null, video);
|
||||
}).catch(error => {
|
||||
callback(error.message || error);
|
||||
process.nextTick(callback, error.message || error, null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ const cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
|
|||
import typecheck from 'json-typecheck';
|
||||
import { isTorExit } from '../tor';
|
||||
import session from '../session';
|
||||
import counters from '../counters';
|
||||
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
|
||||
import Promise from 'bluebird';
|
||||
const verifySession = Promise.promisify(session.verifySession);
|
||||
|
@ -14,6 +15,7 @@ const getAliases = Promise.promisify(db.getAliases);
|
|||
import { CachingGlobalBanlist } from './globalban';
|
||||
import proxyaddr from 'proxy-addr';
|
||||
import { Counter, Gauge } from 'prom-client';
|
||||
import Socket from 'socket.io/lib/socket';
|
||||
import { TokenBucket } from '../util/token-bucket';
|
||||
import http from 'http';
|
||||
|
||||
|
@ -107,6 +109,28 @@ class IOServer {
|
|||
next();
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: see https://github.com/calzoneman/sync/issues/724
|
||||
ipConnectionLimitMiddleware(socket, next) {
|
||||
const ip = socket.context.ipAddress;
|
||||
const count = this.ipCount.get(ip) || 0;
|
||||
if (count >= Config.get('io.ip-connection-limit')) {
|
||||
// TODO: better error message would be nice
|
||||
next(new Error('Too many connections from your IP address'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.ipCount.set(ip, count + 1);
|
||||
console.log(ip, this.ipCount.get(ip));
|
||||
socket.once('disconnect', () => {
|
||||
console.log('Disconnect event has fired for', socket.id);
|
||||
this.ipCount.set(ip, this.ipCount.get(ip) - 1);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
*/
|
||||
|
||||
checkIPLimit(socket) {
|
||||
const ip = socket.context.ipAddress;
|
||||
const count = this.ipCount.get(ip) || 0;
|
||||
|
@ -199,14 +223,12 @@ class IOServer {
|
|||
return;
|
||||
}
|
||||
|
||||
patchTypecheckedFunctions(socket);
|
||||
patchSocketMetrics(socket);
|
||||
|
||||
this.setRateLimiter(socket);
|
||||
|
||||
emitMetrics(socket);
|
||||
|
||||
LOGGER.info('Accepted socket from %s', socket.context.ipAddress);
|
||||
counters.add('socket.io:accept', 1);
|
||||
socket.once('disconnect', (reason, reasonDetail) => {
|
||||
LOGGER.info(
|
||||
'%s disconnected (%s%s)',
|
||||
|
@ -214,6 +236,7 @@ class IOServer {
|
|||
reason,
|
||||
reasonDetail ? ` - ${reasonDetail}` : ''
|
||||
);
|
||||
counters.add('socket.io:disconnect', 1);
|
||||
});
|
||||
|
||||
const user = new User(socket, socket.context.ipAddress, socket.context.user);
|
||||
|
@ -248,10 +271,14 @@ class IOServer {
|
|||
}
|
||||
|
||||
initSocketIO() {
|
||||
patchSocketMetrics();
|
||||
patchTypecheckedFunctions();
|
||||
|
||||
const io = this.io = sio.instance = sio();
|
||||
io.use(this.ipProxyMiddleware.bind(this));
|
||||
io.use(this.ipBanMiddleware.bind(this));
|
||||
io.use(this.ipThrottleMiddleware.bind(this));
|
||||
//io.use(this.ipConnectionLimitMiddleware.bind(this));
|
||||
io.use(this.cookieParsingMiddleware.bind(this));
|
||||
io.use(this.ipSessionCookieMiddleware.bind(this));
|
||||
io.use(this.authUserMiddleware.bind(this));
|
||||
|
@ -266,7 +293,7 @@ class IOServer {
|
|||
const engineOpts = {
|
||||
/*
|
||||
* Set ping timeout to 2 minutes to avoid spurious reconnects
|
||||
* during transient network issues. The default of 20 seconds
|
||||
* during transient network issues. The default of 5 minutes
|
||||
* is too aggressive.
|
||||
*
|
||||
* https://github.com/calzoneman/sync/issues/780
|
||||
|
@ -285,17 +312,11 @@ class IOServer {
|
|||
perMessageDeflate: false,
|
||||
httpCompression: false,
|
||||
|
||||
maxHttpBufferSize: 1 << 20,
|
||||
|
||||
/*
|
||||
* Enable legacy support for socket.io v2 clients (e.g., bots)
|
||||
* Default is 10MB.
|
||||
* Even 1MiB seems like a generous limit...
|
||||
*/
|
||||
allowEIO3: true,
|
||||
|
||||
cors: {
|
||||
origin: getCorsAllowCallback(),
|
||||
credentials: true // enable cookies for auth
|
||||
}
|
||||
maxHttpBufferSize: 1 << 20
|
||||
};
|
||||
|
||||
servers.forEach(server => {
|
||||
|
@ -312,25 +333,26 @@ const outgoingPacketCount = new Counter({
|
|||
name: 'cytube_socketio_outgoing_packets_total',
|
||||
help: 'Number of outgoing socket.io packets to clients'
|
||||
});
|
||||
function patchSocketMetrics(sock) {
|
||||
function patchSocketMetrics() {
|
||||
const onevent = Socket.prototype.onevent;
|
||||
const packet = Socket.prototype.packet;
|
||||
const emit = require('events').EventEmitter.prototype.emit;
|
||||
|
||||
sock.onAny(() => {
|
||||
Socket.prototype.onevent = function patchedOnevent() {
|
||||
onevent.apply(this, arguments);
|
||||
incomingEventCount.inc(1);
|
||||
emit.call(sock, 'cytube:count-event');
|
||||
});
|
||||
emit.call(this, 'cytube:count-event');
|
||||
};
|
||||
|
||||
let packet = sock.packet;
|
||||
sock.packet = function patchedPacket() {
|
||||
Socket.prototype.packet = function patchedPacket() {
|
||||
packet.apply(this, arguments);
|
||||
outgoingPacketCount.inc(1);
|
||||
}.bind(sock);
|
||||
};
|
||||
}
|
||||
|
||||
/* TODO: remove this crap */
|
||||
/* Addendum 2021-08-14: socket.io v4 supports middleware, maybe move type validation to that */
|
||||
function patchTypecheckedFunctions(sock) {
|
||||
sock.typecheckedOn = function typecheckedOn(msg, template, cb) {
|
||||
function patchTypecheckedFunctions() {
|
||||
Socket.prototype.typecheckedOn = function typecheckedOn(msg, template, cb) {
|
||||
this.on(msg, (data, ack) => {
|
||||
typecheck(data, template, (err, data) => {
|
||||
if (err) {
|
||||
|
@ -342,9 +364,9 @@ function patchTypecheckedFunctions(sock) {
|
|||
}
|
||||
});
|
||||
});
|
||||
}.bind(sock);
|
||||
};
|
||||
|
||||
sock.typecheckedOnce = function typecheckedOnce(msg, template, cb) {
|
||||
Socket.prototype.typecheckedOnce = function typecheckedOnce(msg, template, cb) {
|
||||
this.once(msg, data => {
|
||||
typecheck(data, template, (err, data) => {
|
||||
if (err) {
|
||||
|
@ -356,7 +378,7 @@ function patchTypecheckedFunctions(sock) {
|
|||
}
|
||||
});
|
||||
});
|
||||
}.bind(sock);
|
||||
};
|
||||
}
|
||||
|
||||
let globalIPBanlist = null;
|
||||
|
@ -390,17 +412,16 @@ const promSocketReconnect = new Counter({
|
|||
function emitMetrics(sock) {
|
||||
try {
|
||||
let closed = false;
|
||||
let transportName = sock.conn.transport.name;
|
||||
let transportName = sock.client.conn.transport.name;
|
||||
promSocketCount.inc({ transport: transportName });
|
||||
promSocketAccept.inc(1);
|
||||
|
||||
sock.conn.on('upgrade', () => {
|
||||
sock.client.conn.on('upgrade', newTransport => {
|
||||
try {
|
||||
let newTransport = sock.conn.transport.name;
|
||||
// Sanity check
|
||||
if (!closed && newTransport !== transportName) {
|
||||
if (!closed && newTransport.name !== transportName) {
|
||||
promSocketCount.dec({ transport: transportName });
|
||||
transportName = newTransport;
|
||||
transportName = newTransport.name;
|
||||
promSocketCount.inc({ transport: transportName });
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -467,18 +488,6 @@ module.exports = {
|
|||
} else {
|
||||
const server = http.createServer().listen(bind.port, bind.ip);
|
||||
servers.push(server);
|
||||
server.on("error", error => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
LOGGER.fatal(
|
||||
"Could not bind %s: address already in use. Check " +
|
||||
"whether another application has already bound this " +
|
||||
"port, or whether another instance of this server " +
|
||||
"is running.",
|
||||
id
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
uniqueListenAddresses.add(id);
|
||||
|
@ -508,30 +517,3 @@ setInterval(function () {
|
|||
LOGGER.info('Cleaned up %d stale IP throttle token buckets', cleaned);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
function getCorsAllowCallback() {
|
||||
let origins = Array.prototype.slice.call(Config.get('io.cors.allowed-origins'));
|
||||
|
||||
origins = origins.concat([
|
||||
Config.get('io.domain'),
|
||||
Config.get('https.domain')
|
||||
]);
|
||||
|
||||
return function corsOriginAllowed(origin, callback) {
|
||||
if (!origin) {
|
||||
// Non-browser clients might not care about Origin, allow these.
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Different ports are technically cross-origin; a distinction that does not matter to CyTube.
|
||||
origin = origin.replace(/:\d+$/, '');
|
||||
|
||||
if (origins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
LOGGER.warn('Rejecting origin "%s"; allowed origins are %j', origin, origins);
|
||||
callback(new Error('Invalid origin'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
var fs = require("fs");
|
||||
var fs = require("graceful-fs");
|
||||
var path = require("path");
|
||||
import { Logger as JsliLogger, LogLevel } from '@calzoneman/jsli';
|
||||
import jsli from '@calzoneman/jsli';
|
||||
|
|
113
src/main.js
113
src/main.js
|
@ -1,20 +1,12 @@
|
|||
import Config from './config';
|
||||
import * as Switches from './switches';
|
||||
import { isIP as validIP } from 'net';
|
||||
import { eventlog } from './logger';
|
||||
require('source-map-support').install();
|
||||
import * as bannedChannels from './cli/banned-channels';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('main');
|
||||
|
||||
try {
|
||||
Config.load('config.yaml');
|
||||
} catch (e) {
|
||||
LOGGER.fatal(
|
||||
"Failed to load configuration: %s",
|
||||
e
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
Config.load('config.yaml');
|
||||
|
||||
const sv = require('./server').init();
|
||||
|
||||
|
@ -29,49 +21,15 @@ if (!Config.get('debug')) {
|
|||
});
|
||||
}
|
||||
|
||||
async function handleCliCmd(cmd) {
|
||||
try {
|
||||
switch (cmd.command) {
|
||||
case 'ban-channel':
|
||||
return bannedChannels.handleBanChannel(cmd);
|
||||
case 'unban-channel':
|
||||
return bannedChannels.handleUnbanChannel(cmd);
|
||||
case 'show-banned-channel':
|
||||
return bannedChannels.handleShowBannedChannel(cmd);
|
||||
default:
|
||||
throw new Error(`Unrecognized command "${cmd.command}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'error', error: String(error) };
|
||||
}
|
||||
}
|
||||
let profileName = null;
|
||||
|
||||
// TODO: this can probably just be part of servsock.js
|
||||
// servsock should also be refactored to send replies instead of
|
||||
// relying solely on tailing logs
|
||||
function handleLine(line, client) {
|
||||
try {
|
||||
let cmd = JSON.parse(line);
|
||||
handleCliCmd(cmd).then(res => {
|
||||
client.write(JSON.stringify(res) + '\n');
|
||||
}).catch(error => {
|
||||
LOGGER.error(`Unexpected error in handleCliCmd: ${error.stack}`);
|
||||
client.write('{"status":"error","error":"internal error"}\n');
|
||||
});
|
||||
} catch (_error) {
|
||||
// eslint no-empty: off
|
||||
}
|
||||
|
||||
function handleLine(line) {
|
||||
if (line === '/reload') {
|
||||
LOGGER.info('Reloading config');
|
||||
try {
|
||||
Config.load('config.yaml');
|
||||
} catch (e) {
|
||||
LOGGER.error(
|
||||
"Failed to load configuration: %s",
|
||||
e
|
||||
);
|
||||
}
|
||||
Config.load('config.yaml');
|
||||
require('./web/pug').clearCache();
|
||||
} else if (line.indexOf('/switch') === 0) {
|
||||
const args = line.split(' ');
|
||||
|
@ -86,6 +44,29 @@ function handleLine(line, client) {
|
|||
}
|
||||
} else if (line.indexOf('/reload-partitions') === 0) {
|
||||
sv.reloadPartitionMap();
|
||||
} else if (line.indexOf('/globalban') === 0) {
|
||||
const args = line.split(/\s+/); args.shift();
|
||||
if (args.length >= 2 && validIP(args[0]) !== 0) {
|
||||
const ip = args.shift();
|
||||
const comment = args.join(' ');
|
||||
// TODO: this is broken by the knex refactoring
|
||||
require('./database').globalBanIP(ip, comment, function (err, _res) {
|
||||
if (!err) {
|
||||
eventlog.log('[acp] ' + 'SYSTEM' + ' global banned ' + ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (line.indexOf('/unglobalban') === 0) {
|
||||
var args = line.split(/\s+/); args.shift();
|
||||
if (args.length >= 1 && validIP(args[0]) !== 0) {
|
||||
var ip = args.shift();
|
||||
// TODO: this is broken by the knex refactoring
|
||||
require('./database').globalUnbanIP(ip, function (err, _res) {
|
||||
if (!err) {
|
||||
eventlog.log('[acp] ' + 'SYSTEM' + ' un-global banned ' + ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (line.indexOf('/save') === 0) {
|
||||
sv.forceSave();
|
||||
} else if (line.indexOf('/unloadchan') === 0) {
|
||||
|
@ -102,6 +83,40 @@ function handleLine(line, client) {
|
|||
}
|
||||
} else if (line.indexOf('/reloadcert') === 0) {
|
||||
sv.reloadCertificateData();
|
||||
} else if (line.indexOf('/profile') === 0) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const profiler = require('v8-profiler');
|
||||
|
||||
if (profileName !== null) {
|
||||
const filename = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
`${profileName}.cpuprofile`
|
||||
);
|
||||
const profile = profiler.stopProfiling(profileName);
|
||||
profileName = null;
|
||||
|
||||
const stream = profile.export();
|
||||
stream.on('error', error => {
|
||||
LOGGER.error('Error exporting profile: %s', error);
|
||||
profile.delete();
|
||||
});
|
||||
stream.on('finish', () => {
|
||||
LOGGER.info('Exported profile to %s', filename);
|
||||
profile.delete();
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(filename));
|
||||
} else {
|
||||
profileName = `prof_${Date.now()}`;
|
||||
profiler.startProfiling(profileName, true);
|
||||
LOGGER.info('Started CPU profile');
|
||||
}
|
||||
} catch (error) {
|
||||
LOGGER.error('Unable to record CPU profile: %s', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,9 +126,9 @@ if (Config.get('service-socket.enabled')) {
|
|||
const ServiceSocket = require('./servsock');
|
||||
const sock = new ServiceSocket();
|
||||
sock.init(
|
||||
(line, client) => {
|
||||
line => {
|
||||
try {
|
||||
handleLine(line, client);
|
||||
handleLine(line);
|
||||
} catch (error) {
|
||||
LOGGER.error(
|
||||
'Error in UNIX socket command handler: %s',
|
||||
|
|
12
src/media.js
12
src/media.js
|
@ -39,26 +39,18 @@ Media.prototype = {
|
|||
embed: this.meta.embed,
|
||||
gdrive_subtitles: this.meta.gdrive_subtitles,
|
||||
textTracks: this.meta.textTracks,
|
||||
audioTracks: this.meta.audioTracks
|
||||
mixer: this.meta.mixer
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* 2018-03-05: Remove GDrive metadata from saved playlists to save
|
||||
* space since this is no longer used.
|
||||
*
|
||||
* 2020-01-26: Remove Twitch clip metadata since their API changed
|
||||
* and no longer uses direct links
|
||||
*/
|
||||
if (this.type !== "gd" && this.type !== "tc") {
|
||||
if (this.type !== "gd") {
|
||||
result.meta.direct = this.meta.direct;
|
||||
}
|
||||
|
||||
// Only save thumbnails for items which can be audio track only
|
||||
if (['bn','cm'].includes(this.type)) {
|
||||
result.meta.thumbnail = this.meta.thumbnail;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
|
|
73
src/metrics/jsonfilemetricsreporter.js
Normal file
73
src/metrics/jsonfilemetricsreporter.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
import fs from 'fs';
|
||||
|
||||
/** MetricsReporter that records metrics as JSON objects in a file, one per line */
|
||||
class JSONFileMetricsReporter {
|
||||
/**
|
||||
* Create a new JSONFileMetricsReporter that writes to the given file path.
|
||||
*
|
||||
* @param {string} filename file path to write to
|
||||
*/
|
||||
constructor(filename) {
|
||||
this.writeStream = fs.createWriteStream(filename, { flags: 'a' });
|
||||
this.metrics = {};
|
||||
this.timers = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link module:cytube-common/metrics/metrics.incCounter}
|
||||
*/
|
||||
incCounter(counter, value) {
|
||||
if (!this.metrics.hasOwnProperty(counter)) {
|
||||
this.metrics[counter] = 0;
|
||||
}
|
||||
|
||||
this.metrics[counter] += value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a time metric
|
||||
*
|
||||
* @param {string} timer name of the timer
|
||||
* @param {number} ms milliseconds to record
|
||||
*/
|
||||
addTime(timer, ms) {
|
||||
if (!this.timers.hasOwnProperty(timer)) {
|
||||
this.timers[timer] = {
|
||||
totalTime: 0,
|
||||
count: 0,
|
||||
p100: 0
|
||||
};
|
||||
}
|
||||
|
||||
this.timers[timer].totalTime += ms;
|
||||
this.timers[timer].count++;
|
||||
if (ms > this.timers[timer].p100) {
|
||||
this.timers[timer].p100 = ms;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link module:cytube-common/metrics/metrics.addProperty}
|
||||
*/
|
||||
addProperty(property, value) {
|
||||
this.metrics[property] = value;
|
||||
}
|
||||
|
||||
report() {
|
||||
for (const timer in this.timers) {
|
||||
this.metrics[timer+':avg'] = this.timers[timer].totalTime / this.timers[timer].count;
|
||||
this.metrics[timer+':count'] = this.timers[timer].count;
|
||||
this.metrics[timer+':p100'] = this.timers[timer].p100;
|
||||
}
|
||||
|
||||
const line = JSON.stringify(this.metrics) + '\n';
|
||||
try {
|
||||
this.writeStream.write(line);
|
||||
} finally {
|
||||
this.metrics = {};
|
||||
this.timers = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { JSONFileMetricsReporter };
|
136
src/metrics/metrics.js
Normal file
136
src/metrics/metrics.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
import os from 'os';
|
||||
|
||||
/** @module cytube-common/metrics/metrics */
|
||||
|
||||
const MEM_RSS = 'memory:rss';
|
||||
const LOAD_1MIN = 'load:1min';
|
||||
const TIMESTAMP = 'time';
|
||||
const logger = require('@calzoneman/jsli')('metrics');
|
||||
|
||||
var delegate = null;
|
||||
var reportInterval = null;
|
||||
var reportHooks = [];
|
||||
let warnedNoReporter = false;
|
||||
|
||||
function warnNoReporter() {
|
||||
if (!warnedNoReporter) {
|
||||
warnedNoReporter = true;
|
||||
logger.warn('No metrics reporter configured. Metrics will not be recorded.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a metrics counter by the specified amount.
|
||||
*
|
||||
* @param {string} counter name of the counter to increment
|
||||
* @param {number} value optional value to increment by (default 1)
|
||||
*/
|
||||
export function incCounter(counter, amount = 1) {
|
||||
if (delegate === null) {
|
||||
warnNoReporter();
|
||||
} else {
|
||||
delegate.incCounter(counter, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a timer. Returns a handle to use to end the timer.
|
||||
*
|
||||
* @param {string} timer name
|
||||
* @return {object} timer handle
|
||||
*/
|
||||
export function startTimer(timer) {
|
||||
return {
|
||||
timer: timer,
|
||||
hrtime: process.hrtime()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a timer and record the time (as an average)
|
||||
*
|
||||
* @param {object} handle timer handle to Stop
|
||||
*/
|
||||
export function stopTimer(handle) {
|
||||
if (delegate === null) {
|
||||
warnNoReporter();
|
||||
return;
|
||||
}
|
||||
const [seconds, ns] = process.hrtime(handle.hrtime);
|
||||
delegate.addTime(handle.timer, seconds*1e3 + ns/1e6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a property to the current metrics period.
|
||||
*
|
||||
* @param {string} property property name to add
|
||||
* @param {any} property value
|
||||
*/
|
||||
export function addProperty(property, value) {
|
||||
if (delegate === null) {
|
||||
warnNoReporter();
|
||||
} else {
|
||||
delegate.addProperty(property, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the metrics reporter to record to.
|
||||
*
|
||||
* @param {MetricsReporter} reporter reporter to record metrics to
|
||||
*/
|
||||
export function setReporter(reporter) {
|
||||
delegate = reporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the interval at which to report metrics.
|
||||
*
|
||||
* @param {number} interval time in milliseconds between successive reports
|
||||
*/
|
||||
export function setReportInterval(interval) {
|
||||
clearInterval(reportInterval);
|
||||
if (!isNaN(interval) && interval >= 0) {
|
||||
reportInterval = setInterval(reportLoop, interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to add additional metrics before reporting.
|
||||
*
|
||||
* @param {function(metricsReporter)} hook callback to be invoked before reporting
|
||||
*/
|
||||
export function addReportHook(hook) {
|
||||
reportHooks.push(hook);
|
||||
}
|
||||
|
||||
export function clearReportHooks() {
|
||||
reportHooks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Force metrics to be reported right now.
|
||||
*/
|
||||
export function flush() {
|
||||
reportLoop();
|
||||
}
|
||||
|
||||
function addDefaults() {
|
||||
addProperty(MEM_RSS, process.memoryUsage().rss / 1048576);
|
||||
addProperty(LOAD_1MIN, os.loadavg()[0]);
|
||||
addProperty(TIMESTAMP, new Date());
|
||||
}
|
||||
|
||||
function reportLoop() {
|
||||
if (delegate !== null) {
|
||||
try {
|
||||
addDefaults();
|
||||
reportHooks.forEach(hook => {
|
||||
hook(delegate);
|
||||
});
|
||||
delegate.report();
|
||||
} catch (error) {
|
||||
logger.error(error.stack);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import uuid from 'uuid';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('announcementrefresher');
|
||||
|
||||
|
@ -9,7 +9,7 @@ class AnnouncementRefresher {
|
|||
this.pubClient = pubClient;
|
||||
this.subClient = subClient;
|
||||
this.channel = channel;
|
||||
this.uuid = uuidv4();
|
||||
this.uuid = uuid.v4();
|
||||
process.nextTick(this.init.bind(this));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Promise from 'bluebird';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import uuid from 'uuid';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('partitionchannelindex');
|
||||
|
||||
|
@ -9,7 +9,7 @@ const CACHE_EXPIRE_DELAY = 40 * 1000;
|
|||
|
||||
class PartitionChannelIndex {
|
||||
constructor(pubClient, subClient, channel) {
|
||||
this.id = uuidv4();
|
||||
this.id = uuid.v4();
|
||||
this.pubClient = pubClient;
|
||||
this.subClient = subClient;
|
||||
this.channel = channel;
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { fetchPeertubeDomains, setDomains } from '@cytube/mediaquery/lib/provider/peertube';
|
||||
import { stat, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const LOGGER = require('@calzoneman/jsli')('peertubelist');
|
||||
const ONE_DAY = 24 * 3600 * 1000;
|
||||
const FILENAME = path.join(__dirname, '..', 'peertube-hosts.json');
|
||||
|
||||
export async function setupPeertubeDomains() {
|
||||
try {
|
||||
let mtime;
|
||||
try {
|
||||
mtime = (await stat(FILENAME)).mtime;
|
||||
} catch (_error) {
|
||||
mtime = 0;
|
||||
}
|
||||
|
||||
if (Date.now() - mtime > ONE_DAY) {
|
||||
LOGGER.info('Updating peertube host list');
|
||||
const hosts = await fetchPeertubeDomains();
|
||||
await writeFile(FILENAME, JSON.stringify(hosts));
|
||||
}
|
||||
|
||||
const hosts = JSON.parse(await readFile(FILENAME));
|
||||
setDomains(hosts);
|
||||
} catch (error) {
|
||||
LOGGER.error('Failed to initialize peertube host list: %s', error.stack);
|
||||
}
|
||||
}
|
133
src/poll.js
133
src/poll.js
|
@ -1,103 +1,58 @@
|
|||
const link = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
|
||||
const XSS = require('./xss');
|
||||
var XSS = require("./xss");
|
||||
|
||||
function sanitizedWithLinksReplaced(text) {
|
||||
return XSS.sanitizeText(text)
|
||||
.replace(link, '<a href="$1" target="_blank" rel="noopener noreferer">$1</a>');
|
||||
}
|
||||
var Poll = function(initiator, title, options, obscured) {
|
||||
this.initiator = initiator;
|
||||
title = XSS.sanitizeText(title);
|
||||
this.title = title.replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
|
||||
this.options = options;
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
this.options[i] = XSS.sanitizeText(this.options[i]);
|
||||
this.options[i] = this.options[i].replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
|
||||
|
||||
class Poll {
|
||||
static create(createdBy, title, choices, options = { hideVotes: false, retainVotes: false }) {
|
||||
let poll = new Poll();
|
||||
poll.createdAt = new Date();
|
||||
poll.createdBy = createdBy;
|
||||
poll.title = sanitizedWithLinksReplaced(title);
|
||||
poll.choices = choices.map(choice => sanitizedWithLinksReplaced(choice));
|
||||
poll.hideVotes = options.hideVotes;
|
||||
poll.retainVotes = options.retainVotes;
|
||||
poll.votes = new Map();
|
||||
return poll;
|
||||
}
|
||||
|
||||
static fromChannelData({ initiator, title, options, _counts, votes, timestamp, obscured, retainVotes }) {
|
||||
let poll = new Poll();
|
||||
if (timestamp === undefined) // Very old polls still in the database lack timestamps
|
||||
timestamp = Date.now();
|
||||
poll.createdAt = new Date(timestamp);
|
||||
poll.createdBy = initiator;
|
||||
poll.title = title;
|
||||
poll.choices = options;
|
||||
poll.votes = new Map();
|
||||
Object.keys(votes).forEach(key => {
|
||||
if (votes[key] !== null)
|
||||
poll.votes.set(key, votes[key]);
|
||||
});
|
||||
poll.hideVotes = obscured;
|
||||
poll.retainVotes = retainVotes || false;
|
||||
return poll;
|
||||
this.obscured = obscured || false;
|
||||
this.counts = new Array(options.length);
|
||||
for(let i = 0; i < this.counts.length; i++) {
|
||||
this.counts[i] = 0;
|
||||
}
|
||||
this.votes = {};
|
||||
this.timestamp = Date.now();
|
||||
};
|
||||
|
||||
toChannelData() {
|
||||
let counts = new Array(this.choices.length);
|
||||
counts.fill(0);
|
||||
|
||||
// TODO: it would be desirable one day to move away from using an Object here.
|
||||
// This is just for backwards-compatibility with the existing format.
|
||||
let votes = {};
|
||||
|
||||
this.votes.forEach((index, key) => {
|
||||
votes[key] = index;
|
||||
counts[index]++;
|
||||
});
|
||||
|
||||
return {
|
||||
title: this.title,
|
||||
initiator: this.createdBy,
|
||||
options: this.choices,
|
||||
counts,
|
||||
votes,
|
||||
obscured: this.hideVotes,
|
||||
retainVotes: this.retainVotes,
|
||||
timestamp: this.createdAt.getTime()
|
||||
};
|
||||
Poll.prototype.vote = function(ip, option) {
|
||||
if(!(ip in this.votes) || this.votes[ip] == null) {
|
||||
this.votes[ip] = option;
|
||||
this.counts[option]++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
countVote(key, choiceId) {
|
||||
if (choiceId < 0 || choiceId >= this.choices.length)
|
||||
return false;
|
||||
|
||||
let changed = !this.votes.has(key) || this.votes.get(key) !== choiceId;
|
||||
this.votes.set(key, choiceId);
|
||||
return changed;
|
||||
Poll.prototype.unvote = function(ip) {
|
||||
if(ip in this.votes && this.votes[ip] != null) {
|
||||
this.counts[this.votes[ip]]--;
|
||||
this.votes[ip] = null;
|
||||
}
|
||||
};
|
||||
|
||||
uncountVote(key) {
|
||||
let changed = this.votes.has(key);
|
||||
this.votes.delete(key);
|
||||
return changed;
|
||||
}
|
||||
|
||||
toUpdateFrame(showHiddenVotes) {
|
||||
let counts = new Array(this.choices.length);
|
||||
counts.fill(0);
|
||||
|
||||
this.votes.forEach(index => counts[index]++);
|
||||
|
||||
if (this.hideVotes) {
|
||||
counts = counts.map(c => {
|
||||
if (showHiddenVotes) return `${c}?`;
|
||||
else return '?';
|
||||
});
|
||||
Poll.prototype.packUpdate = function (showhidden) {
|
||||
var counts = Array.prototype.slice.call(this.counts);
|
||||
if (this.obscured) {
|
||||
for(var i = 0; i < counts.length; i++) {
|
||||
if (!showhidden)
|
||||
counts[i] = "";
|
||||
counts[i] += "?";
|
||||
}
|
||||
|
||||
return {
|
||||
title: this.title,
|
||||
options: this.choices,
|
||||
counts: counts,
|
||||
initiator: this.createdBy,
|
||||
timestamp: this.createdAt.getTime()
|
||||
};
|
||||
}
|
||||
}
|
||||
var packed = {
|
||||
title: this.title,
|
||||
options: this.options,
|
||||
counts: counts,
|
||||
initiator: this.initiator,
|
||||
timestamp: this.timestamp
|
||||
};
|
||||
return packed;
|
||||
};
|
||||
|
||||
exports.Poll = Poll;
|
||||
|
|
|
@ -5,6 +5,7 @@ import { parse as parseURL } from 'url';
|
|||
const LOGGER = require('@calzoneman/jsli')('prometheus-server');
|
||||
|
||||
let server = null;
|
||||
let defaultMetricsTimer = null;
|
||||
|
||||
export function init(prometheusConfig) {
|
||||
if (server !== null) {
|
||||
|
@ -13,7 +14,7 @@ export function init(prometheusConfig) {
|
|||
return;
|
||||
}
|
||||
|
||||
collectDefaultMetrics();
|
||||
defaultMetricsTimer = collectDefaultMetrics();
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET'
|
||||
|
@ -23,18 +24,10 @@ export function init(prometheusConfig) {
|
|||
return;
|
||||
}
|
||||
|
||||
register.metrics().then(metrics => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': register.contentType
|
||||
});
|
||||
res.end(metrics);
|
||||
}).catch(error => {
|
||||
LOGGER.error('Error generating prometheus metrics: %s', error.stack);
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end('Internal Server Error');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': register.contentType
|
||||
});
|
||||
res.end(register.metrics());
|
||||
});
|
||||
|
||||
server.on('error', error => {
|
||||
|
@ -54,4 +47,6 @@ export function init(prometheusConfig) {
|
|||
export function shutdown() {
|
||||
server.close();
|
||||
server = null;
|
||||
clearInterval(defaultMetricsTimer);
|
||||
defaultMetricsTimer = null;
|
||||
}
|
||||
|
|
111
src/server.js
111
src/server.js
|
@ -15,11 +15,15 @@ module.exports = {
|
|||
exists || fs.mkdirSync(chanlogpath);
|
||||
});
|
||||
|
||||
var chandumppath = path.join(__dirname, "../chandump");
|
||||
fs.exists(chandumppath, function (exists) {
|
||||
exists || fs.mkdirSync(chandumppath);
|
||||
});
|
||||
|
||||
var gdvttpath = path.join(__dirname, "../google-drive-subtitles");
|
||||
fs.exists(gdvttpath, function (exists) {
|
||||
exists || fs.mkdirSync(gdvttpath);
|
||||
});
|
||||
|
||||
singleton = new Server();
|
||||
return singleton;
|
||||
},
|
||||
|
@ -29,15 +33,14 @@ module.exports = {
|
|||
}
|
||||
};
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const express = require("express");
|
||||
const Channel = require("./channel/channel");
|
||||
const db = require("./database");
|
||||
const Flags = require("./flags");
|
||||
const sio = require("socket.io");
|
||||
var path = require("path");
|
||||
var fs = require("fs");
|
||||
var https = require("https");
|
||||
var express = require("express");
|
||||
var Channel = require("./channel/channel");
|
||||
var db = require("./database");
|
||||
var Flags = require("./flags");
|
||||
var sio = require("socket.io");
|
||||
import LocalChannelIndex from './web/localchannelindex';
|
||||
import { PartitionChannelIndex } from './partition/partitionchannelindex';
|
||||
import IOConfiguration from './configuration/ioconfig';
|
||||
|
@ -47,8 +50,6 @@ import { LegacyModule } from './legacymodule';
|
|||
import { PartitionModule } from './partition/partitionmodule';
|
||||
import { Gauge } from 'prom-client';
|
||||
import { EmailController } from './controller/email';
|
||||
import { CaptchaController } from './controller/captcha';
|
||||
import { BannedChannelsController } from './controller/banned-channels';
|
||||
|
||||
var Server = function () {
|
||||
var self = this;
|
||||
|
@ -72,7 +73,6 @@ var Server = function () {
|
|||
const globalMessageBus = this.initModule.getGlobalMessageBus();
|
||||
globalMessageBus.on('UserProfileChanged', this.handleUserProfileChange.bind(this));
|
||||
globalMessageBus.on('ChannelDeleted', this.handleChannelDelete.bind(this));
|
||||
globalMessageBus.on('ChannelBanned', this.handleChannelBanned.bind(this));
|
||||
globalMessageBus.on('ChannelRegistered', this.handleChannelRegister.bind(this));
|
||||
|
||||
// database init ------------------------------------------------------
|
||||
|
@ -106,15 +106,6 @@ var Server = function () {
|
|||
Config.getEmailConfig()
|
||||
);
|
||||
|
||||
const captchaController = new CaptchaController(
|
||||
Config.getCaptchaConfig()
|
||||
);
|
||||
|
||||
self.bannedChannelsController = new BannedChannelsController(
|
||||
self.db.channels,
|
||||
globalMessageBus
|
||||
);
|
||||
|
||||
// webserver init -----------------------------------------------------
|
||||
const ioConfig = IOConfiguration.fromOldConfig(Config);
|
||||
const webConfig = WebConfiguration.fromOldConfig(Config);
|
||||
|
@ -139,10 +130,7 @@ var Server = function () {
|
|||
session,
|
||||
globalMessageBus,
|
||||
Config.getEmailConfig(),
|
||||
emailController,
|
||||
Config.getCaptchaConfig(),
|
||||
captchaController,
|
||||
self.bannedChannelsController
|
||||
emailController
|
||||
);
|
||||
|
||||
// http/https/sio server init -----------------------------------------
|
||||
|
@ -171,37 +159,22 @@ var Server = function () {
|
|||
}
|
||||
|
||||
if (bind.https && Config.get("https.enabled")) {
|
||||
self.servers[id] = https.createServer(opts, self.express);
|
||||
// 2 minute default copied from node <= 12.x
|
||||
self.servers[id].timeout = 120000;
|
||||
self.servers[id].listen(bind.port, bind.ip);
|
||||
self.servers[id].on("error", error => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
LOGGER.fatal(
|
||||
"Could not bind %s: address already in use. Check " +
|
||||
"whether another application has already bound this " +
|
||||
"port, or whether another instance of this server " +
|
||||
"is running.",
|
||||
id
|
||||
);
|
||||
process.exit(1);
|
||||
self.servers[id] = https.createServer(opts, self.express)
|
||||
.listen(bind.port, bind.ip);
|
||||
self.servers[id].on("clientError", function (err, socket) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
} else if (bind.http) {
|
||||
self.servers[id] = http.createServer(self.express);
|
||||
// 2 minute default copied from node <= 12.x
|
||||
self.servers[id].timeout = 120000;
|
||||
self.servers[id].listen(bind.port, bind.ip);
|
||||
self.servers[id].on("error", error => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
LOGGER.fatal(
|
||||
"Could not bind %s: address already in use. Check " +
|
||||
"whether another application has already bound this " +
|
||||
"port, or whether another instance of this server " +
|
||||
"is running.",
|
||||
id
|
||||
);
|
||||
process.exit(1);
|
||||
self.servers[id] = self.express.listen(bind.port, bind.ip);
|
||||
self.servers[id].on("clientError", function (err, socket) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -212,8 +185,6 @@ var Server = function () {
|
|||
// background tasks init ----------------------------------------------
|
||||
require("./bgtask")(self);
|
||||
|
||||
require("./peertubelist").setupPeertubeDomains().then(() => {});
|
||||
|
||||
// prometheus server
|
||||
const prometheusConfig = Config.getPrometheusConfig();
|
||||
if (prometheusConfig.isEnabled()) {
|
||||
|
@ -557,34 +528,6 @@ Server.prototype.handleChannelDelete = function (event) {
|
|||
}
|
||||
};
|
||||
|
||||
Server.prototype.handleChannelBanned = function (event) {
|
||||
try {
|
||||
const lname = event.channel.toLowerCase();
|
||||
const reason = event.externalReason;
|
||||
|
||||
this.channels.forEach(channel => {
|
||||
if (channel.dead) return;
|
||||
|
||||
if (channel.uniqueName === lname) {
|
||||
channel.clearFlag(Flags.C_REGISTERED);
|
||||
|
||||
const users = Array.prototype.slice.call(channel.users);
|
||||
users.forEach(u => {
|
||||
u.kick(`Channel was banned: ${reason}`);
|
||||
});
|
||||
|
||||
if (!channel.dead && !channel.dying) {
|
||||
channel.emit('empty');
|
||||
}
|
||||
|
||||
LOGGER.info('Processed banned channel %s', lname);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
LOGGER.error('handleChannelBanned failed: %s', error);
|
||||
}
|
||||
};
|
||||
|
||||
Server.prototype.handleChannelRegister = function (event) {
|
||||
try {
|
||||
const lname = event.channel.toLowerCase();
|
||||
|
|
|
@ -34,7 +34,7 @@ export default class ServiceSocket {
|
|||
delete this.connections[id];
|
||||
});
|
||||
stream.on('data', (msg) => {
|
||||
this.handler(msg.toString(), stream);
|
||||
this.handler(msg.toString());
|
||||
});
|
||||
}).listen(this.socket);
|
||||
process.on('exit', this.closeServiceSocket.bind(this));
|
||||
|
|
|
@ -7,6 +7,7 @@ const LOGGER = require('@calzoneman/jsli')('setuid');
|
|||
|
||||
var needPermissionsFixed = [
|
||||
path.join(__dirname, "..", "chanlogs"),
|
||||
path.join(__dirname, "..", "chandump"),
|
||||
path.join(__dirname, "..", "google-drive-subtitles")
|
||||
];
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const switches = {
|
||||
plDirtyCheck: true
|
||||
};
|
||||
|
||||
export function isActive(switchName) {
|
||||
|
|
14
src/user.js
14
src/user.js
|
@ -76,6 +76,10 @@ User.prototype.handleJoinChannel = function handleJoinChannel(data) {
|
|||
}
|
||||
|
||||
data.name = data.name.toLowerCase();
|
||||
if (data.name in Config.get("channel-blacklist")) {
|
||||
this.kick("This channel is blacklisted.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.waitFlag(Flags.U_READY, () => {
|
||||
var chan;
|
||||
|
@ -98,6 +102,10 @@ User.prototype.handleJoinChannel = function handleJoinChannel(data) {
|
|||
|
||||
if (!chan.is(Flags.C_READY)) {
|
||||
chan.once("loadFail", reason => {
|
||||
this.socket.emit("errorMsg", {
|
||||
msg: reason,
|
||||
alert: true
|
||||
});
|
||||
this.kick(`Channel could not be loaded: ${reason}`);
|
||||
});
|
||||
}
|
||||
|
@ -274,12 +282,6 @@ User.prototype.autoAFK = function () {
|
|||
};
|
||||
|
||||
User.prototype.kick = function (reason) {
|
||||
LOGGER.info(
|
||||
'%s (%s) was kicked: "%s"',
|
||||
this.realip,
|
||||
this.getName(),
|
||||
reason
|
||||
);
|
||||
this.socket.emit("kick", { reason: reason });
|
||||
this.socket.disconnect();
|
||||
};
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
class SimpleCache {
|
||||
constructor({ maxElem, maxAge }) {
|
||||
this.maxElem = maxElem;
|
||||
this.maxAge = maxAge;
|
||||
this.cache = new Map();
|
||||
|
||||
setInterval(() => {
|
||||
this.cleanup();
|
||||
}, maxAge).unref();
|
||||
}
|
||||
|
||||
put(key, value) {
|
||||
this.cache.set(key, { value: value, at: Date.now() });
|
||||
|
||||
if (this.cache.size > this.maxElem) {
|
||||
this.cache.delete(this.cache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
let val = this.cache.get(key);
|
||||
|
||||
if (val != null && Date.now() < val.at + this.maxAge) {
|
||||
return val.value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
let now = Date.now();
|
||||
|
||||
for (let [key, value] of this.cache) {
|
||||
if (value.at < now - this.maxAge) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SimpleCache };
|
|
@ -177,7 +177,7 @@
|
|||
};
|
||||
},
|
||||
|
||||
root.formatLink = function (id, type, _meta) {
|
||||
root.formatLink = function (id, type, meta) {
|
||||
switch (type) {
|
||||
case "yt":
|
||||
return "https://youtu.be/" + id;
|
||||
|
@ -193,10 +193,16 @@
|
|||
return "https://twitch.tv/" + id;
|
||||
case "rt":
|
||||
return id;
|
||||
case "im":
|
||||
return "https://imgur.com/a/" + id;
|
||||
case "us":
|
||||
return "https://ustream.tv/channel/" + id;
|
||||
case "gd":
|
||||
return "https://docs.google.com/file/d/" + id;
|
||||
case "fi":
|
||||
return id;
|
||||
case "hb":
|
||||
return "https://www.smashcast.tv/" + id;
|
||||
case "hl":
|
||||
return id;
|
||||
case "sb":
|
||||
|
@ -205,22 +211,12 @@
|
|||
return "https://clips.twitch.tv/" + id;
|
||||
case "cm":
|
||||
return id;
|
||||
case "pt": {
|
||||
const [domain,uuid] = id.split(';');
|
||||
return `https://${domain}/videos/watch/${uuid}`;
|
||||
}
|
||||
case "bc":
|
||||
return `https://www.bitchute.com/video/${id}/`;
|
||||
case "bn": {
|
||||
const [artist,track] = id.split(';');
|
||||
return `https://${artist}.bandcamp.com/track/${track}`;
|
||||
}
|
||||
case "od": {
|
||||
const [user,video] = id.split(';');
|
||||
return `https://odysee.com/@${user}/${video}`;
|
||||
}
|
||||
case "nv":
|
||||
return `https://www.nicovideo.jp/watch/${id}`;
|
||||
case "mx":
|
||||
if (meta !== null) {
|
||||
return `https://mixer.com/${meta.mixer.channelToken}`;
|
||||
} else {
|
||||
return `https://mixer.com/${id}`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
@ -230,9 +226,13 @@
|
|||
switch (type) {
|
||||
case "li":
|
||||
case "tw":
|
||||
case "us":
|
||||
case "rt":
|
||||
case "cu":
|
||||
case "im":
|
||||
case "hb":
|
||||
case "hl":
|
||||
case "mx":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
|
|
@ -265,8 +265,6 @@ async function handleNewChannel(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
let banInfo = await db.channels.getBannedChannel(name);
|
||||
|
||||
db.channels.listUserChannels(user.name, function (err, channels) {
|
||||
if (err) {
|
||||
sendPug(res, "account-channels", {
|
||||
|
@ -276,14 +274,6 @@ async function handleNewChannel(req, res) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (banInfo !== null) {
|
||||
sendPug(res, "account-channels", {
|
||||
channels: channels,
|
||||
newChannelError: `Cannot register "${name}": this channel is banned.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.match(Config.get("reserved-names.channels"))) {
|
||||
sendPug(res, "account-channels", {
|
||||
channels: channels,
|
||||
|
@ -641,43 +631,7 @@ function handlePasswordReset(req, res) {
|
|||
/**
|
||||
* Handles a request for /account/passwordrecover/<hash>
|
||||
*/
|
||||
function handleGetPasswordRecover(req, res) {
|
||||
var hash = req.params.hash;
|
||||
if (typeof hash !== "string") {
|
||||
res.send(400);
|
||||
return;
|
||||
}
|
||||
|
||||
db.lookupPasswordReset(hash, function (err, row) {
|
||||
if (err) {
|
||||
sendPug(res, "account-passwordrecover", {
|
||||
recovered: false,
|
||||
recoverErr: err
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() >= row.expire) {
|
||||
sendPug(res, "account-passwordrecover", {
|
||||
recovered: false,
|
||||
recoverErr: "This password recovery link has expired. Password " +
|
||||
"recovery links are valid only for 24 hours after " +
|
||||
"submission."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendPug(res, "account-passwordrecover", {
|
||||
confirm: true,
|
||||
recovered: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a POST request for /account/passwordrecover/<hash>
|
||||
*/
|
||||
function handlePostPasswordRecover(req, res) {
|
||||
function handlePasswordRecover(req, res) {
|
||||
var hash = req.params.hash;
|
||||
if (typeof hash !== "string") {
|
||||
res.send(400);
|
||||
|
@ -749,8 +703,7 @@ module.exports = {
|
|||
app.post("/account/profile", handleAccountProfile);
|
||||
app.get("/account/passwordreset", handlePasswordResetPage);
|
||||
app.post("/account/passwordreset", handlePasswordReset);
|
||||
app.get("/account/passwordrecover/:hash", handleGetPasswordRecover);
|
||||
app.post("/account/passwordrecover/:hash", handlePostPasswordRecover);
|
||||
app.get("/account/passwordrecover/:hash", handlePasswordRecover);
|
||||
app.get("/account", function (req, res) {
|
||||
res.redirect("/login");
|
||||
});
|
||||
|
|
|
@ -150,17 +150,10 @@ function handleLogout(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
function getHcaptchaSiteKey(captchaConfig) {
|
||||
if (captchaConfig.isEnabled())
|
||||
return captchaConfig.getHcaptcha().getSiteKey();
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a GET request for /register
|
||||
*/
|
||||
function handleRegisterPage(captchaConfig, req, res) {
|
||||
function handleRegisterPage(req, res) {
|
||||
if (res.locals.loggedIn) {
|
||||
sendPug(res, "register", {});
|
||||
return;
|
||||
|
@ -168,15 +161,14 @@ function handleRegisterPage(captchaConfig, req, res) {
|
|||
|
||||
sendPug(res, "register", {
|
||||
registered: false,
|
||||
registerError: false,
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
registerError: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a registration request.
|
||||
*/
|
||||
function handleRegister(captchaConfig, captchaController, req, res) {
|
||||
function handleRegister(req, res) {
|
||||
csrf.verify(req);
|
||||
|
||||
var name = req.body.name;
|
||||
|
@ -186,26 +178,15 @@ function handleRegister(captchaConfig, captchaController, req, res) {
|
|||
email = "";
|
||||
}
|
||||
var ip = req.realIP;
|
||||
let captchaToken = req.body['h-captcha-response'];
|
||||
|
||||
if (typeof name !== "string" || typeof password !== "string") {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaConfig.isEnabled() &&
|
||||
(typeof captchaToken !== 'string' || captchaToken === '')) {
|
||||
sendPug(res, "register", {
|
||||
registerError: "Missing CAPTCHA",
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
sendPug(res, "register", {
|
||||
registerError: "Username must not be empty",
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
registerError: "Username must not be empty"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -217,16 +198,14 @@ function handleRegister(captchaConfig, captchaController, req, res) {
|
|||
name
|
||||
);
|
||||
sendPug(res, "register", {
|
||||
registerError: "That username is reserved",
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
registerError: "That username is reserved"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length === 0) {
|
||||
sendPug(res, "register", {
|
||||
registerError: "Password must not be empty",
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
registerError: "Password must not be empty"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -235,63 +214,37 @@ function handleRegister(captchaConfig, captchaController, req, res) {
|
|||
|
||||
if (email.length > 0 && !$util.isValidEmail(email)) {
|
||||
sendPug(res, "register", {
|
||||
registerError: "Invalid email address",
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
registerError: "Invalid email address"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaConfig.isEnabled()) {
|
||||
let captchaSuccess = true;
|
||||
captchaController.verifyToken(captchaToken)
|
||||
.catch(error => {
|
||||
LOGGER.warn('CAPTCHA failed for registration %s: %s', name, error.message);
|
||||
captchaSuccess = false;
|
||||
sendPug(res, "register", {
|
||||
registerError: 'CAPTCHA verification failed: ' + error.message,
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
});
|
||||
}).then(() => {
|
||||
if (captchaSuccess)
|
||||
doRegister();
|
||||
db.users.register(name, password, email, ip, function (err) {
|
||||
if (err) {
|
||||
sendPug(res, "register", {
|
||||
registerError: err
|
||||
});
|
||||
} else {
|
||||
doRegister();
|
||||
}
|
||||
|
||||
function doRegister() {
|
||||
db.users.register(name, password, email, ip, function (err) {
|
||||
if (err) {
|
||||
sendPug(res, "register", {
|
||||
registerError: err,
|
||||
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
|
||||
});
|
||||
} else {
|
||||
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
|
||||
(email.length > 0 ? " <" + email + ">" : ""));
|
||||
sendPug(res, "register", {
|
||||
registered: true,
|
||||
registerName: name,
|
||||
redirect: req.body.redirect
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
|
||||
(email.length > 0 ? " <" + email + ">" : ""));
|
||||
sendPug(res, "register", {
|
||||
registered: true,
|
||||
registerName: name,
|
||||
redirect: req.body.redirect
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Initializes auth callbacks
|
||||
*/
|
||||
init: function (app, captchaConfig, captchaController) {
|
||||
init: function (app) {
|
||||
app.get("/login", handleLoginPage);
|
||||
app.post("/login", handleLogin);
|
||||
app.post("/logout", handleLogout);
|
||||
app.get("/register", (req, res) => {
|
||||
handleRegisterPage(captchaConfig, req, res);
|
||||
});
|
||||
app.post("/register", (req, res) => {
|
||||
handleRegister(captchaConfig, captchaController, req, res);
|
||||
});
|
||||
app.get("/register", handleRegisterPage);
|
||||
app.post("/register", handleRegister);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -14,6 +14,7 @@ function merge(locals, res) {
|
|||
var _locals = {
|
||||
siteTitle: Config.get("html-template.title"),
|
||||
siteDescription: Config.get("html-template.description"),
|
||||
siteAuthor: "Calvin 'calzoneman' 'cyzon' Montgomery",
|
||||
csrfToken: typeof res.req.csrfToken === 'function' ? res.req.csrfToken() : '',
|
||||
baseUrl: getBaseUrl(res),
|
||||
channelPath: Config.get("channel-path"),
|
||||
|
|
|
@ -68,7 +68,7 @@ export default function initialize(
|
|||
|
||||
try {
|
||||
await userDb.requestAccountDeletion(user.id);
|
||||
eventlog.log(`[account] ${req.realIP} requested account deletion for ${user.name}`);
|
||||
eventlog.log(`[account] ${req.ip} requested account deletion for ${user.name}`);
|
||||
} catch (error) {
|
||||
LOGGER.error('Unknown error in requestAccountDeletion: %s', error.stack);
|
||||
await showDeletePage(res, { internalError: true });
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue