Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Additionally, you can pass an options object which should add more functionality
| handleRedirects (**optional**) (with followRedirects 'manual') | When followRedirects is set to 'manual' you need to pass a function that validates if the redirectinon is secure, below you can find an example |
| resolveDNSHost (**optional**) | Function that resolves the final address of the detected/parsed URL to prevent SSRF attacks |
| onResponse (**optional**) | Function that handles the response object to allow for managing special cases |
| forceLegacyUrlResolve (**optional**) (default false) | Force the use of legacy URL resolution instead of WHATWG URL API. Set to `true` for React Native compatibility if auto-detection fails |

```javascript
getLinkPreview("https://www.youtube.com/watch?v=MejbOFk7H6c", {
Expand Down
98 changes: 73 additions & 25 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,53 @@
import cheerio from "cheerio";
import urlObj from "url";
import { CONSTANTS } from "./constants";

/**
* Detects if we're running in a React Native context
*/
function isReactNative(): boolean {

// Check for React Native specific global objects
const hasNativeBridgeHook =
typeof global !== 'undefined' &&
(typeof (global as any).nativeCallSyncHook === 'function' ||
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks outdated and might not be true under the new arch.

typeof (global as any).__BUNDLE_START_TIME__ !== 'undefined' ||
typeof (global as any).nativePerformanceNow === 'function');

// Not a real browser DOM
const lacksDom =
typeof document === 'undefined' ||
typeof window === 'undefined';

return hasNativeBridgeHook && lacksDom;
}

/**
* Resolves a relative URL against a base URL
* Attempts to determine if React Native is being used, and if so, falls back to legacy URL resolution
* If React Native is not being used, falls back to the WHATWG URL API in Node.js and browsers
*/
function resolveUrl(base: string, relative: string, forceLegacy?: boolean): string {
// Use legacy method if forced or if React Native is detected
if (forceLegacy || isReactNative()) {
const url = require('url');
return url.resolve(base, relative);
}

// Use WHATWG URL API for Node.js and browsers
try {
return new URL(relative, base).href;
} catch (e) {
// Fallback to legacy method if URL construction fails
try {
const url = require('url');
return url.resolve(base, relative);
} catch (fallbackError) {
// If all else fails, return the relative URL as-is
return relative;
}
}
}

interface ILinkPreviewResponse {
url: string;
title: string;
Expand Down Expand Up @@ -31,6 +77,7 @@ interface ILinkPreviewOptions {
resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
onResponse?: (response: ILinkPreviewResponse, doc: cheerio.Root, url?: URL) => ILinkPreviewResponse;
forceLegacyUrlResolve?: boolean;
}

interface IPreFetchedResource {
Expand Down Expand Up @@ -97,7 +144,8 @@ function getMediaType(doc: cheerio.Root) {
function getImages(
doc: cheerio.Root,
rootUrl: string,
imagesPropertyType?: string
imagesPropertyType?: string,
forceLegacyUrlResolve?: boolean
) {
let images: string[] = [];
let nodes: cheerio.Cheerio | null;
Expand All @@ -114,7 +162,7 @@ function getImages(
if (node.type === `tag`) {
src = node.attribs.content;
if (src) {
src = urlObj.resolve(rootUrl, src);
src = resolveUrl(rootUrl, src, forceLegacyUrlResolve);
images.push(src);
}
}
Expand All @@ -124,7 +172,7 @@ function getImages(
if (images.length <= 0 && !imagesPropertyType) {
src = doc(`link[rel=image_src]`).attr(`href`);
if (src) {
src = urlObj.resolve(rootUrl, src);
src = resolveUrl(rootUrl, src, forceLegacyUrlResolve);
images = [src];
} else {
nodes = doc(`img`);
Expand All @@ -138,7 +186,7 @@ function getImages(
dic[src] = true;
// width = node.attribs.width;
// height = node.attribs.height;
images.push(urlObj.resolve(rootUrl, src));
images.push(resolveUrl(rootUrl, src, forceLegacyUrlResolve));
}
});
}
Expand Down Expand Up @@ -212,12 +260,12 @@ function getVideos(doc: cheerio.Root) {
}

// returns default favicon (//hostname/favicon.ico) for a url
function getDefaultFavicon(rootUrl: string) {
return urlObj.resolve(rootUrl, `/favicon.ico`);
function getDefaultFavicon(rootUrl: string, forceLegacyUrlResolve?: boolean) {
return resolveUrl(rootUrl, `/favicon.ico`, forceLegacyUrlResolve);
}

// returns an array of URLs to favicon images
function getFavicons(doc: cheerio.Root, rootUrl: string) {
function getFavicons(doc: cheerio.Root, rootUrl: string, forceLegacyUrlResolve?: boolean) {
const images = [];
let nodes: cheerio.Cheerio | never[] = [];
let src: string | undefined;
Expand All @@ -237,7 +285,7 @@ function getFavicons(doc: cheerio.Root, rootUrl: string) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === `tag`) src = node.attribs.href;
if (src) {
src = urlObj.resolve(rootUrl, src);
src = resolveUrl(rootUrl, src, forceLegacyUrlResolve);
images.push(src);
}
});
Expand All @@ -246,45 +294,45 @@ function getFavicons(doc: cheerio.Root, rootUrl: string) {

// if no icon images, use default favicon location
if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl));
images.push(getDefaultFavicon(rootUrl, forceLegacyUrlResolve));
}

return images;
}

function parseImageResponse(url: string, contentType: string) {
function parseImageResponse(url: string, contentType: string, forceLegacyUrlResolve?: boolean) {
return {
url,
mediaType: `image`,
contentType,
favicons: [getDefaultFavicon(url)],
favicons: [getDefaultFavicon(url, forceLegacyUrlResolve)],
};
}

function parseAudioResponse(url: string, contentType: string) {
function parseAudioResponse(url: string, contentType: string, forceLegacyUrlResolve?: boolean) {
return {
url,
mediaType: `audio`,
contentType,
favicons: [getDefaultFavicon(url)],
favicons: [getDefaultFavicon(url, forceLegacyUrlResolve)],
};
}

function parseVideoResponse(url: string, contentType: string) {
function parseVideoResponse(url: string, contentType: string, forceLegacyUrlResolve?: boolean) {
return {
url,
mediaType: `video`,
contentType,
favicons: [getDefaultFavicon(url)],
favicons: [getDefaultFavicon(url, forceLegacyUrlResolve)],
};
}

function parseApplicationResponse(url: string, contentType: string) {
function parseApplicationResponse(url: string, contentType: string, forceLegacyUrlResolve?: boolean) {
return {
url,
mediaType: `application`,
contentType,
favicons: [getDefaultFavicon(url)],
favicons: [getDefaultFavicon(url, forceLegacyUrlResolve)],
};
}

Expand All @@ -303,9 +351,9 @@ function parseTextResponse(
description: getDescription(doc),
mediaType: getMediaType(doc) || `website`,
contentType,
images: getImages(doc, url, options.imagesPropertyType),
images: getImages(doc, url, options.imagesPropertyType, options.forceLegacyUrlResolve),
videos: getVideos(doc),
favicons: getFavicons(doc, url),
favicons: getFavicons(doc, url, options.forceLegacyUrlResolve),
};

if (options?.onResponse && typeof options.onResponse !== `function`) {
Expand Down Expand Up @@ -362,15 +410,15 @@ function parseResponse(

// parse response depending on content type
if (CONSTANTS.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return { ...parseImageResponse(response.url, contentType), charset };
return { ...parseImageResponse(response.url, contentType, options?.forceLegacyUrlResolve), charset };
}

if (CONSTANTS.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return { ...parseAudioResponse(response.url, contentType), charset };
return { ...parseAudioResponse(response.url, contentType, options?.forceLegacyUrlResolve), charset };
}

if (CONSTANTS.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return { ...parseVideoResponse(response.url, contentType), charset };
return { ...parseVideoResponse(response.url, contentType, options?.forceLegacyUrlResolve), charset };
}

if (CONSTANTS.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
Expand All @@ -383,7 +431,7 @@ function parseResponse(

if (CONSTANTS.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return {
...parseApplicationResponse(response.url, contentType),
...parseApplicationResponse(response.url, contentType, options?.forceLegacyUrlResolve),
charset,
};
}
Expand Down Expand Up @@ -476,7 +524,7 @@ export async function getLinkPreview(
// Resolve the URL, handling both absolute and relative URLs
const forwardedUrl = isAbsoluteURI
? locationHeader
: urlObj.resolve(fetchUrl, locationHeader);
: resolveUrl(fetchUrl, locationHeader, options?.forceLegacyUrlResolve);

if (!options.handleRedirects(fetchUrl, forwardedUrl)) {
throw new Error(`link-preview-js could not handle redirect`);
Expand Down
Loading