applinks: add tests

This commit is contained in:
Girish Ramakrishnan
2024-12-04 15:28:21 +01:00
parent 3b9d617e37
commit 2bfa49cc2e
7 changed files with 268 additions and 135 deletions

View File

@@ -7,7 +7,6 @@ exports = module.exports = {
get,
update,
del,
getIcon
};
const assert = require('assert'),
@@ -55,6 +54,25 @@ function validateUpstreamUri(upstreamUri) {
return null;
}
function validateAccessRestriction(accessRestriction) {
assert.strictEqual(typeof accessRestriction, 'object');
if (accessRestriction === null) return null;
if (accessRestriction.users) {
if (!Array.isArray(accessRestriction.users)) return new BoxError(BoxError.BAD_FIELD, 'users array property required');
if (!accessRestriction.users.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All users have to be strings');
}
if (accessRestriction.groups) {
if (!Array.isArray(accessRestriction.groups)) return new BoxError(BoxError.BAD_FIELD, 'groups array property required');
if (!accessRestriction.groups.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All groups have to be strings');
}
// TODO: maybe validate if the users and groups actually exist
return null;
}
async function list() {
const results = await database.query(`SELECT ${APPLINKS_FIELDS} FROM applinks ORDER BY upstreamUri`);
@@ -67,20 +85,18 @@ async function listByUser(user) {
assert.strictEqual(typeof user, 'object');
const result = await list();
return result.filter((app) => apps.canAccess(app, user));
return result.filter((link) => apps.canAccess(link, user));
}
async function detectMetaInfo(applink) {
assert.strictEqual(typeof applink, 'object');
async function detectMetaInfo(upstreamUri) {
assert.strictEqual(typeof upstreamUri, 'string');
const [error, response] = await safe(superagent.get(applink.upstreamUri).set('User-Agent', 'Mozilla').timeout(10*1000));
const [error, response] = await safe(superagent.get(upstreamUri).set('User-Agent', 'Mozilla').timeout(10*1000));
if (error || !response.text) {
debug('detectMetaInfo: Unable to fetch upstreamUri to detect icon and title', error.statusCode);
return;
return null;
}
if (applink.favicon && applink.label) return;
// set redirected URI if any for favicon url
const redirectUri = (response.redirects && response.redirects.length) ? response.redirects[0] : null;
@@ -89,73 +105,84 @@ async function detectMetaInfo(applink) {
// No-op to skip console errors.
});
const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(applink.upstreamUri, { virtualConsole }));
if (jsdomError) console.error('detectMetaInfo: jsdomError', jsdomError);
if (!applink.icon && dom) {
let favicon = '';
if (dom.window.document.querySelector('link[rel="apple-touch-icon"]')) favicon = dom.window.document.querySelector('link[rel="apple-touch-icon"]').href;
if (!favicon && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content;
if (!favicon && dom.window.document.querySelector('link[rel="shortcut icon"]')) favicon = dom.window.document.querySelector('link[rel="shortcut icon"]').href;
if (!favicon && dom.window.document.querySelector('link[rel="icon"]')) {
let iconElements = dom.window.document.querySelectorAll('link[rel="icon"]');
if (iconElements.length) {
favicon = iconElements[0].href; // choose first one for a start
// check if we have sizes attributes and then choose the largest one
iconElements = Array.from(iconElements).filter(function (e) {
return e.attributes.getNamedItem('sizes') && e.attributes.getNamedItem('sizes').value;
}).sort(function (a, b) {
return parseInt(b.attributes.getNamedItem('sizes').value.split('x')[0]) - parseInt(a.attributes.getNamedItem('sizes').value.split('x')[0]);
});
if (iconElements.length) favicon = iconElements[0].href;
}
}
if (!favicon && dom.window.document.querySelector('meta[itemprop="image"]')) favicon = dom.window.document.querySelector('meta[itemprop="image"]').content;
if (favicon) {
favicon = new URL(favicon, redirectUri || applink.upstreamUri).toString();
debug(`detectMetaInfo: found icon: ${favicon}`);
const [error, response] = await safe(superagent.get(favicon).timeout(10*1000));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) applink.icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
}
if (!favicon) {
debug(`Unable to find a suitable icon for ${applink.upstreamUri}, try fallback favicon.ico`);
const [error, response] = await safe(superagent.get(`${applink.upstreamUri}/favicon.ico`).timeout(10*1000));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) applink.icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status} content type ${response.headers['content-type']}`);
}
const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(upstreamUri, { virtualConsole }));
if (jsdomError || !dom) {
console.error('detectMetaInfo: jsdomError', jsdomError);
return null;
}
if (!applink.label) {
if (dom.window.document.querySelector('meta[property="og:title"]')) applink.label = dom.window.document.querySelector('meta[property="og:title"]').content;
else if (dom.window.document.querySelector('meta[property="og:site_name"]')) applink.label = dom.window.document.querySelector('meta[property="og:site_name"]').content;
else if (dom.window.document.title) applink.label = dom.window.document.title;
let icon = null, label = '';
// icon detection
let favicon = '';
if (dom.window.document.querySelector('link[rel="apple-touch-icon"]')) favicon = dom.window.document.querySelector('link[rel="apple-touch-icon"]').href;
if (!favicon && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content;
if (!favicon && dom.window.document.querySelector('link[rel="shortcut icon"]')) favicon = dom.window.document.querySelector('link[rel="shortcut icon"]').href;
if (!favicon && dom.window.document.querySelector('link[rel="icon"]')) {
let iconElements = dom.window.document.querySelectorAll('link[rel="icon"]');
if (iconElements.length) {
favicon = iconElements[0].href; // choose first one for a start
// check if we have sizes attributes and then choose the largest one
iconElements = Array.from(iconElements).filter(function (e) {
return e.attributes.getNamedItem('sizes') && e.attributes.getNamedItem('sizes').value;
}).sort(function (a, b) {
return parseInt(b.attributes.getNamedItem('sizes').value.split('x')[0]) - parseInt(a.attributes.getNamedItem('sizes').value.split('x')[0]);
});
if (iconElements.length) favicon = iconElements[0].href;
}
}
if (!favicon && dom.window.document.querySelector('meta[itemprop="image"]')) favicon = dom.window.document.querySelector('meta[itemprop="image"]').content;
if (favicon) {
favicon = new URL(favicon, redirectUri || upstreamUri).toString();
debug(`detectMetaInfo: found icon: ${favicon}`);
const [error, response] = await safe(superagent.get(favicon).timeout(10*1000));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
}
if (!favicon) {
debug(`Unable to find a suitable icon for ${upstreamUri}, try fallback favicon.ico`);
const [error, response] = await safe(superagent.get(`${upstreamUri}/favicon.ico`).timeout(10*1000));
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'].indexOf('image') !== -1) icon = response.body || response.text;
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status} content type ${response.headers['content-type']}`);
}
// detect label
if (dom.window.document.querySelector('meta[property="og:title"]')) label = dom.window.document.querySelector('meta[property="og:title"]').content;
else if (dom.window.document.querySelector('meta[property="og:site_name"]')) label = dom.window.document.querySelector('meta[property="og:site_name"]').content;
else if (dom.window.document.title) label = dom.window.document.title;
return { icon, label };
}
async function add(applink) {
assert.strictEqual(typeof applink, 'object');
assert.strictEqual(typeof applink.upstreamUri, 'string');
debug(`add: ${applink.upstreamUri}`);
let error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;
error = validateAccessRestriction(applink.accessRestriction);
if (error) throw error;
if (applink.icon) {
if (!validator.isBase64(applink.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
applink.icon = Buffer.from(applink.icon, 'base64');
}
await detectMetaInfo(applink);
if (!applink.icon || !applink.label) {
const meta = await detectMetaInfo(applink.upstreamUri);
if (!applink.label) applink.label = meta?.label;
if (!applink.icon) applink.icon = meta?.icon;
}
const data = {
id: uuid.v4(),
@@ -186,49 +213,54 @@ async function get(applinkId) {
return result[0];
}
async function update(applinkId, applink) {
assert.strictEqual(typeof applinkId, 'string');
async function update(applink, data) {
assert.strictEqual(typeof applink, 'object');
assert.strictEqual(typeof applink.upstreamUri, 'string');
assert.strictEqual(typeof data, 'object');
debug(`update: ${applink.upstreamUri}`);
const error = validateUpstreamUri(applink.upstreamUri);
if (error) throw error;
if (applink.icon) {
if (!validator.isBase64(applink.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
applink.icon = Buffer.from(applink.icon, 'base64');
} else if (applink.icon === '') {
// empty string means we autodetect in detectMetaInfo
applink.icon = '';
} else {
// nothing changed reuse old
const result = await get(applinkId);
applink.icon = result.icon;
let error;
if ('upstreamUri' in data) {
error = validateUpstreamUri(data.upstreamUri);
if (error) throw error;
}
await detectMetaInfo(applink);
if ('accessRestriction' in data) {
error = validateAccessRestriction(data.accessRestriction);
if (error) throw error;
}
const query = 'UPDATE applinks SET label=?, icon=?, upstreamUri=?, tagsJson=?, accessRestrictionJson=? WHERE id = ?';
const args = [ applink.label, applink.icon || null, applink.upstreamUri, applink.tags ? JSON.stringify(applink.tags) : null, applink.accessRestriction ? JSON.stringify(applink.accessRestriction) : null, applinkId ];
if ('icon' in data && data.icon) {
if (!validator.isBase64(data.icon)) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
data.icon = Buffer.from(data.icon, 'base64');
}
const result = await database.query(query, args);
// we don't track if label/icon in db is user-set or was auto detected
if (data.upstreamUri || data.label === '' || data.icon === '') {
const meta = await detectMetaInfo(data.upstreamUri || applink.upstreamUri);
if (data.label === '') data.label = meta?.label;
if (data.icon === '') data.icon = meta?.icon;
}
const args = [], fields = [];
for (const k in data) {
if (k === 'accessRestriction' || k === 'tags') {
fields.push(`${k}Json = ?`);
args.push(JSON.stringify(data[k]));
} else {
fields.push(k + ' = ?');
args.push(data[k]);
}
}
args.push(applink.id);
const [updateError, result] = await safe(database.query('UPDATE applinks SET ' + fields.join(', ') + ' WHERE id = ?', args));
if (updateError) throw updateError;
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
}
async function del(applinkId) {
assert.strictEqual(typeof applinkId, 'string');
async function del(applink) {
assert.strictEqual(typeof applink, 'object');
const result = await database.query('DELETE FROM applinks WHERE id = ?', [ applinkId ]);
const result = await database.query('DELETE FROM applinks WHERE id = ?', [ applink.id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
}
async function getIcon(applinkId) {
assert.strictEqual(typeof applinkId, 'string');
const applink = await get(applinkId);
if (!applink) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
return applink.icon;
}