'use strict'; exports = module.exports = { list, listByUser, add, get, update, del, }; const assert = require('assert'), apps = require('./apps.js'), BoxError = require('./boxerror.js'), database = require('./database.js'), debug = require('debug')('box:applinks'), jsdom = require('jsdom'), safe = require('safetydance'), superagent = require('@cloudron/superagent'), uuid = require('uuid'); const APPLINKS_FIELDS= [ 'id', 'accessRestrictionJson', 'creationTime', 'updateTime', 'ts', 'label', 'tagsJson', 'icon', 'upstreamUri' ].join(','); function postProcess(result) { assert.strictEqual(typeof result, 'object'); assert(result.tagsJson === null || typeof result.tagsJson === 'string'); result.tags = safe.JSON.parse(result.tagsJson) || []; delete result.tagsJson; assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string'); result.accessRestriction = safe.JSON.parse(result.accessRestrictionJson); if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = []; delete result.accessRestrictionJson; result.ts = new Date(result.ts).getTime(); result.icon = result.icon ? result.icon : null; } function validateUpstreamUri(upstreamUri) { assert.strictEqual(typeof upstreamUri, 'string'); if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty'); if (!upstreamUri.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has no schema'); const uri = safe(() => new URL(upstreamUri)); if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`); if (uri.protocol !== 'http:' && uri.protocol !== 'https:') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has an unsupported scheme'); 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`); results.forEach(postProcess); return results; } async function listByUser(user) { assert.strictEqual(typeof user, 'object'); const result = await list(); return result.filter((link) => apps.canAccess(link, user)); } async function detectMetaInfo(upstreamUri) { assert.strictEqual(typeof upstreamUri, 'string'); const [upstreamError, upstreamRespose] = await safe(superagent.get(upstreamUri).set('User-Agent', 'Mozilla').timeout(10*1000)); if (upstreamError) { debug(`detectMetaInfo: error fetching ${upstreamUri}: ${upstreamError.status}`); return null; } const virtualConsole = new jsdom.VirtualConsole(); virtualConsole.on('error', () => { // No-op to skip console errors. }); const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(upstreamUri, { virtualConsole })); if (jsdomError || !dom) { console.error('detectMetaInfo: jsdomError', jsdomError); return null; } 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, upstreamRespose.url).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.message} ${error.status}`); else if (response.headers['content-type']?.indexOf('image') !== -1) icon = response.body || response.text; else debug(`Failed to fetch icon ${favicon}: status=${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.message}`); else if (response.headers['content-type']?.indexOf('image') !== -1) icon = response.body || response.text; else debug(`Failed to fetch icon ${favicon}: status=${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'); debug(`add: ${applink.upstreamUri}`); let error = validateUpstreamUri(applink.upstreamUri); if (error) throw error; error = validateAccessRestriction(applink.accessRestriction); if (error) throw error; if (applink.icon) { applink.icon = Buffer.from(applink.icon, 'base64'); if (applink.icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64'); } 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(), accessRestrictionJson: applink.accessRestriction ? JSON.stringify(applink.accessRestriction) : null, label: applink.label || '', tagsJson: applink.tags ? JSON.stringify(applink.tags) : null, icon: applink.icon || null, upstreamUri: applink.upstreamUri }; const query = 'INSERT INTO applinks (id, accessRestrictionJson, label, tagsJson, icon, upstreamUri) VALUES (?, ?, ?, ?, ?, ?)'; const args = [ data.id, data.accessRestrictionJson, data.label, data.tagsJson, data.icon, data.upstreamUri ]; [error] = await safe(database.query(query, args)); if (error) throw error; return data.id; } async function get(applinkId) { assert.strictEqual(typeof applinkId, 'string'); const result = await database.query(`SELECT ${APPLINKS_FIELDS} FROM applinks WHERE id = ?`, [ applinkId ]); if (result.length === 0) return null; postProcess(result[0]); return result[0]; } async function update(applink, data) { assert.strictEqual(typeof applink, 'object'); assert.strictEqual(typeof data, 'object'); let error; if ('upstreamUri' in data) { error = validateUpstreamUri(data.upstreamUri); if (error) throw error; } if ('accessRestriction' in data) { error = validateAccessRestriction(data.accessRestriction); if (error) throw error; } if ('icon' in data && data.icon) { data.icon = Buffer.from(data.icon, 'base64'); if (data.icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64'); } // 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(applink) { assert.strictEqual(typeof applink, 'object'); 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'); }