converse.js
Version:
Browser based XMPP chat client
304 lines (275 loc) • 8.79 kB
JavaScript
import log from "@converse/log";
import { settings_api } from "../shared/settings/api.js";
const settings = settings_api;
const URL_REGEXES = {
// valid "scheme://" or "www."
start: /(\b|_)(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi,
// everything up to the next whitespace
end: /[\s\r\n]|$/,
// trim trailing punctuation captured by end RegExp
trim: /[`!()\[\]{};:'".,<>?«»“”„‘’]+$/,
// balanced parens inclusion (), [], {}, <>
parens: /(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g,
};
/**
* Will return false if URL is malformed or contains disallowed characters
* @param {string} text
* @returns {boolean}
*/
export function isValidURL(text) {
try {
if (text.startsWith("www.")) {
return !!getURL(`http://${text}`);
}
return !!getURL(text);
} catch {
return false;
}
}
/**
* @param {string|URL} url
* @returns {URL}
*/
export function getURL(url) {
if (url instanceof URL) {
return url;
}
return url.toLowerCase().startsWith("www.") ? getURL(`http://${url}`) : new URL(url);
}
/**
* Given the an array of file extensions, check whether a URL points to a file
* ending in one of them.
* @param {string[]} types - An array of file extensions
* @param {string|URL} url
* @returns {boolean}
* @example
* checkFileTypes(['.gif'], 'https://conversejs.org/cat.gif?foo=bar');
*/
export function checkFileTypes(types, url) {
let parsed_url;
try {
parsed_url = getURL(url);
} catch (error) {
throw new Error(`checkFileTypes: could not parse url ${url}`);
}
const filename = parsed_url.pathname.split("/").pop().toLowerCase();
return !!types.filter((ext) => filename.endsWith(ext)).length;
}
/**
* @param {string|URL} url
* @returns {boolean}
*/
export function isURLWithImageExtension(url) {
return checkFileTypes([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg"], url);
}
/**
* @param {string|URL} url
*/
export function isGIFURL(url) {
return checkFileTypes([".gif"], url);
}
/**
* @param {string|URL} url
* @param {Headers} [headers]
*/
export function isAudioURL(url, headers) {
if (headers?.get("content-type")?.startsWith("audio")) {
return true;
}
return checkFileTypes([".ogg", ".mp3", ".m4a"], url);
}
/**
* @param {string|URL} url
* @param {Headers} [headers]
*/
export function isVideoURL(url, headers) {
if (headers?.get("content-type")?.startsWith("video")) {
return true;
}
return checkFileTypes([".mp4", ".webm"], url);
}
/**
* @param {string|URL} url
* @param {Headers} [headers]
* @returns {boolean}
*/
export function isImageURL(url, headers) {
if (headers?.get("content-type")?.startsWith("video")) {
return true;
}
const regex = settings.get("image_urls_regex");
return regex?.test(url) || isURLWithImageExtension(url);
}
/**
* @param {string|URL} url
*/
export function isEncryptedFileURL(url) {
return getURL(url).href.startsWith("aesgcm://");
}
/**
* Processes a string to find and manipulate substrings based on a callback function.
* This function searches for patterns defined by the provided start and end regular expressions,
* and applies the callback to each matched substring, allowing for modifications
* @copyright Copyright (c) 2011 Rodney Rehm
*
* @param {string} string - The input string to be processed.
* @param {function} callback - A function that takes the matched substring and its start and end indices,
* and returns a modified substring or undefined to skip modification.
* @param {import("./types").ProcessStringOptions} [options]
* @returns {string} The modified string after processing all matches.
*/
export function withinString(string, callback, options) {
options = options || {};
const _start = options.start || URL_REGEXES.start;
const _end = options.end || URL_REGEXES.end;
const _trim = options.trim || URL_REGEXES.trim;
const _parens = options.parens || URL_REGEXES.parens;
const _attributeOpen = /[a-z0-9-]=["']?$/i;
_start.lastIndex = 0;
while (true) {
const match = _start.exec(string);
if (!match) break;
let start = match.index;
if (options.ignoreHtml) {
const attributeOpen = string.slice(Math.max(start - 3, 0), start);
if (attributeOpen && _attributeOpen.test(attributeOpen)) {
continue;
}
}
let end = start + string.slice(start).search(_end);
let slice = string.slice(start, end);
let parensEnd = -1;
while (true) {
const parensMatch = _parens.exec(slice);
if (!parensMatch) break;
const parensMatchEnd = parensMatch.index + parensMatch[0].length;
parensEnd = Math.max(parensEnd, parensMatchEnd);
}
if (parensEnd > -1) {
slice = slice.slice(0, parensEnd) + slice.slice(parensEnd).replace(_trim, "");
} else {
slice = slice.replace(_trim, "");
}
if (slice.length <= match[0].length) continue;
if (options.ignore && options.ignore.test(slice)) continue;
end = start + slice.length;
const result = callback(slice, start, end);
if (result === undefined) {
_start.lastIndex = end;
continue;
}
string = string.slice(0, start) + String(result) + string.slice(end);
_start.lastIndex = start + String(result).length;
}
_start.lastIndex = 0;
return string;
}
/**
* @param {string} url
* @returns {Promise<Headers>}
*/
export async function getHeaders(url) {
try {
const response = await fetch(url, { method: "HEAD" });
return response.headers;
} catch (e) {
console.debug(`Error calling HEAD on url ${url}: ${e}`);
return null;
}
}
/**
* @param {import("./types").MediaURLIndexes} o
* @returns {Promise<import("./types").MediaURLMetadata>}
*/
export async function getMetadataForURL(o) {
const fetch_headers = settings_api.get("fetch_url_headers");
const headers = fetch_headers ? await getHeaders(o.url) : null;
return {
...o,
is_gif: isGIFURL(o.url),
is_audio: isAudioURL(o.url, headers),
is_image: isImageURL(o.url, headers),
is_video: isVideoURL(o.url, headers),
is_encrypted: isEncryptedFileURL(o.url),
};
}
/**
* @param {string} text
* @param {number} offset
* @returns {Promise<{media_urls?: import("./types").MediaURLMetadata[]}>}
*/
export async function getMediaURLsMetadata(text, offset = 0) {
const objs = [];
if (!text) {
return {};
}
try {
withinString(
text,
/**
* @param {string} url
* @param {number} start
* @param {number} end
* @returns {string|undefined}
*/
(url, start, end) => {
if (url.startsWith("_")) {
url = url.slice(1);
start += 1;
}
if (url.endsWith("_")) {
url = url.slice(0, url.length - 1);
end -= 1;
}
if (isValidURL(url)) {
objs.push({ url, start: start + offset, end: end + offset });
}
return url;
}
);
} catch (error) {
log.debug(error);
}
const media_urls = await Promise.all(objs.map(getMetadataForURL));
return media_urls.length ? { media_urls } : {};
}
/**
* @param {Array<import("./types").MediaURLMetadata>} arr
* @param {string} text
* @returns {import("./types").MediaURLMetadata[]}
*/
export function getMediaURLs(arr, text) {
return arr
.map((o) => {
if (o.start < 0 || o.start >= text.length) {
return null;
}
const url = text.substring(o.start, o.end);
return {
...o,
url,
};
})
.filter((o) => o);
}
/**
* @param {Array<import("./types").MediaURLMetadata>} arr
* @param {string} text
* @returns {import("./types").MediaURLMetadata[]}
*/
export function addMediaURLsOffset(arr, text, offset = 0) {
return arr
.map((o) => {
const start = o.start - offset;
const end = o.end - offset;
if (start < 0 || start >= text.length) {
return null;
}
return Object.assign({}, o, {
start,
end,
url: text.substring(o.start-offset, o.end-offset), // BBB
});
})
.filter((o) => o);
}