Compare commits

...

22 commits

Author SHA1 Message Date
secretspecter db6253cd04 Blocked by FIXME 2024-01-04 19:54:13 -07:00
secretspecter 4846b22cd1 fix format and use database.client 2023-12-14 23:06:26 -07:00
secretspecter 0b806cec92 add support for PostgreSQL (and other databases)
* renamed the 'mysql' config.yaml key to 'database'
* added database.client which still defaults to 'mysql'
2023-12-14 22:11:17 -07:00
dependabot[bot] 227244e2d0 Bump socket.io-parser from 4.2.1 to 4.2.3
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 4.2.1 to 4.2.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/4.2.1...4.2.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-28 21:12:55 -07:00
Calvin Montgomery 6f47ed42db Bump mediaquery 2023-05-28 21:12:36 -07:00
Kethsar 98bfb6736e Remove string template around maxlength property for chat input 2023-03-25 14:31:25 -07:00
Kethsar 2c541448a2 Set the cap for max-chat-message-length to 1000 2023-03-25 14:31:25 -07:00
Kethsar 21d7f16413 Fix missed expansion of the option 2023-03-25 14:31:25 -07:00
Kethsar 87198bd4e7 Expand chat message length option to be consistent with other options 2023-03-25 14:31:25 -07:00
Kethsar 986207b46b Add max chat message length config option 2023-03-25 14:31:25 -07:00
Kethsar ed410fdebe Update mediaquery dependency hash 2023-03-25 14:29:56 -07:00
Calvin Montgomery 1a9d920884 Detect old browser JS engines 2023-01-28 19:41:39 -08:00
Calvin Montgomery c78ef333da Fix a couple issues discussed on IRC 2023-01-11 17:57:02 -08:00
Calvin Montgomery fad1da7ab4 deps: fix high sev warnings 2023-01-10 20:56:38 -08:00
Calvin Montgomery d37e69e1a6 Update package-lock for nan so that node v19 builds successfully 2022-10-30 18:10:19 -07:00
Calvin Montgomery 1e2dcee4fa Update NEWS 2022-09-23 21:39:38 -07:00
Calvin Montgomery 6ec2f3d491 Fix todo 2022-09-23 21:39:38 -07:00
Calvin Montgomery 306e3adde8 Work around flaky test 2022-09-23 21:39:38 -07:00
Calvin Montgomery 99740a3673 Add cache, test 2022-09-23 21:39:38 -07:00
Calvin Montgomery 913348d46e Continue working on banned channels 2022-09-23 21:39:38 -07:00
Calvin Montgomery ae5dbf5f48 Continue working on banned channels 2022-09-23 21:39:38 -07:00
Calvin Montgomery 8338fe2f25 Work on banned channels feature 2022-09-23 21:39:38 -07:00
33 changed files with 3443 additions and 8418 deletions

6
.editorconfig Normal file
View file

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

21
NEWS.md
View file

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

176
bin/admin.js Executable file
View file

