UNPKG

converse.js

Version:
304 lines (275 loc) 8.79 kB
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); }