mirror of https://github.com/calzoneman/sync.git
Merge pull request #670 from calzoneman/camo-proxy-chat-images
Support proxying chat images via camo
This commit is contained in:
commit
290f802b7c
|
@ -13,3 +13,4 @@ www/cache
|
||||||
google-drive-subtitles
|
google-drive-subtitles
|
||||||
lib/
|
lib/
|
||||||
integration-test-config.json
|
integration-test-config.json
|
||||||
|
conf/*.toml
|
||||||
|
|
|
@ -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",
|
"author": "Calvin Montgomery",
|
||||||
"name": "CyTube",
|
"name": "CyTube",
|
||||||
"description": "Online media synchronizer and chat",
|
"description": "Online media synchronizer and chat",
|
||||||
"version": "3.36.4",
|
"version": "3.37.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "http://github.com/calzoneman/sync"
|
"url": "http://github.com/calzoneman/sync"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 Flags = require("../flags");
|
||||||
var url = require("url");
|
var url = require("url");
|
||||||
var counters = require("../counters");
|
var counters = require("../counters");
|
||||||
|
import { transformImgTags } from '../camo';
|
||||||
|
|
||||||
const SHADOW_TAG = "[shadow]";
|
const SHADOW_TAG = "[shadow]";
|
||||||
const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
|
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) {
|
ChatModule.prototype.sendModMessage = function (msg, minrank) {
|
||||||
|
|
|
@ -5,6 +5,8 @@ var net = require("net");
|
||||||
var YAML = require("yamljs");
|
var YAML = require("yamljs");
|
||||||
|
|
||||||
import { LoggerFactory } from '@calzoneman/jsli';
|
import { LoggerFactory } from '@calzoneman/jsli';
|
||||||
|
import { loadFromToml } from 'cytube-common/lib/configuration/configloader';
|
||||||
|
import { CamoConfig } from './configuration/camoconfig';
|
||||||
|
|
||||||
const LOGGER = LoggerFactory.getLogger('config');
|
const LOGGER = LoggerFactory.getLogger('config');
|
||||||
|
|
||||||
|
@ -146,6 +148,7 @@ function merge(obj, def, path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg = defaults;
|
var cfg = defaults;
|
||||||
|
let camoConfig = new CamoConfig();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the configuration from the given YAML file
|
* Initializes the configuration from the given YAML file
|
||||||
|
@ -186,8 +189,31 @@ exports.load = function (file) {
|
||||||
|
|
||||||
preprocessConfig(cfg);
|
preprocessConfig(cfg);
|
||||||
LOGGER.info("Loaded configuration from " + file);
|
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
|
// I'm sorry
|
||||||
function preprocessConfig(cfg) {
|
function preprocessConfig(cfg) {
|
||||||
/* Detect 3.0.0-style config and warng the user about it */
|
/* Detect 3.0.0-style config and warng the user about it */
|
||||||
|
@ -447,3 +473,7 @@ exports.set = function (key, value) {
|
||||||
|
|
||||||
obj[current] = value;
|
obj[current] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getCamoConfig = function getCamoConfig() {
|
||||||
|
return camoConfig;
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.sanitizeHTML = function (html) {
|
module.exports.sanitizeHTML = function (html, extraSettings = {}) {
|
||||||
return sanitizeHTML(html, SETTINGS);
|
const options = Object.assign({}, SETTINGS, extraSettings);
|
||||||
|
return sanitizeHTML(html, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.looseSanitizeText = looseSanitizeText;
|
module.exports.looseSanitizeText = looseSanitizeText;
|
||||||
|
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 New Issue