@ -0,0 +1,176 @@
#!/usr/bin/env node
const Config = require('../lib/config');
Config.load('config.yaml');
if (!Config.get('service-socket.enabled')){
console.error('The Service Socket is not enabled.');
process.exit(1);
}
const net = require('net');
const path = require('path');
const readline = require('node:readline/promises');
const socketPath = path.resolve(__dirname, '..', Config.get('service-socket.socket'));
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function doCommand(params) {
return new Promise((resolve, reject) => {
const client = net.createConnection(socketPath);
client.on('connect', () => {
client.write(JSON.stringify(params) + '\n');
});
client.on('data', data => {
client.end();
resolve(JSON.parse(data));
});
client.on('error', error => {
reject(error);
});
});
}
let commands = [
{
command: 'ban-channel',
handler: async args => {
if (args.length !== 3) {
console.log('Usage: ban-channel <name> <externalReason> <internalReason>');
process.exit(1);
}
let [name, externalReason, internalReason] = args;
let answer = await rl.question(`Ban ${name} with external reason "${externalReason}" and internal reason "${internalReason}"? `);
if (!/^[yY]$/.test(answer)) {
console.log('Aborted.');
process.exit(1);
}
let res = await doCommand({
command: 'ban-channel',
name,
externalReason,
internalReason
});
switch (res.status) {
case 'error':
console.log('Error:', res.error);
process.exit(1);
break;
case 'success':
console.log('Ban succeeded.');
process.exit(0);
break;
default:
console.log(`Unknown result: ${res.status}`);
process.exit(1);
break;
}
}
},
{
command: 'unban-channel',
handler: async args => {
if (args.length !== 1) {
console.log('Usage: unban-channel <name>');
process.exit(1);
}
let [name] = args;
let answer = await rl.question(`Unban ${name}? `);
if (!/^[yY]$/.test(answer)) {
console.log('Aborted.');
process.exit(1);
}
let res = await doCommand({
command: 'unban-channel',
name
});
switch (res.status) {
case 'error':
console.log('Error:', res.error);
process.exit(1);
break;
case 'success':
console.log('Unban succeeded.');
process.exit(0);
break;
default:
console.log(`Unknown result: ${res.status}`);
process.exit(1);
break;
}
}
},
{
command: 'show-banned-channel',
handler: async args => {
if (args.length !== 1) {
console.log('Usage: show-banned-channel <name>');
process.exit(1);
}
let [name] = args;
let res = await doCommand({
command: 'show-banned-channel',
name
});
switch (res.status) {
case 'error':
console.log('Error:', res.error);
process.exit(1);
break;
case 'success':
if (res.ban != null) {
console.log(`Channel: ${name}`);
console.log(`Ban issued: ${res.ban.createdAt}`);
console.log(`Banned by: ${res.ban.bannedBy}`);
console.log(`External reason:\n${res.ban.externalReason}`);
console.log(`Internal reason:\n${res.ban.internalReason}`);
} else {
console.log(`Channel ${name} is not banned.`);
}
process.exit(0);
break;
default:
console.log(`Unknown result: ${res.status}`);
process.exit(1);
break;
}
}
}
];
let found = false;
commands.forEach(cmd => {
if (cmd.command === process.argv[2]) {
found = true;
cmd.handler(process.argv.slice(3)).then(() => {
process.exit(0);
}).catch(error => {
console.log('Error in command:', error.stack);
});
}
});
if (!found) {
console.log('Available commands:');
commands.forEach(cmd => {
console.log(` * ${cmd.command}`);
});
process.exit(1);
}

View file

@ -128,6 +128,8 @@ max-channels-per-user: 5
max-accounts-per-ip: 5
# Minimum number of seconds between guest logins from the same IP
guest-login-delay: 60
# Maximum character length of a chat message, capped at 1000 characters
max-chat-message-length: 320
# Allows you to customize the path divider. The /r/ in http://localhost/r/yourchannel
# Acceptable characters are a-z A-Z 0-9 _ and -

View file

