Merge pull request #670 from calzoneman/camo-proxy-chat-images
Support proxying chat images via camo
This commit is contained in:
commit
290f802b7c
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,3 +13,4 @@ www/cache
|
|||
google-drive-subtitles
|
||||
lib/
|
||||
integration-test-config.json
|
||||
conf/*.toml
|
||||
|
|
13
conf/example/camo.toml
Normal file
13
conf/example/camo.toml
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Configuration for proxying images to a camo (or camo-compatible) proxy server.
|
||||
# To use, copy to conf/camo.toml.
|
||||
# More info on camo: https://github.com/atmos/camo
|
||||
[camo]
|
||||
enabled = true
|
||||
server = 'https://my-camo-server'
|
||||
# The key must match the `CAMO_KEY` environment variable passed to the camo server.
|
||||
key = 'ABCDEFGH'
|
||||
# Bypass the proxy for domains you trust that already support HTTPS and won't be harmful to users.
|
||||
whitelisted-domains = [
|
||||
'i.imgur.com',
|
||||
'i.4cdn.org'
|
||||
]
|
|
@ -2,7 +2,7 @@
|
|||
"author": "Calvin Montgomery",
|
||||
"name": "CyTube",
|
||||
"description": "Online media synchronizer and chat",
|
||||
"version": "3.36.4",
|
||||
"version": "3.37.0",
|
||||
"repository": {
|
||||
"url": "http://github.com/calzoneman/sync"
|
||||
},
|
||||
|
|
43
src/camo.js
Normal file
43
src/camo.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
// @flow
|
||||
import crypto from 'crypto';
|
||||
import { LoggerFactory } from '@calzoneman/jsli';
|
||||
import * as urlparse from 'url';
|
||||
import { CamoConfig } from './configuration/camoconfig';
|
||||
|
||||
const LOGGER = LoggerFactory.getLogger('camo');
|
||||
|
||||
function isWhitelisted(camoConfig: CamoConfig, url: string): boolean {
|
||||
const whitelistedDomains = camoConfig.getWhitelistedDomains();
|
||||
const parsed = urlparse.parse(url);
|
||||
return whitelistedDomains.includes(parsed.hostname);
|
||||
}
|
||||
|
||||
export function camoify(camoConfig: CamoConfig, url: string): string {
|
||||
if (typeof url !== 'string') {
|
||||
throw new TypeError(`camoify expected a string, not [${url}]`);
|
||||
}
|
||||
|
||||
if (isWhitelisted(camoConfig, url)) {
|
||||
return url.replace(/^http:/, 'https:');
|
||||
}
|
||||
|
||||
const hmac = crypto.createHmac('sha1', camoConfig.getKey());
|
||||
hmac.update(url);
|
||||
const digest = hmac.digest('hex');
|
||||
const hexUrl = Buffer.from(url, 'utf8').toString('hex');
|
||||
return `${camoConfig.getServer()}/${digest}/${hexUrl}`;
|
||||
}
|
||||
|
||||
export function transformImgTags(camoConfig: CamoConfig, tagName: string, attribs: Object) {
|
||||
if (typeof attribs.src === 'string') {
|
||||
try {
|
||||
const oldSrc = attribs.src;
|
||||
attribs.src = camoify(camoConfig, attribs.src);
|
||||
LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src);
|
||||
} catch (error) {
|
||||
LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { tagName, attribs };
|
||||
}
|
|
@ -6,6 +6,7 @@ var util = require("../utilities");
|
|||
var Flags = require("../flags");
|
||||
var url = require("url");
|
||||
var counters = require("../counters");
|
||||
import { transformImgTags } from '../camo';
|
||||
|
||||
const SHADOW_TAG = "[shadow]";
|
||||
const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
|
||||
|
@ -381,7 +382,17 @@ ChatModule.prototype.filterMessage = function (msg) {
|
|||
}
|
||||
});
|
||||
|
||||
return XSS.sanitizeHTML(result);
|
||||
let settings = {};
|
||||
const camoConfig = Config.getCamoConfig();
|
||||
if (camoConfig.isEnabled()) {
|
||||
settings = {
|
||||
transformTags: {
|
||||
img: transformImgTags.bind(null, camoConfig)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return XSS.sanitizeHTML(result, settings);
|
||||
};
|
||||
|
||||
ChatModule.prototype.sendModMessage = function (msg, minrank) {
|
||||
|
|
|
@ -5,6 +5,8 @@ var net = require("net");
|
|||
var YAML = require("yamljs");
|
||||
|
||||
import { LoggerFactory } from '@calzoneman/jsli';
|
||||
import { loadFromToml } from 'cytube-common/lib/configuration/configloader';
|
||||
import { CamoConfig } from './configuration/camoconfig';
|
||||
|
||||
const LOGGER = LoggerFactory.getLogger('config');
|
||||
|
||||
|
@ -146,6 +148,7 @@ function merge(obj, def, path) {
|
|||
}
|
||||
|
||||
var cfg = defaults;
|
||||
let camoConfig = new CamoConfig();
|
||||
|
||||
/**
|
||||
* Initializes the configuration from the given YAML file
|
||||
|
@ -186,8 +189,31 @@ exports.load = function (file) {
|
|||
|
||||
preprocessConfig(cfg);
|
||||
LOGGER.info("Loaded configuration from " + file);
|
||||
|
||||
loadCamoConfig();
|
||||
};
|
||||
|
||||
function loadCamoConfig() {
|
||||
try {
|
||||
camoConfig = loadFromToml(CamoConfig,
|
||||
path.resolve(__dirname, '..', 'conf', 'camo.toml'));
|
||||
const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED';
|
||||
LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
LOGGER.info('No camo configuration found, chat images will not be proxied.');
|
||||
camoConfig = new CamoConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof error.line !== 'undefined') {
|
||||
LOGGER.error(`Error in conf/camo.toml: ${error} (line ${error.line})`);
|
||||
} else {
|
||||
LOGGER.error(`Error loading conf/camo.toml: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// I'm sorry
|
||||
function preprocessConfig(cfg) {
|
||||
/* Detect 3.0.0-style config and warng the user about it */
|
||||
|
@ -447,3 +473,7 @@ exports.set = function (key, value) {
|
|||
|
||||
obj[current] = value;
|
||||
};
|
||||
|
||||
exports.getCamoConfig = function getCamoConfig() {
|
||||
return camoConfig;
|
||||
};
|
||||
|
|
26
src/configuration/camoconfig.js
Normal file
26
src/configuration/camoconfig.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
class CamoConfig {
|
||||
constructor(config = { camo: { enabled: false } }) {
|
||||
this.config = config.camo;
|
||||
if (this.config.server) {
|
||||
this.config.server = this.config.server.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
getKey() {
|
||||
return this.config.key;
|
||||
}
|
||||
|
||||
getServer() {
|
||||
return this.config.server;
|
||||
}
|
||||
|
||||
getWhitelistedDomains() {
|
||||
return this.config['whitelisted-domains'] || [];
|
||||
}
|
||||
}
|
||||
|
||||
export { CamoConfig };
|
|
@ -103,8 +103,9 @@ function decodeText(str) {
|
|||
return str;
|
||||
}
|
||||
|
||||
module.exports.sanitizeHTML = function (html) {
|
||||
return sanitizeHTML(html, SETTINGS);
|
||||
module.exports.sanitizeHTML = function (html, extraSettings = {}) {
|
||||
const options = Object.assign({}, SETTINGS, extraSettings);
|
||||
return sanitizeHTML(html, options);
|
||||
};
|
||||
|
||||
module.exports.looseSanitizeText = looseSanitizeText;
|
||||
|
|
54
test/camo.js
Normal file
54
test/camo.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
const assert = require('assert');
|
||||
const Camo = require('../lib/camo');
|
||||
const CamoConfig = require('../lib/configuration/camoconfig').CamoConfig;
|
||||
|
||||
describe('Camo', () => {
|
||||
const config = new CamoConfig({
|
||||
camo: {
|
||||
server: 'http://localhost:8081',
|
||||
key: '9LKC7708ZHOVRCTLOLE3G2YJ0U1T8F96',
|
||||
'whitelisted-domains': ['def.xyz']
|
||||
}
|
||||
});
|
||||
|
||||
describe('#camoify', () => {
|
||||
it('constructs a camo url', () => {
|
||||
const result = Camo.camoify(config, 'http://abc.xyz/image.jpeg');
|
||||
assert.strictEqual(result, 'http://localhost:8081/a9c295dd7d8dcbc8247dec97ac5d9b4ee8baeb31/687474703a2f2f6162632e78797a2f696d6167652e6a706567');
|
||||
});
|
||||
|
||||
it('bypasses camo for whitelisted domains', () => {
|
||||
const result = Camo.camoify(config, 'http://def.xyz/image.jpeg');
|
||||
assert.strictEqual(result, 'https://def.xyz/image.jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#transformImgTags', () => {
|
||||
it('transforms an img tag with a src', () => {
|
||||
const attribs = {
|
||||
src: 'http://abc.xyz/image.jpeg',
|
||||
'class': 'some-image'
|
||||
};
|
||||
const expectedAttribs = {
|
||||
src: 'http://localhost:8081/a9c295dd7d8dcbc8247dec97ac5d9b4ee8baeb31/687474703a2f2f6162632e78797a2f696d6167652e6a706567',
|
||||
'class': 'some-image'
|
||||
};
|
||||
const result = Camo.transformImgTags(config, 'img', attribs);
|
||||
assert.deepStrictEqual(result, { tagName: 'img', attribs: expectedAttribs });
|
||||
});
|
||||
|
||||
it('skips img tags with no src', () => {
|
||||
const attribs = { 'class': 'some-image' };
|
||||
const result = Camo.transformImgTags(config, 'img', attribs);
|
||||
assert.deepStrictEqual(result, { tagName: 'img', attribs: attribs });
|
||||
});
|
||||
|
||||
it('fails gracefully', () => {
|
||||
const attribs = { src: 'http://abc.xyz/image.jpeg' };
|
||||
const config = new CamoConfig({ camo: { enabled: true }});
|
||||
config.getKey = () => { throw new Error('something happened'); };
|
||||
const result = Camo.transformImgTags(config, 'img', attribs);
|
||||
assert.deepStrictEqual(result, { tagName: 'img', attribs: attribs });
|
||||
});
|
||||
});
|
||||
});
|
25
test/configuration/camoconfig.js
Normal file
25
test/configuration/camoconfig.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
const assert = require('assert');
|
||||
const CamoConfig = require('../../lib/configuration/camoconfig').CamoConfig;
|
||||
|
||||
describe('CamoConfig', () => {
|
||||
describe('#constructor', () => {
|
||||
it('strips trailing slashes from the server', () => {
|
||||
const config = new CamoConfig({
|
||||
camo: {
|
||||
server: 'http://abc.xyz/'
|
||||
}
|
||||
});
|
||||
assert.strictEqual(config.getServer(), 'http://abc.xyz');
|
||||
});
|
||||
|
||||
it('defaults to enabled=false', () => {
|
||||
assert.strictEqual(new CamoConfig().isEnabled(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getWhitelistedDomains', () => {
|
||||
it('defaults to an empty array', () => {
|
||||
assert.deepStrictEqual(new CamoConfig().getWhitelistedDomains(), []);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue