refactor progress
This commit is contained in:
42
packages/api/src/utils/AgeUtils.tsx
Normal file
42
packages/api/src/utils/AgeUtils.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function calculateAge(dateOfBirth: {year: number; month: number; day: number} | string): number {
|
||||
const today = new Date();
|
||||
|
||||
let birthDate: Date;
|
||||
|
||||
if (typeof dateOfBirth === 'string') {
|
||||
const [year, month, day] = dateOfBirth.split('-').map(Number);
|
||||
birthDate = new Date(year, month - 1, day);
|
||||
} else {
|
||||
birthDate = new Date(dateOfBirth.year, dateOfBirth.month - 1, dateOfBirth.day);
|
||||
}
|
||||
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
const dayDiff = today.getDate() - birthDate.getDate();
|
||||
|
||||
return monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age;
|
||||
}
|
||||
|
||||
export function isUserAdult(dateOfBirth?: {year: number; month: number; day: number} | string | null): boolean {
|
||||
if (!dateOfBirth) return false;
|
||||
return calculateAge(dateOfBirth) >= 18;
|
||||
}
|
||||
190
packages/api/src/utils/AttachmentDecay.tsx
Normal file
190
packages/api/src/utils/AttachmentDecay.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface AttachmentDecayInput {
|
||||
sizeBytes: bigint | number;
|
||||
uploadedAt: Date;
|
||||
curve?: number;
|
||||
pricePerTBPerMonth?: number;
|
||||
}
|
||||
|
||||
export interface AttachmentDecayResult {
|
||||
expiresAt: Date;
|
||||
days: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DECAY_CONSTANTS = {
|
||||
MIN_MB: 5,
|
||||
MAX_MB: 500,
|
||||
MIN_DAYS: 14,
|
||||
MAX_DAYS: 365 * 3,
|
||||
PLAN_MB: 500,
|
||||
CURVE: 0.5,
|
||||
PRICE_PER_TB_PER_MONTH: 0.0081103 * 1000,
|
||||
};
|
||||
|
||||
export const DEFAULT_RENEWAL_CONSTANTS = {
|
||||
RENEW_THRESHOLD_DAYS: 30,
|
||||
RENEW_WINDOW_DAYS: 30,
|
||||
MIN_WINDOW_DAYS: 7,
|
||||
MAX_WINDOW_DAYS: 30,
|
||||
MIN_THRESHOLD_DAYS: 3,
|
||||
MAX_THRESHOLD_DAYS: 14,
|
||||
};
|
||||
|
||||
function toMb(sizeBytes: bigint | number): number {
|
||||
const n = typeof sizeBytes === 'bigint' ? Number(sizeBytes) : sizeBytes;
|
||||
return n / 1024 / 1024;
|
||||
}
|
||||
|
||||
export function computeDecay({
|
||||
sizeBytes,
|
||||
uploadedAt,
|
||||
curve = DEFAULT_DECAY_CONSTANTS.CURVE,
|
||||
pricePerTBPerMonth = DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
}: AttachmentDecayInput): AttachmentDecayResult | null {
|
||||
const constants = DEFAULT_DECAY_CONSTANTS;
|
||||
const sizeMB = toMb(sizeBytes);
|
||||
|
||||
if (sizeMB > constants.PLAN_MB) return null;
|
||||
|
||||
let lifetimeDays: number;
|
||||
if (sizeMB <= constants.MIN_MB) {
|
||||
lifetimeDays = constants.MAX_DAYS;
|
||||
} else if (sizeMB >= constants.MAX_MB) {
|
||||
lifetimeDays = constants.MIN_DAYS;
|
||||
} else {
|
||||
const linearFrac = (sizeMB - constants.MIN_MB) / (constants.MAX_MB - constants.MIN_MB);
|
||||
const logFrac = Math.log(sizeMB / constants.MIN_MB) / Math.log(constants.MAX_MB / constants.MIN_MB);
|
||||
const blend = (1 - curve) * linearFrac + curve * logFrac;
|
||||
lifetimeDays = constants.MAX_DAYS - blend * (constants.MAX_DAYS - constants.MIN_DAYS);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(uploadedAt);
|
||||
expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays);
|
||||
|
||||
const sizeTB = (typeof sizeBytes === 'bigint' ? Number(sizeBytes) : sizeBytes) / 1024 / 1024 / 1024 / 1024;
|
||||
const lifetimeMonths = lifetimeDays / 30;
|
||||
const cost = sizeTB * pricePerTBPerMonth * lifetimeMonths;
|
||||
|
||||
return {
|
||||
expiresAt,
|
||||
cost,
|
||||
days: Math.round(lifetimeDays),
|
||||
};
|
||||
}
|
||||
|
||||
const MS_PER_DAY = ms('1 day');
|
||||
|
||||
export function computeRenewalWindowDays(
|
||||
sizeMB: number,
|
||||
{
|
||||
minMB = DEFAULT_DECAY_CONSTANTS.MIN_MB,
|
||||
maxMB = DEFAULT_DECAY_CONSTANTS.MAX_MB,
|
||||
}: {minMB?: number; maxMB?: number} = {},
|
||||
{
|
||||
minWindowDays = DEFAULT_RENEWAL_CONSTANTS.MIN_WINDOW_DAYS,
|
||||
maxWindowDays = DEFAULT_RENEWAL_CONSTANTS.MAX_WINDOW_DAYS,
|
||||
}: {minWindowDays?: number; maxWindowDays?: number} = {},
|
||||
): number {
|
||||
if (sizeMB <= minMB) return maxWindowDays;
|
||||
if (sizeMB >= maxMB) return minWindowDays;
|
||||
|
||||
const frac = Math.log(sizeMB / minMB) / Math.log(maxMB / minMB);
|
||||
const window = maxWindowDays - frac * (maxWindowDays - minWindowDays);
|
||||
return Math.round(window);
|
||||
}
|
||||
|
||||
export function computeRenewalThresholdDays(
|
||||
windowDays: number,
|
||||
{
|
||||
minThresholdDays = DEFAULT_RENEWAL_CONSTANTS.MIN_THRESHOLD_DAYS,
|
||||
maxThresholdDays = DEFAULT_RENEWAL_CONSTANTS.MAX_THRESHOLD_DAYS,
|
||||
}: {minThresholdDays?: number; maxThresholdDays?: number} = {},
|
||||
): number {
|
||||
const clampedWindow = Math.max(
|
||||
DEFAULT_RENEWAL_CONSTANTS.MIN_WINDOW_DAYS,
|
||||
Math.min(DEFAULT_RENEWAL_CONSTANTS.MAX_WINDOW_DAYS, windowDays),
|
||||
);
|
||||
const threshold = Math.round(clampedWindow / 2);
|
||||
return Math.max(minThresholdDays, Math.min(maxThresholdDays, threshold));
|
||||
}
|
||||
|
||||
export function computeCost({
|
||||
sizeBytes,
|
||||
lifetimeDays,
|
||||
pricePerTBPerMonth = DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
}: {
|
||||
sizeBytes: bigint | number;
|
||||
lifetimeDays: number;
|
||||
pricePerTBPerMonth?: number;
|
||||
}): number {
|
||||
const sizeTB = (typeof sizeBytes === 'bigint' ? Number(sizeBytes) : sizeBytes) / 1024 / 1024 / 1024 / 1024;
|
||||
const lifetimeMonths = lifetimeDays / 30;
|
||||
return sizeTB * pricePerTBPerMonth * lifetimeMonths;
|
||||
}
|
||||
|
||||
export function getExpiryBucket(expiresAt: Date): number {
|
||||
return Number(
|
||||
`${expiresAt.getUTCFullYear()}${String(expiresAt.getUTCMonth() + 1).padStart(2, '0')}${String(
|
||||
expiresAt.getUTCDate(),
|
||||
).padStart(2, '0')}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function extendExpiry(currentExpiry: Date | null, newlyComputed: Date): Date {
|
||||
if (!currentExpiry) return newlyComputed;
|
||||
return currentExpiry > newlyComputed ? currentExpiry : newlyComputed;
|
||||
}
|
||||
|
||||
export function maybeRenewExpiry({
|
||||
currentExpiry,
|
||||
now,
|
||||
thresholdDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
|
||||
windowDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
|
||||
maxExpiry,
|
||||
}: {
|
||||
currentExpiry: Date | null;
|
||||
now: Date;
|
||||
thresholdDays?: number;
|
||||
windowDays?: number;
|
||||
maxExpiry?: Date;
|
||||
}): Date | null {
|
||||
if (!currentExpiry) return null;
|
||||
if (windowDays <= 0) return null;
|
||||
|
||||
const remainingMs = currentExpiry.getTime() - now.getTime();
|
||||
if (remainingMs > thresholdDays * MS_PER_DAY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetMs = now.getTime() + windowDays * MS_PER_DAY;
|
||||
const cappedTargetMs = maxExpiry ? Math.min(maxExpiry.getTime(), targetMs) : targetMs;
|
||||
|
||||
if (cappedTargetMs <= currentExpiry.getTime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = new Date(now);
|
||||
target.setTime(cappedTargetMs);
|
||||
return target;
|
||||
}
|
||||
94
packages/api/src/utils/AuditSerializationUtils.tsx
Normal file
94
packages/api/src/utils/AuditSerializationUtils.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import {toIdString, toSortedIdArray} from '@fluxer/api/src/utils/IdUtils';
|
||||
|
||||
export function serializeGuildForAudit(guild: Guild): Record<string, unknown> {
|
||||
return {
|
||||
guild_id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
owner_id: guild.ownerId.toString(),
|
||||
vanity_url_code: guild.vanityUrlCode ?? null,
|
||||
icon_hash: guild.iconHash ?? null,
|
||||
banner_hash: guild.bannerHash ?? null,
|
||||
banner_width: guild.bannerWidth ?? null,
|
||||
banner_height: guild.bannerHeight ?? null,
|
||||
splash_hash: guild.splashHash ?? null,
|
||||
splash_width: guild.splashWidth ?? null,
|
||||
splash_height: guild.splashHeight ?? null,
|
||||
splash_card_alignment: guild.splashCardAlignment,
|
||||
embed_splash_hash: guild.embedSplashHash ?? null,
|
||||
embed_splash_width: guild.embedSplashWidth ?? null,
|
||||
embed_splash_height: guild.embedSplashHeight ?? null,
|
||||
features: toSortedIdArray(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
mfa_level: guild.mfaLevel,
|
||||
nsfw_level: guild.nsfwLevel,
|
||||
explicit_content_filter: guild.explicitContentFilter,
|
||||
default_message_notifications: guild.defaultMessageNotifications,
|
||||
system_channel_id: toIdString(guild.systemChannelId),
|
||||
system_channel_flags: guild.systemChannelFlags,
|
||||
rules_channel_id: toIdString(guild.rulesChannelId),
|
||||
afk_channel_id: toIdString(guild.afkChannelId),
|
||||
afk_timeout: guild.afkTimeout,
|
||||
disabled_operations: guild.disabledOperations,
|
||||
member_count: guild.memberCount,
|
||||
message_history_cutoff: guild.messageHistoryCutoff ? guild.messageHistoryCutoff.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeChannelForAudit(channel: Channel): Record<string, unknown> {
|
||||
return {
|
||||
channel_id: channel.id.toString(),
|
||||
type: channel.type,
|
||||
name: channel.name ?? null,
|
||||
topic: channel.topic ?? null,
|
||||
parent_id: toIdString(channel.parentId),
|
||||
position: channel.position,
|
||||
nsfw: channel.isNsfw,
|
||||
rate_limit_per_user: channel.rateLimitPerUser,
|
||||
user_limit: channel.userLimit,
|
||||
bitrate: channel.bitrate,
|
||||
rtc_region: channel.rtcRegion ?? null,
|
||||
permission_overwrite_count: channel.permissionOverwrites ? channel.permissionOverwrites.size : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEmojiForAudit(emoji: GuildEmoji): Record<string, unknown> {
|
||||
return {
|
||||
emoji_id: emoji.id.toString(),
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
creator_id: emoji.creatorId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeStickerForAudit(sticker: GuildSticker): Record<string, unknown> {
|
||||
return {
|
||||
sticker_id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
description: sticker.description,
|
||||
animated: sticker.animated,
|
||||
creator_id: sticker.creatorId.toString(),
|
||||
};
|
||||
}
|
||||
160
packages/api/src/utils/AvatarColorUtils.tsx
Normal file
160
packages/api/src/utils/AvatarColorUtils.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
const FALLBACK_AVATAR_COLOR = 0x4641d9;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function scorePixel(r: number, g: number, b: number) {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
return {
|
||||
chroma: max - min,
|
||||
brightness: (r + g + b) / 3,
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rn) {
|
||||
hue = ((gn - bn) / delta) % 6;
|
||||
} else if (max === gn) {
|
||||
hue = (bn - rn) / delta + 2;
|
||||
} else {
|
||||
hue = (rn - gn) / delta + 4;
|
||||
}
|
||||
hue *= 60;
|
||||
if (hue < 0) {
|
||||
hue += 360;
|
||||
}
|
||||
}
|
||||
const lightness = (max + min) / 2;
|
||||
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
|
||||
return [hue, saturation, lightness];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
let r1 = 0;
|
||||
let g1 = 0;
|
||||
let b1 = 0;
|
||||
|
||||
if (hPrime >= 0 && hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
} else if (hPrime >= 1 && hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
} else if (hPrime >= 2 && hPrime < 3) {
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime >= 3 && hPrime < 4) {
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime >= 4 && hPrime < 5) {
|
||||
r1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime >= 5 && hPrime < 6) {
|
||||
r1 = c;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const m = l - c / 2;
|
||||
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
|
||||
}
|
||||
|
||||
export async function deriveDominantAvatarColor(imageBuffer: Uint8Array): Promise<number> {
|
||||
try {
|
||||
const {data, info} = await sharp(Buffer.from(imageBuffer))
|
||||
.resize(64, 64, {fit: 'inside'})
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({resolveWithObject: true});
|
||||
|
||||
const channels = info.channels ?? 4;
|
||||
const stride = Math.max(channels, 1) * 2;
|
||||
const buckets = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i + channels <= data.length; i += stride) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = channels >= 4 ? data[i + 3] : 255;
|
||||
if (a < 180) continue;
|
||||
|
||||
const {chroma, brightness} = scorePixel(r, g, b);
|
||||
if (brightness > 245 || brightness < 12) continue;
|
||||
|
||||
const qr = Math.floor(r / 12);
|
||||
const qg = Math.floor(g / 12);
|
||||
const qb = Math.floor(b / 12);
|
||||
const key = `${qr},${qg},${qb}`;
|
||||
|
||||
const weight = chroma < 18 ? 0.25 : chroma < 40 ? 0.6 : 1.0;
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + weight);
|
||||
}
|
||||
|
||||
if (buckets.size === 0) {
|
||||
return FALLBACK_AVATAR_COLOR;
|
||||
}
|
||||
|
||||
let bestScore = -Infinity;
|
||||
let bestKey: string | null = null;
|
||||
for (const [key, score] of buckets.entries()) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestKey) {
|
||||
return FALLBACK_AVATAR_COLOR;
|
||||
}
|
||||
|
||||
const [qr, qg, qb] = bestKey.split(',').map((value) => Number(value));
|
||||
const baseR = Math.min(255, Math.max(0, qr * 12));
|
||||
const baseG = Math.min(255, Math.max(0, qg * 12));
|
||||
const baseB = Math.min(255, Math.max(0, qb * 12));
|
||||
|
||||
const [h, s, l] = rgbToHsl(baseR, baseG, baseB);
|
||||
const nextL = clamp(l * 0.75, 0.28, 0.62);
|
||||
const [rr, gg, bb] = hslToRgb(h, s, nextL);
|
||||
|
||||
const finalR = Math.max(Math.floor(rr), 30);
|
||||
const finalG = Math.max(Math.floor(gg), 30);
|
||||
const finalB = Math.max(Math.floor(bb), 30);
|
||||
|
||||
return (finalR << 16) | (finalG << 8) | finalB;
|
||||
} catch {
|
||||
return FALLBACK_AVATAR_COLOR;
|
||||
}
|
||||
}
|
||||
65
packages/api/src/utils/CurrencyUtils.tsx
Normal file
65
packages/api/src/utils/CurrencyUtils.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type Currency = 'USD' | 'EUR';
|
||||
|
||||
const EEA_COUNTRIES = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'DE',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
];
|
||||
|
||||
function isEEACountry(countryCode: string): boolean {
|
||||
const upperCode = countryCode.toUpperCase();
|
||||
return EEA_COUNTRIES.includes(upperCode);
|
||||
}
|
||||
|
||||
export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
if (!countryCode) {
|
||||
return 'USD';
|
||||
}
|
||||
return isEEACountry(countryCode) ? 'EUR' : 'USD';
|
||||
}
|
||||
55
packages/api/src/utils/DOMUtils.tsx
Normal file
55
packages/api/src/utils/DOMUtils.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {decode} from 'html-entities';
|
||||
|
||||
export function decodeHTMLEntities(html?: string | null): string {
|
||||
if (!html) return '';
|
||||
return decode(html);
|
||||
}
|
||||
export function stripHtmlTags(html?: string | null): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
export function htmlToMarkdown(html?: string | null): string {
|
||||
if (!html) return '';
|
||||
let md = html
|
||||
.replace(/<p>/gi, '\n\n')
|
||||
.replace(/<\/p>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<h[1-6]>/gi, '\n\n**')
|
||||
.replace(/<\/h[1-6]>/gi, '**\n\n')
|
||||
.replace(/<li>/gi, '• ')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<ul>|<ol>/gi, '\n')
|
||||
.replace(/<\/ul>|<\/ol>/gi, '\n')
|
||||
.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => `\`\`\`\n${code}\n\`\`\``)
|
||||
.replace(/<code>([\s\S]*?)<\/code>/gi, '`$1`')
|
||||
.replace(/<strong>([\s\S]*?)<\/strong>/gi, '**$1**')
|
||||
.replace(/<b>([\s\S]*?)<\/b>/gi, '**$1**')
|
||||
.replace(/<em>([\s\S]*?)<\/em>/gi, '_$1_')
|
||||
.replace(/<i>([\s\S]*?)<\/i>/gi, '_$1_')
|
||||
.replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
|
||||
md = stripHtmlTags(md);
|
||||
md = decodeHTMLEntities(md);
|
||||
return md
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/\s+$/, '')
|
||||
.trim();
|
||||
}
|
||||
292
packages/api/src/utils/EmojiUtils.tsx
Normal file
292
packages/api/src/utils/EmojiUtils.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, UserID, WebhookID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createEmojiID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {
|
||||
PackExpressionAccessResolution,
|
||||
PackExpressionAccessResolver,
|
||||
} from '@fluxer/api/src/pack/PackExpressionAccessResolver';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
type EmojiGuildRepository = Pick<IGuildRepositoryAggregate, 'getEmoji' | 'getEmojiById'>;
|
||||
type EmojiUserRepository = Pick<IUserAccountRepository, 'findUnique'>;
|
||||
|
||||
const CUSTOM_EMOJI_MARKDOWN_REGEX = /<(a)?:([^:]+):(\d+)>/g;
|
||||
const CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL = new RegExp(CUSTOM_EMOJI_MARKDOWN_REGEX.source, 'g');
|
||||
|
||||
interface SanitizeCustomEmojisParams {
|
||||
content: string;
|
||||
userId: UserID | null;
|
||||
webhookId: WebhookID | null;
|
||||
guildId: GuildID | null;
|
||||
userRepository: EmojiUserRepository;
|
||||
guildRepository: EmojiGuildRepository;
|
||||
limitConfigService: LimitConfigService;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}
|
||||
|
||||
interface EmojiMatch {
|
||||
fullMatch: string;
|
||||
name: string;
|
||||
emojiId: EmojiID;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface CodeBlock {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export async function sanitizeCustomEmojis(params: SanitizeCustomEmojisParams): Promise<string> {
|
||||
const {
|
||||
content,
|
||||
userId,
|
||||
webhookId,
|
||||
guildId,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
limitConfigService,
|
||||
hasPermission,
|
||||
packResolver,
|
||||
} = params;
|
||||
|
||||
const escapedContexts = parseEscapedContexts(content);
|
||||
const isInEscapedContext = (index: number): boolean =>
|
||||
escapedContexts.some((ctx) => index >= ctx.start && index < ctx.end);
|
||||
|
||||
const emojiMatches = collectEmojiMatches(content, isInEscapedContext);
|
||||
if (emojiMatches.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const hasGlobalExpressions = userId
|
||||
? await checkUserGlobalExpressions(userId, userRepository, limitConfigService)
|
||||
: 0;
|
||||
const isWebhook = webhookId != null;
|
||||
|
||||
const emojiLookups = await batchFetchEmojis(emojiMatches, guildId, guildRepository);
|
||||
|
||||
let canUseExternalEmojis: boolean | null = null;
|
||||
if (hasGlobalExpressions > 0 && hasPermission && guildId) {
|
||||
const hasExternalEmojis = emojiLookups.some((lookup) => lookup.guildEmoji === null && lookup.globalEmoji !== null);
|
||||
if (hasExternalEmojis) {
|
||||
canUseExternalEmojis = await hasPermission(Permissions.USE_EXTERNAL_EMOJIS);
|
||||
}
|
||||
}
|
||||
|
||||
const replacements = await determineReplacements({
|
||||
emojiMatches,
|
||||
emojiLookups,
|
||||
guildId,
|
||||
isWebhook,
|
||||
hasGlobalExpressions,
|
||||
canUseExternalEmojis,
|
||||
packResolver,
|
||||
});
|
||||
|
||||
return applyReplacements(content, replacements);
|
||||
}
|
||||
|
||||
function parseEscapedContexts(content: string): Array<CodeBlock> {
|
||||
const contexts: Array<CodeBlock> = [];
|
||||
|
||||
const blockCodeRegex = /```[\s\S]*?```/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = blockCodeRegex.exec(content)) !== null) {
|
||||
contexts.push({start: match.index, end: match.index + match[0].length});
|
||||
}
|
||||
|
||||
const inlineCodeRegex = /`[^`]+`/g;
|
||||
while ((match = inlineCodeRegex.exec(content)) !== null) {
|
||||
const isInsideBlock = contexts.some((ctx) => match!.index >= ctx.start && match!.index < ctx.end);
|
||||
if (!isInsideBlock) {
|
||||
contexts.push({start: match.index, end: match.index + match[0].length});
|
||||
}
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function collectEmojiMatches(content: string, isInEscapedContext: (index: number) => boolean): Array<EmojiMatch> {
|
||||
const matches: Array<EmojiMatch> = [];
|
||||
CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL.exec(content)) !== null) {
|
||||
if (isInEscapedContext(match.index)) continue;
|
||||
|
||||
const [fullMatch, , name, emojiId] = match;
|
||||
matches.push({
|
||||
fullMatch,
|
||||
name,
|
||||
emojiId: createEmojiID(BigInt(emojiId)),
|
||||
start: match.index,
|
||||
end: match.index + fullMatch.length,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function checkUserGlobalExpressions(
|
||||
userId: UserID,
|
||||
userRepository: EmojiUserRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
): Promise<number> {
|
||||
const user = await userRepository.findUnique(userId);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
return resolveLimitSafe(limitConfigService.getConfigSnapshot(), ctx, 'feature_global_expressions', 0);
|
||||
}
|
||||
|
||||
interface EmojiLookupResult {
|
||||
emojiId: EmojiID;
|
||||
guildEmoji: GuildEmoji | null;
|
||||
globalEmoji: GuildEmoji | null;
|
||||
}
|
||||
|
||||
async function batchFetchEmojis(
|
||||
matches: Array<EmojiMatch>,
|
||||
guildId: GuildID | null,
|
||||
guildRepository: EmojiGuildRepository,
|
||||
): Promise<Array<EmojiLookupResult>> {
|
||||
const uniqueEmojiIds = [...new Set(matches.map((m) => m.emojiId))];
|
||||
|
||||
const lookupResults = await Promise.all(
|
||||
uniqueEmojiIds.map(async (emojiId) => {
|
||||
const [guildEmoji, globalEmoji] = await Promise.all([
|
||||
guildId ? guildRepository.getEmoji(emojiId, guildId) : Promise.resolve(null),
|
||||
guildRepository.getEmojiById(emojiId),
|
||||
]);
|
||||
return {emojiId, guildEmoji, globalEmoji};
|
||||
}),
|
||||
);
|
||||
|
||||
const lookupMap = new Map<EmojiID, EmojiLookupResult>();
|
||||
for (const result of lookupResults) {
|
||||
lookupMap.set(result.emojiId, result);
|
||||
}
|
||||
|
||||
return matches.map((match) => lookupMap.get(match.emojiId)!);
|
||||
}
|
||||
|
||||
interface Replacement {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
async function determineReplacements(params: {
|
||||
emojiMatches: Array<EmojiMatch>;
|
||||
emojiLookups: Array<EmojiLookupResult>;
|
||||
guildId: GuildID | null;
|
||||
isWebhook: boolean;
|
||||
hasGlobalExpressions: number;
|
||||
canUseExternalEmojis: boolean | null;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}): Promise<Array<Replacement>> {
|
||||
const {emojiMatches, emojiLookups, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis} = params;
|
||||
const replacements: Array<Replacement> = [];
|
||||
|
||||
for (let i = 0; i < emojiMatches.length; i++) {
|
||||
const match = emojiMatches[i];
|
||||
const lookup = emojiLookups[i];
|
||||
const shouldReplace = await shouldReplaceEmoji({
|
||||
lookup,
|
||||
guildId,
|
||||
isWebhook,
|
||||
hasGlobalExpressions,
|
||||
canUseExternalEmojis,
|
||||
packResolver: params.packResolver,
|
||||
});
|
||||
|
||||
if (shouldReplace) {
|
||||
replacements.push({
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
replacement: `:${match.name}:`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
async function shouldReplaceEmoji(params: {
|
||||
lookup: EmojiLookupResult;
|
||||
guildId: GuildID | null;
|
||||
isWebhook: boolean;
|
||||
hasGlobalExpressions: number;
|
||||
canUseExternalEmojis: boolean | null;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}): Promise<boolean> {
|
||||
const {lookup, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis, packResolver} = params;
|
||||
|
||||
if (!guildId) {
|
||||
if (!lookup.globalEmoji) return true;
|
||||
if (!isWebhook && hasGlobalExpressions === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookup.guildEmoji) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!lookup.globalEmoji) return true;
|
||||
|
||||
const packAccess = await resolvePackAccessStatus(lookup.globalEmoji.guildId, packResolver);
|
||||
if (packAccess === 'not-accessible') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isWebhook && hasGlobalExpressions === 0) return true;
|
||||
|
||||
if (hasGlobalExpressions > 0 && canUseExternalEmojis === false) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolvePackAccessStatus(
|
||||
packId: GuildID,
|
||||
packResolver?: PackExpressionAccessResolver,
|
||||
): Promise<PackExpressionAccessResolution> {
|
||||
if (!packResolver) return 'not-pack';
|
||||
return await packResolver.resolve(packId);
|
||||
}
|
||||
|
||||
function applyReplacements(content: string, replacements: Array<Replacement>): string {
|
||||
if (replacements.length === 0) return content;
|
||||
|
||||
const sorted = [...replacements].sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = content;
|
||||
for (const {start, end, replacement} of sorted) {
|
||||
result = result.substring(0, start) + replacement + result.substring(end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
61
packages/api/src/utils/FetchUtils.tsx
Normal file
61
packages/api/src/utils/FetchUtils.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createHttpClient} from '@fluxer/http_client/src/HttpClient';
|
||||
import type {HttpClient, RequestOptions, StreamResponse} from '@fluxer/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
|
||||
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
|
||||
const client: HttpClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
requestUrlPolicy,
|
||||
});
|
||||
const redirectScopedClients = new Map<number, HttpClient>();
|
||||
|
||||
interface SendRequestOptions {
|
||||
maxRedirects?: number;
|
||||
}
|
||||
|
||||
function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
|
||||
if (!options?.maxRedirects) {
|
||||
return client;
|
||||
}
|
||||
|
||||
const existingClient = redirectScopedClients.get(options.maxRedirects);
|
||||
if (existingClient) {
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
const redirectScopedClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
maxRedirects: options.maxRedirects,
|
||||
requestUrlPolicy,
|
||||
});
|
||||
redirectScopedClients.set(options.maxRedirects, redirectScopedClient);
|
||||
return redirectScopedClient;
|
||||
}
|
||||
|
||||
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
|
||||
const requestClient = getHttpClientForRequest(options);
|
||||
return requestClient.sendRequest(opts);
|
||||
}
|
||||
|
||||
export function streamToString(stream: StreamResponse['stream']) {
|
||||
return client.streamToString(stream);
|
||||
}
|
||||
37
packages/api/src/utils/GeoUtils.tsx
Normal file
37
packages/api/src/utils/GeoUtils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
export function parseCoordinate(value?: string | null): number | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
138
packages/api/src/utils/GuildVerificationUtils.tsx
Normal file
138
packages/api/src/utils/GuildVerificationUtils.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createRoleIDSet, createUserID, type RoleID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {EmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError';
|
||||
import {GuildPhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/GuildPhoneVerificationRequiredError';
|
||||
import {AccountTooNewForGuildError} from '@fluxer/errors/src/domains/guild/AccountTooNewForGuildError';
|
||||
import {GuildVerificationRequiredError} from '@fluxer/errors/src/domains/guild/GuildVerificationRequiredError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface VerificationParams {
|
||||
user: User;
|
||||
ownerId: UserID;
|
||||
verificationLevel: number;
|
||||
memberJoinedAt?: Date | string | null;
|
||||
memberRoles?: Set<RoleID>;
|
||||
}
|
||||
|
||||
function checkGuildVerification(params: VerificationParams): void {
|
||||
const {user, ownerId, verificationLevel, memberJoinedAt, memberRoles} = params;
|
||||
|
||||
if (user.id === ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (verificationLevel === GuildVerificationLevel.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.isBot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memberRoles && memberRoles.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw new GuildVerificationRequiredError('You need to claim your account to send messages in this guild.');
|
||||
}
|
||||
|
||||
if (verificationLevel >= GuildVerificationLevel.LOW) {
|
||||
if (!user.emailVerified) {
|
||||
throw new EmailVerificationRequiredError();
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
|
||||
const createdAt = snowflakeToDate(BigInt(user.id)).getTime();
|
||||
const accountAge = Date.now() - createdAt;
|
||||
if (accountAge < ms('5 minutes')) {
|
||||
throw new AccountTooNewForGuildError();
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationLevel >= GuildVerificationLevel.HIGH) {
|
||||
if (memberJoinedAt) {
|
||||
const joinedAtTime =
|
||||
typeof memberJoinedAt === 'string' ? new Date(memberJoinedAt).getTime() : memberJoinedAt.getTime();
|
||||
const membershipDuration = Date.now() - joinedAtTime;
|
||||
if (membershipDuration < ms('10 minutes')) {
|
||||
throw new GuildVerificationRequiredError(
|
||||
"You haven't been a member of this guild long enough to send messages.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationLevel >= GuildVerificationLevel.VERY_HIGH) {
|
||||
if (!user.phone) {
|
||||
throw new GuildPhoneVerificationRequiredError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function checkGuildVerificationWithGuildModel({
|
||||
user,
|
||||
guild,
|
||||
member,
|
||||
}: {
|
||||
user: User;
|
||||
guild: Guild;
|
||||
member: GuildMember;
|
||||
}): void {
|
||||
checkGuildVerification({
|
||||
user,
|
||||
ownerId: guild.ownerId,
|
||||
verificationLevel: guild.verificationLevel ?? GuildVerificationLevel.NONE,
|
||||
memberJoinedAt: member.joinedAt,
|
||||
memberRoles: member.roleIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function checkGuildVerificationWithResponse({
|
||||
user,
|
||||
guild,
|
||||
member,
|
||||
}: {
|
||||
user: User;
|
||||
guild: GuildResponse;
|
||||
member: GuildMemberResponse;
|
||||
}): void {
|
||||
if (!guild.owner_id) {
|
||||
throw new Error('Guild owner_id is missing - cannot perform verification');
|
||||
}
|
||||
const ownerIdBigInt = typeof guild.owner_id === 'bigint' ? guild.owner_id : BigInt(guild.owner_id);
|
||||
|
||||
checkGuildVerification({
|
||||
user,
|
||||
ownerId: createUserID(ownerIdBigInt),
|
||||
verificationLevel: guild.verification_level ?? GuildVerificationLevel.NONE,
|
||||
memberJoinedAt: member.joined_at,
|
||||
memberRoles: createRoleIDSet(new Set(member.roles.map((roleId) => BigInt(roleId)))),
|
||||
});
|
||||
}
|
||||
41
packages/api/src/utils/IdUtils.tsx
Normal file
41
packages/api/src/utils/IdUtils.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
|
||||
export function toIdString(
|
||||
value: GuildID | ChannelID | RoleID | UserID | EmojiID | StickerID | bigint | string | null | undefined,
|
||||
): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
function toIdArray<T extends {toString(): string}>(values: Array<T> | Set<T> | null | undefined): Array<string> {
|
||||
if (!values) return [];
|
||||
return Array.from(values).map((v) => v.toString());
|
||||
}
|
||||
|
||||
export function toSortedIdArray<T extends {toString(): string}>(
|
||||
values: Array<T> | Set<T> | null | undefined,
|
||||
): Array<string> {
|
||||
return toIdArray(values).sort();
|
||||
}
|
||||
75
packages/api/src/utils/InviteUtils.tsx
Normal file
75
packages/api/src/utils/InviteUtils.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import * as RegexUtils from '@fluxer/api/src/utils/RegexUtils';
|
||||
|
||||
let _invitePattern: RegExp | null = null;
|
||||
|
||||
function getInvitePattern(): RegExp {
|
||||
if (!_invitePattern) {
|
||||
_invitePattern = new RegExp(
|
||||
[
|
||||
'(?:https?:\\/\\/)?',
|
||||
'(?:',
|
||||
`${RegexUtils.escapeRegex(Config.hosts.invite)}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
'|',
|
||||
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
|
||||
')',
|
||||
].join(''),
|
||||
'gi',
|
||||
);
|
||||
}
|
||||
return _invitePattern;
|
||||
}
|
||||
|
||||
export function findInvites(content: string | null): Array<string> {
|
||||
if (!content) return [];
|
||||
|
||||
const invites: Array<string> = [];
|
||||
const seenCodes = new Set<string>();
|
||||
|
||||
const pattern = getInvitePattern();
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null && invites.length < 10) {
|
||||
const code = match[1] || match[2];
|
||||
if (code && !seenCodes.has(code)) {
|
||||
seenCodes.add(code);
|
||||
invites.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
return invites;
|
||||
}
|
||||
|
||||
export function findInvite(content: string | null): string | null {
|
||||
if (!content) return null;
|
||||
|
||||
const pattern = getInvitePattern();
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(content);
|
||||
|
||||
if (match) {
|
||||
return match[1] || match[2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
321
packages/api/src/utils/IpRangeUtils.tsx
Normal file
321
packages/api/src/utils/IpRangeUtils.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {isIP} from 'node:net';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
interface ParsedBase {
|
||||
canonical: string;
|
||||
family: IpAddressFamily;
|
||||
}
|
||||
|
||||
export interface ParsedIpSingle extends ParsedBase {
|
||||
type: 'single';
|
||||
value: bigint;
|
||||
}
|
||||
|
||||
export interface ParsedIpRange extends ParsedBase {
|
||||
type: 'range';
|
||||
prefixLength: number;
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
export type IpBanEntry = ParsedIpSingle | ParsedIpRange;
|
||||
|
||||
const BIT_LENGTHS: Record<IpAddressFamily, number> = {
|
||||
ipv4: 32,
|
||||
ipv6: 128,
|
||||
};
|
||||
|
||||
function bufferToBigInt(bytes: Buffer): bigint {
|
||||
let value = 0n;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8n) | BigInt(byte);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatNormalizedAddress(family: IpAddressFamily, bytes: Buffer): string {
|
||||
if (family === 'ipv4') {
|
||||
return Array.from(bytes.values()).join('.');
|
||||
}
|
||||
|
||||
const groups: Array<string> = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
const high = bytes[i];
|
||||
const low = bytes[i + 1];
|
||||
const value = (high << 8) | low;
|
||||
groups.push(value.toString(16).padStart(4, '0'));
|
||||
}
|
||||
return groups.join(':');
|
||||
}
|
||||
|
||||
interface ParsedIP {
|
||||
family: IpAddressFamily;
|
||||
bytes: Buffer;
|
||||
mappedIpv4Bytes: Buffer | null;
|
||||
}
|
||||
|
||||
interface ParsedCIDR {
|
||||
family: IpAddressFamily;
|
||||
address: Buffer;
|
||||
prefixLength: number;
|
||||
}
|
||||
|
||||
function parseRange(value: string): ParsedIpRange | null {
|
||||
const normalized = normalizeIpString(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const parsed = parseCIDR(normalized);
|
||||
if (!parsed) return null;
|
||||
|
||||
const {family, address, prefixLength} = parsed;
|
||||
const totalBits = BIT_LENGTHS[family];
|
||||
const hostBits = totalBits - prefixLength;
|
||||
const baseValue = bufferToBigInt(address);
|
||||
const mask = hostBits === 0 ? 0n : (1n << BigInt(hostBits)) - 1n;
|
||||
const start = baseValue & ~mask;
|
||||
const end = baseValue | mask;
|
||||
|
||||
return {
|
||||
type: 'range',
|
||||
family,
|
||||
canonical: `${formatNormalizedAddress(family, address)}/${prefixLength}`,
|
||||
prefixLength,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSingle(value: string): ParsedIpSingle | null {
|
||||
const normalized = normalizeIpString(value);
|
||||
if (!normalized || normalized.includes('/')) return null;
|
||||
|
||||
const parsed = parseIP(normalized);
|
||||
if (!parsed) return null;
|
||||
|
||||
const resolved = resolveMappedIpv4(parsed);
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
family: resolved.family,
|
||||
canonical: formatNormalizedAddress(resolved.family, resolved.bytes),
|
||||
value: bufferToBigInt(resolved.bytes),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMappedIpv4(parsed: ParsedIP): {family: IpAddressFamily; bytes: Buffer} {
|
||||
if (parsed.family === 'ipv6' && parsed.mappedIpv4Bytes) {
|
||||
return {family: 'ipv4', bytes: parsed.mappedIpv4Bytes};
|
||||
}
|
||||
|
||||
return {family: parsed.family, bytes: parsed.bytes};
|
||||
}
|
||||
|
||||
function extractMappedIpv4(bytes: Buffer): Buffer | null {
|
||||
if (bytes.length !== 16) return null;
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (bytes[i] !== 0) return null;
|
||||
}
|
||||
|
||||
if (bytes[10] !== 0xff || bytes[11] !== 0xff) return 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;
|
||||
|
||||
const slashIdx = trimmed.indexOf('/');
|
||||
if (slashIdx === -1) return null;
|
||||
|
||||
const ipPart = trimmed.slice(0, slashIdx).trim();
|
||||
const prefixPart = trimmed.slice(slashIdx + 1).trim();
|
||||
if (!ipPart || !prefixPart) return null;
|
||||
|
||||
const prefixLength = Number(prefixPart);
|
||||
if (!Number.isInteger(prefixLength) || prefixLength < 0) return null;
|
||||
|
||||
const parsed = parseIP(ipPart);
|
||||
if (!parsed) return null;
|
||||
|
||||
let resolvedFamily = parsed.family;
|
||||
let resolvedAddress = Buffer.from(parsed.bytes);
|
||||
let resolvedPrefix = prefixLength;
|
||||
|
||||
if (parsed.family === 'ipv6' && parsed.mappedIpv4Bytes && prefixLength >= 96) {
|
||||
resolvedFamily = 'ipv4';
|
||||
resolvedAddress = Buffer.from(parsed.mappedIpv4Bytes);
|
||||
resolvedPrefix = prefixLength - 96;
|
||||
}
|
||||
|
||||
const maxPrefix = resolvedFamily === 'ipv4' ? 32 : 128;
|
||||
if (resolvedPrefix > maxPrefix) return null;
|
||||
|
||||
const network = Buffer.from(resolvedAddress);
|
||||
const fullBytes = Math.floor(resolvedPrefix / 8);
|
||||
const remainingBits = resolvedPrefix % 8;
|
||||
|
||||
if (remainingBits > 0 && fullBytes < network.length) {
|
||||
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
network[fullBytes] &= mask;
|
||||
for (let i = fullBytes + 1; i < network.length; i++) {
|
||||
network[i] = 0;
|
||||
}
|
||||
} else {
|
||||
for (let i = fullBytes; i < network.length; i++) {
|
||||
network[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
family: resolvedFamily,
|
||||
address: network,
|
||||
prefixLength: resolvedPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIpBanEntry(value: string): IpBanEntry | null {
|
||||
return parseRange(value) ?? parseSingle(value);
|
||||
}
|
||||
|
||||
export function tryParseSingleIp(value: string): ParsedIpSingle | null {
|
||||
const parsed = parseIpBanEntry(value);
|
||||
return parsed?.type === 'single' ? parsed : null;
|
||||
}
|
||||
|
||||
export function isValidIpOrRange(value: string): boolean {
|
||||
return parseIpBanEntry(value) !== null;
|
||||
}
|
||||
205
packages/api/src/utils/IpUtils.tsx
Normal file
205
packages/api/src/utils/IpUtils.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import dns from 'node:dns';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import maxmind, {type CityResponse, type Reader} from 'maxmind';
|
||||
|
||||
const REVERSE_DNS_CACHE_TTL_SECONDS = seconds('1 day');
|
||||
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
|
||||
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
|
||||
export interface GeoipResult {
|
||||
countryCode: string | null;
|
||||
normalizedIp: string | null;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
countryName: string | null;
|
||||
}
|
||||
|
||||
type CacheEntry = {
|
||||
result: GeoipResult;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
|
||||
|
||||
export async function lookupGeoip(req: Request): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(ip: string): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(input: string | Request): Promise<GeoipResult> {
|
||||
const ip =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: extractClientIp(input, {trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip});
|
||||
if (!ip) {
|
||||
return buildFallbackResult('');
|
||||
}
|
||||
return lookupGeoipFromString(ip);
|
||||
}
|
||||
|
||||
function buildFallbackResult(clean: string): GeoipResult {
|
||||
return {
|
||||
countryCode: null,
|
||||
normalizedIp: clean || null,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
if (maxmindReader) return maxmindReader;
|
||||
|
||||
if (!maxmindReaderPromise) {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
throw new Error('Missing MaxMind DB path');
|
||||
}
|
||||
|
||||
maxmindReaderPromise = maxmind
|
||||
.open<CityResponse>(dbPath)
|
||||
.then((reader) => {
|
||||
maxmindReader = reader;
|
||||
return reader;
|
||||
})
|
||||
.catch((error) => {
|
||||
maxmindReaderPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return maxmindReaderPromise;
|
||||
}
|
||||
|
||||
function stateLabel(record?: CityResponse): string | null {
|
||||
const subdivision = record?.subdivisions?.[0];
|
||||
if (!subdivision) return null;
|
||||
return subdivision.names?.en || subdivision.iso_code || null;
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = await ensureMaxmindReader();
|
||||
const record = reader.get(clean);
|
||||
if (!record) return buildFallbackResult(clean);
|
||||
|
||||
const isoCode = record.country?.iso_code;
|
||||
const countryCode = isoCode ? isoCode.toUpperCase() : null;
|
||||
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: clean,
|
||||
city: record.city?.names?.en ?? null,
|
||||
region: stateLabel(record),
|
||||
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error).message ?? 'unknown';
|
||||
Logger.warn({error, maxmind_db_path: dbPath, message}, 'MaxMind lookup failed');
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
const cached = geoipCache.get(clean);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = await lookupMaxmind(clean);
|
||||
geoipCache.set(clean, {result, expiresAt: now + ms('10 minutes')});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function lookupGeoipFromString(value: string): Promise<GeoipResult> {
|
||||
const clean = normalizeIpString(value);
|
||||
if (!isValidIp(clean)) {
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
return resolveGeoip(clean);
|
||||
}
|
||||
|
||||
function countryDisplayName(code: string, locale = 'en'): string | null {
|
||||
const upper = code.toUpperCase();
|
||||
if (!isAsciiUpperAlpha2(upper)) return null;
|
||||
return getRegionDisplayName(upper, {locale}) ?? null;
|
||||
}
|
||||
|
||||
export function formatGeoipLocation(result: GeoipResult): string | null {
|
||||
const parts: Array<string> = [];
|
||||
if (result.city) parts.push(result.city);
|
||||
if (result.region) parts.push(result.region);
|
||||
const countryLabel = result.countryName ?? result.countryCode;
|
||||
if (countryLabel) parts.push(countryLabel);
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
export async function getIpAddressReverse(ip: string, cacheService?: ICacheService): Promise<string | null> {
|
||||
const cacheKey = `${REVERSE_DNS_CACHE_PREFIX}${ip}`;
|
||||
if (cacheService) {
|
||||
const cached = await cacheService.get<string | null>(cacheKey);
|
||||
if (cached !== null) return cached === '' ? null : cached;
|
||||
}
|
||||
|
||||
let result: string | null = null;
|
||||
try {
|
||||
const hostnames = await dns.promises.reverse(ip);
|
||||
result = hostnames[0] ?? null;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (cacheService) {
|
||||
await cacheService.set(cacheKey, result ?? '', REVERSE_DNS_CACHE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLocationLabelFromIp(ip: string): Promise<string | null> {
|
||||
const result = await lookupGeoip(ip);
|
||||
return formatGeoipLocation(result);
|
||||
}
|
||||
|
||||
function isAsciiUpperAlpha2(value: string): boolean {
|
||||
return (
|
||||
value.length === 2 &&
|
||||
value.charCodeAt(0) >= 65 &&
|
||||
value.charCodeAt(0) <= 90 &&
|
||||
value.charCodeAt(1) >= 65 &&
|
||||
value.charCodeAt(1) <= 90
|
||||
);
|
||||
}
|
||||
127
packages/api/src/utils/LosslessJsonParser.tsx
Normal file
127
packages/api/src/utils/LosslessJsonParser.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// json ids (snowflakes) are often emitted as unquoted numbers by older clients and bot libraries.
|
||||
// in js, numbers above 2^53 - 1 lose integer precision, which breaks id validation and routing.
|
||||
//
|
||||
// this helper does a tiny pre-pass over the raw json text and wraps any *unsafe* integer literals in quotes
|
||||
// before handing off to `JSON.parse`. that way, schema code can treat large ids as strings and stay lossless.
|
||||
//
|
||||
// this is intentionally not a full json parser; it only tokenises enough to avoid touching string contents.
|
||||
const MAX_SAFE_INTEGER_DECIMAL = Number.MAX_SAFE_INTEGER.toString();
|
||||
|
||||
function isDigit(char: string): boolean {
|
||||
return char >= '0' && char <= '9';
|
||||
}
|
||||
|
||||
function isValidJsonIntegerToken(token: string): boolean {
|
||||
// only plain integers (no decimals/exponents), and only if they'd be valid json numbers.
|
||||
// note: json doesn't allow leading zeros (except the literal "0").
|
||||
if (!/^-?\d+$/.test(token)) return false;
|
||||
if (token === '0' || token === '-0') return true;
|
||||
const digits = token[0] === '-' ? token.slice(1) : token;
|
||||
return digits.length > 0 && digits[0] !== '0';
|
||||
}
|
||||
|
||||
function isUnsafeIntegerToken(token: string): boolean {
|
||||
// call this only for tokens that are already known to be valid json integers.
|
||||
// we compare as strings so we don't accidentally introduce precision loss here too.
|
||||
const digits = token[0] === '-' ? token.slice(1) : token;
|
||||
if (digits === '0') return false;
|
||||
|
||||
if (digits.length < MAX_SAFE_INTEGER_DECIMAL.length) return false;
|
||||
if (digits.length > MAX_SAFE_INTEGER_DECIMAL.length) return true;
|
||||
return digits > MAX_SAFE_INTEGER_DECIMAL;
|
||||
}
|
||||
|
||||
function coerceUnsafeIntegersToStrings(jsonText: string): string {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
let i = 0;
|
||||
let lastCopyIndex = 0;
|
||||
let outputParts: Array<string> | null = null;
|
||||
|
||||
// walk the json once, keeping track of whether we're inside a string literal.
|
||||
// we only consider number tokens when we're *not* in a string.
|
||||
while (i < jsonText.length) {
|
||||
const char = jsonText[i]!;
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// detect a json number token start (only '-' or a digit are valid starts).
|
||||
if (char === '-' || isDigit(char)) {
|
||||
const start = i;
|
||||
i++;
|
||||
|
||||
// consume the remainder of the number token.
|
||||
// we keep this permissive and rely on `JSON.parse` to reject anything that's not actually json.
|
||||
while (i < jsonText.length) {
|
||||
const c = jsonText[i]!;
|
||||
if (isDigit(c) || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const token = jsonText.slice(start, i);
|
||||
if (isValidJsonIntegerToken(token) && isUnsafeIntegerToken(token)) {
|
||||
// lazily build an output buffer only if we actually find something to rewrite.
|
||||
// this keeps the common case (already-quoted ids) allocation-free.
|
||||
if (!outputParts) {
|
||||
outputParts = [];
|
||||
}
|
||||
outputParts.push(jsonText.slice(lastCopyIndex, start), `"${token}"`);
|
||||
lastCopyIndex = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!outputParts) {
|
||||
return jsonText;
|
||||
}
|
||||
|
||||
outputParts.push(jsonText.slice(lastCopyIndex));
|
||||
return outputParts.join('');
|
||||
}
|
||||
|
||||
export function parseJsonPreservingLargeIntegers(jsonText: string): unknown {
|
||||
const processed = coerceUnsafeIntegersToStrings(jsonText);
|
||||
return JSON.parse(processed) as unknown;
|
||||
}
|
||||
42
packages/api/src/utils/PasswordUtils.tsx
Normal file
42
packages/api/src/utils/PasswordUtils.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import argon2 from 'argon2';
|
||||
|
||||
const TEST_ARGON2_OPTIONS: argon2.Options = {
|
||||
memoryCost: 1024,
|
||||
timeCost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const options = Config.dev.testModeEnabled ? TEST_ARGON2_OPTIONS : undefined;
|
||||
return argon2.hash(password, options);
|
||||
}
|
||||
|
||||
export async function verifyPassword({
|
||||
password,
|
||||
passwordHash,
|
||||
}: {
|
||||
password: string;
|
||||
passwordHash: string;
|
||||
}): Promise<boolean> {
|
||||
return argon2.verify(passwordHash, password);
|
||||
}
|
||||
111
packages/api/src/utils/PermissionUtils.tsx
Normal file
111
packages/api/src/utils/PermissionUtils.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
|
||||
interface PermissionsDiff {
|
||||
added: Array<string>;
|
||||
removed: Array<string>;
|
||||
}
|
||||
|
||||
export function computePermissionsDiff(oldPermissions: bigint, newPermissions: bigint): PermissionsDiff {
|
||||
const added: Array<string> = [];
|
||||
const removed: Array<string> = [];
|
||||
|
||||
for (const [name, value] of Object.entries(Permissions)) {
|
||||
const hadPermission = (oldPermissions & value) !== 0n;
|
||||
const hasPermission = (newPermissions & value) !== 0n;
|
||||
|
||||
if (!hadPermission && hasPermission) {
|
||||
added.push(name);
|
||||
} else if (hadPermission && !hasPermission) {
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return {added, removed};
|
||||
}
|
||||
|
||||
export async function requirePermission(
|
||||
gatewayService: IGatewayService,
|
||||
params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
permission: bigint;
|
||||
channelId?: ChannelID;
|
||||
},
|
||||
): Promise<void> {
|
||||
const result = await gatewayService.checkPermission(params);
|
||||
|
||||
let permissionId = 'unknown';
|
||||
for (const [name, value] of Object.entries(Permissions)) {
|
||||
if (value === params.permission) {
|
||||
permissionId = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {
|
||||
permission_id: permissionId,
|
||||
granted: result.toString(),
|
||||
resource_type: params.channelId ? 'channel' : 'guild',
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasPermission(
|
||||
gatewayService: IGatewayService,
|
||||
params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
permission: bigint;
|
||||
channelId?: ChannelID;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const result = await gatewayService.checkPermission(params);
|
||||
|
||||
let permissionId = 'unknown';
|
||||
for (const [name, value] of Object.entries(Permissions)) {
|
||||
if (value === params.permission) {
|
||||
permissionId = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {
|
||||
permission_id: permissionId,
|
||||
granted: result.toString(),
|
||||
resource_type: params.channelId ? 'channel' : 'guild',
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
49
packages/api/src/utils/RandomUtils.tsx
Normal file
49
packages/api/src/utils/RandomUtils.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const RANDOM_STRING_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
export function randomString(length: number) {
|
||||
const alphabetLength = RANDOM_STRING_ALPHABET.length;
|
||||
const rangeSize = 256 - (256 % alphabetLength);
|
||||
const randomBytes = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
|
||||
let result = '';
|
||||
let byteIndex = 0;
|
||||
|
||||
while (result.length < length) {
|
||||
if (byteIndex >= randomBytes.length) {
|
||||
crypto.getRandomValues(randomBytes);
|
||||
byteIndex = 0;
|
||||
}
|
||||
|
||||
const randomByte = randomBytes[byteIndex++];
|
||||
|
||||
if (randomByte >= rangeSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result += RANDOM_STRING_ALPHABET.charAt(randomByte % alphabetLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
22
packages/api/src/utils/RegexUtils.tsx
Normal file
22
packages/api/src/utils/RegexUtils.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function escapeRegex(str: string) {
|
||||
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
|
||||
}
|
||||
56
packages/api/src/utils/RequestPathUtils.tsx
Normal file
56
packages/api/src/utils/RequestPathUtils.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const VERSION_PATTERN = /^\/v\d+/;
|
||||
|
||||
export function stripApiPrefix(path: string): string {
|
||||
if (path === '/api') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/')) {
|
||||
const afterApi = path.slice(4);
|
||||
if (VERSION_PATTERN.test(afterApi)) {
|
||||
const versionMatch = afterApi.match(VERSION_PATTERN);
|
||||
if (versionMatch) {
|
||||
const remaining = afterApi.slice(versionMatch[0].length);
|
||||
return remaining === '' ? '/' : remaining;
|
||||
}
|
||||
}
|
||||
return afterApi;
|
||||
}
|
||||
|
||||
if (VERSION_PATTERN.test(path)) {
|
||||
const versionMatch = path.match(VERSION_PATTERN);
|
||||
if (versionMatch) {
|
||||
const remaining = path.slice(versionMatch[0].length);
|
||||
return remaining === '' ? '/' : remaining;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function normalizeRequestPath(path: string): string {
|
||||
let normalized = stripApiPrefix(path);
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
25
packages/api/src/utils/StringUtils.tsx
Normal file
25
packages/api/src/utils/StringUtils.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {decode} from 'html-entities';
|
||||
import _ from 'lodash';
|
||||
|
||||
export function parseString(value: string, maxLength: number) {
|
||||
return _.truncate(decode(value).trim(), {length: maxLength});
|
||||
}
|
||||
79
packages/api/src/utils/SudoCookieUtils.tsx
Normal file
79
packages/api/src/utils/SudoCookieUtils.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import type {Context} from 'hono';
|
||||
import {getCookie, setCookie} from 'hono/cookie';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const SUDO_COOKIE_PREFIX = '__flx_sudo';
|
||||
|
||||
const SUDO_COOKIE_MAX_AGE = seconds('5 minutes');
|
||||
|
||||
function getCookieDomain(): string {
|
||||
const domain = Config.cookie.domain;
|
||||
if (domain) {
|
||||
return domain;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(Config.endpoints.webApp);
|
||||
const hostname = url.hostname;
|
||||
const parts = hostname.split('.');
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
} else {
|
||||
return hostname;
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getSudoCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: Config.cookie.secure,
|
||||
sameSite: 'Lax' as const,
|
||||
domain: getCookieDomain(),
|
||||
path: '/',
|
||||
maxAge: SUDO_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function sudoCookieName(userId?: string | number): string {
|
||||
if (userId === undefined || userId === null) {
|
||||
return SUDO_COOKIE_PREFIX;
|
||||
}
|
||||
return `${SUDO_COOKIE_PREFIX}_${userId}`;
|
||||
}
|
||||
|
||||
export function setSudoCookie(ctx: Context<HonoEnv>, token: string, userId?: string | number): void {
|
||||
const cookieName = sudoCookieName(userId);
|
||||
const options = getSudoCookieOptions();
|
||||
|
||||
setCookie(ctx, cookieName, token, options);
|
||||
}
|
||||
|
||||
export function getSudoCookie(ctx: Context<HonoEnv>, userId?: string | number): string | undefined {
|
||||
const cookieName = sudoCookieName(userId);
|
||||
return getCookie(ctx, cookieName);
|
||||
}
|
||||
180
packages/api/src/utils/TotpGenerator.tsx
Normal file
180
packages/api/src/utils/TotpGenerator.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {timingSafeEqual, webcrypto} from 'node:crypto';
|
||||
|
||||
export type HashAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||
export interface TotpOptions {
|
||||
timeStep?: number;
|
||||
digits?: number;
|
||||
window?: number;
|
||||
algorithm?: HashAlgorithm;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
export class TotpGenerator {
|
||||
private readonly timeStep: number;
|
||||
private readonly digits: number;
|
||||
private readonly window: number;
|
||||
private readonly algorithm: HashAlgorithm;
|
||||
private readonly startTime: number;
|
||||
|
||||
private readonly secretBytes: Uint8Array;
|
||||
private readonly keyPromise: Promise<webcrypto.CryptoKey>;
|
||||
|
||||
constructor(secretBase32: string, options: TotpOptions = {}) {
|
||||
this.timeStep = options.timeStep ?? 30;
|
||||
this.digits = options.digits ?? 6;
|
||||
this.window = options.window ?? 1;
|
||||
this.algorithm = options.algorithm ?? 'SHA-1';
|
||||
this.startTime = options.startTime ?? 0;
|
||||
|
||||
if (!Number.isInteger(this.timeStep) || this.timeStep <= 0) {
|
||||
throw new Error('Invalid timeStep.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(this.digits) || this.digits <= 0 || this.digits > 10) {
|
||||
throw new Error('Invalid digits.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(this.window) || this.window < 0 || this.window > 50) {
|
||||
throw new Error('Invalid window.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(this.startTime) || this.startTime < 0) {
|
||||
throw new Error('Invalid startTime.');
|
||||
}
|
||||
|
||||
if (this.algorithm !== 'SHA-1' && this.algorithm !== 'SHA-256' && this.algorithm !== 'SHA-512') {
|
||||
throw new Error('Invalid algorithm.');
|
||||
}
|
||||
|
||||
const normalized = TotpGenerator.normalizeBase32(secretBase32);
|
||||
this.secretBytes = TotpGenerator.base32ToBytes(normalized);
|
||||
|
||||
const {subtle} = webcrypto;
|
||||
const keyBytes = new ArrayBuffer(this.secretBytes.byteLength);
|
||||
new Uint8Array(keyBytes).set(this.secretBytes);
|
||||
this.keyPromise = subtle.importKey('raw', keyBytes, {name: 'HMAC', hash: this.algorithm}, false, ['sign']);
|
||||
}
|
||||
|
||||
private static normalizeBase32(input: string): string {
|
||||
const s = input.trim().replace(/[\s-]/g, '').toUpperCase().replace(/=+$/g, '');
|
||||
if (s.length === 0) {
|
||||
throw new Error('Invalid base32 character.');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static base32ToBytes(base32: string): Uint8Array {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let buffer = 0;
|
||||
let bitsLeft = 0;
|
||||
const out: Array<number> = [];
|
||||
|
||||
for (let i = 0; i < base32.length; i++) {
|
||||
const ch = base32.charAt(i);
|
||||
const value = alphabet.indexOf(ch);
|
||||
if (value === -1) {
|
||||
throw new Error('Invalid base32 character.');
|
||||
}
|
||||
buffer = (buffer << 5) | value;
|
||||
bitsLeft += 5;
|
||||
|
||||
while (bitsLeft >= 8) {
|
||||
out.push((buffer >> (bitsLeft - 8)) & 0xff);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
const ab = new ArrayBuffer(out.length);
|
||||
const bytes = new Uint8Array(ab);
|
||||
for (let i = 0; i < out.length; i++) bytes[i] = out[i];
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private getCounter(nowMs: number): bigint {
|
||||
const epochSeconds = BigInt(Math.floor(nowMs / 1000));
|
||||
const t0 = BigInt(this.startTime);
|
||||
const step = BigInt(this.timeStep);
|
||||
if (epochSeconds <= t0) return 0n;
|
||||
return (epochSeconds - t0) / step;
|
||||
}
|
||||
|
||||
private encodeCounter(counter: bigint): ArrayBuffer {
|
||||
const ab = new ArrayBuffer(8);
|
||||
const view = new DataView(ab);
|
||||
const high = Number((counter >> 32n) & 0xffffffffn);
|
||||
const low = Number(counter & 0xffffffffn);
|
||||
view.setUint32(0, high, false);
|
||||
view.setUint32(4, low, false);
|
||||
return ab;
|
||||
}
|
||||
|
||||
private async otpForCounter(counter: bigint): Promise<string> {
|
||||
const key = await this.keyPromise;
|
||||
const {subtle} = webcrypto;
|
||||
|
||||
const msg = this.encodeCounter(counter);
|
||||
const hmac = new Uint8Array(await subtle.sign('HMAC', key, msg));
|
||||
|
||||
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((hmac[offset] & 0x7f) << 24) |
|
||||
((hmac[offset + 1] & 0xff) << 16) |
|
||||
((hmac[offset + 2] & 0xff) << 8) |
|
||||
(hmac[offset + 3] & 0xff);
|
||||
|
||||
const mod = 10 ** this.digits;
|
||||
const otp = binary % mod;
|
||||
return String(otp).padStart(this.digits, '0');
|
||||
}
|
||||
|
||||
async generateTotp(): Promise<Array<string>> {
|
||||
const nowMs = Date.now();
|
||||
const base = this.getCounter(nowMs);
|
||||
|
||||
const otps: Array<string> = [];
|
||||
for (let i = -this.window; i <= this.window; i++) {
|
||||
const c = base + BigInt(i);
|
||||
if (c < 0n) continue;
|
||||
otps.push(await this.otpForCounter(c));
|
||||
}
|
||||
|
||||
return otps;
|
||||
}
|
||||
|
||||
async validateTotp(code: string): Promise<boolean> {
|
||||
if (code.length !== this.digits) return false;
|
||||
if (!/^\d+$/.test(code)) return false;
|
||||
|
||||
const candidates = await this.generateTotp();
|
||||
const enc = new TextEncoder();
|
||||
const target = enc.encode(code);
|
||||
|
||||
let ok = false;
|
||||
for (const candidate of candidates) {
|
||||
const cand = enc.encode(candidate);
|
||||
if (cand.length !== target.length) continue;
|
||||
if (timingSafeEqual(cand, target)) ok = true;
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
132
packages/api/src/utils/UnfurlerUtils.tsx
Normal file
132
packages/api/src/utils/UnfurlerUtils.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import * as InviteUtils from '@fluxer/api/src/utils/InviteUtils';
|
||||
import {URL_REGEX} from '@fluxer/constants/src/Core';
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const MARKETING_PATH_PREFIXES = ['/channels/', '/theme/'];
|
||||
|
||||
function normalizeHostname(hostname: string | undefined) {
|
||||
return hostname?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
let _marketingHostname: string | null = null;
|
||||
function getMarketingHostname() {
|
||||
if (!_marketingHostname) {
|
||||
_marketingHostname = normalizeHostname(Config.hosts.marketing);
|
||||
}
|
||||
return _marketingHostname;
|
||||
}
|
||||
|
||||
const isMarketingPath = (hostname: string, pathname: string) =>
|
||||
hostname === getMarketingHostname() && MARKETING_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
|
||||
function getWebAppHostname() {
|
||||
try {
|
||||
return new URL(Config.endpoints.webApp).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
let _excludedHostnames: Set<string> | null = null;
|
||||
function getExcludedHostnames(): Set<string> {
|
||||
if (!_excludedHostnames) {
|
||||
_excludedHostnames = new Set<string>();
|
||||
const addHostname = (hostname: string | undefined) => {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
if (normalized) {
|
||||
_excludedHostnames!.add(normalized);
|
||||
}
|
||||
};
|
||||
addHostname(Config.hosts.invite);
|
||||
addHostname(Config.hosts.gift);
|
||||
Config.hosts.unfurlIgnored.forEach(addHostname);
|
||||
addHostname(getWebAppHostname());
|
||||
}
|
||||
return _excludedHostnames;
|
||||
}
|
||||
|
||||
export 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();
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to encode URL');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidURL(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFluxerAppExcludedURL(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const hostname = normalizeHostname(parsedUrl.hostname);
|
||||
const isMarketingPathMatch = isMarketingPath(hostname, parsedUrl.pathname);
|
||||
|
||||
return isMarketingPathMatch || getExcludedHostnames().has(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractURLs(inputText: string) {
|
||||
let text = inputText;
|
||||
text = text.replace(/`[^`]*`/g, '');
|
||||
text = text.replace(/```.*?```/gs, '');
|
||||
text = text.replace(/\|\|([\s\S]*?)\|\|/g, ' $1 ');
|
||||
text = text.replace(/\|\|/g, ' ');
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$2');
|
||||
text = text.replace(/<https?:\/\/[^\s]+>/g, '');
|
||||
const urls = text.match(URL_REGEX) || [];
|
||||
const seen = new Set<string>();
|
||||
const result: Array<string> = [];
|
||||
|
||||
for (const url of urls) {
|
||||
if (!isValidURL(url)) continue;
|
||||
if (InviteUtils.findInvite(url) != null) continue;
|
||||
if (isFluxerAppExcludedURL(url)) continue;
|
||||
|
||||
const encoded = idnaEncodeURL(url);
|
||||
if (!encoded) continue;
|
||||
|
||||
if (!seen.has(encoded)) {
|
||||
seen.add(encoded);
|
||||
result.push(encoded);
|
||||
if (result.length >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
39
packages/api/src/utils/UrlSanitizer.tsx
Normal file
39
packages/api/src/utils/UrlSanitizer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function sanitizeOptionalAbsoluteUrl(url: string | null | undefined): string | undefined {
|
||||
if (typeof url !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmedUrl).toString();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeOptionalAbsoluteUrlOrNull(url: string | null | undefined): string | null {
|
||||
return sanitizeOptionalAbsoluteUrl(url) ?? null;
|
||||
}
|
||||
61
packages/api/src/utils/UserAgentUtils.tsx
Normal file
61
packages/api/src/utils/UserAgentUtils.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import Bowser from 'bowser';
|
||||
|
||||
export interface UserAgentInfo {
|
||||
clientOs: string;
|
||||
detectedPlatform: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
|
||||
function formatName(name?: string | null): string {
|
||||
const normalized = name?.trim();
|
||||
return normalized || UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
return {
|
||||
clientOs: formatName(parser.getOSName()),
|
||||
detectedPlatform: formatName(parser.getBrowserName()),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||
clientOs: string;
|
||||
clientPlatform: string;
|
||||
} {
|
||||
const parsed = parseUserAgentSafe(args.userAgent ?? '');
|
||||
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
|
||||
return {
|
||||
clientOs: parsed.clientOs,
|
||||
clientPlatform,
|
||||
};
|
||||
}
|
||||
298
packages/api/src/utils/UserPermissionUtils.tsx
Normal file
298
packages/api/src/utils/UserPermissionUtils.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {
|
||||
FriendSourceFlags,
|
||||
GroupDmAddPermissionFlags,
|
||||
IncomingCallFlags,
|
||||
RelationshipTypes,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {FriendRequestBlockedError} from '@fluxer/errors/src/domains/user/FriendRequestBlockedError';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
|
||||
type UserPermissionRepository = Pick<IUserRepository, 'findSettings' | 'listRelationships' | 'getRelationship'>;
|
||||
type GuildPermissionRepository = Pick<IGuildRepositoryAggregate, 'listUserGuilds'>;
|
||||
|
||||
export class UserPermissionUtils {
|
||||
constructor(
|
||||
private userRepository: UserPermissionRepository,
|
||||
private guildRepository: GuildPermissionRepository,
|
||||
) {}
|
||||
|
||||
async validateFriendSourcePermissions({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const targetSettings = await this.userRepository.findSettings(targetId);
|
||||
if (!targetSettings) return;
|
||||
|
||||
const friendSourceFlags = targetSettings.friendSourceFlags;
|
||||
|
||||
if ((friendSourceFlags & FriendSourceFlags.NO_RELATION) === FriendSourceFlags.NO_RELATION) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'friend_source', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((friendSourceFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS) {
|
||||
const hasMutualFriends = await this.checkMutualFriends({userId, targetId});
|
||||
if (hasMutualFriends) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'friend_source_mutual_friends', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ((friendSourceFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS) {
|
||||
const hasMutualGuilds = await this.checkMutualGuildsAsync({userId, targetId});
|
||||
if (hasMutualGuilds) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'friend_source_mutual_guilds', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'friend_source', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
|
||||
async validateGroupDmAddPermissions({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const targetSettings = await this.userRepository.findSettings(targetId);
|
||||
if (!targetSettings) return;
|
||||
|
||||
const groupDmAddPermissionFlags = targetSettings.groupDmAddPermissionFlags;
|
||||
|
||||
if ((groupDmAddPermissionFlags & GroupDmAddPermissionFlags.NOBODY) === GroupDmAddPermissionFlags.NOBODY) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
if ((groupDmAddPermissionFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add_everyone', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(groupDmAddPermissionFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) ===
|
||||
GroupDmAddPermissionFlags.FRIENDS_ONLY
|
||||
) {
|
||||
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
if (friendship) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add_friends', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add_friends', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
let hasPermission = false;
|
||||
|
||||
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
if (friendship) {
|
||||
hasPermission = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasPermission &&
|
||||
(groupDmAddPermissionFlags & GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS) ===
|
||||
GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS
|
||||
) {
|
||||
const hasMutualFriends = await this.checkMutualFriends({userId, targetId});
|
||||
if (hasMutualFriends) hasPermission = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasPermission &&
|
||||
(groupDmAddPermissionFlags & GroupDmAddPermissionFlags.GUILD_MEMBERS) === GroupDmAddPermissionFlags.GUILD_MEMBERS
|
||||
) {
|
||||
const hasMutualGuilds = await this.checkMutualGuildsAsync({userId, targetId});
|
||||
if (hasMutualGuilds) hasPermission = true;
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'group_dm_add', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
}
|
||||
|
||||
async validateIncomingCallPermissions({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const targetSettings = await this.userRepository.findSettings(targetId);
|
||||
if (!targetSettings) return;
|
||||
|
||||
const incomingCallFlags = targetSettings.incomingCallFlags;
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.EVERYONE) === IncomingCallFlags.EVERYONE) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call_everyone', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
|
||||
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
if (friendship) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call_friends', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
return;
|
||||
}
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call_friends', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
let hasPermission = false;
|
||||
|
||||
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
if (friendship) {
|
||||
hasPermission = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasPermission &&
|
||||
(incomingCallFlags & IncomingCallFlags.FRIENDS_OF_FRIENDS) === IncomingCallFlags.FRIENDS_OF_FRIENDS
|
||||
) {
|
||||
const hasMutualFriends = await this.checkMutualFriends({userId, targetId});
|
||||
if (hasMutualFriends) hasPermission = true;
|
||||
}
|
||||
|
||||
if (!hasPermission && (incomingCallFlags & IncomingCallFlags.GUILD_MEMBERS) === IncomingCallFlags.GUILD_MEMBERS) {
|
||||
const hasMutualGuilds = await this.checkMutualGuildsAsync({userId, targetId});
|
||||
if (hasMutualGuilds) hasPermission = true;
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call', granted: 'false', resource_type: 'user'},
|
||||
});
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
recordCounter({
|
||||
name: 'auth.permission_check',
|
||||
dimensions: {permission_id: 'incoming_call', granted: 'true', resource_type: 'user'},
|
||||
});
|
||||
}
|
||||
|
||||
async checkMutualFriends({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<boolean> {
|
||||
const userFriends = await this.userRepository.listRelationships(userId);
|
||||
const targetFriends = await this.userRepository.listRelationships(targetId);
|
||||
|
||||
const userFriendIds = new Set(
|
||||
userFriends.filter((rel) => rel.type === RelationshipTypes.FRIEND).map((rel) => rel.targetUserId.toString()),
|
||||
);
|
||||
|
||||
const targetFriendIds = targetFriends
|
||||
.filter((rel) => rel.type === RelationshipTypes.FRIEND)
|
||||
.map((rel) => rel.targetUserId.toString());
|
||||
|
||||
return targetFriendIds.some((friendId) => userFriendIds.has(friendId));
|
||||
}
|
||||
|
||||
private async fetchGuildIdsForUsers({
|
||||
userId,
|
||||
targetId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
}): Promise<{userGuildIds: Array<GuildID>; targetGuildIds: Array<GuildID>}> {
|
||||
const [userGuilds, targetGuilds] = await Promise.all([
|
||||
this.guildRepository.listUserGuilds(userId),
|
||||
this.guildRepository.listUserGuilds(targetId),
|
||||
]);
|
||||
|
||||
return {
|
||||
userGuildIds: userGuilds.map((g) => g.id),
|
||||
targetGuildIds: targetGuilds.map((g) => g.id),
|
||||
};
|
||||
}
|
||||
|
||||
async checkMutualGuildsAsync({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<boolean> {
|
||||
const {userGuildIds, targetGuildIds} = await this.fetchGuildIdsForUsers({userId, targetId});
|
||||
return this.checkMutualGuilds(userGuildIds, targetGuildIds);
|
||||
}
|
||||
|
||||
checkMutualGuilds(userGuildIds: Array<GuildID>, targetGuildIds: Array<GuildID>): boolean {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
71
packages/api/src/utils/UsernameGenerator.tsx
Normal file
71
packages/api/src/utils/UsernameGenerator.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {adjectives, animals, colors, uniqueNamesGenerator} from 'unique-names-generator';
|
||||
|
||||
export function generateRandomUsername(): string {
|
||||
const MAX_LENGTH = 32;
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 3,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 2,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 1,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 1,
|
||||
});
|
||||
}
|
||||
52
packages/api/src/utils/UsernameSuggestionUtils.tsx
Normal file
52
packages/api/src/utils/UsernameSuggestionUtils.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
import {transliterate as tr} from 'transliteration';
|
||||
|
||||
const MAX_USERNAME_LENGTH = 32;
|
||||
|
||||
function sanitizeDisplayName(globalName: string): string | null {
|
||||
const trimmed = globalName.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let sanitized = tr(trimmed);
|
||||
sanitized = sanitized.replace(/[\s\-.]+/g, '_');
|
||||
sanitized = sanitized.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
if (!sanitized) return null;
|
||||
if (sanitized.length > MAX_USERNAME_LENGTH) {
|
||||
sanitized = sanitized.substring(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
const validation = UsernameType.safeParse(sanitized);
|
||||
if (!validation.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function deriveUsernameFromDisplayName(globalName: string): string | null {
|
||||
return sanitizeDisplayName(globalName);
|
||||
}
|
||||
|
||||
export function generateUsernameSuggestions(globalName: string): Array<string> {
|
||||
const candidate = deriveUsernameFromDisplayName(globalName);
|
||||
return candidate ? [candidate] : [];
|
||||
}
|
||||
53
packages/api/src/utils/featureUtils.tsx
Normal file
53
packages/api/src/utils/featureUtils.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function areFeatureSetsEqual(
|
||||
a: Iterable<string> | null | undefined,
|
||||
b: Iterable<string> | null | undefined,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const setA = new Set<string>();
|
||||
for (const entry of a) {
|
||||
setA.add(entry);
|
||||
}
|
||||
|
||||
const setB = new Set<string>();
|
||||
for (const entry of b) {
|
||||
setB.add(entry);
|
||||
}
|
||||
|
||||
if (setA.size !== setB.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of setA) {
|
||||
if (!setB.has(entry)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
181
packages/api/src/utils/tests/AgeUtils.test.tsx
Normal file
181
packages/api/src/utils/tests/AgeUtils.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {calculateAge, isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('calculateAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 24));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('calculates age for birthday earlier this year', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 1})).toBe(26);
|
||||
});
|
||||
|
||||
it('calculates age for birthday later this year', () => {
|
||||
expect(calculateAge({year: 2000, month: 12, day: 31})).toBe(25);
|
||||
});
|
||||
|
||||
it('calculates age for birthday today', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 24})).toBe(26);
|
||||
});
|
||||
|
||||
it('calculates age for birthday yesterday', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 23})).toBe(26);
|
||||
});
|
||||
|
||||
it('calculates age for birthday tomorrow', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 25})).toBe(25);
|
||||
});
|
||||
|
||||
it('calculates age for same month different day (before)', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 1})).toBe(26);
|
||||
});
|
||||
|
||||
it('calculates age for same month different day (after)', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 31})).toBe(25);
|
||||
});
|
||||
|
||||
it('handles leap year birthday on non-leap year', () => {
|
||||
expect(calculateAge({year: 2004, month: 2, day: 29})).toBe(21);
|
||||
});
|
||||
|
||||
it('calculates age of 0 for person born this year', () => {
|
||||
expect(calculateAge({year: 2026, month: 1, day: 1})).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates age of 0 for person born later this year', () => {
|
||||
expect(calculateAge({year: 2026, month: 6, day: 15})).toBe(-1);
|
||||
});
|
||||
|
||||
it('calculates age for very old dates', () => {
|
||||
expect(calculateAge({year: 1926, month: 1, day: 1})).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('parses and calculates age from ISO date string', () => {
|
||||
expect(calculateAge('2000-01-01')).toBe(26);
|
||||
});
|
||||
|
||||
it('parses date string with birthday later this year', () => {
|
||||
expect(calculateAge('2000-12-31')).toBe(25);
|
||||
});
|
||||
|
||||
it('parses date string with birthday today', () => {
|
||||
expect(calculateAge('2000-01-24')).toBe(26);
|
||||
});
|
||||
|
||||
it('parses date string with leading zeros', () => {
|
||||
expect(calculateAge('2000-01-05')).toBe(26);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary conditions', () => {
|
||||
it('handles end of year birthday', () => {
|
||||
expect(calculateAge({year: 2000, month: 12, day: 31})).toBe(25);
|
||||
});
|
||||
|
||||
it('handles start of year birthday', () => {
|
||||
expect(calculateAge({year: 2000, month: 1, day: 1})).toBe(26);
|
||||
});
|
||||
|
||||
it('handles birthday on current date exactly', () => {
|
||||
expect(calculateAge({year: 2008, month: 1, day: 24})).toBe(18);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserAdult', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 24));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('returns true for person exactly 18', () => {
|
||||
expect(isUserAdult({year: 2008, month: 1, day: 24})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for person over 18', () => {
|
||||
expect(isUserAdult({year: 2000, month: 1, day: 1})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for person almost 18', () => {
|
||||
expect(isUserAdult({year: 2008, month: 1, day: 25})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for person under 18', () => {
|
||||
expect(isUserAdult({year: 2010, month: 6, day: 15})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for very old person', () => {
|
||||
expect(isUserAdult({year: 1926, month: 1, day: 1})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('returns true for adult with string date', () => {
|
||||
expect(isUserAdult('2000-01-01')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for minor with string date', () => {
|
||||
expect(isUserAdult('2010-06-15')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles exactly 18 with string date', () => {
|
||||
expect(isUserAdult('2008-01-24')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles null and undefined', () => {
|
||||
it('returns false for null', () => {
|
||||
expect(isUserAdult(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isUserAdult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary on 18th birthday', () => {
|
||||
it('returns true on 18th birthday', () => {
|
||||
expect(isUserAdult({year: 2008, month: 1, day: 24})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false one day before 18th birthday', () => {
|
||||
expect(isUserAdult({year: 2008, month: 1, day: 25})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true one day after 18th birthday', () => {
|
||||
expect(isUserAdult({year: 2008, month: 1, day: 23})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/api/src/utils/tests/BucketUtils.test.tsx
Normal file
139
packages/api/src/utils/tests/BucketUtils.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FLUXER_EPOCH} from '@fluxer/constants/src/Core';
|
||||
import {makeBucket, makeBuckets} from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function createSnowflake(timestamp: number): bigint {
|
||||
const relativeTimestamp = timestamp - FLUXER_EPOCH;
|
||||
return BigInt(relativeTimestamp) << 22n;
|
||||
}
|
||||
|
||||
describe('makeBucket', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns bucket 0 for snowflake at epoch', () => {
|
||||
const snowflake = createSnowflake(FLUXER_EPOCH);
|
||||
expect(makeBucket(snowflake)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns bucket 0 for snowflake within first 10 days', () => {
|
||||
const snowflake = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS - 1);
|
||||
expect(makeBucket(snowflake)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns bucket 1 for snowflake at exactly 10 days', () => {
|
||||
const snowflake = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS);
|
||||
expect(makeBucket(snowflake)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns bucket 2 for snowflake at 20 days', () => {
|
||||
const snowflake = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 2);
|
||||
expect(makeBucket(snowflake)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns current bucket for null snowflake', () => {
|
||||
const now = FLUXER_EPOCH + TEN_DAYS_MS * 5 + 1000;
|
||||
vi.setSystemTime(now);
|
||||
expect(makeBucket(null)).toBe(5);
|
||||
});
|
||||
|
||||
it('handles snowflakes far in the future', () => {
|
||||
const futureSnowflake = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 1000);
|
||||
expect(makeBucket(futureSnowflake)).toBe(1000);
|
||||
});
|
||||
|
||||
it('handles minimum snowflake value', () => {
|
||||
const minSnowflake = 0n << 22n;
|
||||
expect(makeBucket(minSnowflake)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeBuckets', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns single bucket when start equals end', () => {
|
||||
const snowflake = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS);
|
||||
expect(makeBuckets(snowflake, snowflake)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('returns range of buckets from start to end', () => {
|
||||
const start = createSnowflake(FLUXER_EPOCH);
|
||||
const end = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 2);
|
||||
expect(makeBuckets(start, end)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('returns buckets from start to current when end is null', () => {
|
||||
const now = FLUXER_EPOCH + TEN_DAYS_MS * 3 + 1000;
|
||||
vi.setSystemTime(now);
|
||||
const start = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS);
|
||||
expect(makeBuckets(start, null)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns buckets from current to end when start is null', () => {
|
||||
const now = FLUXER_EPOCH + TEN_DAYS_MS + 1000;
|
||||
vi.setSystemTime(now);
|
||||
const end = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 3);
|
||||
expect(makeBuckets(null, end)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('handles both null (current bucket only)', () => {
|
||||
const now = FLUXER_EPOCH + TEN_DAYS_MS * 5 + 1000;
|
||||
vi.setSystemTime(now);
|
||||
expect(makeBuckets(null, null)).toEqual([5]);
|
||||
});
|
||||
|
||||
it('returns many buckets for large time range', () => {
|
||||
const start = createSnowflake(FLUXER_EPOCH);
|
||||
const end = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 9);
|
||||
const buckets = makeBuckets(start, end);
|
||||
expect(buckets).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
expect(buckets.length).toBe(10);
|
||||
});
|
||||
|
||||
it('handles snowflakes within same bucket', () => {
|
||||
const start = createSnowflake(FLUXER_EPOCH + 1000);
|
||||
const end = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS - 1000);
|
||||
expect(makeBuckets(start, end)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('returns ordered buckets from low to high', () => {
|
||||
const start = createSnowflake(FLUXER_EPOCH);
|
||||
const end = createSnowflake(FLUXER_EPOCH + TEN_DAYS_MS * 5);
|
||||
const buckets = makeBuckets(start, end);
|
||||
for (let i = 1; i < buckets.length; i++) {
|
||||
expect(buckets[i]).toBeGreaterThan(buckets[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
214
packages/api/src/utils/tests/CurrencyUtils.test.tsx
Normal file
214
packages/api/src/utils/tests/CurrencyUtils.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getCurrency} from '@fluxer/api/src/utils/CurrencyUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('getCurrency', () => {
|
||||
describe('returns USD for non-EEA countries', () => {
|
||||
it('returns USD for United States', () => {
|
||||
expect(getCurrency('US')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for Canada', () => {
|
||||
expect(getCurrency('CA')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for United Kingdom', () => {
|
||||
expect(getCurrency('GB')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for Japan', () => {
|
||||
expect(getCurrency('JP')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for Australia', () => {
|
||||
expect(getCurrency('AU')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for Switzerland', () => {
|
||||
expect(getCurrency('CH')).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns EUR for EEA countries', () => {
|
||||
it('returns EUR for Germany', () => {
|
||||
expect(getCurrency('DE')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for France', () => {
|
||||
expect(getCurrency('FR')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Italy', () => {
|
||||
expect(getCurrency('IT')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Spain', () => {
|
||||
expect(getCurrency('ES')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Netherlands', () => {
|
||||
expect(getCurrency('NL')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Belgium', () => {
|
||||
expect(getCurrency('BE')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Austria', () => {
|
||||
expect(getCurrency('AT')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Portugal', () => {
|
||||
expect(getCurrency('PT')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Ireland', () => {
|
||||
expect(getCurrency('IE')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Finland', () => {
|
||||
expect(getCurrency('FI')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Poland', () => {
|
||||
expect(getCurrency('PL')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Greece', () => {
|
||||
expect(getCurrency('GR')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Czech Republic', () => {
|
||||
expect(getCurrency('CZ')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Hungary', () => {
|
||||
expect(getCurrency('HU')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Romania', () => {
|
||||
expect(getCurrency('RO')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Norway (EEA but not EU)', () => {
|
||||
expect(getCurrency('NO')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Iceland (EEA but not EU)', () => {
|
||||
expect(getCurrency('IS')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns EUR for Liechtenstein (EEA but not EU)', () => {
|
||||
expect(getCurrency('LI')).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles case insensitivity', () => {
|
||||
it('returns EUR for lowercase country code', () => {
|
||||
expect(getCurrency('de')).toBe('EUR');
|
||||
expect(getCurrency('fr')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('returns USD for lowercase non-EEA', () => {
|
||||
expect(getCurrency('us')).toBe('USD');
|
||||
expect(getCurrency('gb')).toBe('USD');
|
||||
});
|
||||
|
||||
it('handles mixed case', () => {
|
||||
expect(getCurrency('De')).toBe('EUR');
|
||||
expect(getCurrency('dE')).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles null and undefined', () => {
|
||||
it('returns USD for null', () => {
|
||||
expect(getCurrency(null)).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for undefined', () => {
|
||||
expect(getCurrency(undefined)).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles empty and invalid inputs', () => {
|
||||
it('returns USD for empty string', () => {
|
||||
expect(getCurrency('')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for invalid country code', () => {
|
||||
expect(getCurrency('XX')).toBe('USD');
|
||||
expect(getCurrency('ZZ')).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns USD for numeric strings', () => {
|
||||
expect(getCurrency('12')).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('covers all EEA member states', () => {
|
||||
const eeaCountries = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'DE',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
];
|
||||
|
||||
for (const country of eeaCountries) {
|
||||
it(`returns EUR for ${country}`, () => {
|
||||
expect(getCurrency(country)).toBe('EUR');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
188
packages/api/src/utils/tests/DOMUtils.test.tsx
Normal file
188
packages/api/src/utils/tests/DOMUtils.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {decodeHTMLEntities, htmlToMarkdown, stripHtmlTags} from '@fluxer/api/src/utils/DOMUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('decodeHTMLEntities', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(decodeHTMLEntities(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(decodeHTMLEntities(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty string input', () => {
|
||||
expect(decodeHTMLEntities('')).toBe('');
|
||||
});
|
||||
|
||||
it('decodes basic HTML entities', () => {
|
||||
expect(decodeHTMLEntities('&')).toBe('&');
|
||||
expect(decodeHTMLEntities('<')).toBe('<');
|
||||
expect(decodeHTMLEntities('>')).toBe('>');
|
||||
expect(decodeHTMLEntities('"')).toBe('"');
|
||||
expect(decodeHTMLEntities(''')).toBe("'");
|
||||
});
|
||||
|
||||
it('decodes numeric HTML entities', () => {
|
||||
expect(decodeHTMLEntities('<')).toBe('<');
|
||||
expect(decodeHTMLEntities('<')).toBe('<');
|
||||
});
|
||||
|
||||
it('decodes mixed content', () => {
|
||||
expect(decodeHTMLEntities('Hello & World')).toBe('Hello & World');
|
||||
expect(decodeHTMLEntities('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves plain text without entities', () => {
|
||||
expect(decodeHTMLEntities('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('decodes special characters', () => {
|
||||
expect(decodeHTMLEntities(' ')).toBe('\u00A0');
|
||||
expect(decodeHTMLEntities('©')).toBe('\u00A9');
|
||||
expect(decodeHTMLEntities('€')).toBe('\u20AC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripHtmlTags', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(stripHtmlTags(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(stripHtmlTags(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty string input', () => {
|
||||
expect(stripHtmlTags('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips simple HTML tags', () => {
|
||||
expect(stripHtmlTags('<p>Hello</p>')).toBe('Hello');
|
||||
expect(stripHtmlTags('<div>World</div>')).toBe('World');
|
||||
});
|
||||
|
||||
it('strips self-closing tags', () => {
|
||||
expect(stripHtmlTags('Hello<br/>World')).toBe('HelloWorld');
|
||||
expect(stripHtmlTags('Hello<br />World')).toBe('HelloWorld');
|
||||
});
|
||||
|
||||
it('strips tags with attributes', () => {
|
||||
expect(stripHtmlTags('<a href="https://example.com">Link</a>')).toBe('Link');
|
||||
expect(stripHtmlTags('<img src="image.png" alt="Image"/>')).toBe('');
|
||||
});
|
||||
|
||||
it('strips nested tags', () => {
|
||||
expect(stripHtmlTags('<div><p><span>Nested</span></p></div>')).toBe('Nested');
|
||||
});
|
||||
|
||||
it('preserves plain text', () => {
|
||||
expect(stripHtmlTags('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('strips multiple tags', () => {
|
||||
expect(stripHtmlTags('<p>One</p><p>Two</p><p>Three</p>')).toBe('OneTwoThree');
|
||||
});
|
||||
|
||||
it('handles malformed tags', () => {
|
||||
expect(stripHtmlTags('<div>Content<div>')).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(htmlToMarkdown(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(htmlToMarkdown(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty string input', () => {
|
||||
expect(htmlToMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('converts paragraph tags', () => {
|
||||
expect(htmlToMarkdown('<p>First paragraph</p><p>Second paragraph</p>')).toBe('First paragraph\n\nSecond paragraph');
|
||||
});
|
||||
|
||||
it('converts br tags to newlines', () => {
|
||||
expect(htmlToMarkdown('Line one<br>Line two')).toBe('Line one\nLine two');
|
||||
expect(htmlToMarkdown('Line one<br/>Line two')).toBe('Line one\nLine two');
|
||||
expect(htmlToMarkdown('Line one<br />Line two')).toBe('Line one\nLine two');
|
||||
});
|
||||
|
||||
it('converts heading tags to bold', () => {
|
||||
expect(htmlToMarkdown('<h1>Heading</h1>')).toBe('**Heading**');
|
||||
expect(htmlToMarkdown('<h2>Heading</h2>')).toBe('**Heading**');
|
||||
expect(htmlToMarkdown('<h6>Heading</h6>')).toBe('**Heading**');
|
||||
});
|
||||
|
||||
it('converts list items', () => {
|
||||
const html = '<ul><li>Item 1</li><li>Item 2</li></ul>';
|
||||
const result = htmlToMarkdown(html);
|
||||
expect(result).toContain('Item 1');
|
||||
expect(result).toContain('Item 2');
|
||||
});
|
||||
|
||||
it('converts code blocks', () => {
|
||||
expect(htmlToMarkdown('<pre><code>const x = 1;</code></pre>')).toContain('```\nconst x = 1;\n```');
|
||||
});
|
||||
|
||||
it('converts inline code', () => {
|
||||
expect(htmlToMarkdown('Use <code>npm install</code> to install')).toBe('Use `npm install` to install');
|
||||
});
|
||||
|
||||
it('converts bold tags', () => {
|
||||
expect(htmlToMarkdown('<strong>Bold text</strong>')).toBe('**Bold text**');
|
||||
expect(htmlToMarkdown('<b>Bold text</b>')).toBe('**Bold text**');
|
||||
});
|
||||
|
||||
it('converts italic tags', () => {
|
||||
expect(htmlToMarkdown('<em>Italic text</em>')).toBe('_Italic text_');
|
||||
expect(htmlToMarkdown('<i>Italic text</i>')).toBe('_Italic text_');
|
||||
});
|
||||
|
||||
it('converts links', () => {
|
||||
expect(htmlToMarkdown('<a href="https://example.com">Example</a>')).toBe('[Example](https://example.com)');
|
||||
});
|
||||
|
||||
it('collapses multiple newlines', () => {
|
||||
expect(htmlToMarkdown('<p>A</p><p></p><p></p><p>B</p>')).toBe('A\n\nB');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(htmlToMarkdown(' <p>Content</p> ')).toBe('Content');
|
||||
});
|
||||
|
||||
it('decodes HTML entities in the result', () => {
|
||||
expect(htmlToMarkdown('<p>Hello & World</p>')).toBe('Hello & World');
|
||||
});
|
||||
|
||||
it('handles complex mixed HTML', () => {
|
||||
const html =
|
||||
'<p>This is <strong>bold</strong> and <em>italic</em> with a <a href="https://example.com">link</a>.</p>';
|
||||
const expected = 'This is **bold** and _italic_ with a [link](https://example.com).';
|
||||
expect(htmlToMarkdown(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
151
packages/api/src/utils/tests/GeoUtils.test.tsx
Normal file
151
packages/api/src/utils/tests/GeoUtils.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {calculateDistance, parseCoordinate} from '@fluxer/api/src/utils/GeoUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('calculateDistance', () => {
|
||||
it('returns 0 for identical coordinates', () => {
|
||||
expect(calculateDistance(0, 0, 0, 0)).toBe(0);
|
||||
expect(calculateDistance(40.7128, -74.006, 40.7128, -74.006)).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates distance between New York and Los Angeles', () => {
|
||||
const nyLat = 40.7128;
|
||||
const nyLon = -74.006;
|
||||
const laLat = 34.0522;
|
||||
const laLon = -118.2437;
|
||||
const distance = calculateDistance(nyLat, nyLon, laLat, laLon);
|
||||
expect(distance).toBeGreaterThan(3900);
|
||||
expect(distance).toBeLessThan(4000);
|
||||
});
|
||||
|
||||
it('calculates distance between London and Paris', () => {
|
||||
const londonLat = 51.5074;
|
||||
const londonLon = -0.1278;
|
||||
const parisLat = 48.8566;
|
||||
const parisLon = 2.3522;
|
||||
const distance = calculateDistance(londonLat, londonLon, parisLat, parisLon);
|
||||
expect(distance).toBeGreaterThan(330);
|
||||
expect(distance).toBeLessThan(350);
|
||||
});
|
||||
|
||||
it('calculates distance across the equator', () => {
|
||||
const distance = calculateDistance(10, 0, -10, 0);
|
||||
expect(distance).toBeGreaterThan(2200);
|
||||
expect(distance).toBeLessThan(2250);
|
||||
});
|
||||
|
||||
it('calculates distance across the prime meridian', () => {
|
||||
const distance = calculateDistance(51.5, -0.1, 51.5, 0.1);
|
||||
expect(distance).toBeGreaterThan(10);
|
||||
expect(distance).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it('calculates distance across the international date line', () => {
|
||||
const distance = calculateDistance(0, 179, 0, -179);
|
||||
expect(distance).toBeGreaterThan(200);
|
||||
expect(distance).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('handles negative latitudes (Southern Hemisphere)', () => {
|
||||
const sydneyLat = -33.8688;
|
||||
const sydneyLon = 151.2093;
|
||||
const melbourneLat = -37.8136;
|
||||
const melbourneLon = 144.9631;
|
||||
const distance = calculateDistance(sydneyLat, sydneyLon, melbourneLat, melbourneLon);
|
||||
expect(distance).toBeGreaterThan(700);
|
||||
expect(distance).toBeLessThan(750);
|
||||
});
|
||||
|
||||
it('handles extreme latitudes near poles', () => {
|
||||
const distance = calculateDistance(89, 0, 89, 180);
|
||||
expect(distance).toBeGreaterThan(0);
|
||||
expect(distance).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('calculates short distances accurately', () => {
|
||||
const distance = calculateDistance(40.7128, -74.006, 40.713, -74.007);
|
||||
expect(distance).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('calculates very long distances (antipodal points)', () => {
|
||||
const distance = calculateDistance(0, 0, 0, 180);
|
||||
expect(distance).toBeGreaterThan(20000);
|
||||
expect(distance).toBeLessThan(20050);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCoordinate', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(parseCoordinate(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(parseCoordinate(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseCoordinate('')).toBe(null);
|
||||
});
|
||||
|
||||
it('parses positive integer coordinates', () => {
|
||||
expect(parseCoordinate('40')).toBe(40);
|
||||
expect(parseCoordinate('180')).toBe(180);
|
||||
});
|
||||
|
||||
it('parses negative integer coordinates', () => {
|
||||
expect(parseCoordinate('-74')).toBe(-74);
|
||||
expect(parseCoordinate('-180')).toBe(-180);
|
||||
});
|
||||
|
||||
it('parses positive decimal coordinates', () => {
|
||||
expect(parseCoordinate('40.7128')).toBe(40.7128);
|
||||
expect(parseCoordinate('0.5')).toBe(0.5);
|
||||
});
|
||||
|
||||
it('parses negative decimal coordinates', () => {
|
||||
expect(parseCoordinate('-74.006')).toBe(-74.006);
|
||||
expect(parseCoordinate('-0.1278')).toBe(-0.1278);
|
||||
});
|
||||
|
||||
it('parses zero', () => {
|
||||
expect(parseCoordinate('0')).toBe(0);
|
||||
expect(parseCoordinate('0.0')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses scientific notation', () => {
|
||||
expect(parseCoordinate('1e2')).toBe(100);
|
||||
expect(parseCoordinate('1.5e1')).toBe(15);
|
||||
});
|
||||
|
||||
it('returns null for invalid strings', () => {
|
||||
expect(parseCoordinate('abc')).toBe(null);
|
||||
expect(parseCoordinate('NaN')).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for Infinity', () => {
|
||||
expect(parseCoordinate('Infinity')).toBe(null);
|
||||
expect(parseCoordinate('-Infinity')).toBe(null);
|
||||
});
|
||||
|
||||
it('parses coordinates with leading/trailing zeros', () => {
|
||||
expect(parseCoordinate('007.500')).toBe(7.5);
|
||||
});
|
||||
});
|
||||
120
packages/api/src/utils/tests/IdUtils.test.tsx
Normal file
120
packages/api/src/utils/tests/IdUtils.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {toIdString, toSortedIdArray} from '@fluxer/api/src/utils/IdUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('toIdString', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(toIdString(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(toIdString(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('converts bigint to string', () => {
|
||||
expect(toIdString(123456789012345678n)).toBe('123456789012345678');
|
||||
});
|
||||
|
||||
it('converts string to string (passthrough)', () => {
|
||||
expect(toIdString('123456789012345678')).toBe('123456789012345678');
|
||||
});
|
||||
|
||||
it('handles zero bigint', () => {
|
||||
expect(toIdString(0n)).toBe('0');
|
||||
});
|
||||
|
||||
it('handles very large bigints', () => {
|
||||
const large = 999999999999999999999999999999n;
|
||||
expect(toIdString(large)).toBe(large.toString());
|
||||
});
|
||||
|
||||
it('handles negative bigints', () => {
|
||||
expect(toIdString(-1n)).toBe('-1');
|
||||
});
|
||||
|
||||
it('preserves numeric string exactly', () => {
|
||||
expect(toIdString('0')).toBe('0');
|
||||
expect(toIdString('00123')).toBe('00123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSortedIdArray', () => {
|
||||
it('returns empty array for null input', () => {
|
||||
expect(toSortedIdArray(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for undefined input', () => {
|
||||
expect(toSortedIdArray(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty array input', () => {
|
||||
expect(toSortedIdArray([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('converts and sorts array of bigints', () => {
|
||||
const input = [300n, 100n, 200n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
it('converts and sorts array of strings', () => {
|
||||
const input = ['300', '100', '200'];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
it('converts Set to sorted array', () => {
|
||||
const input = new Set([300n, 100n, 200n]);
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
it('handles single element array', () => {
|
||||
expect(toSortedIdArray([42n])).toEqual(['42']);
|
||||
});
|
||||
|
||||
it('handles already sorted input', () => {
|
||||
const input = [100n, 200n, 300n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
it('handles reverse sorted input', () => {
|
||||
const input = [300n, 200n, 100n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
it('sorts lexicographically (string sort)', () => {
|
||||
const input = [2n, 10n, 1n];
|
||||
expect(toSortedIdArray(input)).toEqual(['1', '10', '2']);
|
||||
});
|
||||
|
||||
it('handles duplicates in input array', () => {
|
||||
const input = [100n, 100n, 200n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '100', '200']);
|
||||
});
|
||||
|
||||
it('handles Set with single element', () => {
|
||||
const input = new Set([123n]);
|
||||
expect(toSortedIdArray(input)).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('handles empty Set', () => {
|
||||
const input = new Set<bigint>();
|
||||
expect(toSortedIdArray(input)).toEqual([]);
|
||||
});
|
||||
});
|
||||
331
packages/api/src/utils/tests/IpRangeUtils.test.tsx
Normal file
331
packages/api/src/utils/tests/IpRangeUtils.test.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {isValidIpOrRange, parseIpBanEntry, tryParseSingleIp} from '@fluxer/api/src/utils/IpRangeUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('parseIpBanEntry', () => {
|
||||
describe('single IPv4 addresses', () => {
|
||||
it('parses a simple IPv4 address', () => {
|
||||
const result = parseIpBanEntry('192.168.1.1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('single IPv6 addresses', () => {
|
||||
it('parses a full IPv6 address', () => {
|
||||
const result = parseIpBanEntry('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
|
||||
it('parses a compressed IPv6 address', () => {
|
||||
const result = parseIpBanEntry('2001:db8::1');
|
||||
expect(result).not.toBeNull();
|
||||
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();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
|
||||
it('parses IPv6 address with zone identifier stripped', () => {
|
||||
const result = parseIpBanEntry('fe80::1%eth0');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 addresses', () => {
|
||||
it('parses hex IPv4-mapped IPv6 as IPv4', () => {
|
||||
const result = parseIpBanEntry('::ffff:7f00:1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv4');
|
||||
expect(result?.canonical).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('parses dotted IPv4-mapped IPv6 as IPv4', () => {
|
||||
const result = parseIpBanEntry('::ffff:127.0.0.1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('single');
|
||||
expect(result?.family).toBe('ipv4');
|
||||
expect(result?.canonical).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4 CIDR ranges', () => {
|
||||
it('parses /32 (single host)', () => {
|
||||
const result = parseIpBanEntry('192.168.1.1/32');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv4');
|
||||
expect(result.prefixLength).toBe(32);
|
||||
expect(result.start).toBe(result.end);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses /24 (256 hosts)', () => {
|
||||
const result = parseIpBanEntry('192.168.1.0/24');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv4');
|
||||
expect(result.prefixLength).toBe(24);
|
||||
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();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.canonical).toBe('192.168.1.0/24');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv6 CIDR ranges', () => {
|
||||
it('parses /128 (single host)', () => {
|
||||
const result = parseIpBanEntry('2001:db8::1/128');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv6');
|
||||
expect(result.prefixLength).toBe(128);
|
||||
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();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.prefixLength).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 CIDR ranges', () => {
|
||||
it('maps IPv4-mapped /96 to IPv4 /0', () => {
|
||||
const result = parseIpBanEntry('::ffff:0:0/96');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv4');
|
||||
expect(result.prefixLength).toBe(0);
|
||||
expect(result.canonical).toBe('0.0.0.0/0');
|
||||
}
|
||||
});
|
||||
|
||||
it('maps IPv4-mapped /120 to IPv4 /24', () => {
|
||||
const result = parseIpBanEntry('::ffff:192.0.2.0/120');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('range');
|
||||
if (result?.type === 'range') {
|
||||
expect(result.family).toBe('ipv4');
|
||||
expect(result.prefixLength).toBe(24);
|
||||
expect(result.canonical).toBe('192.0.2.0/24');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseIpBanEntry('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for whitespace only', () => {
|
||||
expect(parseIpBanEntry(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid IPv4', () => {
|
||||
expect(parseIpBanEntry('256.256.256.256')).toBeNull();
|
||||
expect(parseIpBanEntry('192.168.1')).toBeNull();
|
||||
expect(parseIpBanEntry('192.168.1.1.1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid CIDR prefix', () => {
|
||||
expect(parseIpBanEntry('192.168.1.0/33')).toBeNull();
|
||||
expect(parseIpBanEntry('192.168.1.0/-1')).toBeNull();
|
||||
expect(parseIpBanEntry('2001:db8::/129')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for random text', () => {
|
||||
expect(parseIpBanEntry('not-an-ip')).toBeNull();
|
||||
expect(parseIpBanEntry('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing prefix after slash', () => {
|
||||
expect(parseIpBanEntry('192.168.1.0/')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles whitespace around IP', () => {
|
||||
const result = parseIpBanEntry(' 192.168.1.1 ');
|
||||
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();
|
||||
expect(result?.family).toBe('ipv6');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseSingleIp', () => {
|
||||
it('returns parsed result for single IPv4', () => {
|
||||
const result = tryParseSingleIp('192.168.1.1');
|
||||
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();
|
||||
});
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(tryParseSingleIp('invalid')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidIpOrRange', () => {
|
||||
it('returns true for valid single IPv4', () => {
|
||||
expect(isValidIpOrRange('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid single IPv6', () => {
|
||||
expect(isValidIpOrRange('2001:db8::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid IPv4 CIDR', () => {
|
||||
expect(isValidIpOrRange('192.168.1.0/24')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid IPv6 CIDR', () => {
|
||||
expect(isValidIpOrRange('2001:db8::/32')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid input', () => {
|
||||
expect(isValidIpOrRange('')).toBe(false);
|
||||
expect(isValidIpOrRange('invalid')).toBe(false);
|
||||
expect(isValidIpOrRange('256.256.256.256')).toBe(false);
|
||||
});
|
||||
});
|
||||
56
packages/api/src/utils/tests/LosslessJsonParser.test.tsx
Normal file
56
packages/api/src/utils/tests/LosslessJsonParser.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('parseJsonPreservingLargeIntegers', () => {
|
||||
it('keeps safe integers as numbers', () => {
|
||||
const parsed = parseJsonPreservingLargeIntegers('{"id":9007199254740991}') as {id: unknown};
|
||||
expect(parsed.id).toBe(9007199254740991);
|
||||
expect(typeof parsed.id).toBe('number');
|
||||
});
|
||||
|
||||
it('converts unsafe integers to strings', () => {
|
||||
const parsed = parseJsonPreservingLargeIntegers('{"id":9007199254740992}') as {id: unknown};
|
||||
expect(parsed.id).toBe('9007199254740992');
|
||||
expect(typeof parsed.id).toBe('string');
|
||||
});
|
||||
|
||||
it('preserves floating point numbers', () => {
|
||||
const parsed = parseJsonPreservingLargeIntegers('{"took":0.062,"id":1472109478688579732}') as {
|
||||
took: unknown;
|
||||
id: unknown;
|
||||
};
|
||||
expect(parsed.took).toBe(0.062);
|
||||
expect(typeof parsed.took).toBe('number');
|
||||
expect(parsed.id).toBe('1472109478688579732');
|
||||
});
|
||||
|
||||
it('does not touch numbers inside strings', () => {
|
||||
const parsed = parseJsonPreservingLargeIntegers('{"id":"1472109478688579732"}') as {id: unknown};
|
||||
expect(parsed.id).toBe('1472109478688579732');
|
||||
});
|
||||
|
||||
it('handles arrays of values', () => {
|
||||
const parsed = parseJsonPreservingLargeIntegers('{"arr":[1,1472109478688579732]}') as {arr: Array<unknown>};
|
||||
expect(parsed.arr[0]).toBe(1);
|
||||
expect(parsed.arr[1]).toBe('1472109478688579732');
|
||||
});
|
||||
});
|
||||
114
packages/api/src/utils/tests/RegexUtils.test.tsx
Normal file
114
packages/api/src/utils/tests/RegexUtils.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {escapeRegex} from '@fluxer/api/src/utils/RegexUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('escapeRegex', () => {
|
||||
it('escapes hyphen', () => {
|
||||
expect(escapeRegex('-')).toBe('\\-');
|
||||
});
|
||||
|
||||
it('escapes square brackets', () => {
|
||||
expect(escapeRegex('[')).toBe('\\[');
|
||||
expect(escapeRegex(']')).toBe('\\]');
|
||||
});
|
||||
|
||||
it('escapes forward slash', () => {
|
||||
expect(escapeRegex('/')).toBe('\\/');
|
||||
});
|
||||
|
||||
it('escapes curly braces', () => {
|
||||
expect(escapeRegex('{')).toBe('\\{');
|
||||
expect(escapeRegex('}')).toBe('\\}');
|
||||
});
|
||||
|
||||
it('escapes parentheses', () => {
|
||||
expect(escapeRegex('(')).toBe('\\(');
|
||||
expect(escapeRegex(')')).toBe('\\)');
|
||||
});
|
||||
|
||||
it('escapes asterisk', () => {
|
||||
expect(escapeRegex('*')).toBe('\\*');
|
||||
});
|
||||
|
||||
it('escapes plus', () => {
|
||||
expect(escapeRegex('+')).toBe('\\+');
|
||||
});
|
||||
|
||||
it('escapes question mark', () => {
|
||||
expect(escapeRegex('?')).toBe('\\?');
|
||||
});
|
||||
|
||||
it('escapes period', () => {
|
||||
expect(escapeRegex('.')).toBe('\\.');
|
||||
});
|
||||
|
||||
it('escapes backslash', () => {
|
||||
expect(escapeRegex('\\')).toBe('\\\\');
|
||||
});
|
||||
|
||||
it('escapes caret', () => {
|
||||
expect(escapeRegex('^')).toBe('\\^');
|
||||
});
|
||||
|
||||
it('escapes dollar sign', () => {
|
||||
expect(escapeRegex('$')).toBe('\\$');
|
||||
});
|
||||
|
||||
it('escapes pipe', () => {
|
||||
expect(escapeRegex('|')).toBe('\\|');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(escapeRegex('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves normal characters', () => {
|
||||
expect(escapeRegex('abc')).toBe('abc');
|
||||
expect(escapeRegex('123')).toBe('123');
|
||||
expect(escapeRegex('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('escapes multiple special characters in a string', () => {
|
||||
expect(escapeRegex('[a-z]+')).toBe('\\[a\\-z\\]\\+');
|
||||
expect(escapeRegex('hello.*world')).toBe('hello\\.\\*world');
|
||||
});
|
||||
|
||||
it('escapes URL patterns', () => {
|
||||
expect(escapeRegex('https://example.com/path?query=value')).toBe('https:\\/\\/example\\.com\\/path\\?query=value');
|
||||
});
|
||||
|
||||
it('escapes regex character class patterns', () => {
|
||||
expect(escapeRegex('[^a-zA-Z0-9]')).toBe('\\[\\^a\\-zA\\-Z0\\-9\\]');
|
||||
});
|
||||
|
||||
it('creates valid regex from escaped string', () => {
|
||||
const input = 'price: $10.00 (tax*)';
|
||||
const escaped = escapeRegex(input);
|
||||
const regex = new RegExp(escaped);
|
||||
expect(regex.test(input)).toBe(true);
|
||||
expect(regex.test('price: $1000 (tax)')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles repeated special characters', () => {
|
||||
expect(escapeRegex('...')).toBe('\\.\\.\\.');
|
||||
expect(escapeRegex('***')).toBe('\\*\\*\\*');
|
||||
});
|
||||
});
|
||||
97
packages/api/src/utils/tests/StringUtils.test.tsx
Normal file
97
packages/api/src/utils/tests/StringUtils.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parseString} from '@fluxer/api/src/utils/StringUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('parseString', () => {
|
||||
it('returns trimmed string within max length', () => {
|
||||
expect(parseString('Hello World', 50)).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('trims whitespace from the string', () => {
|
||||
expect(parseString(' Hello World ', 50)).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('truncates string exceeding max length', () => {
|
||||
const result = parseString('This is a very long string that needs truncation', 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
|
||||
it('handles exact max length', () => {
|
||||
const result = parseString('12345', 5);
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
|
||||
it('decodes HTML entities before processing', () => {
|
||||
expect(parseString('Hello & World', 50)).toBe('Hello & World');
|
||||
expect(parseString('<script>', 50)).toBe('<script>');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(parseString('', 50)).toBe('');
|
||||
});
|
||||
|
||||
it('handles string with only whitespace', () => {
|
||||
expect(parseString(' ', 50)).toBe('');
|
||||
});
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
expect(parseString('Hello \u{1F600} World', 50)).toBe('Hello \u{1F600} World');
|
||||
});
|
||||
|
||||
it('truncates with ellipsis by default', () => {
|
||||
const result = parseString('This is a test string for truncation', 15);
|
||||
expect(result.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles max length of 0 (lodash truncate returns ellipsis)', () => {
|
||||
const result = parseString('Hello', 0);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
|
||||
it('handles max length of 1 (lodash truncate minimum is ellipsis length)', () => {
|
||||
const result = parseString('Hello', 1);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
|
||||
it('handles max length of 3 (minimum for truncation)', () => {
|
||||
const result = parseString('Hello', 3);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
|
||||
it('handles max length of 4', () => {
|
||||
const result = parseString('Hello', 4);
|
||||
expect(result.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('preserves string with special characters', () => {
|
||||
const input = 'Hello\nWorld\tTab';
|
||||
const result = parseString(input, 50);
|
||||
expect(result).toContain('\n');
|
||||
expect(result).toContain('\t');
|
||||
});
|
||||
|
||||
it('handles combined HTML entities and long strings', () => {
|
||||
const input = '& '.repeat(20);
|
||||
const result = parseString(input, 30);
|
||||
expect(result.length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
});
|
||||
54
packages/api/src/utils/tests/UrlSanitizer.test.tsx
Normal file
54
packages/api/src/utils/tests/UrlSanitizer.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {sanitizeOptionalAbsoluteUrl, sanitizeOptionalAbsoluteUrlOrNull} from '@fluxer/api/src/utils/UrlSanitizer';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('sanitizeOptionalAbsoluteUrl', () => {
|
||||
it('returns undefined for nullish values', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrl(undefined)).toBeUndefined();
|
||||
expect(sanitizeOptionalAbsoluteUrl(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty or whitespace-only values', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrl('')).toBeUndefined();
|
||||
expect(sanitizeOptionalAbsoluteUrl(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for invalid URLs', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrl('not-a-valid-url')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims and normalises valid absolute URLs', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrl(' https://example.com/path ')).toBe('https://example.com/path');
|
||||
expect(sanitizeOptionalAbsoluteUrl('https://example.com')).toBe('https://example.com/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeOptionalAbsoluteUrlOrNull', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrlOrNull(undefined)).toBeNull();
|
||||
expect(sanitizeOptionalAbsoluteUrlOrNull(null)).toBeNull();
|
||||
expect(sanitizeOptionalAbsoluteUrlOrNull('not-a-valid-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns normalised URLs for valid inputs', () => {
|
||||
expect(sanitizeOptionalAbsoluteUrlOrNull(' https://example.com/path ')).toBe('https://example.com/path');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user