mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-07-05 06:07:35 +02:00
Merge remote-tracking branch 'origin/master'
# Conflicts: # package.json
This commit is contained in:
@@ -89,7 +89,7 @@ uploadsController.uploadLogo = function (req, res, next) {
|
||||
uploadsController.uploadSound = function (req, res, next) {
|
||||
var uploadedFile = req.files.files[0];
|
||||
|
||||
var mimeType = mime.lookup(uploadedFile.name);
|
||||
var mimeType = mime.getType(uploadedFile.name);
|
||||
if (!/^audio\//.test(mimeType)) {
|
||||
return next(Error('[[error:invalid-data]]'));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
var nconf = require('nconf');
|
||||
var winston = require('winston');
|
||||
var validator = require('validator');
|
||||
var plugins = require('../plugins');
|
||||
|
||||
exports.handleURIErrors = function (err, req, res, next) {
|
||||
// Handle cases where malformed URIs are passed in
|
||||
@@ -35,30 +36,50 @@ exports.handleURIErrors = function (err, req, res, next) {
|
||||
// this needs to have four arguments or express treats it as `(req, res, next)`
|
||||
// don't remove `next`!
|
||||
exports.handleErrors = function (err, req, res, next) { // eslint-disable-line no-unused-vars
|
||||
switch (err.code) {
|
||||
case 'EBADCSRFTOKEN':
|
||||
winston.error(req.path + '\n', err.message);
|
||||
return res.sendStatus(403);
|
||||
case 'blacklisted-ip':
|
||||
return res.status(403).type('text/plain').send(err.message);
|
||||
}
|
||||
var cases = {
|
||||
EBADCSRFTOKEN: function () {
|
||||
winston.error(req.path + '\n', err.message);
|
||||
res.sendStatus(403);
|
||||
},
|
||||
'blacklisted-ip': function () {
|
||||
res.status(403).type('text/plain').send(err.message);
|
||||
},
|
||||
};
|
||||
var defaultHandler = function () {
|
||||
// Display NodeBB error page
|
||||
var status = parseInt(err.status, 10);
|
||||
if ((status === 302 || status === 308) && err.path) {
|
||||
return res.locals.isAPI ? res.set('X-Redirect', err.path).status(200).json(err.path) : res.redirect(err.path);
|
||||
}
|
||||
|
||||
var status = parseInt(err.status, 10);
|
||||
if ((status === 302 || status === 308) && err.path) {
|
||||
return res.locals.isAPI ? res.set('X-Redirect', err.path).status(200).json(err.path) : res.redirect(err.path);
|
||||
}
|
||||
winston.error(req.path + '\n', err.stack);
|
||||
|
||||
winston.error(req.path + '\n', err.stack);
|
||||
res.status(status || 500);
|
||||
|
||||
res.status(status || 500);
|
||||
var path = String(req.path || '');
|
||||
if (res.locals.isAPI) {
|
||||
res.json({ path: validator.escape(path), error: err.message });
|
||||
} else {
|
||||
var middleware = require('../middleware');
|
||||
middleware.buildHeader(req, res, function () {
|
||||
res.render('500', { path: validator.escape(path), error: validator.escape(String(err.message)) });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var path = String(req.path || '');
|
||||
if (res.locals.isAPI) {
|
||||
res.json({ path: validator.escape(path), error: err.message });
|
||||
} else {
|
||||
var middleware = require('../middleware');
|
||||
middleware.buildHeader(req, res, function () {
|
||||
res.render('500', { path: validator.escape(path), error: validator.escape(String(err.message)) });
|
||||
});
|
||||
}
|
||||
plugins.fireHook('filter:error.handle', {
|
||||
cases: cases,
|
||||
}, function (_err, data) {
|
||||
if (_err) {
|
||||
// Assume defaults
|
||||
winston.warn('[errors/handle] Unable to retrieve plugin handlers for errors: ' + _err.message);
|
||||
data.cases = cases;
|
||||
}
|
||||
|
||||
if (data.cases.hasOwnProperty(err.code)) {
|
||||
data.cases[err.code](err, req, res, defaultHandler);
|
||||
} else {
|
||||
defaultHandler();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ var meta = require('../meta');
|
||||
|
||||
module.exports.handle = function (req, res, next) {
|
||||
if (plugins.hasListeners('filter:search.query')) {
|
||||
res.type('application/xml').send(generateXML());
|
||||
res.type('application/opensearchdescription+xml').send(generateXML());
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
@@ -17,9 +17,21 @@ module.exports.handle = function (req, res, next) {
|
||||
function generateXML() {
|
||||
return xml([{
|
||||
OpenSearchDescription: [
|
||||
{ _attr: { xmlns: 'http://a9.com/-/spec/opensearch/1.1/' } },
|
||||
{ ShortName: String(meta.config.title || meta.config.browserTitle || 'NodeBB') },
|
||||
{ Description: String(meta.config.description || '') },
|
||||
{ _attr: {
|
||||
xmlns: 'http://a9.com/-/spec/opensearch/1.1/',
|
||||
'xmlns:moz': 'http://www.mozilla.org/2006/browser/search/',
|
||||
} },
|
||||
{ ShortName: trimToLength(String(meta.config.title || meta.config.browserTitle || 'NodeBB'), 16) },
|
||||
{ Description: trimToLength(String(meta.config.description || ''), 1024) },
|
||||
{ InputEncoding: 'UTF-8' },
|
||||
{ Image: [
|
||||
{ _attr: {
|
||||
width: '16',
|
||||
height: '16',
|
||||
type: 'image/x-icon',
|
||||
} },
|
||||
nconf.get('url') + '/favicon.ico',
|
||||
] },
|
||||
{ Url: {
|
||||
_attr: {
|
||||
type: 'text/html',
|
||||
@@ -27,6 +39,11 @@ function generateXML() {
|
||||
template: nconf.get('url') + '/search?term={searchTerms}&in=titlesposts',
|
||||
},
|
||||
} },
|
||||
{ 'moz:SearchForm': nconf.get('url') + '/search' },
|
||||
],
|
||||
}], { declaration: true, indent: '\t' });
|
||||
}
|
||||
|
||||
function trimToLength(string, length) {
|
||||
return string.trim().substring(0, length).trim();
|
||||
}
|
||||
|
||||
@@ -56,17 +56,33 @@ Emailer.registerApp = function (expressApp) {
|
||||
|
||||
// Enable Gmail transport if enabled in ACP
|
||||
if (parseInt(meta.config['email:smtpTransport:enabled'], 10) === 1) {
|
||||
var smtpOptions = {
|
||||
auth: {
|
||||
var smtpOptions = {};
|
||||
|
||||
if (meta.config['email:smtpTransport:user'] || meta.config['email:smtpTransport:pass']) {
|
||||
smtpOptions.auth = {
|
||||
user: meta.config['email:smtpTransport:user'],
|
||||
pass: meta.config['email:smtpTransport:pass'],
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.config['email:smtpTransport:serice'] === 'nodebb-custom-smtp') {
|
||||
if (meta.config['email:smtpTransport:service'] === 'nodebb-custom-smtp') {
|
||||
smtpOptions.port = meta.config['email:smtpTransport:port'];
|
||||
smtpOptions.host = meta.config['email:smtpTransport:host'];
|
||||
smtpOptions.secure = true;
|
||||
|
||||
if (meta.config['email:smtpTransport:security'] === 'NONE') {
|
||||
smtpOptions.secure = false;
|
||||
smtpOptions.requireTLS = false;
|
||||
smtpOptions.ignoreTLS = true;
|
||||
} else if (meta.config['email:smtpTransport:security'] === 'STARTTLS') {
|
||||
smtpOptions.secure = false;
|
||||
smtpOptions.requireTLS = true;
|
||||
smtpOptions.ignoreTLS = false;
|
||||
} else {
|
||||
// meta.config['email:smtpTransport:security'] === 'ENCRYPTED' or undefined
|
||||
smtpOptions.secure = true;
|
||||
smtpOptions.requireTLS = true;
|
||||
smtpOptions.ignoreTLS = false;
|
||||
}
|
||||
} else {
|
||||
smtpOptions.service = meta.config['email:smtpTransport:service'];
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ file.saveFileToLocal = function (filename, folder, tempPath, callback) {
|
||||
};
|
||||
|
||||
file.base64ToLocal = function (imageData, uploadPath, callback) {
|
||||
var buffer = new Buffer(imageData.slice(imageData.indexOf('base64') + 7), 'base64');
|
||||
var buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64');
|
||||
uploadPath = path.join(nconf.get('upload_path'), uploadPath);
|
||||
|
||||
fs.writeFile(uploadPath, buffer, {
|
||||
@@ -141,7 +141,7 @@ file.linkDirs = function linkDirs(sourceDir, destDir, callback) {
|
||||
file.typeToExtension = function (type) {
|
||||
var extension;
|
||||
if (type) {
|
||||
extension = '.' + mime.extension(type);
|
||||
extension = '.' + mime.getExtension(type);
|
||||
}
|
||||
return extension;
|
||||
};
|
||||
|
||||
@@ -240,7 +240,7 @@ Flags.validate = function (payload, callback) {
|
||||
}
|
||||
|
||||
var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
|
||||
// Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply)
|
||||
// Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply)
|
||||
if (!editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) {
|
||||
return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
|
||||
}
|
||||
@@ -256,7 +256,7 @@ Flags.validate = function (payload, callback) {
|
||||
}
|
||||
|
||||
var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
|
||||
// Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply)
|
||||
// Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply)
|
||||
if (!editable && parseInt(data.reporter.reputation, 10) < minimumReputation) {
|
||||
return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = function (Groups) {
|
||||
|
||||
var tempPath = data.file ? data.file : '';
|
||||
var url;
|
||||
var type = data.file ? mime.lookup(data.file) : 'image/png';
|
||||
var type = data.file ? mime.getType(data.file) : 'image/png';
|
||||
|
||||
async.waterfall([
|
||||
function (next) {
|
||||
|
||||
@@ -141,7 +141,7 @@ image.writeImageDataToTempFile = function (imageData, callback) {
|
||||
|
||||
var filepath = path.join(os.tmpdir(), filename + extension);
|
||||
|
||||
var buffer = new Buffer(imageData.slice(imageData.indexOf('base64') + 7), 'base64');
|
||||
var buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64');
|
||||
|
||||
fs.writeFile(filepath, buffer, {
|
||||
encoding: 'base64',
|
||||
|
||||
@@ -20,7 +20,7 @@ Blacklist.load = function (callback) {
|
||||
Blacklist.get,
|
||||
Blacklist.validate,
|
||||
function (rules, next) {
|
||||
winston.verbose('[meta/blacklist] Loading ' + rules.valid.length + ' blacklist rules');
|
||||
winston.verbose('[meta/blacklist] Loading ' + rules.valid.length + ' blacklist rule(s)' + (rules.duplicateCount > 0 ? ', ignored ' + rules.duplicateCount + ' duplicate(s)' : ''));
|
||||
if (rules.invalid.length) {
|
||||
winston.warn('[meta/blacklist] ' + rules.invalid.length + ' invalid blacklist rule(s) were ignored.');
|
||||
}
|
||||
@@ -44,8 +44,8 @@ Blacklist.save = function (rules, callback) {
|
||||
db.setObject('ip-blacklist-rules', { rules: rules }, next);
|
||||
},
|
||||
function (next) {
|
||||
Blacklist.load(next);
|
||||
pubsub.publish('blacklist:reload');
|
||||
setImmediate(next);
|
||||
},
|
||||
], callback);
|
||||
};
|
||||
@@ -71,8 +71,11 @@ Blacklist.test = function (clientIp, callback) {
|
||||
Blacklist._rules.ipv4.indexOf(clientIp) === -1 && // not explicitly specified in ipv4 list
|
||||
Blacklist._rules.ipv6.indexOf(clientIp) === -1 && // not explicitly specified in ipv6 list
|
||||
!Blacklist._rules.cidr.some(function (subnet) {
|
||||
return addr.match(ipaddr.parseCIDR(subnet));
|
||||
// return ip.cidrSubnet(subnet).contains(clientIp);
|
||||
var cidr = ipaddr.parseCIDR(subnet);
|
||||
if (addr.kind() !== cidr[0].kind()) {
|
||||
return false;
|
||||
}
|
||||
return addr.match(cidr);
|
||||
}) // not in a blacklisted IPv4 or IPv6 cidr range
|
||||
) {
|
||||
plugins.fireHook('filter:blacklist.test', { // To return test failure, pass back an error in callback
|
||||
@@ -108,6 +111,7 @@ Blacklist.validate = function (rules, callback) {
|
||||
var ipv6 = [];
|
||||
var cidr = [];
|
||||
var invalid = [];
|
||||
var duplicateCount = 0;
|
||||
|
||||
var inlineCommentMatch = /#.*$/;
|
||||
var whitelist = ['127.0.0.1', '::1', '::ffff:0:127.0.0.1'];
|
||||
@@ -119,6 +123,16 @@ Blacklist.validate = function (rules, callback) {
|
||||
return rule.length && !rule.startsWith('#') ? rule : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Filter out duplicates
|
||||
rules = rules.filter(function (rule, index) {
|
||||
const pass = rules.indexOf(rule) === index;
|
||||
if (!pass) {
|
||||
duplicateCount += 1;
|
||||
}
|
||||
|
||||
return pass;
|
||||
});
|
||||
|
||||
// Filter out invalid rules
|
||||
rules = rules.filter(function (rule) {
|
||||
var addr;
|
||||
@@ -164,6 +178,7 @@ Blacklist.validate = function (rules, callback) {
|
||||
cidr: cidr,
|
||||
valid: rules,
|
||||
invalid: invalid,
|
||||
duplicateCount: duplicateCount,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -282,9 +282,9 @@ function buildCSS(data, callback) {
|
||||
processImportFrom: ['local'],
|
||||
}),
|
||||
] : [autoprefixer]).process(lessOutput.css).then(function (result) {
|
||||
callback(null, { code: result.css });
|
||||
process.nextTick(callback, null, { code: result.css });
|
||||
}, function (err) {
|
||||
callback(err);
|
||||
process.nextTick(callback, err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ Tags.parse = function (req, data, meta, link, callback) {
|
||||
defaultLinks.push({
|
||||
rel: 'search',
|
||||
type: 'application/opensearchdescription+xml',
|
||||
title: validator.escape(String(Meta.config.title || Meta.config.browserTitle || 'NodeBB')),
|
||||
href: nconf.get('relative_path') + '/osd.xml',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ var nconf = require('nconf');
|
||||
var ensureLoggedIn = require('connect-ensure-login');
|
||||
var toobusy = require('toobusy-js');
|
||||
var Benchpress = require('benchpressjs');
|
||||
var LRU = require('lru-cache');
|
||||
|
||||
var plugins = require('../plugins');
|
||||
var meta = require('../meta');
|
||||
@@ -23,6 +24,10 @@ var controllers = {
|
||||
helpers: require('../controllers/helpers'),
|
||||
};
|
||||
|
||||
var delayCache = LRU({
|
||||
maxAge: 1000 * 60,
|
||||
});
|
||||
|
||||
var middleware = module.exports;
|
||||
|
||||
middleware.applyCSRF = csrf();
|
||||
@@ -186,6 +191,14 @@ middleware.processTimeagoLocales = function (req, res, next) {
|
||||
|
||||
middleware.delayLoading = function (req, res, next) {
|
||||
// Introduces an artificial delay during load so that brute force attacks are effectively mitigated
|
||||
|
||||
// Add IP to cache so if too many requests are made, subsequent requests are blocked for a minute
|
||||
var timesSeen = delayCache.get(req.ip) || 0;
|
||||
if (timesSeen > 10) {
|
||||
return res.sendStatus(429);
|
||||
}
|
||||
delayCache.set(req.ip, timesSeen += 1);
|
||||
|
||||
setTimeout(next, 1000);
|
||||
};
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ module.exports = function (Plugins) {
|
||||
(Plugins.deprecatedHooks[data.hook] ?
|
||||
'please use `' + Plugins.deprecatedHooks[data.hook] + '` instead.' :
|
||||
'there is no alternative.'
|
||||
)
|
||||
);
|
||||
));
|
||||
} else {
|
||||
// handle hook's startsWith, i.e. action:homepage.get
|
||||
var parts = data.hook.split(':');
|
||||
@@ -61,7 +60,7 @@ module.exports = function (Plugins) {
|
||||
if (memo && memo[prop]) {
|
||||
return memo[prop];
|
||||
}
|
||||
// Couldn't find method by path, aborting
|
||||
// Couldn't find method by path, aborting
|
||||
return null;
|
||||
}, Plugins.libraries[data.id]);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ module.exports = function (Topics) {
|
||||
|
||||
var extension = path.extname(data.thumb);
|
||||
if (!extension) {
|
||||
extension = '.' + mime.extension(type);
|
||||
extension = '.' + mime.getExtension(type);
|
||||
}
|
||||
filename = Date.now() + '-topic-thumb' + extension;
|
||||
pathToUpload = path.join(nconf.get('upload_path'), 'files', filename);
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
fs.access(sourcePath, function (err) {
|
||||
if (err) {
|
||||
if (err || path.extname(sourcePath) === '.svg') {
|
||||
skip = true;
|
||||
return setImmediate(next);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ module.exports = {
|
||||
|
||||
meta.configs.setMultiple({
|
||||
'brand:logo': path.join(nconf.get('upload_path'), 'system', path.basename(meta.config['brand:logo'])),
|
||||
'brand:emailLogo': '/assets/uploads/system/site-logo-x50.png',
|
||||
}, next);
|
||||
},
|
||||
], callback);
|
||||
|
||||
@@ -28,7 +28,7 @@ module.exports = function (User) {
|
||||
var uploadSize = parseInt(meta.config.maximumProfileImageSize, 10) || 256;
|
||||
var size = res.headers['content-length'];
|
||||
var type = res.headers['content-type'];
|
||||
var extension = mime.extension(type);
|
||||
var extension = mime.getExtension(type);
|
||||
|
||||
if (['png', 'jpeg', 'jpg', 'gif'].indexOf(extension) === -1) {
|
||||
return callback(new Error('[[error:invalid-image-extension]]'));
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script>
|
||||
define('/assets/templates/500.js', function () {
|
||||
function compiled(helpers, context, get, iter, helper) {
|
||||
return '<div class="alert alert-danger">\n\t<strong>[[global:500.title]]</strong>\n\t<p>[[global:500.message]]</p>\n\t<p>' +
|
||||
helpers.__escape(get(context && context['path'])) + '</p>\n\t' +
|
||||
(get(context && context['error']) ? '<p>' + helpers.__escape(get(context && context['error'])) + '</p>' : '') + '\n\n\t' +
|
||||
(get(context && context['returnLink']) ? '\n\t<p>[[error:goback]]</p>\n\t' : '') + '\n</div>\n';
|
||||
}
|
||||
window.addEventListener('load', function () {
|
||||
define('/assets/templates/500.js', function () {
|
||||
function compiled(helpers, context, get, iter, helper) {
|
||||
return '<div class="alert alert-danger">\n\t<strong>[[global:500.title]]</strong>\n\t<p>[[global:500.message]]</p>\n\t<p>' +
|
||||
helpers.__escape(get(context && context['path'])) + '</p>\n\t' +
|
||||
(get(context && context['error']) ? '<p>' + helpers.__escape(get(context && context['error'])) + '</p>' : '') + '\n\n\t' +
|
||||
(get(context && context['returnLink']) ? '\n\t<p>[[error:goback]]</p>\n\t' : '') + '\n</div>\n';
|
||||
}
|
||||
|
||||
return compiled;
|
||||
});
|
||||
return compiled;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -40,12 +40,12 @@
|
||||
<div class="form-group">
|
||||
<label for="email:smtpTransport:service"><strong>[[admin/settings/email:smtp-transport.service]]</strong></label>
|
||||
<select class="form-control input-lg" id="email:smtpTransport:service" data-field="email:smtpTransport:service">
|
||||
<option value="nodebb-custom-smtp" style="font-weight: bold">[[admin/settings/email:smtp-transport.service-custom]]</option>
|
||||
<option style="font-size: 10px" disabled> </option>
|
||||
|
||||
<!-- BEGIN services -->
|
||||
<option value="@value">@value</option>
|
||||
<!-- END services -->
|
||||
|
||||
<option style="font-size: 10px" disabled> </option>
|
||||
<option value="nodebb-custom-smtp" style="font-weight: bold">[[admin/settings/email:smtp-transport.service-custom]]</option>
|
||||
</select>
|
||||
<p class="help-block">
|
||||
[[admin/settings/email:smtp-transport.service-help]]
|
||||
@@ -63,6 +63,13 @@
|
||||
|
||||
<label for="email:smtpTransport:port">[[admin/settings/email:smtp-transport.port]]</label>
|
||||
<input type="text" class="form-control input-md" id="email:smtpTransport:port" data-field="email:smtpTransport:port" placeholder="5555">
|
||||
|
||||
<label for="email:smtpTransport:security">[[admin/settings/email:smtp-transport.security]]</label>
|
||||
<select class="form-control" id="email:smtpTransport:security" data-field="email:smtpTransport:security">
|
||||
<option value="ENCRYPTED">[[admin/settings/email:smtp-transport.security-encrypted]]</option>
|
||||
<option value="STARTTLS">[[admin/settings/email:smtp-transport.security-starttls]]</option>
|
||||
<option value="NONE">[[admin/settings/email:smtp-transport.security-none]]</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email:smtpTransport:user"><strong>[[admin/settings/email:smtp-transport.username]]</strong></label>
|
||||
@@ -136,4 +143,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IMPORT admin/partials/settings/footer.tpl -->
|
||||
<!-- IMPORT admin/partials/settings/footer.tpl -->
|
||||
|
||||
@@ -180,8 +180,15 @@ function setupExpressApp(app, callback) {
|
||||
setupAutoLocale(app, callback);
|
||||
}
|
||||
|
||||
function ping(req, res) {
|
||||
res.status(200).send(req.path === '/sping' ? 'healthy' : '200');
|
||||
function ping(req, res, next) {
|
||||
async.waterfall([
|
||||
function (next) {
|
||||
db.getObject('config', next);
|
||||
},
|
||||
function () {
|
||||
res.status(200).send(req.path === '/sping' ? 'healthy' : '200');
|
||||
},
|
||||
], next);
|
||||
}
|
||||
|
||||
function setupFavicon(app) {
|
||||
|
||||
Reference in New Issue
Block a user