mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user