@ -110,6 +110,25 @@ describe('KickbanModule', () => {
);
});
it('rejects if the username is invalid', done => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {
assert.strictEqual(
obj.msg,
'Invalid username'
);
done();
}
};
kickban.handleCmdBan(
mockUser,
'/ban test_user<>%$# because reasons',
{}
);
});
it('rejects if the user does not have ban permission', done => {
mockUser.socket.emit = (frame, obj) => {
if (frame === 'errorMsg') {

View file

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

10952
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,17 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.83.0",
"version": "3.86.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
"license": "MIT",
"dependencies": {
"@calzoneman/jsli": "^2.0.1",
"@cytube/mediaquery": "github:CyTube/mediaquery#c1dcf792cd6e9977c04c1e96f23315dad5e3294d",
"@cytube/mediaquery": "github:CyTube/mediaquery#564d0c4615e80f72722b0f68ac81f837a4c5fc81",
"bcrypt": "^5.0.1",
"bluebird": "^3.7.2",
"body-parser": "^1.19.0",
"body-parser": "^1.20.1",
"cheerio": "^1.0.0-rc.10",
"clone": "^2.1.2",
"compression": "^1.7.4",
@ -20,21 +20,22 @@
"create-error": "^0.3.1",
"csrf": "^3.1.0",
"cytubefilters": "github:calzoneman/cytubefilters#c67b2dab2dc5cc5ed11018819f71273d0f8a1bf5",
"express": "^4.17.1",
"express": "^4.18.2",
"express-minify": "^1.0.0",
"json-typecheck": "^0.1.3",
"knex": "^0.95.2",
"knex": "^2.4.0",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"mysql": "^2.18.1",
"nodemailer": "^6.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.14.1",
"socket.io": "^4.5.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",

View file

@ -15,8 +15,19 @@ window.CustomEmbedPlayer = class CustomEmbedPlayer extends EmbedPlayer
return
embedSrc = data.meta.embed.src
link = "<a href=\"#{embedSrc}\" target=\"_blank\"><strong>#{embedSrc}</strong></a>"
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link),
link = document.createElement('a')
link.href = embedSrc
link.target = '_blank'
link.rel = 'noopener noreferer'
strong = document.createElement('strong')
strong.textContent = embedSrc
link.appendChild(strong)
# TODO: Ideally makeAlert() would allow optionally providing a DOM
# element instead of requiring HTML text
alert = makeAlert('Untrusted Content', CUSTOM_EMBED_WARNING.replace('%link%', link.outerHTML),
'alert-warning')
.removeClass('col-md-12')
$('<button/>').addClass('btn btn-default')

View file

@ -94,7 +94,10 @@ function Channel(name) {
}, USERCOUNT_THROTTLE);
const self = this;
db.channels.load(this, function (err) {
if (err && err !== "Channel is not registered") {
if (err && err.code === 'EBANNED') {
self.emit("loadFail", err.message);
self.setFlag(Flags.C_ERROR);
} else if (err && err !== "Channel is not registered") {
self.emit("loadFail", "Failed to load channel data from the database. Please try again later.");
self.setFlag(Flags.C_ERROR);
} else {

View file

@ -162,7 +162,7 @@ ChatModule.prototype.handleChatMsg = function (user, data) {
return;
}
data.msg = data.msg.substring(0, 320);
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
// Restrict new accounts/IPs from chatting and posting links
if (this.restrictNewAccount(user, data)) {
@ -248,7 +248,7 @@ ChatModule.prototype.handlePm = function (user, data) {
}
data.msg = data.msg.substring(0, 320);
data.msg = data.msg.substring(0, Config.get("max-chat-message-length"));
var to = null;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === data.to) {

View file

@ -4,6 +4,7 @@ var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
import Promise from 'bluebird';
const XSS = require("../xss");
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
@ -261,7 +262,6 @@ KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
this.banAll(user, name, range, reason).catch(error => {
//console.log('!!!', error.stack);
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
@ -276,6 +276,10 @@ KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
};
KickBanModule.prototype.banName = async function banName(actor, name, reason) {
if (!util.isValidUserName(name)) {
throw new Error("Invalid username");
}
reason = reason.substring(0, 255);
var chan = this.channel;
@ -323,6 +327,9 @@ KickBanModule.prototype.banName = async function banName(actor, name, reason) {
};
KickBanModule.prototype.banIP = async function banIP(actor, ip, name, reason) {
if (!util.isValidUserName(name)) {
throw new Error("Invalid username");
}
reason = reason.substring(0, 255);
var masked = util.cloakIP(ip);
@ -404,7 +411,12 @@ KickBanModule.prototype.banAll = async function banAll(
);
if (!await dbIsNameBanned(chan.name, name)) {
promises.push(this.banName(actor, name, reason));
promises.push(this.banName(actor, name, reason).catch(error => {
// TODO: banning should be made idempotent, not throw an error
if (!/already banned/.test(error.message)) {
throw error;
}
}));
}
await Promise.all(promises);
@ -440,8 +452,9 @@ KickBanModule.prototype.handleUnban = function (user, data) {
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
if (self.channel.modules.chat) {
var banperm = self.channel.modules.permissions.permissions.ban;
// TODO: quick fix, shouldn't trust name from unban frame.
self.channel.modules.chat.sendModMessage(
user.getName() + " unbanned " + data.name,
user.getName() + " unbanned " + XSS.sanitizeText(data.name),
banperm
);
}

View file

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

View file

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

View file

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

View file

@ -169,7 +169,7 @@ export function validate(data) {
validateURL(data.thumbnail);
}
validateSources(data.sources, data);
validateSources(data.sources);
validateAudioTracks(data.audioTracks);
validateTextTracks(data.textTracks);
/*
@ -182,7 +182,7 @@ export function validate(data) {
validateFonts(data.fonts);
}
function validateSources(sources, data) {
function validateSources(sources) {
if (!Array.isArray(sources))
throw new ValidationError('sources must be a list');
if (sources.length === 0)

View file

@ -30,19 +30,19 @@ class Database {
constructor(knexConfig = null) {
if (knexConfig === null) {
knexConfig = {
client: 'mysql',
client: Config.get('database.client'),
connection: {
host: Config.get('mysql.server'),
port: Config.get('mysql.port'),
user: Config.get('mysql.user'),
password: Config.get('mysql.password'),
database: Config.get('mysql.database'),
host: Config.get('database.server'),
port: Config.get('database.port'),
user: Config.get('database.user'),
password: Config.get('database.password'),
database: Config.get('database.database'),
multipleStatements: true, // Legacy thing
charset: 'utf8mb4'
},
pool: {
min: Config.get('mysql.pool-size'),
max: Config.get('mysql.pool-size')
min: Config.get('database.pool-size'),
max: Config.get('database.pool-size')
},
debug: !!process.env.KNEX_DEBUG
};
@ -73,6 +73,8 @@ module.exports.init = function (newDB) {
} else {
db = new Database();
}
// FIXME Initial database connection failed: error: select 1 from dual
// relation "dual" does not exist
db.knex.raw('select 1 from dual')
.catch(error => {
LOGGER.error('Initial database connection failed: %s', error.stack);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -265,6 +265,8 @@ async function handleNewChannel(req, res) {
});
}
let banInfo = await db.channels.getBannedChannel(name);
db.channels.listUserChannels(user.name, function (err, channels) {
if (err) {
sendPug(res, "account-channels", {
@ -274,6 +276,14 @@ async function handleNewChannel(req, res) {
return;
}
if (banInfo !== null) {
sendPug(res, "account-channels", {
channels: channels,
newChannelError: `Cannot register "${name}": this channel is banned.`
});
return;
}
if (name.match(Config.get("reserved-names.channels"))) {
sendPug(res, "account-channels", {
channels: channels,

View file

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

View file

@ -143,7 +143,8 @@ module.exports = {
emailConfig,
emailController,
captchaConfig,
captchaController
captchaController,
bannedChannelsController
) {
patchExpressToHandleAsync();
const chanPath = Config.get('channel-path');
@ -194,7 +195,12 @@ module.exports = {
LOGGER.info('Enabled express-minify for CSS and JS');
}
require('./routes/channel')(app, ioConfig, chanPath);
require('./routes/channel')(
app,
ioConfig,
chanPath,
async name => bannedChannelsController.getBannedChannel(name)
);
require('./routes/index')(app, channelIndex, webConfig.getMaxIndexEntries());
require('./routes/socketconfig')(app, clusterClient);
require('./routes/contact')(app, webConfig);

View file

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

View file

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

View file

@ -46,7 +46,7 @@ html(lang="en")
#userlist
#messagebuffer.linewrap
form(action="javascript:void(0)")
input#chatline.form-control(type="text", maxlength="320", style="display: none")
input#chatline.form-control(type="text", maxlength=maxMsgLen, style="display: none")
#guestlogin.input-group
span.input-group-addon Guest login
input#guestname.form-control(type="text", placeholder="Name")

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

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

View file

@ -1,4 +1,55 @@
/*eslint no-unused-vars: "off"*/
(function() {
/**
* Test whether the browser supports nullish-coalescing operator.
*
* Users with old browsers will probably fail to load the client correctly
* because parsing this operator in older browsers results in a SyntaxError
* that aborts compilation of the entire script (not just an exception where
* it is used). In particular, as of 2023-01-28, Utherverse ships with
* a rather old browser version (Chrome 76) and several users have reported
* it not working.
*/
try {
try {
new Function('x?.y');
} catch (e) {
if (e.name === 'SyntaxError') {
/**
* If we're at this point, we can't be sure what scripts have
* actually loaded, so construct the error alert the old
* fashioned way.
*/
var wrap = document.createElement('div');
wrap.className = 'col-md-12';
var al = document.createElement('div');
al.className = 'alert alert-danger';
var title = document.createElement('strong');
title.textContent = 'Unsupported Browser';
var msg = document.createElement('p');
msg.textContent = 'It looks like your browser does not support ' +
'the required JavaScript features to run ' +
'CyTube. This is usually caused by ' +
'using an outdated browser version. Please '+
'check if an update is available. Your ' +
'browser version is reported as:';
var version = document.createElement('tt');
version.textContent = navigator.userAgent;
wrap.appendChild(al);
al.appendChild(title);
al.appendChild(msg);
al.appendChild(document.createElement('br'));
al.appendChild(version);
document.getElementById('motdrow').appendChild(wrap);
}
}
} catch (e) {
console.error('Error probing for feature support:', e.stack);
}
})();
var CL_VERSION = 3.0;
var GS_VERSION = 1.7; // Google Drive Userscript

View file

@ -1287,6 +1287,11 @@ function playlistMove(from, after, cb) {
}
}
function checkYP(id) {
if (!/^(PL[a-zA-Z0-9_-]{32}|PL[A-F0-9]{16}|OLA[a-zA-Z0-9_-]{38})$/.test(id)) {
throw new Error('Invalid YouTube Playlist ID. Note that only regular user-created playlists are supported.');
}
}
function parseMediaLink(url) {
function parseShortCode(url){
@ -1301,6 +1306,9 @@ function parseMediaLink(url) {
case 'fi':
case 'cm':
return { type, id };
case 'yp':
checkYP(id);
return { type, id };
// Generic for the rest.
default:
return { type, id: id.match(/([^\?&#]+)/)[1] };
@ -1356,6 +1364,7 @@ function parseMediaLink(url) {
return { type: 'yt', id: data.pathname.slice(8,19) }
}
if(data.pathname == '/playlist'){
checkYP(data.searchParams.get('list'));
return { type: 'yp', id: data.searchParams.get('list') }
}
case 'youtu.be':