mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
list,
|
|
listByUser,
|
|
add,
|
|
get,
|
|
update,
|
|
del,
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
apps = require('./apps.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
crypto = require('node:crypto'),
|
|
database = require('./database.js'),
|
|
debug = require('debug')('box:applinks'),
|
|
jsdom = require('jsdom'),
|
|
safe = require('safetydance'),
|
|
superagent = require('@cloudron/superagent');
|
|
|
|
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: crypto.randomUUID(),
|
|
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');
|
|
}
|