refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View 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;
}

View 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;
}

View 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(),
};
}

View 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;
}
}

View 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';
}

View 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();
}

View 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;
}

View 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);
}

View 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;
}

View 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)))),
});
}

View 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();
}

View 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;
}

View 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;
}

View 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
);
}

View 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;
}

View 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);
}

View 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;
}

View 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;
}

View 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, '\\$&');
}

View 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;
}

View 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});
}

View 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);
}

View 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;
}
}

View 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;
}

View 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;
}

View 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,
};
}

View 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()));
}
}

View 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,
});
}

View 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] : [];
}

View 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;
}

View 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);
});
});
});

View 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]);
}
});
});

View 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');
});
}
});
});

View 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('&amp;')).toBe('&');
expect(decodeHTMLEntities('&lt;')).toBe('<');
expect(decodeHTMLEntities('&gt;')).toBe('>');
expect(decodeHTMLEntities('&quot;')).toBe('"');
expect(decodeHTMLEntities('&#39;')).toBe("'");
});
it('decodes numeric HTML entities', () => {
expect(decodeHTMLEntities('&#60;')).toBe('<');
expect(decodeHTMLEntities('&#x3C;')).toBe('<');
});
it('decodes mixed content', () => {
expect(decodeHTMLEntities('Hello &amp; World')).toBe('Hello & World');
expect(decodeHTMLEntities('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')).toBe(
'<script>alert("xss")</script>',
);
});
it('preserves plain text without entities', () => {
expect(decodeHTMLEntities('Hello World')).toBe('Hello World');
});
it('decodes special characters', () => {
expect(decodeHTMLEntities('&nbsp;')).toBe('\u00A0');
expect(decodeHTMLEntities('&copy;')).toBe('\u00A9');
expect(decodeHTMLEntities('&euro;')).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 &amp; 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);
});
});

View 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);
});
});

View 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([]);
});
});

View 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);
});
});

View 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');
});
});

View 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('\\*\\*\\*');
});
});

View 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 &amp; World', 50)).toBe('Hello & World');
expect(parseString('&lt;script&gt;', 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 = '&amp; '.repeat(20);
const result = parseString(input, 30);
expect(result.length).toBeLessThanOrEqual(30);
});
});

View 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');
});
});