fix(codebase audit): preserve cleanup without regressions

Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
This commit is contained in:
Tommaso Casaburi
2026-04-24 15:48:07 +07:00
committed by GitHub
parent 5df994b2c7
commit 5dc5408a15
70 changed files with 1478 additions and 518 deletions
+8 -2
View File
@@ -1,3 +1,5 @@
import { isPrivateNetworkHostname } from './utils/url-utils';
const DEFAULT_RELEASE_API_URL = 'https://api.github.com/repos/bitsocialnet/5chan/releases/latest';
const DEFAULT_RELEASES_BASE_URL = 'https://github.com/bitsocialnet/5chan/releases/tag/';
@@ -23,11 +25,15 @@ const isAllowedDownloadUrl = (url: string): boolean => {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
if (parsedUrl.protocol === 'https:' && hostname === 'github.com' && parsedUrl.pathname.startsWith('/bitsocialnet/5chan/releases/download/')) {
return true;
}
return configuredDownloadHosts.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
if (!configuredDownloadHosts.has(hostname)) {
return false;
}
return parsedUrl.protocol === 'https:' || (parsedUrl.protocol === 'http:' && isPrivateNetworkHostname(hostname));
} catch {
return false;
}
+39 -1
View File
@@ -160,6 +160,20 @@ describe('media-utils', () => {
type: 'image',
url: 'https://example.com/file.png',
});
expect(getCommentMediaInfo('https://example.com/file.png', 'http://127.0.0.1/thumb.png', 320, 240)).toEqual({
linkHeight: 240,
linkWidth: 320,
thumbnail: undefined,
type: 'image',
url: 'https://example.com/file.png',
});
expect(getCommentMediaInfo('https://example.com/post', '//192.168.1.1/thumb.png', 320, 240)).toEqual({
linkHeight: 240,
linkWidth: 320,
thumbnail: undefined,
type: 'webpage',
url: 'https://example.com/post',
});
expect(getCommentMediaInfo('https://x.com/post/123', 'https://example.com/thumb.png', 100, 50)).toEqual({
linkHeight: 50,
linkWidth: 100,
@@ -220,7 +234,7 @@ describe('media-utils', () => {
url: 'https://example.com/og-page',
});
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' } }));
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' }, redirect: 'manual' }));
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/og-page', 'https://cdn.example/og.png');
expect(result).toEqual({
thumbnail: 'https://cdn.example/og.png',
@@ -229,6 +243,29 @@ describe('media-utils', () => {
});
});
it('falls back to the first image when og:image is not allowed', async () => {
testState.fetchMock.mockResolvedValue(
createFetchResponse(`
<html>
<head><meta property="og:image" content="http://127.0.0.1/og.png" /></head>
<body><img src="https://cdn.example/fallback.png" /></body>
</html>
`),
);
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/fallback-page',
});
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/fallback-page', 'https://cdn.example/fallback.png');
expect(result).toEqual({
thumbnail: 'https://cdn.example/fallback.png',
type: 'webpage',
url: 'https://example.com/fallback-page',
});
});
it('fetches first-image thumbnails on native and resolves relative urls', async () => {
testState.isNativePlatform = true;
testState.capacitorHttpGetMock.mockResolvedValue({
@@ -247,6 +284,7 @@ describe('media-utils', () => {
expect(testState.capacitorHttpGetMock).toHaveBeenCalledWith(
expect.objectContaining({
connectTimeout: 5000,
disableRedirects: true,
headers: { Accept: 'text/html', Range: 'bytes=0-1048575' },
readTimeout: 5000,
responseType: 'text',
+19
View File
@@ -12,6 +12,7 @@ import {
copyShareLinkToClipboard,
getHostname,
is5chanLink,
isPrivateNetworkHostname,
isValidCrossboardPattern,
isValidPublishURL,
isValidURL,
@@ -28,9 +29,27 @@ describe('url-utils', () => {
expect(getHostname('https://www.5chan.app/#/music.eth')).toBe('5chan.app');
expect(getHostname('not-a-url')).toBe('');
expect(isValidURL('https://5chan.app')).toBe(true);
expect(isValidURL('http://5chan.app')).toBe(true);
expect(isValidURL('javascript:alert(1)')).toBe(false);
expect(isValidURL('data:text/html,hello')).toBe(false);
expect(isValidURL('file:///tmp/pic.png')).toBe(false);
expect(isValidURL('not-a-url')).toBe(false);
});
it('detects private network hostnames used by URL safety checks', () => {
expect(isPrivateNetworkHostname('localhost')).toBe(true);
expect(isPrivateNetworkHostname('branch.localhost')).toBe(true);
expect(isPrivateNetworkHostname('127.0.0.1')).toBe(true);
expect(isPrivateNetworkHostname('192.168.1.1')).toBe(true);
expect(isPrivateNetworkHostname('[::1]')).toBe(true);
expect(isPrivateNetworkHostname('[::ffff:7f00:1]')).toBe(true);
expect(isPrivateNetworkHostname('fc00::1')).toBe(true);
expect(isPrivateNetworkHostname('fd12:3456:789a::1')).toBe(true);
expect(isPrivateNetworkHostname('fcbarcelona.com')).toBe(false);
expect(isPrivateNetworkHostname('fdic.gov')).toBe(false);
expect(isPrivateNetworkHostname('example.com')).toBe(false);
});
it('normalizes publish links to the https URLs accepted by communities', () => {
expect(normalizePublishURL(' http://i.imgur.com/YpB7qfa.jpg ')).toBe('https://i.imgur.com/YpB7qfa.jpg');
expect(normalizePublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe('https://i.imgur.com/YpB7qfa.jpg');
+18 -5
View File
@@ -1,4 +1,4 @@
import { ChallengeVerification } from '@bitsocial/bitsocial-react-hooks';
import type { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-hooks';
import { getFallbackDirectoriesData } from '../../hooks/use-directories';
import { getCommentCommunityAddress } from './comment-utils';
import { getBoardPath } from './route-utils';
@@ -12,7 +12,20 @@ const resolveBoardIdentifier = (communityAddress: unknown): string => {
return boardPath === communityAddress ? communityAddress : `/${boardPath}/`;
};
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: any) => {
export type ChallengePublication = Partial<Comment> & {
author?: unknown;
commentCid?: string;
communityAddress?: string;
content?: string;
link?: string;
parentCid?: string;
shortCommunityAddress?: string;
subplebbitAddress?: string;
title?: string;
vote?: number;
};
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
if (challengeVerification?.challengeSuccess === false) {
console.warn('Challenge Verification Failed:', challengeVerification, 'Publication:', publication);
@@ -44,7 +57,7 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
}
};
export const getPublicationType = (publication: any) => {
export const getPublicationType = (publication: ChallengePublication | undefined) => {
if (!publication) {
return;
}
@@ -60,7 +73,7 @@ export const getPublicationType = (publication: any) => {
return 'post';
};
export const getVotePreview = (publication: any) => {
export const getVotePreview = (publication: ChallengePublication | undefined) => {
if (typeof publication?.vote !== 'number') {
return '';
}
@@ -73,7 +86,7 @@ export const getVotePreview = (publication: any) => {
return votePreview;
};
export const getPublicationPreview = (publication: any) => {
export const getPublicationPreview = (publication: ChallengePublication | undefined) => {
if (!publication) {
return '';
}
+2 -2
View File
@@ -3,9 +3,9 @@
*/
export const copyToClipboard = async (text: string): Promise<void> => {
// Check if we're in Electron and use its clipboard API
if (typeof window !== 'undefined' && (window as any).electronApi?.copyToClipboard) {
if (typeof window !== 'undefined' && window.electronApi?.copyToClipboard) {
try {
const result = await (window as any).electronApi.copyToClipboard(text);
const result = await window.electronApi.copyToClipboard(text);
if (!result.success) {
throw new Error(result.error || 'Failed to copy to clipboard');
}
+18 -16
View File
@@ -4,7 +4,7 @@ type CommentWithCommunityAddress = {
pages?: Record<
string,
| {
comments?: Array<CommentWithCommunityAddress | undefined>;
comments?: unknown[];
}
| undefined
>;
@@ -24,15 +24,16 @@ export const getCommentCommunityAddress = (comment?: unknown) => {
return undefined;
};
const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']) => {
if (!replies?.pages) {
const withResolvedReplyPages = <T>(replies: T): T => {
const replyCollection = replies as CommentWithCommunityAddress['replies'];
if (!replyCollection?.pages) {
return replies;
}
let nextPages = replies.pages;
let nextPages = replyCollection.pages;
let pagesChanged = false;
for (const [sortType, page] of Object.entries(replies.pages)) {
for (const [sortType, page] of Object.entries(replyCollection.pages)) {
if (!page?.comments?.length) {
continue;
}
@@ -58,7 +59,7 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
}
if (!pagesChanged) {
nextPages = { ...replies.pages };
nextPages = { ...replyCollection.pages };
pagesChanged = true;
}
@@ -73,27 +74,28 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
}
return {
...replies,
...replyCollection,
pages: nextPages,
};
} as T;
};
export const withResolvedCommentCommunityAddress = <T extends CommentWithCommunityAddress | undefined | null>(comment: T): T => {
if (!comment) {
export const withResolvedCommentCommunityAddress = <T>(comment: T): T => {
if (!comment || typeof comment !== 'object') {
return comment;
}
const communityAddress = getCommentCommunityAddress(comment);
const replies = withResolvedReplyPages(comment.replies);
const needsResolvedCommunityAddress = !!communityAddress && comment.communityAddress !== communityAddress;
const commentRecord = comment as CommentWithCommunityAddress;
const communityAddress = getCommentCommunityAddress(commentRecord);
const replies = withResolvedReplyPages(commentRecord.replies);
const needsResolvedCommunityAddress = !!communityAddress && commentRecord.communityAddress !== communityAddress;
if (!needsResolvedCommunityAddress && replies === comment.replies) {
if (!needsResolvedCommunityAddress && replies === commentRecord.replies) {
return comment;
}
return {
...comment,
...commentRecord,
...(needsResolvedCommunityAddress ? { communityAddress } : {}),
...(replies !== comment.replies ? { replies } : {}),
...(replies !== commentRecord.replies ? { replies } : {}),
} as T;
};
+42 -12
View File
@@ -1,7 +1,7 @@
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { canEmbed } from '../../components/embed';
import memoize from 'memoizee';
import { isValidURL } from './url-utils';
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
export interface CommentMediaInfo {
@@ -15,7 +15,9 @@ export interface CommentMediaInfo {
linkHeight?: number;
}
export const getDisplayMediaInfoType = (type: string, t: any) => {
type Translate = (key: string) => string;
export const getDisplayMediaInfoType = (type: string, t: Translate) => {
switch (type) {
case 'image':
return t('image');
@@ -95,6 +97,23 @@ const isThumbnailDomainBlacklisted = (link: string | undefined): boolean => {
}
};
const parseAllowedThumbnailFetchUrl = (value: string): URL | undefined => {
const parsedUrl = parseHttpUrl(value);
if (!parsedUrl || parsedUrl.protocol !== 'https:' || isPrivateNetworkHostname(parsedUrl.hostname)) {
return undefined;
}
return parsedUrl;
};
const getAllowedThumbnailUrl = (value: string, baseUrl: string): string | undefined => {
try {
const parsedUrl = new URL(value, baseUrl);
return parsedUrl.protocol === 'https:' && !isPrivateNetworkHostname(parsedUrl.hostname) ? parsedUrl.href : undefined;
} catch {
return undefined;
}
};
export const getLinkMediaInfo = memoize(
(link: string): CommentMediaInfo | undefined => {
if (!isValidURL(link)) {
@@ -148,6 +167,9 @@ export const getLinkMediaInfo = memoize(
const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> => {
try {
const parsedUrl = parseAllowedThumbnailFetchUrl(url);
if (!parsedUrl) return undefined;
let html: string;
const MAX_HTML_SIZE = 1024 * 1024;
const TIMEOUT = 5000;
@@ -155,10 +177,11 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
if (Capacitor.isNativePlatform()) {
// in the native app, the Capacitor HTTP plugin is used to fetch the thumbnail
const response = await CapacitorHttp.get({
url,
url: parsedUrl.href,
readTimeout: TIMEOUT,
connectTimeout: TIMEOUT,
responseType: 'text',
disableRedirects: true,
headers: { Accept: 'text/html', Range: `bytes=0-${MAX_HTML_SIZE - 1}` },
});
html = response.data.slice(0, MAX_HTML_SIZE);
@@ -167,8 +190,9 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
const response = await fetch(url, {
const response = await fetch(parsedUrl.href, {
signal: controller.signal,
redirect: 'manual',
headers: { Accept: 'text/html' },
});
@@ -177,9 +201,10 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
if (!response.ok) throw new Error('Network response was not ok');
const reader = response.body?.getReader();
if (!reader) return undefined;
let result = '';
while (true) {
const { done, value } = await reader!.read();
const { done, value } = await reader.read();
if (done || result.length >= MAX_HTML_SIZE) break;
result += new TextDecoder().decode(value);
}
@@ -191,14 +216,17 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
// Try to find Open Graph image
const ogImage = doc.querySelector('meta[property="og:image"]');
if (ogImage && ogImage.getAttribute('content')) {
return ogImage.getAttribute('content')!;
const ogImageContent = ogImage?.getAttribute('content');
if (ogImageContent) {
const ogImageUrl = getAllowedThumbnailUrl(ogImageContent, parsedUrl.href);
if (ogImageUrl) return ogImageUrl;
}
// If no Open Graph image, try to find the first image
const firstImage = doc.querySelector('img');
if (firstImage && firstImage.getAttribute('src')) {
return new URL(firstImage.getAttribute('src')!, url).href;
const firstImageSrc = firstImage?.getAttribute('src');
if (firstImageSrc) {
return getAllowedThumbnailUrl(firstImageSrc, parsedUrl.href);
}
return undefined;
@@ -214,6 +242,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
}
const linkInfo = link ? getLinkMediaInfo(link) : undefined;
if (linkInfo) {
const safeThumbnailUrl = thumbnailUrl ? getAllowedThumbnailUrl(thumbnailUrl, linkInfo.url) : undefined;
// Don't show thumbnails for blacklisted domains (e.g., Twitter/X) as they return non-thumbnail images like emojis
if (isThumbnailDomainBlacklisted(link)) {
return {
@@ -226,7 +255,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
}
return {
...linkInfo,
thumbnail: thumbnailUrl || linkInfo.thumbnail,
thumbnail: safeThumbnailUrl || linkInfo.thumbnail,
linkWidth,
linkHeight,
};
@@ -287,8 +316,9 @@ const setCachedThumbnail = async (url: string, thumbnail: string): Promise<void>
export const fetchWebpageThumbnailIfNeeded = async (commentMediaInfo: CommentMediaInfo): Promise<CommentMediaInfo> => {
if (commentMediaInfo.type === 'webpage' && !commentMediaInfo.thumbnail) {
const cachedThumbnail = await getCachedThumbnail(commentMediaInfo.url);
if (cachedThumbnail) {
return { ...commentMediaInfo, thumbnail: cachedThumbnail };
const safeCachedThumbnail = cachedThumbnail ? getAllowedThumbnailUrl(cachedThumbnail, commentMediaInfo.url) : undefined;
if (safeCachedThumbnail) {
return { ...commentMediaInfo, thumbnail: safeCachedThumbnail };
}
const thumbnail = await fetchWebpageThumbnail(commentMediaInfo.url);
if (thumbnail) {
+40 -20
View File
@@ -6,6 +6,37 @@ type CommunityLike = {
roles?: Record<string, { role?: string }>;
};
const compiledRegexCache = new Map<string, RegExp>();
const commentTextCache = new WeakMap<Comment, string>();
const MAX_COMPILED_REGEX_CACHE_SIZE = 500;
const getCompiledRegex = (pattern: string, flags = ''): RegExp => {
const cacheKey = `${pattern}\u0000${flags}`;
const cachedRegex = compiledRegexCache.get(cacheKey);
if (cachedRegex) return cachedRegex;
const regex = new RegExp(pattern, flags);
if (compiledRegexCache.size >= MAX_COMPILED_REGEX_CACHE_SIZE) {
compiledRegexCache.clear();
}
compiledRegexCache.set(cacheKey, regex);
return regex;
};
const testRegex = (regex: RegExp, text: string): boolean => {
regex.lastIndex = 0;
return regex.test(text);
};
const getCommentSearchText = (comment: Comment): string => {
const cachedText = commentTextCache.get(comment);
if (cachedText !== undefined) return cachedText;
const searchText = `${comment?.title?.toLowerCase() || ''} ${comment?.content?.toLowerCase() || ''}`;
commentTextCache.set(comment, searchText);
return searchText;
};
/**
* Checks if a text matches a pattern according to various pattern matching rules:
* - Whole word matching: 'feel' matches 'feel' but not 'feeling'
@@ -31,8 +62,7 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
const lastSlashIndex = pattern.lastIndexOf('/');
const regexPattern = pattern.substring(1, lastSlashIndex);
const flags = pattern.substring(lastSlashIndex + 1);
const regex = new RegExp(regexPattern, flags);
return regex.test(textLower);
return testRegex(getCompiledRegex(regexPattern, flags), textLower);
}
// Check if it's an exact match pattern (surrounded by quotes)
else if (pattern.startsWith('"') && pattern.endsWith('"') && pattern.length > 2) {
@@ -50,12 +80,10 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
// Handle wildcards in OR terms
if (term.includes('*')) {
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
} else {
// Match whole word only
const regex = new RegExp(`\\b${term}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
}
});
});
@@ -70,25 +98,21 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
// Handle wildcards in AND terms
if (term.includes('*')) {
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
} else {
// Match whole word only
const regex = new RegExp(`\\b${term}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
}
});
}
// Handle wildcard patterns
else if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
}
// Simple whole word match
else {
const regex = new RegExp(`\\b${pattern.toLowerCase()}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${pattern.toLowerCase()}\\b`, 'i'), textLower);
}
} catch (error) {
// If regex parsing fails, fall back to simple includes
@@ -233,7 +257,7 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
// If there's also a content filter, check if the comment matches it as well
if (contentFilter) {
return allSpecialFiltersMatch && matchesPattern((comment?.title || '') + ' ' + (comment?.content || ''), contentFilter);
return allSpecialFiltersMatch && matchesPattern(getCommentSearchText(comment), contentFilter);
}
return allSpecialFiltersMatch;
@@ -260,9 +284,5 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
}
// Regular content matching
const titleLower = comment?.title?.toLowerCase() || '';
const contentLower = comment?.content?.toLowerCase() || '';
const textToMatch = titleLower + ' ' + contentLower;
return matchesPattern(textToMatch, pattern);
return matchesPattern(getCommentSearchText(comment), pattern);
};
+24 -7
View File
@@ -1,11 +1,12 @@
import i18next from 'i18next';
export const getFormattedDate = (commentTimestamp: number) => {
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
return '';
}
const locale = i18next.language || 'en';
const string = new Intl.DateTimeFormat(locale, {
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
const getDateTimeFormatter = (locale: string) => {
const cachedFormatter = dateTimeFormatters.get(locale);
if (cachedFormatter) return cachedFormatter;
const formatter = new Intl.DateTimeFormat(locale, {
hour12: false,
year: '2-digit',
month: '2-digit',
@@ -14,7 +15,23 @@ export const getFormattedDate = (commentTimestamp: number) => {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(commentTimestamp * 1000));
});
dateTimeFormatters.set(locale, formatter);
return formatter;
};
if (typeof i18next.on === 'function') {
i18next.on('languageChanged', () => {
dateTimeFormatters.clear();
});
}
export const getFormattedDate = (commentTimestamp: number) => {
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
return '';
}
const locale = i18next.language || 'en';
const string = getDateTimeFormatter(locale).format(new Date(commentTimestamp * 1000));
if (locale.startsWith('ar')) {
return string;
}
+34 -2
View File
@@ -11,12 +11,44 @@ export const getHostname = (url: string) => {
};
export const isValidURL = (url: string) => {
return parseHttpUrl(url) !== null;
};
export const parseHttpUrl = (url: string): URL | null => {
try {
new URL(url);
return true;
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null;
} catch {
return null;
}
};
export const isPrivateNetworkHostname = (hostname: string): boolean => {
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
normalizedHostname === 'localhost' ||
normalizedHostname.endsWith('.localhost') ||
normalizedHostname.endsWith('.local') ||
normalizedHostname === '0.0.0.0' ||
normalizedHostname === '::1' ||
normalizedHostname === '::' ||
normalizedHostname.startsWith('::ffff:')
) {
return true;
}
const ipv4Parts = normalizedHostname.split('.').map((part) => Number(part));
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4Parts;
return first === 10 || first === 127 || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
}
if (!normalizedHostname.includes(':')) {
return false;
}
return normalizedHostname.startsWith('fc') || normalizedHostname.startsWith('fd') || normalizedHostname.startsWith('fe80:');
};
export const normalizePublishURL = (url: string) => {