mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
chore(repo): prune dead code and duplicate abstractions (#3413)
This commit is contained in:
1 parent
c17091768c
commit
c9fdf8fead
2937 files changed
+42233
-176793
No files matched your search
@@ -54,3 +54,17 @@ export async function mapWithConcurrency<T, TResult>(
|
||||
if (failure) throw failure.error;
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function allSettledWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<PromiseSettledResult<TResult>>> {
|
||||
return mapWithConcurrency(items, concurrency, async (item, index): Promise<PromiseSettledResult<TResult>> => {
|
||||
try {
|
||||
return {status: 'fulfilled', value: await mapper(item, index)};
|
||||
} catch (reason) {
|
||||
return {status: 'rejected', reason};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function collectEmojiMatches(content: string, isInEscapedContext: (index: number
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function checkUserGlobalExpressions(
|
||||
export async function checkUserGlobalExpressions(
|
||||
userId: UserID,
|
||||
userRepository: EmojiUserRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isIP} from 'node:net';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {type IpAddressFamily, ipToBytes, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
interface ParsedBase {
|
||||
canonical: string;
|
||||
@@ -101,26 +100,11 @@ function parseSingle(value: string): ParsedIpSingle | null {
|
||||
function parseIP(value: string): ParsedIP | null {
|
||||
const ipWithoutZone = value.split('%', 1)[0].trim();
|
||||
if (!ipWithoutZone) return null;
|
||||
const family = isIP(ipWithoutZone);
|
||||
if (!family) return null;
|
||||
if (family === 4) {
|
||||
const bytes = parseIPv4(ipWithoutZone);
|
||||
if (!bytes) return null;
|
||||
return {family: 'ipv4', bytes, mappedIpv4Bytes: null};
|
||||
}
|
||||
let ipv6Address = ipWithoutZone;
|
||||
if (ipWithoutZone.includes('.')) {
|
||||
const converted = convertEmbeddedIpv4(ipWithoutZone);
|
||||
if (!converted) return null;
|
||||
ipv6Address = converted;
|
||||
}
|
||||
const bytes = parseIPv6(ipv6Address);
|
||||
if (!bytes) return null;
|
||||
return {
|
||||
family: 'ipv6',
|
||||
bytes,
|
||||
mappedIpv4Bytes: extractMappedIpv4(bytes),
|
||||
};
|
||||
if (!isIP(ipWithoutZone)) return null;
|
||||
const parsed = ipToBytes(ipWithoutZone);
|
||||
if (!parsed) return null;
|
||||
const bytes = Buffer.from(parsed.bytes);
|
||||
return {family: parsed.family, bytes, mappedIpv4Bytes: parsed.family === 'ipv6' ? extractMappedIpv4(bytes) : null};
|
||||
}
|
||||
|
||||
function resolveMappedIpv4(parsed: ParsedIP): {
|
||||
@@ -142,73 +126,6 @@ function extractMappedIpv4(bytes: Buffer): Buffer | null {
|
||||
return Buffer.from(bytes.subarray(12, 16));
|
||||
}
|
||||
|
||||
function convertEmbeddedIpv4(value: string): string | null {
|
||||
const lastColon = value.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
const ipv4Part = value.slice(lastColon + 1);
|
||||
const ipv4Bytes = parseIPv4(ipv4Part);
|
||||
if (!ipv4Bytes) return null;
|
||||
const high = ((ipv4Bytes[0] << 8) | ipv4Bytes[1]).toString(16);
|
||||
const low = ((ipv4Bytes[2] << 8) | ipv4Bytes[3]).toString(16);
|
||||
return `${value.slice(0, lastColon + 1)}${high}:${low}`;
|
||||
}
|
||||
|
||||
function parseIPv4(ip: string): Buffer | null {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const bytes = Buffer.alloc(4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const part = parts[i];
|
||||
if (!/^\d+$/.test(part)) return null;
|
||||
const octet = Number(part);
|
||||
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
|
||||
bytes[i] = octet;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseIPv6(ip: string): Buffer | null {
|
||||
const addr = ip.trim();
|
||||
const doubleColonIndex = addr.indexOf('::');
|
||||
let headPart = '';
|
||||
let tailPart = '';
|
||||
if (doubleColonIndex !== -1) {
|
||||
const parts = addr.split('::');
|
||||
if (parts.length !== 2) return null;
|
||||
[headPart, tailPart] = parts;
|
||||
} else {
|
||||
headPart = addr;
|
||||
tailPart = '';
|
||||
}
|
||||
const headGroups = headPart ? headPart.split(':').filter((g) => g.length > 0) : [];
|
||||
const tailGroups = tailPart ? tailPart.split(':').filter((g) => g.length > 0) : [];
|
||||
if (doubleColonIndex === -1 && headGroups.length !== 8) return null;
|
||||
const totalGroups = headGroups.length + tailGroups.length;
|
||||
if (totalGroups > 8) return null;
|
||||
const zerosToInsert = 8 - totalGroups;
|
||||
const groups: Array<number> = [];
|
||||
for (const group of headGroups) {
|
||||
const value = parseInt(group, 16);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
|
||||
groups.push(value);
|
||||
}
|
||||
for (let i = 0; i < zerosToInsert; i++) {
|
||||
groups.push(0);
|
||||
}
|
||||
for (const group of tailGroups) {
|
||||
const value = parseInt(group, 16);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
|
||||
groups.push(value);
|
||||
}
|
||||
if (groups.length !== 8) return null;
|
||||
const bytes = Buffer.alloc(16);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
bytes[i * 2] = (groups[i] >> 8) & 0xff;
|
||||
bytes[i * 2 + 1] = groups[i] & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseCIDR(cidr: string): ParsedCIDR | null {
|
||||
const trimmed = cidr.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
@@ -27,3 +27,15 @@ export function parseJsonWithGuard<T>(text: string, guard: (value: unknown) => v
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isStringArray(value: unknown): value is Array<string> {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
||||
}
|
||||
|
||||
export function parseJsonOrNull(raw: string): unknown {
|
||||
try {
|
||||
return parseJsonUnknown(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {buildPhraseMatchForms, canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
|
||||
import {SubstringMatcher} from '@app/api/utils/SubstringMatcher';
|
||||
|
||||
export class PhraseMatcherSet {
|
||||
private rawPhrases: Array<string> = [];
|
||||
|
||||
private rawPhraseSet = new Set<string>();
|
||||
|
||||
private rawMatcher: SubstringMatcher | null = null;
|
||||
|
||||
private wordMatcher: SubstringMatcher | null = null;
|
||||
|
||||
private compactMatcher: SubstringMatcher | null = null;
|
||||
|
||||
private asciiWordMatcher: SubstringMatcher | null = null;
|
||||
|
||||
private asciiCompactMatcher: SubstringMatcher | null = null;
|
||||
|
||||
containsBannedPhrase(text: string): boolean {
|
||||
if (this.rawPhrases.length === 0) return false;
|
||||
const forms = buildPhraseMatchForms(text);
|
||||
return (
|
||||
this.rawMatcher?.test(forms.raw) === true ||
|
||||
this.wordMatcher?.test(forms.words) === true ||
|
||||
this.compactMatcher?.test(forms.compact) === true ||
|
||||
this.asciiWordMatcher?.test(forms.asciiWords) === true ||
|
||||
this.asciiCompactMatcher?.test(forms.asciiCompact) === true
|
||||
);
|
||||
}
|
||||
|
||||
private rebuildMatchers(): void {
|
||||
const rawPhraseSet = new Set<string>();
|
||||
const wordPhraseSet = new Set<string>();
|
||||
const compactPhraseSet = new Set<string>();
|
||||
const asciiWordPhraseSet = new Set<string>();
|
||||
const asciiCompactPhraseSet = new Set<string>();
|
||||
for (const phrase of this.rawPhrases) {
|
||||
const canonical = canonicalizeStoredPhrase(phrase);
|
||||
if (!canonical) continue;
|
||||
const forms = buildPhraseMatchForms(canonical);
|
||||
rawPhraseSet.add(forms.raw);
|
||||
if (forms.words) wordPhraseSet.add(forms.words);
|
||||
if (forms.compact) compactPhraseSet.add(forms.compact);
|
||||
if (forms.asciiWords) asciiWordPhraseSet.add(forms.asciiWords);
|
||||
if (forms.asciiCompact) asciiCompactPhraseSet.add(forms.asciiCompact);
|
||||
}
|
||||
this.rawPhraseSet = rawPhraseSet;
|
||||
this.rawPhrases = Array.from(rawPhraseSet);
|
||||
this.rawMatcher = SubstringMatcher.fromPatterns(rawPhraseSet);
|
||||
this.wordMatcher = SubstringMatcher.fromPatterns(wordPhraseSet);
|
||||
this.compactMatcher = SubstringMatcher.fromPatterns(compactPhraseSet);
|
||||
this.asciiWordMatcher = SubstringMatcher.fromPatterns(asciiWordPhraseSet);
|
||||
this.asciiCompactMatcher = SubstringMatcher.fromPatterns(asciiCompactPhraseSet);
|
||||
}
|
||||
|
||||
isPhraseBanned(phrase: string): boolean {
|
||||
const canonical = canonicalizeStoredPhrase(phrase);
|
||||
return !!canonical && this.rawPhraseSet.has(canonical);
|
||||
}
|
||||
|
||||
add(phrase: string): void {
|
||||
const canonical = canonicalizeStoredPhrase(phrase);
|
||||
if (!canonical || this.rawPhraseSet.has(canonical)) return;
|
||||
this.rawPhrases.push(canonical);
|
||||
this.rebuildMatchers();
|
||||
}
|
||||
|
||||
remove(phrase: string): void {
|
||||
const canonical = canonicalizeStoredPhrase(phrase);
|
||||
if (!canonical || !this.rawPhraseSet.has(canonical)) return;
|
||||
this.rawPhrases = this.rawPhrases.filter((item) => item !== canonical);
|
||||
this.rebuildMatchers();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.rawPhraseSet.size;
|
||||
}
|
||||
|
||||
replaceAll(phrases: Iterable<string>): void {
|
||||
this.rawPhrases = Array.from(phrases);
|
||||
this.rebuildMatchers();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
type RefreshTrigger = 'notification' | 'periodic';
|
||||
|
||||
interface RefreshSubscriptionOptions {
|
||||
export interface RefreshSubscriptionOptions {
|
||||
name: string;
|
||||
channels: ReadonlyArray<string>;
|
||||
refresh: () => Promise<void>;
|
||||
onRefreshError: (error: unknown, trigger: RefreshTrigger) => void;
|
||||
onRefreshError?: (error: unknown, trigger: RefreshTrigger) => void;
|
||||
onPeriodicRefreshError?: (error: unknown) => void;
|
||||
escalateAfterConsecutiveFailures?: number;
|
||||
shouldRefresh?: (channel: string, message: string) => boolean;
|
||||
drainInitialNotifications?: boolean;
|
||||
periodicIntervalMs?: () => number | null;
|
||||
@@ -21,6 +26,10 @@ export class RefreshSubscriptionUnavailableError extends Error {
|
||||
}
|
||||
|
||||
export class RefreshSubscription {
|
||||
resetFailures(): void {
|
||||
this.session?.resetFailures();
|
||||
}
|
||||
|
||||
private session: RefreshSubscriptionSession | null = null;
|
||||
|
||||
constructor(private readonly options: RefreshSubscriptionOptions) {}
|
||||
@@ -62,6 +71,28 @@ export class RefreshSubscription {
|
||||
}
|
||||
|
||||
class RefreshSubscriptionSession {
|
||||
private consecutiveFailures = 0;
|
||||
resetFailures(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
private reportRefreshError(error: unknown, trigger: RefreshTrigger): void {
|
||||
if (trigger === 'periodic' && this.options.onPeriodicRefreshError) {
|
||||
this.options.onPeriodicRefreshError(error);
|
||||
return;
|
||||
}
|
||||
if (this.options.onRefreshError) {
|
||||
this.options.onRefreshError(error, trigger);
|
||||
return;
|
||||
}
|
||||
this.consecutiveFailures++;
|
||||
const details = {error: error instanceof Error ? error.message : String(error)};
|
||||
const message = `Failed to refresh ${this.options.name} after notification`;
|
||||
if (this.consecutiveFailures >= (this.options.escalateAfterConsecutiveFailures ?? 5))
|
||||
Logger.error(details, message);
|
||||
else Logger.warn(details, message);
|
||||
}
|
||||
|
||||
private readonly subscription: IKVSubscription | null;
|
||||
private phase: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting';
|
||||
private startCompletion: Promise<void> | null = null;
|
||||
@@ -117,10 +148,12 @@ class RefreshSubscriptionSession {
|
||||
}
|
||||
}
|
||||
await this.options.refresh();
|
||||
this.resetFailures();
|
||||
this.assertActive();
|
||||
while (this.options.drainInitialNotifications && this.pendingRefresh !== null) {
|
||||
this.pendingRefresh = null;
|
||||
await this.options.refresh();
|
||||
this.resetFailures();
|
||||
this.assertActive();
|
||||
}
|
||||
this.phase = 'running';
|
||||
@@ -160,8 +193,9 @@ class RefreshSubscriptionSession {
|
||||
this.pendingRefresh = null;
|
||||
try {
|
||||
await this.options.refresh();
|
||||
this.resetFailures();
|
||||
} catch (error) {
|
||||
this.options.onRefreshError(error, trigger);
|
||||
this.reportRefreshError(error, trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {RefreshSubscription, type RefreshSubscriptionOptions} from '@app/api/utils/RefreshSubscription';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
export abstract class RefreshedCache {
|
||||
private kvClient: IKVProvider | null = null;
|
||||
private readonly refreshSubscription: RefreshSubscription;
|
||||
constructor(
|
||||
private readonly options: Pick<
|
||||
RefreshSubscriptionOptions,
|
||||
'name' | 'channels' | 'periodicIntervalMs' | 'onPeriodicRefreshError'
|
||||
>,
|
||||
) {
|
||||
this.refreshSubscription = new RefreshSubscription({
|
||||
...options,
|
||||
refresh: () => this.refresh(),
|
||||
escalateAfterConsecutiveFailures: 5,
|
||||
});
|
||||
}
|
||||
protected abstract load(): Promise<void>;
|
||||
async refresh(): Promise<void> {
|
||||
await this.load();
|
||||
this.refreshSubscription.resetFailures();
|
||||
}
|
||||
setRefreshSubscriber(kvClient: IKVProvider | null): void {
|
||||
this.kvClient = kvClient;
|
||||
}
|
||||
initialize(): Promise<void> {
|
||||
return this.refreshSubscription.start(this.kvClient);
|
||||
}
|
||||
shutdown(): Promise<void> {
|
||||
return this.refreshSubscription.stop();
|
||||
}
|
||||
protected resetRefreshState(): void {
|
||||
void this.shutdown().catch((error) => Logger.error({error}, `Failed to shut down ${this.options.name}`));
|
||||
this.kvClient = null;
|
||||
this.refreshSubscription.resetFailures();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
|
||||
export function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function secretMatchesHash(
|
||||
presented: string | null | undefined,
|
||||
storedHash: string | null | undefined,
|
||||
): boolean {
|
||||
if (presented == null || !storedHash) return false;
|
||||
const presentedBuffer = Buffer.from(sha256Hex(presented), 'hex');
|
||||
const storedBuffer = Buffer.from(storedHash, 'hex');
|
||||
return presentedBuffer.length === storedBuffer.length && timingSafeEqual(presentedBuffer, storedBuffer);
|
||||
}
|
||||
|
||||
export function constantTimeEquals(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
|
||||
}
|
||||
|
||||
export function randomBase64Url(bytes: number): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac, timingSafeEqual} from 'node:crypto';
|
||||
import {parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
|
||||
|
||||
interface ExpiringPayload {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function computeSignature(payloadBase64: string, key: string | Buffer): Buffer {
|
||||
return createHmac('sha256', key).update(payloadBase64).digest();
|
||||
}
|
||||
|
||||
export function signExpiringToken<T extends ExpiringPayload>(payload: T, key: string | Buffer): string {
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = computeSignature(payloadBase64, key).toString('base64url');
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyExpiringToken<T extends ExpiringPayload>(
|
||||
token: string,
|
||||
key: string | Buffer,
|
||||
guard: (value: unknown) => value is T,
|
||||
): T | null {
|
||||
const dotIndex = token.indexOf('.');
|
||||
if (dotIndex === -1) return null;
|
||||
const payloadBase64 = token.slice(0, dotIndex);
|
||||
const signatureBase64 = token.slice(dotIndex + 1);
|
||||
const expectedSignature = computeSignature(payloadBase64, key);
|
||||
let providedSignature: Buffer;
|
||||
try {
|
||||
providedSignature = Buffer.from(signatureBase64, 'base64url');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (expectedSignature.length !== providedSignature.length) return null;
|
||||
if (!timingSafeEqual(expectedSignature, providedSignature)) return null;
|
||||
let payload: T | null;
|
||||
try {
|
||||
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
|
||||
payload = parseJsonWithGuard(payloadJson, guard);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || Date.now() > payload.expiresAt) return null;
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {parseString} from '@app/api/utils/StringUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('parseString', () => {
|
||||
it('keeps family emoji intact at the webhook title truncation boundary', () => {
|
||||
expect(parseString('ab👨👩👧cdefghij', 6)).toBe('ab👨👩👧...');
|
||||
expect(parseString(`${'👨👩👧👦'.repeat(5)}abcdef`, 6)).toBe(`${'👨👩👧👦'.repeat(3)}...`);
|
||||
});
|
||||
it('keeps combining marks and flags together and decodes entities before truncating', () => {
|
||||
expect(parseString('e\u0301🇸🇪&more text', 6)).toBe('e\u0301🇸🇪&...');
|
||||
expect(parseString(' &🇸🇪 ', 2)).toBe('&🇸🇪');
|
||||
});
|
||||
});
|
||||
@@ -2,47 +2,13 @@
|
||||
|
||||
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
|
||||
import {decode} from 'html-entities';
|
||||
import _ from 'lodash';
|
||||
|
||||
function isInvisibleContentCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint === 0x0000 ||
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x034f ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x115f ||
|
||||
codePoint === 0x1160 ||
|
||||
codePoint === 0x17b4 ||
|
||||
codePoint === 0x17b5 ||
|
||||
codePoint === 0x180e ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2060 && codePoint <= 0x2069) ||
|
||||
codePoint === 0x2800 ||
|
||||
codePoint === 0x3164 ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
codePoint === 0xfeff ||
|
||||
codePoint === 0xffa0 ||
|
||||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasVisibleContent(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (char.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (codePoint == null || isInvisibleContentCodePoint(codePoint)) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const graphemeSegmenter = new Intl.Segmenter('en', {granularity: 'grapheme'});
|
||||
|
||||
export function parseString(value: string, maxLength: number) {
|
||||
return _.truncate(decode(value).trim(), {length: maxLength});
|
||||
const decoded = decode(value).trim();
|
||||
const graphemes = Array.from(graphemeSegmenter.segment(decoded), ({segment}) => segment);
|
||||
return graphemes.length > maxLength ? `${graphemes.slice(0, Math.max(0, maxLength - 3)).join('')}...` : decoded;
|
||||
}
|
||||
|
||||
export function safeUrl(value: unknown): string | undefined {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import * as InviteUtils from '@app/api/utils/InviteUtils';
|
||||
import {URL_REGEX} from '@fluxer/constants/src/Core';
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/', '/invite/', '/gift/', '/oauth2/', '/users/'];
|
||||
|
||||
@@ -60,8 +59,6 @@ function getExcludedLinkBases(): Array<ExcludedLinkBase> {
|
||||
function idnaEncodeURL(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const encodedDomain = idna.toAscii(parsedUrl.hostname).toLowerCase();
|
||||
parsedUrl.hostname = encodedDomain;
|
||||
parsedUrl.username = '';
|
||||
parsedUrl.password = '';
|
||||
return parsedUrl.toString();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/u, '');
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import type {Guild} from '@app/api/models/Guild';
|
||||
import type {Relationship} from '@app/api/models/Relationship';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {FriendSourceFlags, GroupDmAddPermissionFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
@@ -127,21 +126,4 @@ export class UserPermissionUtils {
|
||||
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
|
||||
return targetGuildIds.some((id) => userGuildIdSet.has(id.toString()));
|
||||
}
|
||||
|
||||
async getMutualFriends({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<Array<Relationship>> {
|
||||
const userFriends = await this.userRepository.listRelationships(userId);
|
||||
const targetFriends = await this.userRepository.listRelationships(targetId);
|
||||
const targetFriendIds = new Set(
|
||||
targetFriends.filter((rel) => rel.type === RelationshipTypes.FRIEND).map((rel) => rel.targetUserId.toString()),
|
||||
);
|
||||
return userFriends.filter(
|
||||
(rel) => rel.type === RelationshipTypes.FRIEND && targetFriendIds.has(rel.targetUserId.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
async getMutualGuildIds({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<Array<GuildID>> {
|
||||
const {userGuildIds, targetGuildIds} = await this.fetchGuildIdsForUsers({userId, targetId});
|
||||
const targetGuildIdSet = new Set(targetGuildIds.map((guildId) => guildId.toString()));
|
||||
return userGuildIds.filter((guildId) => targetGuildIdSet.has(guildId.toString()));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {THREAD_AWARE_ALL_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constant
|
||||
export const CLIENT_FEATURES_HEADER = Headers.X_FLUXER_FEATURES;
|
||||
export const CHANNEL_THREADS_CLIENT_FEATURE = 'channel_threads';
|
||||
|
||||
export type ThreadPermissionMode = 'control' | 'active' | 'retired';
|
||||
export type ThreadPermissionMode = 'active';
|
||||
|
||||
export interface ProtectedBitActor {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
@@ -16,8 +16,7 @@ export interface ProtectedBitActor {
|
||||
|
||||
function threadBitsWritable(actor: ProtectedBitActor): boolean {
|
||||
const threadBits = actor.threadBits;
|
||||
if (!threadBits || threadBits.mode === 'control') return true;
|
||||
return threadBits.mode === 'active' && threadBits.writer;
|
||||
return !threadBits || threadBits.writer;
|
||||
}
|
||||
|
||||
export function protectedThreadBits(actor: ProtectedBitActor): bigint {
|
||||
@@ -25,7 +24,7 @@ export function protectedThreadBits(actor: ProtectedBitActor): bigint {
|
||||
}
|
||||
|
||||
export function permissionWriteMask(actor: ProtectedBitActor): bigint {
|
||||
return !actor.threadBits || actor.threadBits.mode === 'control' ? ALL_PERMISSIONS : THREAD_AWARE_ALL_PERMISSIONS;
|
||||
return !actor.threadBits ? ALL_PERMISSIONS : THREAD_AWARE_ALL_PERMISSIONS;
|
||||
}
|
||||
|
||||
const PERMISSION_BIT_CLIENT_FEATURES: ReadonlyArray<{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {allSettledWithConcurrency, mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('mapWithConcurrency', () => {
|
||||
@@ -115,3 +115,43 @@ describe('mapWithConcurrency', () => {
|
||||
expect(started).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allSettledWithConcurrency', () => {
|
||||
it('continues queued work after synchronous and asynchronous failures and retains input order', async () => {
|
||||
const syncFailure = new Error('synchronous');
|
||||
const asyncFailure = new Error('asynchronous');
|
||||
const started: Array<number> = [];
|
||||
const results = await allSettledWithConcurrency([0, 1, 2, 3], 1, (item, index) => {
|
||||
started.push(index);
|
||||
if (item === 0) throw syncFailure;
|
||||
if (item === 1) return Promise.reject(asyncFailure);
|
||||
return Promise.resolve(item * 10);
|
||||
});
|
||||
expect(started).toEqual([0, 1, 2, 3]);
|
||||
expect(results).toEqual([
|
||||
{status: 'rejected', reason: syncFailure},
|
||||
{status: 'rejected', reason: asyncFailure},
|
||||
{status: 'fulfilled', value: 20},
|
||||
{status: 'fulfilled', value: 30},
|
||||
]);
|
||||
});
|
||||
|
||||
it('waits for every in-flight result when a later item fails first', async () => {
|
||||
const first = Promise.withResolvers<number>();
|
||||
const second = Promise.withResolvers<number>();
|
||||
const failure = new Error('later failure');
|
||||
let completed = false;
|
||||
const results = allSettledWithConcurrency([first.promise, second.promise], 2, (item) => item).then((value) => {
|
||||
completed = true;
|
||||
return value;
|
||||
});
|
||||
second.reject(failure);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(completed).toBe(false);
|
||||
first.resolve(10);
|
||||
await expect(results).resolves.toEqual([
|
||||
{status: 'fulfilled', value: 10},
|
||||
{status: 'rejected', reason: failure},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,25 +9,8 @@ import {
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('getCurrencyPreferences first choice', () => {
|
||||
describe('returns USD for non-EEA countries', () => {
|
||||
it('returns USD for United States', () => {
|
||||
expect(getCurrencyPreferences('US')[0]).toBe('USD');
|
||||
});
|
||||
it('returns USD for Canada', () => {
|
||||
expect(getCurrencyPreferences('CA')[0]).toBe('USD');
|
||||
});
|
||||
it('returns USD for United Kingdom', () => {
|
||||
expect(getCurrencyPreferences('GB')[0]).toBe('USD');
|
||||
});
|
||||
it('returns USD for Japan', () => {
|
||||
expect(getCurrencyPreferences('JP')[0]).toBe('USD');
|
||||
});
|
||||
it('returns USD for Australia', () => {
|
||||
expect(getCurrencyPreferences('AU')[0]).toBe('USD');
|
||||
});
|
||||
it('returns USD for Switzerland', () => {
|
||||
expect(getCurrencyPreferences('CH')[0]).toBe('USD');
|
||||
});
|
||||
it.each(['US', 'CA', 'GB', 'JP', 'AU', 'CH'])('returns USD for non-EEA country %s', (country) => {
|
||||
expect(getCurrencyPreferences(country)[0]).toBe('USD');
|
||||
});
|
||||
describe('handles case insensitivity', () => {
|
||||
it('returns EUR for lowercase country code', () => {
|
||||
|
||||
@@ -12,18 +12,6 @@ describe('parseIpBanEntry', () => {
|
||||
expect(result?.family).toBe('ipv4');
|
||||
expect(result?.canonical).toBe('192.168.1.1');
|
||||
});
|
||||
it('parses IPv4 address 0.0.0.0', () => {
|
||||
const result = parseIpBanEntry('0.0.0.0');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.canonical).toBe('0.0.0.0');
|
||||
});
|
||||
it('parses IPv4 address 255.255.255.255', () => {
|
||||
const result = parseIpBanEntry('255.255.255.255');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.canonical).toBe('255.255.255.255');
|
||||
});
|
||||
it('rejects IPv4 with leading zeros (octal ambiguity)', () => {
|
||||
const result = parseIpBanEntry('192.168.001.001');
|
||||
expect(result).toBeNull();
|
||||
@@ -42,12 +30,6 @@ describe('parseIpBanEntry', () => {
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
it('parses IPv6 loopback address', () => {
|
||||
const result = parseIpBanEntry('::1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
it('parses IPv6 unspecified address', () => {
|
||||
const result = parseIpBanEntry('::');
|
||||
expect(result).not.toBeNull();
|
||||
@@ -98,32 +80,6 @@ describe('parseIpBanEntry', () => {
|
||||
expect(result.end - result.start).toBe(255n);
|
||||
}
|
||||
});
|
||||
it('parses /16 (65536 hosts)', () => {
|
||||
const result = parseIpBanEntry('192.168.0.0/16');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv4');
|
||||
expect(result.prefixLength).toBe(16);
|
||||
expect(result.end - result.start).toBe(65535n);
|
||||
}
|
||||
});
|
||||
it('parses /8 (class A)', () => {
|
||||
const result = parseIpBanEntry('10.0.0.0/8');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.prefixLength).toBe(8);
|
||||
}
|
||||
});
|
||||
it('parses /0 (all addresses)', () => {
|
||||
const result = parseIpBanEntry('0.0.0.0/0');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.prefixLength).toBe(0);
|
||||
}
|
||||
});
|
||||
it('normalizes network address in CIDR', () => {
|
||||
const result = parseIpBanEntry('192.168.1.100/24');
|
||||
expect(result).not.toBeNull();
|
||||
@@ -144,23 +100,6 @@ describe('parseIpBanEntry', () => {
|
||||
expect(result.start).toBe(result.end);
|
||||
}
|
||||
});
|
||||
it('parses /64 (common subnet)', () => {
|
||||
const result = parseIpBanEntry('2001:db8::/64');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv6');
|
||||
expect(result.prefixLength).toBe(64);
|
||||
}
|
||||
});
|
||||
it('parses /48 (site prefix)', () => {
|
||||
const result = parseIpBanEntry('2001:db8:abcd::/48');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.prefixLength).toBe(48);
|
||||
}
|
||||
});
|
||||
it('parses /0 (all addresses)', () => {
|
||||
const result = parseIpBanEntry('::/0');
|
||||
expect(result).not.toBeNull();
|
||||
@@ -193,6 +132,9 @@ describe('parseIpBanEntry', () => {
|
||||
});
|
||||
});
|
||||
describe('invalid inputs', () => {
|
||||
it('rejects bracketed IPv6 CIDR addresses', () => {
|
||||
expect(parseIpBanEntry('[::ffff:192.0.2.1]/120')).toBeNull();
|
||||
});
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseIpBanEntry('')).toBeNull();
|
||||
});
|
||||
@@ -223,11 +165,6 @@ describe('parseIpBanEntry', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
});
|
||||
it('handles whitespace around CIDR', () => {
|
||||
const result = parseIpBanEntry(' 192.168.1.0/24 ');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
});
|
||||
it('handles bracketed IPv6', () => {
|
||||
const result = parseIpBanEntry('[2001:db8::1]');
|
||||
expect(result).not.toBeNull();
|
||||
@@ -242,11 +179,6 @@ describe('tryParseSingleIp', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
});
|
||||
it('returns parsed result for single IPv6', () => {
|
||||
const result = tryParseSingleIp('2001:db8::1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
});
|
||||
it('returns null for CIDR range', () => {
|
||||
expect(tryParseSingleIp('192.168.1.0/24')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -95,11 +95,6 @@ function toResult(parse: () => unknown): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function parseWithDigitRunGate(jsonText: string, digitRun: number): ParseResult {
|
||||
const gate = new RegExp(String.raw`\d{${digitRun}}`);
|
||||
return toResult(() => JSON.parse(gate.test(jsonText) ? coerceUnsafeIntegersToStrings(jsonText) : jsonText));
|
||||
}
|
||||
|
||||
function parseUngated(jsonText: string): ParseResult {
|
||||
return toResult(() => JSON.parse(coerceUnsafeIntegersToStrings(jsonText)));
|
||||
}
|
||||
@@ -113,11 +108,6 @@ describe('parseJsonPreservingLargeIntegers digit-run gate', () => {
|
||||
).toEqual(parseUngated(body));
|
||||
}
|
||||
});
|
||||
it('exercises both the fast path and the scan', () => {
|
||||
const gate = /\d{16}/;
|
||||
expect(CORPUS.some((body) => !gate.test(body))).toBe(true);
|
||||
expect(CORPUS.some((body) => gate.test(body))).toBe(true);
|
||||
});
|
||||
it('rejects malformed bodies identically on both paths', () => {
|
||||
for (const body of ['{"id":00}', '{"id":0001472109478688579732}']) {
|
||||
const gated = toResult(() => parseJsonPreservingLargeIntegers(body));
|
||||
@@ -125,19 +115,4 @@ describe('parseJsonPreservingLargeIntegers digit-run gate', () => {
|
||||
expect(gated, body).toEqual(parseUngated(body));
|
||||
}
|
||||
});
|
||||
it('diverges from the ungated scanner when the gate is loosened to 17 digits', () => {
|
||||
const broken = CORPUS.filter((body) => {
|
||||
const loosened = parseWithDigitRunGate(body, 17);
|
||||
const ungated = parseUngated(body);
|
||||
return JSON.stringify(loosened) !== JSON.stringify(ungated);
|
||||
});
|
||||
expect(broken).toContain('{"id":9007199254740992}');
|
||||
expect(broken).toContain('{"id":9999999999999999}');
|
||||
expect(broken).toContain('{"id":-9007199254740992}');
|
||||
});
|
||||
it('agrees with the ungated scanner when the gate is tightened to 15 digits', () => {
|
||||
for (const body of CORPUS) {
|
||||
expect(parseWithDigitRunGate(body, 15), body).toEqual(parseUngated(body));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,27 +28,6 @@ function createCountingRequest(headers: Record<string, string>): CountingRequest
|
||||
}
|
||||
|
||||
describe('RequestClientIp', () => {
|
||||
it('resolves the client ip once and hands the same value to every consumer', async () => {
|
||||
const {request, countReads} = createCountingRequest({'x-forwarded-for': '203.0.113.10, 10.0.0.1'});
|
||||
const seen: Array<string | null> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
seen.push(getRequestClientIp(ctx));
|
||||
await next();
|
||||
});
|
||||
app.use(async (ctx, next) => {
|
||||
seen.push(getRequestClientIp(ctx));
|
||||
await next();
|
||||
});
|
||||
app.get('/v1/messages', (ctx) => {
|
||||
seen.push(requireRequestClientIp(ctx));
|
||||
return ctx.text('ok');
|
||||
});
|
||||
const response = await app.request(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(seen).toEqual(['203.0.113.10', '203.0.113.10', '203.0.113.10']);
|
||||
expect(countReads('x-forwarded-for')).toBe(1);
|
||||
});
|
||||
it('normalizes the resolved address the same way for every consumer', async () => {
|
||||
const seen: Array<string | null> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
@@ -86,20 +65,6 @@ describe('RequestClientIp', () => {
|
||||
expect(errors[0]).toBeInstanceOf(MissingClientIpError);
|
||||
expect(countReads('x-forwarded-for')).toBe(1);
|
||||
});
|
||||
it('reports a malformed client ip header exactly as before', async () => {
|
||||
const seen: Array<string | null> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
seen.push(getRequestClientIp(ctx));
|
||||
await next();
|
||||
});
|
||||
app.get('/v1/messages', (ctx) => {
|
||||
seen.push(getRequestClientIp(ctx));
|
||||
return ctx.text('ok');
|
||||
});
|
||||
await app.request('http://localhost/v1/messages', {headers: {'x-forwarded-for': 'not-an-ip'}});
|
||||
expect(seen).toEqual([null, null]);
|
||||
});
|
||||
it('never reuses a resolution made under a different header name', async () => {
|
||||
const seen: Array<string | null> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
|
||||
@@ -14,39 +14,6 @@ describe('canonicalizeUrl', () => {
|
||||
it('preserves path', () => {
|
||||
expect(canonicalizeUrl('https://example.com/foo/bar')).toBe('https://example.com/foo/bar');
|
||||
});
|
||||
it('strips utm_source tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?utm_source=twitter')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips utm_medium tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?utm_medium=social')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips utm_campaign tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?utm_campaign=spring')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips fbclid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?fbclid=abc123')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips gclid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?gclid=xyz')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips mc_cid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?mc_cid=abc')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips mc_eid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?mc_eid=def')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips msclkid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?msclkid=bing')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips _ga tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?_ga=123')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips _gl tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?_gl=456')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips igshid tracking param', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?igshid=abc')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips ref_src and ref_url tracking params', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?ref_src=twsrc&ref_url=https://t.co')).toBe('https://example.com/');
|
||||
});
|
||||
@@ -61,9 +28,6 @@ describe('canonicalizeUrl', () => {
|
||||
it('strips fragment', () => {
|
||||
expect(canonicalizeUrl('https://example.com/page#section')).toBe('https://example.com/page');
|
||||
});
|
||||
it('strips fragment along with tracking params', () => {
|
||||
expect(canonicalizeUrl('https://example.com/?utm_source=tw#top')).toBe('https://example.com/');
|
||||
});
|
||||
it('strips default port 80 for http', () => {
|
||||
expect(canonicalizeUrl('http://example.com:80/path')).toBe('http://example.com/path');
|
||||
});
|
||||
@@ -85,9 +49,6 @@ describe('canonicalizeUrl', () => {
|
||||
it('rejects ftp scheme', () => {
|
||||
expect(canonicalizeUrl('ftp://example.com/file')).toBeNull();
|
||||
});
|
||||
it('rejects tel scheme', () => {
|
||||
expect(canonicalizeUrl('tel:+1234567890')).toBeNull();
|
||||
});
|
||||
it('rejects invalid URLs', () => {
|
||||
expect(canonicalizeUrl('not-a-url')).toBeNull();
|
||||
});
|
||||
@@ -145,16 +106,6 @@ describe('extractUrlCandidates', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('https://example.com/page');
|
||||
});
|
||||
it('strips trailing comma', () => {
|
||||
const result = extractUrlCandidates('see https://example.com/page, then');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('https://example.com/page');
|
||||
});
|
||||
it('strips trailing exclamation mark', () => {
|
||||
const result = extractUrlCandidates('wow https://example.com/page!');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('https://example.com/page');
|
||||
});
|
||||
it('does not match full email addresses as URLs', () => {
|
||||
const result = extractUrlCandidates('contact [email protected]');
|
||||
for (const url of result) {
|
||||
|
||||
Reference in new issue
Block a user