initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 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 const 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 const isUserAdult = (dateOfBirth?: {year: number; month: number; day: number} | string | null): boolean => {
if (!dateOfBirth) return false;
return calculateAge(dateOfBirth) >= 18;
};

View File

@@ -0,0 +1,213 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {
computeDecay,
computeRenewalThresholdDays,
computeRenewalWindowDays,
DEFAULT_DECAY_CONSTANTS,
DEFAULT_RENEWAL_CONSTANTS,
extendExpiry,
maybeRenewExpiry,
} from './AttachmentDecay';
const toBytes = (mb: number) => mb * 1024 * 1024;
describe('computeDecay', () => {
const uploadDate = new Date('2024-01-01T00:00:00.000Z');
it('returns null when size exceeds plan limit', () => {
const sizeBytes = toBytes(DEFAULT_DECAY_CONSTANTS.PLAN_MB + 1);
const result = computeDecay({sizeBytes, uploadedAt: uploadDate});
expect(result).toBeNull();
});
it('uses MAX_DAYS for small files at or below MIN_MB', () => {
const sizeBytes = toBytes(DEFAULT_DECAY_CONSTANTS.MIN_MB);
const result = computeDecay({sizeBytes, uploadedAt: uploadDate});
expect(result).not.toBeNull();
expect(result!.days).toBe(DEFAULT_DECAY_CONSTANTS.MAX_DAYS);
const expectedExpiry = new Date(uploadDate);
expectedExpiry.setUTCDate(expectedExpiry.getUTCDate() + DEFAULT_DECAY_CONSTANTS.MAX_DAYS);
expect(result!.expiresAt.toISOString()).toBe(expectedExpiry.toISOString());
});
it('uses MIN_DAYS for large files at or above MAX_MB', () => {
const sizeBytes = toBytes(DEFAULT_DECAY_CONSTANTS.MAX_MB);
const result = computeDecay({sizeBytes, uploadedAt: uploadDate});
expect(result).not.toBeNull();
expect(result!.days).toBe(DEFAULT_DECAY_CONSTANTS.MIN_DAYS);
});
it('blends lifetime between MIN_MB and MAX_MB', () => {
const sizeBytes = toBytes((DEFAULT_DECAY_CONSTANTS.MIN_MB + DEFAULT_DECAY_CONSTANTS.MAX_MB) / 2);
const result = computeDecay({sizeBytes, uploadedAt: uploadDate});
expect(result).not.toBeNull();
expect(result!.days).toBeGreaterThan(DEFAULT_DECAY_CONSTANTS.MIN_DAYS);
expect(result!.days).toBeLessThan(DEFAULT_DECAY_CONSTANTS.MAX_DAYS);
});
it('computes cost proportionally to size and lifetime', () => {
const sizeMb = 100;
const sizeBytes = toBytes(sizeMb);
const result = computeDecay({sizeBytes, uploadedAt: uploadDate});
expect(result).not.toBeNull();
const sizeTb = sizeBytes / 1024 ** 4;
const lifetimeMonths = result!.days / 30;
const expectedCost = sizeTb * DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH * lifetimeMonths;
expect(result!.cost).toBeCloseTo(expectedCost, 4);
});
});
describe('extendExpiry', () => {
it('returns newer expiry when current is older', () => {
const current = new Date('2024-01-01T00:00:00.000Z');
const next = new Date('2024-02-01T00:00:00.000Z');
expect(extendExpiry(current, next)).toEqual(next);
});
it('keeps current expiry when it is already later', () => {
const current = new Date('2024-03-01T00:00:00.000Z');
const next = new Date('2024-02-01T00:00:00.000Z');
expect(extendExpiry(current, next)).toEqual(current);
});
it('handles null current expiry', () => {
const next = new Date('2024-02-01T00:00:00.000Z');
expect(extendExpiry(null, next)).toEqual(next);
});
});
describe('maybeRenewExpiry with size-aware windows', () => {
const baseUpload = new Date('2024-01-01T00:00:00.000Z');
it('uses a short window for large files and clamps to max', () => {
const sizeMb = DEFAULT_DECAY_CONSTANTS.MAX_MB;
const decay = computeDecay({sizeBytes: toBytes(sizeMb), uploadedAt: baseUpload});
expect(decay).not.toBeNull();
const windowDays = computeRenewalWindowDays(sizeMb);
const thresholdDays = computeRenewalThresholdDays(windowDays);
const maxExpiry = new Date(baseUpload);
maxExpiry.setUTCDate(maxExpiry.getUTCDate() + decay!.days + windowDays);
const now = new Date(baseUpload);
now.setUTCDate(now.getUTCDate() + (decay!.days - thresholdDays));
const renewed = maybeRenewExpiry({
currentExpiry: decay!.expiresAt,
now,
windowDays,
thresholdDays,
maxExpiry,
});
expect(windowDays).toBe(DEFAULT_RENEWAL_CONSTANTS.MIN_WINDOW_DAYS);
expect(renewed).not.toBeNull();
if (renewed) {
const expected = new Date(now);
expected.setUTCDate(expected.getUTCDate() + windowDays);
expect(renewed.toISOString()).toBe(expected.toISOString());
}
});
it('caps renewal so total lifetime cannot exceed the size budget', () => {
const sizeMb = DEFAULT_DECAY_CONSTANTS.MAX_MB;
const decay = computeDecay({sizeBytes: toBytes(sizeMb), uploadedAt: baseUpload});
expect(decay).not.toBeNull();
const windowDays = computeRenewalWindowDays(sizeMb);
const thresholdDays = computeRenewalThresholdDays(windowDays);
const maxExpiry = new Date(baseUpload);
maxExpiry.setUTCDate(maxExpiry.getUTCDate() + decay!.days + windowDays);
const current = new Date(baseUpload);
current.setUTCDate(current.getUTCDate() + decay!.days + windowDays - 1);
const now = new Date(current);
now.setUTCDate(now.getUTCDate() - thresholdDays + 1);
const renewed = maybeRenewExpiry({
currentExpiry: current,
now,
windowDays,
thresholdDays,
maxExpiry,
});
expect(renewed).not.toBeNull();
if (renewed) {
expect(renewed.toISOString()).toBe(maxExpiry.toISOString());
}
});
it('uses a longer window for small files and extends when near threshold', () => {
const sizeMb = DEFAULT_DECAY_CONSTANTS.MIN_MB;
const decay = computeDecay({sizeBytes: toBytes(sizeMb), uploadedAt: baseUpload});
expect(decay).not.toBeNull();
const windowDays = computeRenewalWindowDays(sizeMb);
const thresholdDays = computeRenewalThresholdDays(windowDays);
const maxExpiry = new Date(baseUpload);
maxExpiry.setUTCDate(maxExpiry.getUTCDate() + decay!.days + windowDays);
const now = new Date(baseUpload);
now.setUTCDate(now.getUTCDate() + (decay!.days - thresholdDays + 1));
const renewed = maybeRenewExpiry({
currentExpiry: decay!.expiresAt,
now,
windowDays,
thresholdDays,
maxExpiry,
});
expect(windowDays).toBe(DEFAULT_RENEWAL_CONSTANTS.MAX_WINDOW_DAYS);
expect(thresholdDays).toBeGreaterThan(0);
expect(renewed).not.toBeNull();
if (renewed) {
const expected = new Date(now);
expected.setUTCDate(expected.getUTCDate() + windowDays);
expect(renewed.toISOString()).toBe(expected.toISOString());
}
});
it('does not extend when outside the renewal threshold', () => {
const sizeMb = 100;
const decay = computeDecay({sizeBytes: toBytes(sizeMb), uploadedAt: baseUpload});
expect(decay).not.toBeNull();
const windowDays = computeRenewalWindowDays(sizeMb);
const thresholdDays = computeRenewalThresholdDays(windowDays);
const maxExpiry = new Date(baseUpload);
maxExpiry.setUTCDate(maxExpiry.getUTCDate() + decay!.days + windowDays);
const now = new Date(baseUpload);
now.setUTCDate(now.getUTCDate() + (decay!.days - thresholdDays - 5));
const renewed = maybeRenewExpiry({
currentExpiry: decay!.expiresAt,
now,
windowDays,
thresholdDays,
maxExpiry,
});
expect(renewed).toBeNull();
});
});

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/>.
*/
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 = 1000 * 60 * 60 * 24;
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,28 @@
/*
* 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 '~/Config';
import {ATTACHMENT_MAX_SIZE_NON_PREMIUM, ATTACHMENT_MAX_SIZE_PREMIUM} from '~/Constants';
export function getAttachmentMaxSize(isPremium: boolean): number {
if (Config.instance.selfHosted) {
return ATTACHMENT_MAX_SIZE_PREMIUM;
}
return isPremium ? ATTACHMENT_MAX_SIZE_PREMIUM : ATTACHMENT_MAX_SIZE_NON_PREMIUM;
}

View File

@@ -0,0 +1,90 @@
/*
* 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, Guild, GuildEmoji, GuildSticker} from '~/Models';
import {toIdString, toSortedIdArray} from './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,
};
}
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,
format_type: sticker.formatType,
creator_id: sticker.creatorId.toString(),
};
}

View File

@@ -0,0 +1,158 @@
/*
* 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;
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
const 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,
};
};
const 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];
};
const 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,158 @@
/*
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {FLUXER_EPOCH} from '~/Constants';
import {makeBucket, makeBuckets} from './BucketUtils';
describe('BucketUtils', () => {
describe('makeBucket', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should create bucket from snowflake', () => {
const snowflake = 1000000000000000n << 22n;
const bucket = makeBucket(snowflake);
expect(typeof bucket).toBe('number');
expect(bucket).toBeGreaterThanOrEqual(0);
});
it('should create bucket from null snowflake using current time', () => {
const mockTime = FLUXER_EPOCH + 1000000000;
vi.setSystemTime(mockTime);
const bucket = makeBucket(null);
expect(typeof bucket).toBe('number');
expect(bucket).toBeGreaterThanOrEqual(0);
});
it('should create same bucket for snowflakes in same time period', () => {
const baseTimestamp = 1000000n;
const snowflake1 = baseTimestamp << 22n;
const snowflake2 = (baseTimestamp + 1000n) << 22n;
const bucket1 = makeBucket(snowflake1);
const bucket2 = makeBucket(snowflake2);
expect(bucket1).toBe(bucket2);
});
it('should create different buckets for snowflakes in different time periods', () => {
const bucketSizeMs = 1000 * 60 * 60 * 24 * 10;
const baseTimestamp = BigInt(bucketSizeMs);
const snowflake1 = baseTimestamp << 22n;
const snowflake2 = (baseTimestamp + BigInt(bucketSizeMs * 2)) << 22n;
const bucket1 = makeBucket(snowflake1);
const bucket2 = makeBucket(snowflake2);
expect(bucket2).toBeGreaterThan(bucket1);
});
it('should handle zero snowflake', () => {
const bucket = makeBucket(0n);
expect(bucket).toBe(0);
});
});
describe('makeBuckets', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should create array of buckets between two snowflakes', () => {
const bucketSizeMs = 1000 * 60 * 60 * 24 * 10;
const startTimestamp = BigInt(bucketSizeMs);
const endTimestamp = BigInt(bucketSizeMs * 3);
const startSnowflake = startTimestamp << 22n;
const endSnowflake = endTimestamp << 22n;
const buckets = makeBuckets(startSnowflake, endSnowflake);
expect(Array.isArray(buckets)).toBe(true);
expect(buckets.length).toBeGreaterThan(0);
expect(buckets[0]).toBeLessThanOrEqual(buckets[buckets.length - 1]);
});
it('should create single bucket when start and end are in same period', () => {
const baseTimestamp = 1000000n;
const startSnowflake = baseTimestamp << 22n;
const endSnowflake = (baseTimestamp + 1000n) << 22n;
const buckets = makeBuckets(startSnowflake, endSnowflake);
expect(buckets).toHaveLength(1);
});
it('should handle null endId by using current time', () => {
const mockTime = FLUXER_EPOCH + 1000000000;
vi.setSystemTime(mockTime);
const startSnowflake = 1000n << 22n;
const buckets = makeBuckets(startSnowflake, null);
expect(Array.isArray(buckets)).toBe(true);
expect(buckets.length).toBeGreaterThanOrEqual(1);
});
it('should handle both null values by using current time', () => {
const mockTime = FLUXER_EPOCH + 1000000000;
vi.setSystemTime(mockTime);
const buckets = makeBuckets(null, null);
expect(buckets).toHaveLength(1);
});
it('should include all buckets in range', () => {
const bucketSizeMs = 1000 * 60 * 60 * 24 * 10;
const startTimestamp = BigInt(0);
const endTimestamp = BigInt(bucketSizeMs * 2);
const startSnowflake = startTimestamp << 22n;
const endSnowflake = endTimestamp << 22n;
const buckets = makeBuckets(startSnowflake, endSnowflake);
expect(buckets).toEqual([0, 1, 2]);
});
it('should handle reverse order (endId before startId)', () => {
const bucketSizeMs = 1000 * 60 * 60 * 24 * 10;
const startTimestamp = BigInt(bucketSizeMs * 2);
const endTimestamp = BigInt(bucketSizeMs);
const startSnowflake = startTimestamp << 22n;
const endSnowflake = endTimestamp << 22n;
const buckets = makeBuckets(startSnowflake, endSnowflake);
expect(Array.isArray(buckets)).toBe(true);
});
});
});

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 {FLUXER_EPOCH} from '~/Constants';
const BUCKET_SIZE = BigInt(1000 * 60 * 60 * 24 * 10);
export const makeBucket = (snowflake: bigint | null): number => {
let timestamp: bigint;
if (snowflake == null) {
timestamp = BigInt(Date.now() - FLUXER_EPOCH);
} else {
timestamp = snowflake >> 22n;
}
return Math.floor(Number(timestamp / BUCKET_SIZE));
};
export const makeBuckets = (startId: bigint | null, endId: bigint | null = null): Array<number> => {
const start = makeBucket(startId);
const end = makeBucket(endId);
const result: Array<number> = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};

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,202 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {decodeHTMLEntities, htmlToMarkdown, stripHtmlTags} from './DOMUtils';
describe('DOMUtils', () => {
describe('decodeHTMLEntities', () => {
it('should decode common HTML entities', () => {
expect(decodeHTMLEntities('&amp;')).toBe('&');
expect(decodeHTMLEntities('&lt;')).toBe('<');
expect(decodeHTMLEntities('&gt;')).toBe('>');
expect(decodeHTMLEntities('&quot;')).toBe('"');
expect(decodeHTMLEntities('&#39;')).toBe("'");
});
it('should handle empty or null input', () => {
expect(decodeHTMLEntities('')).toBe('');
expect(decodeHTMLEntities(null)).toBe('');
expect(decodeHTMLEntities(undefined)).toBe('');
});
it('should decode numeric entities', () => {
expect(decodeHTMLEntities('&#65;')).toBe('A');
expect(decodeHTMLEntities('&#8364;')).toBe('€');
});
it('should handle text without entities', () => {
expect(decodeHTMLEntities('Hello World')).toBe('Hello World');
});
it('should decode mixed content', () => {
expect(decodeHTMLEntities('Hello &amp; goodbye &lt;script&gt;')).toBe('Hello & goodbye <script>');
});
});
describe('stripHtmlTags', () => {
it('should remove simple HTML tags', () => {
expect(stripHtmlTags('<p>Hello</p>')).toBe('Hello');
expect(stripHtmlTags('<div>World</div>')).toBe('World');
});
it('should handle empty or null input', () => {
expect(stripHtmlTags('')).toBe('');
expect(stripHtmlTags(null)).toBe('');
expect(stripHtmlTags(undefined)).toBe('');
});
it('should remove nested tags', () => {
expect(stripHtmlTags('<div><p>Hello <strong>World</strong></p></div>')).toBe('Hello World');
});
it('should remove self-closing tags', () => {
expect(stripHtmlTags('Line 1<br/>Line 2<hr/>')).toBe('Line 1Line 2');
});
it('should handle tags with attributes', () => {
expect(stripHtmlTags('<a href="http://example.com">Link</a>')).toBe('Link');
expect(stripHtmlTags('<img src="image.jpg" alt="Image"/>')).toBe('');
});
it('should handle malformed tags', () => {
expect(stripHtmlTags('<p>Hello <world')).toBe('Hello <world');
expect(stripHtmlTags('Hello > World')).toBe('Hello > World');
});
it('should preserve text content', () => {
expect(stripHtmlTags('No tags here')).toBe('No tags here');
});
});
describe('htmlToMarkdown', () => {
it('should handle empty or null input', () => {
expect(htmlToMarkdown('')).toBe('');
expect(htmlToMarkdown(null)).toBe('');
expect(htmlToMarkdown(undefined)).toBe('');
});
it('should convert paragraphs', () => {
expect(htmlToMarkdown('<p>First paragraph</p><p>Second paragraph</p>')).toBe(
'First paragraph\n\nSecond paragraph',
);
});
it('should convert line breaks', () => {
expect(htmlToMarkdown('Line 1<br>Line 2<br/>Line 3')).toBe('Line 1\nLine 2\nLine 3');
});
it('should convert headings', () => {
expect(htmlToMarkdown('<h1>Title</h1>')).toBe('**Title**');
expect(htmlToMarkdown('<h2>Subtitle</h2>')).toBe('**Subtitle**');
expect(htmlToMarkdown('<h6>Small heading</h6>')).toBe('**Small heading**');
});
it('should convert lists', () => {
const html = '<ul><li>Item 1</li><li>Item 2</li></ul>';
const expected = '• Item 1\n• Item 2';
expect(htmlToMarkdown(html).trim()).toBe(expected);
});
it('should convert ordered lists', () => {
const html = '<ol><li>Item 1</li><li>Item 2</li></ol>';
const expected = '• Item 1\n• Item 2';
expect(htmlToMarkdown(html).trim()).toBe(expected);
});
it('should convert code blocks', () => {
const html = '<pre><code>console.log("hello");</code></pre>';
const expected = '```\nconsole.log("hello");\n```';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should convert inline code', () => {
const html = 'Use <code>console.log</code> to debug';
const expected = 'Use `console.log` to debug';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should convert bold text', () => {
expect(htmlToMarkdown('<strong>Bold text</strong>')).toBe('**Bold text**');
expect(htmlToMarkdown('<b>Bold text</b>')).toBe('**Bold text**');
});
it('should convert italic text', () => {
expect(htmlToMarkdown('<em>Italic text</em>')).toBe('_Italic text_');
expect(htmlToMarkdown('<i>Italic text</i>')).toBe('_Italic text_');
});
it('should convert links', () => {
const html = '<a href="https://example.com">Link text</a>';
const expected = '[Link text](https://example.com)';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should convert complex links with attributes', () => {
const html = '<a class="link" href="https://example.com" target="_blank">Link text</a>';
const expected = '[Link text](https://example.com)';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should handle nested formatting', () => {
const html = '<p><strong>Bold <em>and italic</em></strong></p>';
const expected = '**Bold _and italic_**';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should decode HTML entities', () => {
const html = '<p>&amp;lt;script&amp;gt;</p>';
const expected = '&lt;script&gt;';
expect(htmlToMarkdown(html)).toBe(expected);
});
it('should clean up excessive newlines', () => {
const html = '<p>Para 1</p><p></p><p></p><p>Para 2</p>';
const result = htmlToMarkdown(html);
expect(result).not.toContain('\n\n\n');
});
it('should trim whitespace', () => {
const html = ' <p>Content</p> ';
expect(htmlToMarkdown(html)).toBe('Content');
});
it('should handle mixed content', () => {
const html = `
<h1>Title</h1>
<p>This is a <strong>paragraph</strong> with <em>formatting</em>.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
<p>Check out <a href="https://example.com">this link</a>.</p>
<pre><code>const x = 42;</code></pre>
`;
const result = htmlToMarkdown(html);
expect(result).toContain('**Title**');
expect(result).toContain('**paragraph**');
expect(result).toContain('_formatting_');
expect(result).toContain('• List item 1');
expect(result).toContain('[this link](https://example.com)');
expect(result).toContain('```\nconst x = 42;\n```');
});
});
});

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 {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,278 @@
/*
* 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 {createEmojiID, type EmojiID, type GuildID, type UserID, type WebhookID} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {GuildEmoji} from '~/Models';
import type {PackExpressionAccessResolution, PackExpressionAccessResolver} from '~/pack/PackExpressionAccessResolver';
import type {IUserAccountRepository} from '~/user/repositories/IUserAccountRepository';
type EmojiGuildRepository = Pick<IGuildRepository, 'getEmoji' | 'getEmojiById'>;
type EmojiUserRepository = Pick<IUserAccountRepository, 'findUnique'>;
const CUSTOM_EMOJI_MARKDOWN_REGEX = /<(a)?:([^:]+):(\d+)>/g;
interface SanitizeCustomEmojisParams {
content: string;
userId: UserID | null;
webhookId: WebhookID | null;
guildId: GuildID | null;
userRepository: EmojiUserRepository;
guildRepository: EmojiGuildRepository;
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, 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 isPremium = userId ? await checkUserPremium(userId, userRepository) : false;
const isWebhook = webhookId != null;
const shouldSanitizeForRegularUser = userId != null && !isWebhook && !isPremium;
if (shouldSanitizeForRegularUser) {
return applyReplacements(
content,
emojiMatches.map((match) => ({
start: match.start,
end: match.end,
replacement: `:${match.name}:`,
})),
);
}
const emojiLookups = await batchFetchEmojis(emojiMatches, guildId, guildRepository);
let canUseExternalEmojis: boolean | null = null;
if (isPremium && 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,
isPremium,
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> = [];
const emojiRegex = new RegExp(CUSTOM_EMOJI_MARKDOWN_REGEX.source, 'g');
let match: RegExpExecArray | null;
while ((match = emojiRegex.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 checkUserPremium(userId: UserID, userRepository: EmojiUserRepository): Promise<boolean> {
const user = await userRepository.findUnique(userId);
return user?.canUseGlobalExpressions() ?? false;
}
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;
isPremium: boolean;
canUseExternalEmojis: boolean | null;
packResolver?: PackExpressionAccessResolver;
}): Promise<Array<Replacement>> {
const {emojiMatches, emojiLookups, guildId, isWebhook, isPremium, 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,
isPremium,
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;
isPremium: boolean;
canUseExternalEmojis: boolean | null;
packResolver?: PackExpressionAccessResolver;
}): Promise<boolean> {
const {lookup, guildId, isWebhook, isPremium, canUseExternalEmojis, packResolver} = params;
if (!guildId) {
if (!lookup.globalEmoji) return true;
if (!isWebhook && !isPremium) 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 && !isPremium) return true;
if (isPremium && 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,259 @@
/*
* 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 {Readable} from 'node:stream';
import {errors, request} from 'undici';
import {FLUXER_USER_AGENT} from '~/Constants';
interface RequestOptions {
url: string;
method?: 'GET' | 'POST' | 'HEAD';
headers?: Record<string, string>;
body?: unknown;
signal?: AbortSignal;
timeout?: number;
}
type UndiciResponse = Awaited<ReturnType<typeof request>>;
type ResponseBody = UndiciResponse['body'];
interface StreamResponse {
stream: ResponseBody;
headers: Headers;
status: number;
url: string;
}
interface RedirectResult {
body: ResponseBody;
headers: Record<string, string | Array<string>>;
statusCode: number;
finalUrl: string;
}
class HttpError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly response?: Response,
public readonly isExpected = false,
) {
super(message);
this.name = 'HttpError';
}
}
// biome-ignore lint/complexity/noStaticOnlyClass: this is fine
class HttpClient {
private static readonly DEFAULT_TIMEOUT = 30_000;
private static readonly MAX_REDIRECTS = 5;
private static readonly DEFAULT_HEADERS = {
Accept: '*/*',
'User-Agent': FLUXER_USER_AGENT,
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
};
private static getHeadersForUrl(_url: string, customHeaders?: Record<string, string>): Record<string, string> {
return {...HttpClient.DEFAULT_HEADERS, ...customHeaders};
}
private static createCombinedController(...signals: Array<AbortSignal>): AbortController {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener('abort', () => controller.abort(signal.reason), {once: true});
}
return controller;
}
private static createTimeoutController(timeout: number): AbortController {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('Request timed out'), timeout);
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), {once: true});
return controller;
}
private static async handleRedirect(
statusCode: number,
headers: Record<string, string | Array<string>>,
currentUrl: string,
options: RequestOptions,
signal: AbortSignal,
redirectCount = 0,
): Promise<RedirectResult> {
if (redirectCount >= HttpClient.MAX_REDIRECTS) {
throw new HttpError(`Maximum number of redirects (${HttpClient.MAX_REDIRECTS}) exceeded`);
}
if (![301, 302, 303, 307, 308].includes(statusCode)) {
throw new HttpError(`Expected redirect status but got ${statusCode}`);
}
const location = headers.location;
if (!location) {
throw new HttpError('Received redirect response without Location header', statusCode);
}
const redirectUrl = new URL(Array.isArray(location) ? location[0] : location, currentUrl).toString();
const requestHeaders = HttpClient.getHeadersForUrl(redirectUrl, options.headers);
const redirectMethod = statusCode === 303 ? 'GET' : (options.method ?? 'GET');
const redirectBody = statusCode === 303 ? undefined : options.body;
const {
statusCode: newStatusCode,
headers: newHeaders,
body,
} = await request(redirectUrl, {
method: redirectMethod,
headers: requestHeaders,
body: redirectBody ? JSON.stringify(redirectBody) : undefined,
signal,
});
if ([301, 302, 303, 307, 308].includes(newStatusCode)) {
return HttpClient.handleRedirect(
newStatusCode,
newHeaders as Record<string, string | Array<string>>,
redirectUrl,
options,
signal,
redirectCount + 1,
);
}
return {
body,
headers: newHeaders as Record<string, string | Array<string>>,
statusCode: newStatusCode,
finalUrl: redirectUrl,
};
}
public static async sendRequest(options: RequestOptions): Promise<StreamResponse> {
const timeoutController = HttpClient.createTimeoutController(options.timeout ?? HttpClient.DEFAULT_TIMEOUT);
const combinedController = options.signal
? HttpClient.createCombinedController(options.signal, timeoutController.signal)
: timeoutController;
const headers = HttpClient.getHeadersForUrl(options.url, options.headers);
try {
const {
statusCode,
headers: responseHeaders,
body,
} = await request(options.url, {
method: options.method ?? 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: combinedController.signal,
});
let finalBody = body;
let finalHeaders = responseHeaders;
let finalStatusCode = statusCode;
let finalUrl = options.url;
if (statusCode === 304) {
return {
stream: body,
headers: new Headers(responseHeaders as Record<string, string>),
status: statusCode,
url: options.url,
};
}
if ([301, 302, 303, 307, 308].includes(statusCode)) {
const redirectResult = await HttpClient.handleRedirect(
statusCode,
responseHeaders as Record<string, string | Array<string>>,
options.url,
options,
combinedController.signal,
);
finalBody = redirectResult.body;
finalHeaders = redirectResult.headers;
finalStatusCode = redirectResult.statusCode;
finalUrl = redirectResult.finalUrl;
}
const headersObject = new Headers();
for (const [key, value] of Object.entries(finalHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headersObject.append(key, v);
}
} else if (value) {
headersObject.set(key, value);
}
}
return {
stream: finalBody,
headers: headersObject,
status: finalStatusCode,
url: finalUrl,
};
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
if (error instanceof errors.RequestAbortedError) {
throw new HttpError('Request aborted', undefined, undefined, true);
}
if (error instanceof errors.BodyTimeoutError) {
throw new HttpError('Request timed out', undefined, undefined, true);
}
if (error instanceof errors.ConnectTimeoutError) {
throw new HttpError('Connection timeout', undefined, undefined, true);
}
if (error instanceof errors.SocketError) {
throw new HttpError(`Socket error: ${error.message}`, undefined, undefined, true);
}
const errorMessage = error instanceof Error ? error.message : 'Request failed';
const isNetworkError =
error instanceof Error &&
(error.message.includes('ENOTFOUND') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('ECONNRESET') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('EAI_AGAIN') ||
error.message.includes('EHOSTUNREACH') ||
error.message.includes('ENETUNREACH'));
throw new HttpError(errorMessage, undefined, undefined, isNetworkError);
}
}
public static async streamToString(stream: Readable): Promise<string> {
const chunks: Array<Uint8Array> = [];
for await (const chunk of stream) {
chunks.push(new Uint8Array(Buffer.from(chunk)));
}
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf-8');
}
}
export const {sendRequest, streamToString} = HttpClient;

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,131 @@
/*
* 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 '~/BrandedTypes';
import {GuildVerificationLevel} from '~/Constants';
import {GuildVerificationRequiredError} from '~/Errors';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {Guild, GuildMember, User} from '~/Models';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
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 GuildVerificationRequiredError('You need to verify your email to send messages in this guild.');
}
}
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
const createdAt = extractTimestamp(BigInt(user.id));
const accountAge = Date.now() - createdAt;
const FIVE_MINUTES_MS = 5 * 60 * 1000;
if (accountAge < FIVE_MINUTES_MS) {
throw new GuildVerificationRequiredError('Your account is too new to send messages in this guild.');
}
}
if (verificationLevel >= GuildVerificationLevel.HIGH) {
if (memberJoinedAt) {
const joinedAtTime =
typeof memberJoinedAt === 'string' ? new Date(memberJoinedAt).getTime() : memberJoinedAt.getTime();
const membershipDuration = Date.now() - joinedAtTime;
const TEN_MINUTES_MS = 10 * 60 * 1000;
if (membershipDuration < TEN_MINUTES_MS) {
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 GuildVerificationRequiredError('You need to add a phone number to send messages in this guild.');
}
}
}
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 {
const ownerIdSource = guild.owner_id ?? user.id;
const ownerIdBigInt = typeof ownerIdSource === 'bigint' ? ownerIdSource : BigInt(ownerIdSource);
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 '~/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,356 @@
/*
* 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 {describe, expect, it, vi} from 'vitest';
import {findInvite, findInvites} from './InviteUtils';
vi.mock('~/Config', () => ({
Config: {
hosts: {
invite: 'fluxer.gg',
gift: 'fluxer.gift',
marketing: 'marketing.fluxer.app',
unfurlIgnored: [],
},
endpoints: {
webApp: 'https://web.fluxer.app',
},
},
}));
describe('InviteUtils', () => {
describe('findInvites', () => {
it('should return empty array for null or empty content', () => {
expect(findInvites(null)).toEqual([]);
expect(findInvites('')).toEqual([]);
expect(findInvites(' ')).toEqual([]);
});
it('should find invite codes from fluxer.gg URLs (direct, no /invite/)', () => {
const content = 'Check out this guild: https://fluxer.gg/abc123';
const result = findInvites(content);
expect(result).toEqual(['abc123']);
});
it('should find invite codes from web.fluxer.app/invite/ URLs', () => {
const content = 'Join us: https://web.fluxer.app/invite/test123';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('test123');
});
it('should NOT match fluxer.gg/invite/ URLs', () => {
const content = 'Invalid: https://fluxer.gg/invite/shouldnotwork';
const result = findInvites(content);
expect(result).toEqual([]);
});
it('should handle URLs without protocol', () => {
const content = 'Join us: fluxer.gg/test123';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('test123');
});
it('should handle URLs with hash fragment', () => {
const content = 'Come join: https://web.fluxer.app/#/invite/hash456';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('hash456');
});
it('should find multiple unique invite codes from different hosts', () => {
const content = `
First: https://fluxer.gg/invite1
Second: https://web.fluxer.app/invite/invite2
Third: https://web.fluxer.app/#/invite/invite3
`;
const result = findInvites(content);
expect(result).toHaveLength(3);
expect(result).toEqual(['invite1', 'invite2', 'invite3']);
});
it('should deduplicate identical invite codes', () => {
const content = `
https://fluxer.gg/duplicate
fluxer.gg/duplicate
Another mention: https://fluxer.gg/duplicate
`;
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('duplicate');
});
it('should deduplicate codes across different hosts', () => {
const content = `
https://fluxer.gg/samecode
https://web.fluxer.app/invite/samecode
`;
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('samecode');
});
it('should limit to maximum 10 invites', () => {
let content = '';
for (let i = 1; i <= 15; i++) {
content += `https://fluxer.gg/code${i.toString().padStart(2, '0')} `;
}
const result = findInvites(content);
expect(result).toHaveLength(10);
});
it('should handle invite codes with valid characters', () => {
const validCodes = ['abc123', 'TEST-CODE', 'mix3d-Ch4rs', 'AB', 'a'.repeat(32)];
validCodes.forEach((code) => {
const content = `https://fluxer.gg/${code}`;
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe(code);
});
});
it('should ignore invite codes that are too short', () => {
const code = 'a';
const content = `https://fluxer.gg/${code}`;
const result = findInvites(content);
expect(result).toHaveLength(0);
});
it('should ignore invite codes that are too long', () => {
const code = 'a'.repeat(33);
const content = `https://fluxer.gg/${code}`;
const result = findInvites(content);
expect(result).toHaveLength(0);
});
it('should handle mixed case URLs', () => {
const content = 'Join: https://fluxer.gg/MixedCase123';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('MixedCase123');
});
it('should handle URLs with extra text around them', () => {
const content = 'Before text https://fluxer.gg/surrounded123 after text';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('surrounded123');
});
it('should handle web.fluxer.app URLs with and without protocol', () => {
const content = `
https://web.fluxer.app/invite/withprotocol
web.fluxer.app/invite/withoutprotocol
`;
const result = findInvites(content);
expect(result).toHaveLength(2);
expect(result).toEqual(['withprotocol', 'withoutprotocol']);
});
it('should handle mixed fluxer.gg and web.fluxer.app URLs', () => {
const content = `
Direct: fluxer.gg/direct123
Web app: web.fluxer.app/invite/local456
Another direct: https://fluxer.gg/direct789
`;
const result = findInvites(content);
expect(result).toHaveLength(3);
expect(result).toEqual(['direct123', 'local456', 'direct789']);
});
it('should handle canary domain', () => {
const content = `
Canary: https://web.canary.fluxer.app/invite/canary123
Stable: https://web.fluxer.app/invite/stable456
`;
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result).toEqual(['stable456']);
});
it('should NOT match marketing site invite URLs', () => {
const content = 'Invalid: https://fluxer.app/invite/shouldnotwork';
const result = findInvites(content);
expect(result).toEqual([]);
});
});
describe('findInvite', () => {
it('should return null for null or empty content', () => {
expect(findInvite(null)).toBeNull();
expect(findInvite('')).toBeNull();
expect(findInvite(' ')).toBeNull();
});
it('should find first invite code from fluxer.gg', () => {
const content = 'Check out: https://fluxer.gg/first123';
const result = findInvite(content);
expect(result).toBe('first123');
});
it('should find first invite code from web.fluxer.app', () => {
const content = 'Check out: https://web.fluxer.app/invite/first123';
const result = findInvite(content);
expect(result).toBe('first123');
});
it('should return first invite when multiple exist', () => {
const content = `
First: https://fluxer.gg/first456
Second: web.fluxer.app/invite/second789
`;
const result = findInvite(content);
expect(result).toBe('first456');
});
it('should handle URLs without protocol', () => {
const content = 'Join: fluxer.gg/noprotocol';
const result = findInvite(content);
expect(result).toBe('noprotocol');
});
it('should handle URLs with hash fragment', () => {
const content = 'Visit: https://web.fluxer.app/#/invite/hashcode';
const result = findInvite(content);
expect(result).toBe('hashcode');
});
it('should return null when no valid invite found', () => {
const invalidContents = [
'No invites here',
'https://other-site.com/invite/code123',
'https://fluxer.gg/invite/shouldnotmatch',
'https://fluxer.gg/a',
'https://fluxer.app/invite/marketing',
];
invalidContents.forEach((content) => {
expect(findInvite(content)).toBeNull();
});
});
it('should handle case insensitive matching', () => {
const content = 'Visit: HTTPS://FLUXER.GG/CaseTest';
const result = findInvite(content);
expect(result).toBe('CaseTest');
});
it('should handle complex content with multiple URLs', () => {
const content = `
Visit our website at https://fluxer.app
Join our guild: https://fluxer.gg/complex123
Learn more at https://fluxer.app/about
`;
const result = findInvite(content);
expect(result).toBe('complex123');
});
});
describe('edge cases', () => {
it('should handle content with special regex characters', () => {
const content = 'Check this (important): https://fluxer.gg/special123 [link]';
const result = findInvites(content);
expect(result).toHaveLength(1);
expect(result[0]).toBe('special123');
});
it('should handle very long content without crashing', () => {
const longContent = `${'a'.repeat(10000)}https://fluxer.gg/buried123${'b'.repeat(10000)}`;
const result = findInvites(longContent);
expect(result).toEqual([]);
});
it('should handle malformed URLs gracefully', () => {
const content = `
https://fluxer.gg/good123
https://fluxer.gg/
https://fluxer.gg
fluxer.gg/another456
web.fluxer.app/invite/valid789
`;
const result = findInvites(content);
expect(result).toHaveLength(3);
expect(result).toEqual(['good123', 'another456', 'valid789']);
});
it('should reset regex state between calls', () => {
const content1 = 'https://fluxer.gg/first123';
const content2 = 'https://web.fluxer.app/invite/second456';
const result1 = findInvite(content1);
const result2 = findInvite(content2);
expect(result1).toBe('first123');
expect(result2).toBe('second456');
});
it('should handle codes at exact length boundaries', () => {
const minCode = 'ab';
const maxCode = 'a'.repeat(32);
const contentMin = `https://fluxer.gg/${minCode}`;
const contentMax = `https://fluxer.gg/${maxCode}`;
expect(findInvite(contentMin)).toBe(minCode);
expect(findInvite(contentMax)).toBe(maxCode);
});
it('should distinguish between marketing and web app domains', () => {
const content = `
Marketing: https://fluxer.app/invite/marketing123
Web app: https://web.fluxer.app/invite/webapp456
Shortlink: https://fluxer.gg/shortlink789
`;
const result = findInvites(content);
expect(result).toHaveLength(2);
expect(result).toEqual(['webapp456', 'shortlink789']);
});
});
});

View File

@@ -0,0 +1,66 @@
/*
* 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 '~/Config';
import * as RegexUtils from '~/utils/RegexUtils';
const INVITE_PATTERN = 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',
);
export function findInvites(content: string | null): Array<string> {
if (!content) return [];
const invites: Array<string> = [];
const seenCodes = new Set<string>();
INVITE_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INVITE_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;
INVITE_PATTERN.lastIndex = 0;
const match = INVITE_PATTERN.exec(content);
if (match) {
return match[1] || match[2];
}
return null;
}

View File

@@ -0,0 +1,76 @@
/*
* 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 {describe, expect, test} from 'vitest';
import {isValidIpOrRange, parseIpBanEntry, tryParseSingleIp} from './IpRangeUtils';
const IPv4Value = BigInt('0xc0a80001');
const IPv4RangeStart = BigInt('0xc0a80100');
const IPv4RangeEnd = BigInt('0xc0a801ff');
const IPv6Value = BigInt('0x20010db8000000000000000000000001');
const IPv6RangeStart = BigInt('0x20010db8000000000000000000000000');
const IPv6HostSpan = (1n << 96n) - 1n;
describe('IpRangeUtils', () => {
test('parse single IPv4 address', () => {
const parsed = parseIpBanEntry(' 192.168.0.1 ');
expect(parsed).not.toBeNull();
expect(parsed?.type).toBe('single');
if (parsed?.type !== 'single') return;
expect(parsed?.family).toBe('ipv4');
expect(parsed?.canonical).toBe('192.168.0.1');
expect(parsed.value).toBe(IPv4Value);
});
test('parse single IPv6 address with zone', () => {
const parsed = tryParseSingleIp('2001:db8::1%eth0');
expect(parsed).not.toBeNull();
expect(parsed?.canonical).toBe('2001:0db8:0000:0000:0000:0000:0000:0001');
expect(parsed?.value).toBe(IPv6Value);
});
test('parse IPv4 range and trim host bits', () => {
const parsed = parseIpBanEntry('192.168.1.5/24');
expect(parsed?.type).toBe('range');
if (parsed?.type !== 'range') return;
expect(parsed.family).toBe('ipv4');
expect(parsed.canonical).toBe('192.168.1.0/24');
expect(parsed.prefixLength).toBe(24);
expect(parsed.start).toBe(IPv4RangeStart);
expect(parsed.end).toBe(IPv4RangeEnd);
});
test('parse IPv6 range and compute host span', () => {
const parsed = parseIpBanEntry('2001:db8::/32');
expect(parsed?.type).toBe('range');
if (parsed?.type !== 'range') return;
expect(parsed.family).toBe('ipv6');
expect(parsed.canonical).toBe('2001:0db8:0000:0000:0000:0000:0000:0000/32');
expect(parsed.prefixLength).toBe(32);
expect(parsed.start).toBe(IPv6RangeStart);
expect(parsed.end - parsed.start).toBe(IPv6HostSpan);
});
test('invalid input returns null and isValidIpOrRange rejects it', () => {
expect(parseIpBanEntry('300.1.1.1')).toBeNull();
expect(parseIpBanEntry('10.0.0.0/33')).toBeNull();
expect(isValidIpOrRange('10.0.0.0/33')).toBe(false);
expect(isValidIpOrRange('something')).toBe(false);
});
});

View File

@@ -0,0 +1,272 @@
/*
* 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 {normalizeIpString} from '~/utils/IpUtils';
export type IpFamily = 'ipv4' | 'ipv6';
interface ParsedBase {
canonical: string;
family: IpFamily;
}
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<IpFamily, 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: IpFamily, 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: IpFamily;
bytes: Buffer;
}
interface ParsedCIDR {
family: IpFamily;
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;
return {
type: 'single',
family: parsed.family,
canonical: formatNormalizedAddress(parsed.family, parsed.bytes),
value: bufferToBigInt(parsed.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 === 6 && ipWithoutZone.includes('.')) {
const lastColon = ipWithoutZone.lastIndexOf(':');
const ipv4Part = ipWithoutZone.slice(lastColon + 1);
const bytes = parseIPv4(ipv4Part);
if (!bytes) return null;
return {family: 'ipv4', bytes};
}
if (family === 4) {
const bytes = parseIPv4(ipWithoutZone);
if (!bytes) return null;
return {family: 'ipv4', bytes};
}
const bytes = parseIPv6(ipWithoutZone);
if (!bytes) return null;
return {family: 'ipv6', bytes};
}
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;
const maxPrefix = parsed.family === 'ipv4' ? 32 : 128;
if (prefixLength > maxPrefix) return null;
const network = Buffer.from(parsed.bytes);
const fullBytes = Math.floor(prefixLength / 8);
const remainingBits = prefixLength % 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: parsed.family,
address: network,
prefixLength,
};
}
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,136 @@
/*
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {__clearIpCache, DEFAULT_CC, FETCH_TIMEOUT_MS, formatGeoipLocation, getCountryCode} from './IpUtils';
vi.mock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: 'geoip:8080'}}}));
describe('getCountryCode(ip)', () => {
const realFetch = globalThis.fetch;
beforeEach(() => {
__clearIpCache();
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = realFetch;
});
it('returns US if GEOIP_HOST is not set', async () => {
vi.doMock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: ''}}}));
const {getCountryCode: modFn, __clearIpCache: reset} = await import('./IpUtils');
reset();
const cc = await modFn('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('returns US for invalid IP', async () => {
const cc = await getCountryCode('not_an_ip');
expect(cc).toBe(DEFAULT_CC);
});
it('accepts bracketed IPv6', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('[2001:db8::1]');
expect(cc).toBe('SE');
});
it('returns uppercase alpha-2 on success', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe('SE');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('falls back on non-2xx', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: false, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on invalid body', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('USA')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('uses cache for repeated lookups within TTL', async () => {
const spyNow = vi.spyOn(Date, 'now');
spyNow.mockReturnValueOnce(1_000);
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('gb')});
const r1 = await getCountryCode('1.1.1.1');
expect(r1).toBe('GB');
spyNow.mockReturnValueOnce(2_000);
const r2 = await getCountryCode('1.1.1.1');
expect(r2).toBe('GB');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('aborts after timeout', async () => {
vi.useFakeTimers();
const abortingFetch = vi.fn<typeof fetch>(
(_url, opts) =>
new Promise((_res, rej) => {
opts?.signal?.addEventListener('abort', () =>
rej(Object.assign(new Error('AbortError'), {name: 'AbortError'})),
);
}),
);
globalThis.fetch = abortingFetch;
const p = getCountryCode('8.8.8.8');
vi.advanceTimersByTime(FETCH_TIMEOUT_MS + 5);
await expect(p).resolves.toBe(DEFAULT_CC);
expect(abortingFetch).toHaveBeenCalledTimes(1);
});
});
describe('formatGeoipLocation(result)', () => {
it('returns Unknown Location when no parts are available', () => {
const label = formatGeoipLocation({
countryCode: DEFAULT_CC,
normalizedIp: '1.1.1.1',
reason: null,
city: null,
region: null,
countryName: null,
});
expect(label).toBe('Unknown Location');
});
it('concatenates available city, region, and country', () => {
const label = formatGeoipLocation({
countryCode: 'US',
normalizedIp: '1.1.1.1',
reason: null,
city: 'San Francisco',
region: 'CA',
countryName: 'United States',
});
expect(label).toBe('San Francisco, CA, United States');
});
});

View File

@@ -0,0 +1,270 @@
/*
* 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 {isIPv4, isIPv6} from 'node:net';
import maxmind, {type CityResponse, type Reader} from 'maxmind';
import {Config} from '~/Config';
import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
const CACHE_TTL_MS = 10 * 60 * 1000;
export const DEFAULT_CC = 'US';
export const FETCH_TIMEOUT_MS = 1500;
export const UNKNOWN_LOCATION = 'Unknown Location';
const REVERSE_DNS_CACHE_TTL_SECONDS = 24 * 60 * 60;
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
export interface GeoipResult {
countryCode: string;
normalizedIp: string | null;
reason: string | null;
city: string | null;
region: string | null;
countryName: string | null;
}
interface CacheVal {
result: GeoipResult;
exp: number;
}
const cache = new Map<string, CacheVal>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
export function __clearIpCache(): void {
cache.clear();
}
export function extractClientIp(req: Request): string | null {
const xff = (req.headers.get('X-Forwarded-For') ?? '').trim();
if (!xff) {
return null;
}
const first = xff.split(',')[0]?.trim() ?? '';
return normalizeIpString(first);
}
export function requireClientIp(req: Request): string {
const ip = extractClientIp(req);
if (!ip) {
throw new Error('X-Forwarded-For header is required for this endpoint');
}
return ip;
}
function buildFallbackResult(clean: string, reason: string | null): GeoipResult {
return {
countryCode: DEFAULT_CC,
normalizedIp: clean,
reason,
city: null,
region: null,
countryName: null,
};
}
async function getMaxmindReader(): Promise<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return Promise.reject(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 getSubdivisionLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
return subdivision.iso_code || subdivision.names?.en || null;
}
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return buildFallbackResult(clean, 'maxmind_db_missing');
}
try {
const reader = await getMaxmindReader();
const record = reader.get(clean);
if (!record) {
return buildFallbackResult(clean, 'maxmind_not_found');
}
const countryCode = (record.country?.iso_code ?? DEFAULT_CC).toUpperCase();
return {
countryCode,
normalizedIp: clean,
reason: null,
city: record.city?.names?.en ?? null,
region: getSubdivisionLabel(record),
countryName: record.country?.names?.en ?? countryDisplayName(countryCode) ?? null,
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
Logger.warn({error, maxmind_db_path: dbPath}, 'MaxMind lookup failed');
return buildFallbackResult(clean, `maxmind_error:${message}`);
}
}
async function lookupIpinfo(clean: string): Promise<GeoipResult> {
const host = Config.geoip.host;
if (!host) {
return buildFallbackResult(clean, 'geoip_host_missing');
}
const url = `http://${host}/lookup?ip=${encodeURIComponent(clean)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await globalThis.fetch!(url, {
signal: controller.signal,
headers: {Accept: 'text/plain'},
});
if (!res.ok) {
return buildFallbackResult(clean, `non_ok:${res.status}`);
}
const text = (await res.text()).trim().toUpperCase();
const countryCode = isAsciiUpperAlpha2(text) ? text : DEFAULT_CC;
const reason = isAsciiUpperAlpha2(text) ? null : 'invalid_response';
return {
countryCode,
normalizedIp: clean,
reason,
city: null,
region: null,
countryName: countryDisplayName(countryCode),
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
return buildFallbackResult(clean, `error:${message}`);
} finally {
clearTimeout(timer);
}
}
export async function getCountryCodeDetailed(ip: string): Promise<GeoipResult> {
const clean = normalizeIpString(ip);
if (!isIPv4(clean) && !isIPv6(clean)) {
return buildFallbackResult(clean, 'invalid_ip');
}
const cached = cache.get(clean);
const now = Date.now();
if (cached && now < cached.exp) {
return cached.result;
}
const provider = Config.geoip.provider;
const result = provider === 'maxmind' ? await lookupMaxmind(clean) : await lookupIpinfo(clean);
cache.set(clean, {result, exp: now + CACHE_TTL_MS});
return result;
}
export async function getCountryCode(ip: string): Promise<string> {
const result = await getCountryCodeDetailed(ip);
return result.countryCode;
}
export async function getCountryCodeFromReq(req: Request): Promise<string> {
const ip = extractClientIp(req);
if (!ip) return DEFAULT_CC;
return await getCountryCode(ip);
}
function countryDisplayName(code: string, locale = 'en'): string | null {
const c = code.toUpperCase();
if (!isAsciiUpperAlpha2(c)) return null;
const dn = new Intl.DisplayNames([locale], {type: 'region', fallback: 'none'});
return dn.of(c) ?? null;
}
export function formatGeoipLocation(result: GeoipResult): string {
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(', ') : UNKNOWN_LOCATION;
}
function stripBrackets(s: string): string {
return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
}
export function normalizeIpString(value: string): string {
const trimmed = value.trim();
const stripped = stripBrackets(trimmed);
const zoneIndex = stripped.indexOf('%');
return zoneIndex === -1 ? stripped : stripped.slice(0, zoneIndex);
}
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;
}
function isAsciiUpperAlpha2(s: string): boolean {
if (s.length !== 2) return false;
const a = s.charCodeAt(0);
const b = s.charCodeAt(1);
return a >= 65 && a <= 90 && b >= 65 && b <= 90;
}

View File

@@ -0,0 +1,101 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {Locales} from '~/Constants';
import {parseAcceptLanguage} from './LocaleUtils';
describe('LocaleUtils', () => {
describe('parseAcceptLanguage', () => {
it('should return en-US when header is null', () => {
expect(parseAcceptLanguage(null)).toBe(Locales.EN_US);
});
it('should return en-US when header is undefined', () => {
expect(parseAcceptLanguage(undefined)).toBe(Locales.EN_US);
});
it('should return en-US when header is empty', () => {
expect(parseAcceptLanguage('')).toBe(Locales.EN_US);
});
it('should return exact match for supported locale', () => {
expect(parseAcceptLanguage('fr')).toBe(Locales.FR);
expect(parseAcceptLanguage('de')).toBe(Locales.DE);
expect(parseAcceptLanguage('ja')).toBe(Locales.JA);
});
it('should return exact match for supported locale with region', () => {
expect(parseAcceptLanguage('en-US')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('en-GB')).toBe(Locales.EN_GB);
expect(parseAcceptLanguage('pt-BR')).toBe(Locales.PT_BR);
expect(parseAcceptLanguage('es-ES')).toBe(Locales.ES_ES);
});
it('should handle quality values correctly', () => {
expect(parseAcceptLanguage('en;q=0.8,fr;q=0.9')).toBe(Locales.FR);
expect(parseAcceptLanguage('de;q=0.5,ja;q=0.9,en-US;q=0.3')).toBe(Locales.JA);
});
it('should handle complex Accept-Language headers', () => {
expect(parseAcceptLanguage('fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7')).toBe(Locales.FR);
expect(parseAcceptLanguage('de-DE,de;q=0.9,en;q=0.8')).toBe(Locales.DE);
});
it('should match language code when exact locale not found', () => {
expect(parseAcceptLanguage('en')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('es')).toBe(Locales.ES_ES);
expect(parseAcceptLanguage('pt')).toBe(Locales.PT_BR);
expect(parseAcceptLanguage('zh')).toBe(Locales.ZH_CN);
});
it('should fallback to en-US for unsupported locales', () => {
expect(parseAcceptLanguage('xx-XX')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('invalid')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('unsupported-locale')).toBe(Locales.EN_US);
});
it('should handle malformed headers gracefully', () => {
expect(parseAcceptLanguage(';;;')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('q=0.9')).toBe(Locales.EN_US);
});
it('should prioritize first exact match when multiple supported locales exist', () => {
expect(parseAcceptLanguage('ja,fr,de')).toBe(Locales.JA);
expect(parseAcceptLanguage('en-GB,en-US')).toBe(Locales.EN_GB);
});
it('should handle whitespace correctly', () => {
expect(parseAcceptLanguage(' fr ')).toBe(Locales.FR);
expect(parseAcceptLanguage('en-US , fr ; q=0.8')).toBe(Locales.EN_US);
});
it('should respect quality values over order', () => {
expect(parseAcceptLanguage('en-US;q=0.5,fr;q=1.0')).toBe(Locales.FR);
expect(parseAcceptLanguage('de;q=0.3,ja;q=0.9,en-GB;q=0.7')).toBe(Locales.JA);
});
it('should handle case insensitivity for language codes', () => {
expect(parseAcceptLanguage('EN-us')).toBe(Locales.EN_US);
expect(parseAcceptLanguage('FR')).toBe(Locales.FR);
expect(parseAcceptLanguage('EN-gb')).toBe(Locales.EN_GB);
expect(parseAcceptLanguage('pt-br')).toBe(Locales.PT_BR);
});
});
});

View File

@@ -0,0 +1,76 @@
/*
* 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 {Locales} from '~/Constants';
const SUPPORTED_LOCALES = new Set<string>(Object.values(Locales));
const DEFAULT_LOCALE = Locales.EN_US;
export function parseAcceptLanguage(acceptLanguageHeader: string | null | undefined): string {
if (!acceptLanguageHeader) {
return DEFAULT_LOCALE;
}
try {
const languages = acceptLanguageHeader
.split(',')
.map((lang) => {
const parts = lang.trim().split(';');
const locale = parts[0].trim();
const qMatch = parts[1]?.match(/q=([\d.]+)/);
const quality = qMatch ? Number.parseFloat(qMatch[1]) : 1.0;
return {locale, quality};
})
.sort((a, b) => b.quality - a.quality);
for (const {locale} of languages) {
for (const supportedLocale of SUPPORTED_LOCALES) {
if (supportedLocale.toLowerCase() === locale.toLowerCase()) {
return supportedLocale;
}
}
}
const languagePreferences: Record<string, string> = {
en: Locales.EN_US,
es: Locales.ES_ES,
pt: Locales.PT_BR,
zh: Locales.ZH_CN,
sv: Locales.SV_SE,
};
for (const {locale} of languages) {
const languageCode = locale.split('-')[0].toLowerCase();
if (languagePreferences[languageCode] && SUPPORTED_LOCALES.has(languagePreferences[languageCode])) {
return languagePreferences[languageCode];
}
for (const supportedLocale of SUPPORTED_LOCALES) {
if (supportedLocale.toLowerCase().startsWith(`${languageCode}-`)) {
return supportedLocale;
}
}
}
return DEFAULT_LOCALE;
} catch (_error) {
return DEFAULT_LOCALE;
}
}

View File

@@ -0,0 +1,317 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {
createSignature,
getExternalMediaProxyURL,
getProxyURLPath,
reconstructOriginalURL,
verifySignature,
} from './MediaProxyUtils';
describe('MediaProxyUtils', () => {
const testSecretKey = 'test-secret-key';
const testEndpoint = 'https://media-proxy.example.com';
describe('getProxyURLPath', () => {
it('should encode basic URL components', () => {
const url = 'https://example.com/image.jpg';
const result = getProxyURLPath(url);
expect(result).toBe('https/example.com/image.jpg');
});
it('should handle URLs with ports', () => {
const url = 'http://example.com:8080/image.jpg';
const result = getProxyURLPath(url);
expect(result).toBe('http/example.com:8080/image.jpg');
});
it('should handle URLs with query parameters', () => {
const url = 'https://example.com/image.jpg?size=large&format=webp';
const result = getProxyURLPath(url);
expect(result).toBe('size%3Dlarge%26format%3Dwebp/https/example.com/image.jpg');
});
it('should handle URLs with paths containing slashes', () => {
const url = 'https://example.com/folder/subfolder/image.jpg';
const result = getProxyURLPath(url);
expect(result).toBe('https/example.com/folder/subfolder/image.jpg');
});
it('should handle URLs with special characters in path', () => {
const url = 'https://example.com/images/my%20file.jpg';
const result = getProxyURLPath(url);
expect(result).toBe('https/example.com/images/my%2520file.jpg');
});
it('should handle URLs with fragments (ignored)', () => {
const url = 'https://example.com/image.jpg#section';
const result = getProxyURLPath(url);
expect(result).toBe('https/example.com/image.jpg');
});
it('should preserve forward slashes in encoded components', () => {
const url = 'https://example.com/api/v1/images/photo.jpg';
const result = getProxyURLPath(url);
expect(result).toBe('https/example.com/api/v1/images/photo.jpg');
});
});
describe('createSignature', () => {
it('should create consistent signatures for same input', () => {
const input = 'test-input-string';
const signature1 = createSignature(input, testSecretKey);
const signature2 = createSignature(input, testSecretKey);
expect(signature1).toBe(signature2);
expect(typeof signature1).toBe('string');
expect(signature1.length).toBeGreaterThan(0);
});
it('should create different signatures for different inputs', () => {
const signature1 = createSignature('input1', testSecretKey);
const signature2 = createSignature('input2', testSecretKey);
expect(signature1).not.toBe(signature2);
});
it('should create different signatures for different keys', () => {
const input = 'same-input';
const signature1 = createSignature(input, 'key1');
const signature2 = createSignature(input, 'key2');
expect(signature1).not.toBe(signature2);
});
it('should handle empty input', () => {
const signature = createSignature('', testSecretKey);
expect(typeof signature).toBe('string');
expect(signature.length).toBeGreaterThan(0);
});
it('should produce base64url-safe signatures', () => {
const signature = createSignature('test-input', testSecretKey);
expect(signature).not.toMatch(/[+/=]/);
expect(signature).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe('getExternalMediaProxyURL', () => {
it('should generate complete proxy URL', () => {
const inputURL = 'https://example.com/image.jpg';
const result = getExternalMediaProxyURL({
inputURL,
mediaProxyEndpoint: testEndpoint,
mediaProxySecretKey: testSecretKey,
});
expect(result).toMatch(new RegExp(`^${testEndpoint}/external/[A-Za-z0-9_-]+/https/example\\.com/image\\.jpg$`));
});
it('should include query parameters in URL path', () => {
const inputURL = 'https://example.com/image.jpg?size=large';
const result = getExternalMediaProxyURL({
inputURL,
mediaProxyEndpoint: testEndpoint,
mediaProxySecretKey: testSecretKey,
});
expect(result).toContain('size%3Dlarge');
});
it('should handle complex URLs', () => {
const inputURL = 'https://cdn.example.com:443/api/v2/images/photo.jpg?format=webp&quality=80';
const result = getExternalMediaProxyURL({
inputURL,
mediaProxyEndpoint: testEndpoint,
mediaProxySecretKey: testSecretKey,
});
expect(result).toContain(testEndpoint);
expect(result).toContain('/external/');
expect(result).toContain('format%3Dwebp%26quality%3D80');
expect(result).toContain('https/cdn.example.com');
});
it('should generate verifiable URLs', () => {
const inputURL = 'https://example.com/test.jpg';
const proxyURL = getExternalMediaProxyURL({
inputURL,
mediaProxyEndpoint: testEndpoint,
mediaProxySecretKey: testSecretKey,
});
const urlParts = proxyURL.replace(`${testEndpoint}/external/`, '').split('/');
const signature = urlParts[0];
const path = urlParts.slice(1).join('/');
expect(verifySignature(path, signature, testSecretKey)).toBe(true);
});
});
describe('verifySignature', () => {
it('should verify valid signatures', () => {
const path = 'https/example.com/image.jpg';
const signature = createSignature(path, testSecretKey);
expect(verifySignature(path, signature, testSecretKey)).toBe(true);
});
it('should reject invalid signatures', () => {
const path = 'https/example.com/image.jpg';
const validSignature = createSignature(path, testSecretKey);
const invalidSignature = `${validSignature.slice(0, -1)}X`;
expect(verifySignature(path, invalidSignature, testSecretKey)).toBe(false);
});
it('should reject signatures with wrong key', () => {
const path = 'https/example.com/image.jpg';
const signature = createSignature(path, 'wrong-key');
expect(verifySignature(path, signature, testSecretKey)).toBe(false);
});
it('should reject signatures for wrong path', () => {
const signature = createSignature('https/example.com/image1.jpg', testSecretKey);
expect(verifySignature('https/example.com/image2.jpg', signature, testSecretKey)).toBe(false);
});
it('should handle timing-safe comparison', () => {
const path = 'https/example.com/image.jpg';
const correctSignature = createSignature(path, testSecretKey);
const wrongSignature = `${correctSignature.slice(0, -1)}X`;
expect(verifySignature(path, wrongSignature, testSecretKey)).toBe(false);
});
});
describe('reconstructOriginalURL', () => {
it('should reconstruct basic URLs', () => {
const originalURL = 'https://example.com/image.jpg';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
it('should reconstruct URLs with ports', () => {
const originalURL = 'http://example.com:8080/image.jpg';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
it('should reconstruct URLs with query parameters', () => {
const originalURL = 'https://example.com/image.jpg?size=large&format=webp';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
it('should reconstruct URLs with complex paths', () => {
const originalURL = 'https://cdn.example.com/api/v2/images/folder/photo.jpg';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
it('should throw error for missing protocol', () => {
const invalidPath = 'example.com/image.jpg';
const result = reconstructOriginalURL(invalidPath);
expect(result).toBe('example.com://image.jpg/');
});
it('should throw error for missing hostname', () => {
const invalidPath = 'https//image.jpg';
expect(() => reconstructOriginalURL(invalidPath)).toThrow('Hostname is missing in the proxy URL path.');
});
it('should handle URLs without query parameters', () => {
const originalURL = 'https://example.com/simple.jpg';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
it('should handle edge case with empty path', () => {
const originalURL = 'https://example.com/';
const proxyPath = getProxyURLPath(originalURL);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(originalURL);
});
});
describe('roundtrip encoding/decoding', () => {
const testURLs = [
'https://example.com/image.jpg',
'http://localhost:3000/api/image',
'https://cdn.example.com/folder/subfolder/image.png?size=large&format=webp',
'https://example.com:8443/path/to/resource.jpg',
'https://example.com/images/file%20with%20spaces.jpg',
'https://api.example.com/v1/media/123456789.jpg?token=abc123&expires=1234567890',
];
testURLs.forEach((url) => {
it(`should correctly roundtrip encode/decode: ${url}`, () => {
const proxyPath = getProxyURLPath(url);
const reconstructed = reconstructOriginalURL(proxyPath);
expect(reconstructed).toBe(url);
});
});
testURLs.forEach((url) => {
it(`should generate valid proxy URLs for: ${url}`, () => {
const proxyURL = getExternalMediaProxyURL({
inputURL: url,
mediaProxyEndpoint: testEndpoint,
mediaProxySecretKey: testSecretKey,
});
const urlParts = proxyURL.replace(`${testEndpoint}/external/`, '').split('/');
const signature = urlParts[0];
const path = urlParts.slice(1).join('/');
expect(verifySignature(path, signature, testSecretKey)).toBe(true);
const reconstructed = reconstructOriginalURL(path);
expect(reconstructed).toBe(url);
});
});
});
});

View File

@@ -0,0 +1,90 @@
/*
* 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 BASE64_URL_REGEX = /=*$/;
const encodeComponent = (component: string) => encodeURIComponent(component).replace(/%2F/g, '/');
const decodeComponent = (component: string) => decodeURIComponent(component);
export const getProxyURLPath = (inputUrl: string): string => {
const parsedUrl = new URL(inputUrl);
const query = parsedUrl.search.slice(1);
const protocol = parsedUrl.protocol.slice(0, -1);
const hostname = parsedUrl.hostname;
const port = parsedUrl.port;
const path = parsedUrl.pathname.slice(1);
const encodedQuery = query ? `${encodeComponent(query)}/` : '';
const encodedHostname = encodeComponent(hostname);
const encodedPort = port ? `:${encodeComponent(port)}` : '';
const encodedPath = encodeComponent(path);
return `${encodedQuery}${protocol}/${encodedHostname}${encodedPort}/${encodedPath}`;
};
export const createSignature = (inputString: string, mediaProxySecretKey: string): string => {
const hmac = crypto.createHmac('sha256', mediaProxySecretKey);
hmac.update(inputString);
return hmac.digest('base64url').replace(BASE64_URL_REGEX, '');
};
interface MediaProxyParams {
inputURL: string;
mediaProxyEndpoint: string;
mediaProxySecretKey: string;
}
export const getExternalMediaProxyURL = ({
inputURL,
mediaProxyEndpoint,
mediaProxySecretKey,
}: MediaProxyParams): string => {
const proxyUrlPath = getProxyURLPath(inputURL);
const proxyUrlSignature = createSignature(proxyUrlPath, mediaProxySecretKey);
return `${mediaProxyEndpoint}/external/${proxyUrlSignature}/${proxyUrlPath}`;
};
export const verifySignature = (
proxyUrlPath: string,
providedSignature: string,
mediaProxySecretKey: string,
): boolean => {
const expectedSignature = createSignature(proxyUrlPath, mediaProxySecretKey);
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(providedSignature));
};
export const reconstructOriginalURL = (proxyUrlPath: string): string => {
const parts = proxyUrlPath.split('/');
let currentIndex = 0;
let query = '';
if (parts[currentIndex].includes('%3D')) {
query = decodeComponent(parts[currentIndex]);
currentIndex += 1;
}
const protocol = parts[currentIndex++];
if (!protocol) throw new Error('Protocol is missing in the proxy URL path.');
const hostPart = parts[currentIndex++];
if (!hostPart) throw new Error('Hostname is missing in the proxy URL path.');
const [encodedHostname, encodedPort] = hostPart.split(':');
const hostname = decodeComponent(encodedHostname);
const port = encodedPort ? decodeComponent(encodedPort) : '';
const encodedPath = parts.slice(currentIndex).join('/');
const path = decodeComponent(encodedPath);
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
};

View File

@@ -0,0 +1,34 @@
/*
* 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 argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password);
}
export async function verifyPassword({
password,
passwordHash,
}: {
password: string;
passwordHash: string;
}): Promise<boolean> {
return argon2.verify(passwordHash, password);
}

View File

@@ -0,0 +1,73 @@
/*
* 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 '~/BrandedTypes';
import {Permissions} from '~/Constants';
import {MissingPermissionsError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
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);
if (!result) {
throw new MissingPermissionsError();
}
}
export async function hasPermission(
gatewayService: IGatewayService,
params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
channelId?: ChannelID;
},
): Promise<boolean> {
return gatewayService.checkPermission(params);
}

View File

@@ -0,0 +1,130 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {randomString} from './RandomUtils';
describe('RandomUtils', () => {
describe('randomString', () => {
it('should generate string of correct length', () => {
expect(randomString(10)).toHaveLength(10);
expect(randomString(5)).toHaveLength(5);
expect(randomString(100)).toHaveLength(100);
});
it('should handle zero length', () => {
expect(randomString(0)).toBe('');
});
it('should generate different strings on multiple calls', () => {
const str1 = randomString(20);
const str2 = randomString(20);
const str3 = randomString(20);
expect(str1).not.toBe(str2);
expect(str2).not.toBe(str3);
expect(str1).not.toBe(str3);
});
it('should only contain valid alphabet characters', () => {
const validChars = /^[A-Za-z0-9]+$/;
const result = randomString(1000);
expect(validChars.test(result)).toBe(true);
});
it('should contain mix of uppercase, lowercase, and numbers', () => {
const result = randomString(1000);
const hasUppercase = /[A-Z]/.test(result);
const hasLowercase = /[a-z]/.test(result);
const hasNumbers = /[0-9]/.test(result);
expect(hasUppercase || hasLowercase || hasNumbers).toBe(true);
});
it('should handle various lengths', () => {
const lengths = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
lengths.forEach((length) => {
const result = randomString(length);
expect(result).toHaveLength(length);
expect(/^[A-Za-z0-9]*$/.test(result)).toBe(true);
});
});
it('should be reasonably random', () => {
const length = 10;
const iterations = 100;
const results = new Set();
for (let i = 0; i < iterations; i++) {
results.add(randomString(length));
}
expect(results.size).toBeGreaterThan(iterations * 0.95);
});
it('should handle edge case lengths', () => {
expect(randomString(1)).toHaveLength(1);
expect(randomString(2)).toHaveLength(2);
const largeString = randomString(10000);
expect(largeString).toHaveLength(10000);
});
it('should use all characters from alphabet over many iterations', () => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const usedChars = new Set<string>();
for (let i = 0; i < 1000; i++) {
const str = randomString(10);
for (const char of str) {
usedChars.add(char);
}
}
expect(usedChars.size).toBeGreaterThan(alphabet.length * 0.8);
for (const char of usedChars) {
expect(alphabet.includes(char)).toBe(true);
}
});
it('should be cryptographically random', () => {
const results: Array<string> = [];
for (let i = 0; i < 100; i++) {
results.push(randomString(20));
}
const firstChars = results.map((s) => s[0]);
const firstCharCounts = firstChars.reduce(
(acc, char) => {
acc[char] = (acc[char] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const maxCount = Math.max(...Object.values(firstCharCounts));
expect(maxCount).toBeLessThan(20);
});
});
});

View File

@@ -0,0 +1,32 @@
/*
* 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 const randomString = (length: number) => {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = '';
for (let i = 0; i < length; i++) {
result += RANDOM_STRING_ALPHABET.charAt(array[i] % RANDOM_STRING_ALPHABET.length);
}
return result;
};

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 {describe, expect, it} from 'vitest';
import {escapeRegex} from './RegexUtils';
describe('RegexUtils', () => {
describe('escapeRegex', () => {
it('should escape special regex characters', () => {
expect(escapeRegex('.')).toBe('\\.');
expect(escapeRegex('^')).toBe('\\^');
expect(escapeRegex('$')).toBe('\\$');
expect(escapeRegex('*')).toBe('\\*');
expect(escapeRegex('+')).toBe('\\+');
expect(escapeRegex('?')).toBe('\\?');
expect(escapeRegex('(')).toBe('\\(');
expect(escapeRegex(')')).toBe('\\)');
expect(escapeRegex('[')).toBe('\\[');
expect(escapeRegex(']')).toBe('\\]');
expect(escapeRegex('{')).toBe('\\{');
expect(escapeRegex('}')).toBe('\\}');
expect(escapeRegex('|')).toBe('\\|');
expect(escapeRegex('\\')).toBe('\\\\');
});
it('should handle strings with multiple special characters', () => {
expect(escapeRegex('hello.world')).toBe('hello\\.world');
expect(escapeRegex('[test]')).toBe('\\[test\\]');
expect(escapeRegex('(group)')).toBe('\\(group\\)');
expect(escapeRegex('{min,max}')).toBe('\\{min,max\\}');
expect(escapeRegex('start^end$')).toBe('start\\^end\\$');
});
it('should leave regular characters unchanged', () => {
expect(escapeRegex('hello')).toBe('hello');
expect(escapeRegex('world123')).toBe('world123');
expect(escapeRegex('test_string')).toBe('test_string');
expect(escapeRegex('email@domain.com')).toBe('email@domain\\.com');
});
it('should handle empty string', () => {
expect(escapeRegex('')).toBe('');
});
it('should handle complex patterns', () => {
const input = '.*+?^$' + '{}' + '[]|()\\\\';
const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\[\\]\\|\\(\\)\\\\\\\\';
expect(escapeRegex(input)).toBe(expected);
});
it('should work with regex constructor', () => {
const userInput = 'test.string*with?special^chars$';
const escaped = escapeRegex(userInput);
const regex = new RegExp(escaped);
expect(regex.test(userInput)).toBe(true);
expect(regex.test('testXstringYwithZspecialAcharsB')).toBe(false);
});
it('should handle whitespace', () => {
expect(escapeRegex('hello world')).toBe('hello world');
expect(escapeRegex('tab\there')).toBe('tab\there');
expect(escapeRegex('new\nline')).toBe('new\nline');
});
it('should handle unicode characters', () => {
expect(escapeRegex('café')).toBe('café');
expect(escapeRegex('🎉.test')).toBe('🎉\\.test');
expect(escapeRegex('中文.txt')).toBe('中文\\.txt');
});
it('should handle file patterns', () => {
expect(escapeRegex('*.txt')).toBe('\\*\\.txt');
expect(escapeRegex('file?.log')).toBe('file\\?\\.log');
expect(escapeRegex('[abc].csv')).toBe('\\[abc\\]\\.csv');
});
it('should make user input safe for regex', () => {
const dangerousInputs = ['.*', '^$', '[a-z]+', '(test|demo)', '\\d{3}'];
dangerousInputs.forEach((input) => {
const escaped = escapeRegex(input);
const regex = new RegExp(escaped);
expect(regex.test(input)).toBe(true);
if (input === '.*') {
expect(regex.test('anything')).toBe(false);
}
});
});
it('should preserve string length for non-special chars', () => {
const input = 'normalstring123';
expect(escapeRegex(input)).toHaveLength(input.length);
});
it('should escape consecutive special characters', () => {
expect(escapeRegex('...')).toBe('\\.\\.\\.');
expect(escapeRegex('***')).toBe('\\*\\*\\*');
expect(escapeRegex('(((')).toBe('\\(\\(\\(');
});
});
});

View File

@@ -0,0 +1,20 @@
/*
* 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 const escapeRegex = (str: string) => str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');

View File

@@ -0,0 +1,96 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {FLUXER_EPOCH} from '~/Constants';
import {extractTimestamp, getSnowflake} from './SnowflakeUtils';
describe('SnowflakeUtils', () => {
describe('getSnowflake', () => {
it('should generate snowflake from timestamp', () => {
const timestamp = Date.now();
const snowflake = getSnowflake(timestamp);
expect(typeof snowflake).toBe('bigint');
expect(snowflake > 0n).toBe(true);
});
it('should generate different snowflakes for different timestamps', () => {
const timestamp1 = Date.now();
const timestamp2 = timestamp1 + 1000;
const snowflake1 = getSnowflake(timestamp1);
const snowflake2 = getSnowflake(timestamp2);
expect(snowflake1).not.toBe(snowflake2);
expect(snowflake2 > snowflake1).toBe(true);
});
it('should use current timestamp when no timestamp provided', () => {
const beforeCall = Date.now();
const snowflake = getSnowflake();
const afterCall = Date.now();
const extractedTimestamp = extractTimestamp(snowflake);
expect(extractedTimestamp).toBeGreaterThanOrEqual(beforeCall);
expect(extractedTimestamp).toBeLessThanOrEqual(afterCall);
});
it('should handle FLUXER_EPOCH correctly', () => {
const snowflake = getSnowflake(FLUXER_EPOCH);
expect(snowflake).toBe(0n);
});
});
describe('extractTimestamp', () => {
it('should extract timestamp from snowflake', () => {
const originalTimestamp = Date.now();
const snowflake = getSnowflake(originalTimestamp);
const extractedTimestamp = extractTimestamp(snowflake);
expect(extractedTimestamp).toBe(originalTimestamp);
});
it('should handle zero snowflake', () => {
const extractedTimestamp = extractTimestamp(0n);
expect(extractedTimestamp).toBe(FLUXER_EPOCH);
});
it('should be inverse of getSnowflake', () => {
const timestamp = 1609459200000;
const snowflake = getSnowflake(timestamp);
const extracted = extractTimestamp(snowflake);
expect(extracted).toBe(timestamp);
});
});
describe('roundtrip conversion', () => {
it('should maintain timestamp integrity through conversion', () => {
const timestamps = [FLUXER_EPOCH, FLUXER_EPOCH + 1000, Date.now(), Date.now() + 86400000];
timestamps.forEach((timestamp) => {
const snowflake = getSnowflake(timestamp);
const extracted = extractTimestamp(snowflake);
expect(extracted).toBe(timestamp);
});
});
});
});

View File

@@ -0,0 +1,24 @@
/*
* 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 '~/Constants';
export const getSnowflake = (timestamp: number = Date.now()) => BigInt(timestamp - FLUXER_EPOCH) << 22n;
export const extractTimestamp = (snowflake: bigint) => Number(snowflake >> 22n) + FLUXER_EPOCH;

View File

@@ -0,0 +1,93 @@
/*
* 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 {describe, expect, it} from 'vitest';
import {parseString} from './StringUtils';
describe('StringUtils', () => {
describe('parseString', () => {
it('should decode HTML entities', () => {
const result = parseString('&amp;&lt;&gt;&quot;', 50);
expect(result).toBe('&<>"');
});
it('should trim whitespace', () => {
const result = parseString(' hello world ', 50);
expect(result).toBe('hello world');
});
it('should truncate to max length', () => {
const longString = 'This is a very long string that should be truncated';
const result = parseString(longString, 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toBe('This is a very lo...');
});
it('should handle empty strings', () => {
const result = parseString('', 50);
expect(result).toBe('');
});
it('should handle strings with only whitespace', () => {
const result = parseString(' ', 50);
expect(result).toBe('');
});
it('should handle strings shorter than max length', () => {
const shortString = 'hello';
const result = parseString(shortString, 50);
expect(result).toBe('hello');
});
it('should decode complex HTML entities and truncate', () => {
const complexString = '&amp;lt;div&amp;gt;This is a test with HTML entities&amp;lt;/div&amp;gt;';
const result = parseString(complexString, 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result.includes('&lt;div&gt;')).toBe(true);
});
it('should handle unicode characters', () => {
const unicodeString = 'Hello 🌍 World';
const result = parseString(unicodeString, 50);
expect(result).toBe('Hello 🌍 World');
});
it('should handle newlines and tabs in trimming', () => {
const result = parseString('\n\t hello world \t\n', 50);
expect(result).toBe('hello world');
});
it('should preserve internal whitespace while trimming external', () => {
const result = parseString(' hello world ', 50);
expect(result).toBe('hello world');
});
it('should handle maxLength of 0', () => {
const result = parseString('hello world', 0);
expect(result).toBe('...');
});
it('should handle maxLength of 1', () => {
const result = parseString('hello world', 1);
expect(result).toBe('...');
});
});
});

View File

@@ -0,0 +1,23 @@
/*
* 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 const parseString = (value: string, maxLength: number) => _.truncate(decode(value).trim(), {length: maxLength});

View File

@@ -0,0 +1,78 @@
/*
* 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 {Context} from 'hono';
import {getCookie, setCookie} from 'hono/cookie';
import type {HonoEnv} from '~/App';
import {Config} from '~/Config';
const SUDO_COOKIE_PREFIX = '__flx_sudo';
const SUDO_COOKIE_MAX_AGE = 5 * 60;
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,328 @@
/*
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {type HashAlgorithm, TotpGenerator} from './TotpGenerator';
function asciiToBase32(input: string): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const bytes = new TextEncoder().encode(input);
let out = '';
let buffer = 0;
let bitsLeft = 0;
for (const b of bytes) {
buffer = (buffer << 8) | b;
bitsLeft += 8;
while (bitsLeft >= 5) {
const idx = (buffer >> (bitsLeft - 5)) & 31;
out += alphabet[idx];
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
const idx = (buffer << (5 - bitsLeft)) & 31;
out += alphabet[idx];
}
while (out.length % 8 !== 0) out += '=';
return out;
}
describe('TotpGenerator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('RFC 6238 Appendix B vectors', () => {
const seedSha1 = '12345678901234567890';
const seedSha256 = '12345678901234567890123456789012';
const seedSha512 = '1234567890123456789012345678901234567890123456789012345678901234';
const cases: Array<{
algorithm: HashAlgorithm;
secret: string;
vectors: Array<{t: number; otp: string}>;
}> = [
{
algorithm: 'SHA-1',
secret: asciiToBase32(seedSha1),
vectors: [
{t: 59, otp: '94287082'},
{t: 1111111109, otp: '07081804'},
{t: 1111111111, otp: '14050471'},
{t: 1234567890, otp: '89005924'},
{t: 2000000000, otp: '69279037'},
{t: 20000000000, otp: '65353130'},
],
},
{
algorithm: 'SHA-256',
secret: asciiToBase32(seedSha256),
vectors: [
{t: 59, otp: '46119246'},
{t: 1111111109, otp: '68084774'},
{t: 1111111111, otp: '67062674'},
{t: 1234567890, otp: '91819424'},
{t: 2000000000, otp: '90698825'},
{t: 20000000000, otp: '77737706'},
],
},
{
algorithm: 'SHA-512',
secret: asciiToBase32(seedSha512),
vectors: [
{t: 59, otp: '90693936'},
{t: 1111111109, otp: '25091201'},
{t: 1111111111, otp: '99943326'},
{t: 1234567890, otp: '93441116'},
{t: 2000000000, otp: '38618901'},
{t: 20000000000, otp: '47863826'},
],
},
];
for (const c of cases) {
for (const v of c.vectors) {
it(`matches RFC vector (${c.algorithm}) at t=${v.t}`, async () => {
vi.setSystemTime(v.t * 1000);
const gen = new TotpGenerator(c.secret, {
algorithm: c.algorithm,
digits: 8,
timeStep: 30,
window: 0,
startTime: 0,
});
const otps = await gen.generateTotp();
expect(otps).toHaveLength(1);
expect(otps[0]).toBe(v.otp);
expect(await gen.validateTotp(v.otp)).toBe(true);
});
}
}
});
describe('constructor validation', () => {
it('throws on invalid base32', () => {
expect(() => new TotpGenerator('INVALID@CHARS')).toThrow('Invalid base32 character.');
});
it('throws on invalid timeStep', () => {
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {timeStep: 0})).toThrow('Invalid timeStep.');
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {timeStep: -1})).toThrow('Invalid timeStep.');
});
it('throws on invalid digits', () => {
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {digits: 0})).toThrow('Invalid digits.');
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {digits: 11})).toThrow('Invalid digits.');
});
it('throws on invalid window', () => {
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {window: -1})).toThrow('Invalid window.');
});
it('throws on invalid startTime', () => {
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {startTime: -1})).toThrow('Invalid startTime.');
});
it('throws on invalid algorithm', () => {
expect(() => new TotpGenerator('JBSWY3DPEHPK3PXP', {algorithm: 'MD5' as HashAlgorithm})).toThrow(
'Invalid algorithm.',
);
});
});
describe('generateTotp behavior', () => {
it('returns 2*window+1 codes', async () => {
vi.setSystemTime(1609459200000);
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {window: 2});
const otps = await gen.generateTotp();
expect(otps).toHaveLength(5);
});
it('uses Date.now exactly once per call', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1609459200000);
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {window: 2});
await gen.generateTotp();
expect(nowSpy).toHaveBeenCalledTimes(1);
});
it('generates numeric codes of the configured length', async () => {
vi.setSystemTime(1609459200000);
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {digits: 8});
const otps = await gen.generateTotp();
for (const otp of otps) {
expect(otp).toHaveLength(8);
expect(/^\d+$/.test(otp)).toBe(true);
}
});
it('respects timeStep (same window => same TOTP)', async () => {
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {timeStep: 60, window: 0});
vi.setSystemTime(30_000);
const [a] = await gen.generateTotp();
vi.setSystemTime(59_999);
const [b] = await gen.generateTotp();
expect(a).toBe(b);
vi.setSystemTime(60_000);
const [c] = await gen.generateTotp();
expect(a).not.toBe(c);
});
it('is consistent across instances for the same time', async () => {
vi.setSystemTime(1609459200000);
const g1 = new TotpGenerator('JBSWY3DPEHPK3PXP');
const g2 = new TotpGenerator('JBSWY3DPEHPK3PXP');
expect(await g1.generateTotp()).toEqual(await g2.generateTotp());
});
});
describe('base32 normalization', () => {
it('treats lowercase/whitespace/hyphens/padding as equivalent', async () => {
vi.setSystemTime(59_000);
const base = asciiToBase32('12345678901234567890');
const stripped = base.replace(/=+$/g, '');
const variants = [
stripped.toLowerCase(),
stripped.match(/.{1,4}/g)!.join(' '),
stripped.match(/.{1,4}/g)!.join('-'),
base,
];
const expected = (
await new TotpGenerator(variants[0], {
algorithm: 'SHA-1',
digits: 8,
window: 0,
}).generateTotp()
)[0];
for (const s of variants) {
const got = (
await new TotpGenerator(s, {
algorithm: 'SHA-1',
digits: 8,
window: 0,
}).generateTotp()
)[0];
expect(got).toBe(expected);
}
});
});
describe('validateTotp behavior', () => {
it('accepts the current code', async () => {
vi.setSystemTime(1609459200000);
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {window: 1});
const otps = await gen.generateTotp();
expect(await gen.validateTotp(otps[1])).toBe(true);
});
it('accepts codes within the configured window', async () => {
vi.setSystemTime(1111111109 * 1000);
const secret = asciiToBase32('12345678901234567890');
const gen = new TotpGenerator(secret, {
algorithm: 'SHA-1',
digits: 8,
timeStep: 30,
window: 1,
});
const codes = await gen.generateTotp();
expect(codes).toHaveLength(3);
for (const code of codes) {
expect(await gen.validateTotp(code)).toBe(true);
}
});
it('rejects codes outside the window', async () => {
const secret = asciiToBase32('12345678901234567890');
const gen = new TotpGenerator(secret, {
algorithm: 'SHA-1',
digits: 8,
timeStep: 30,
window: 0,
});
vi.setSystemTime(1111111109 * 1000);
const [code] = await gen.generateTotp();
vi.setSystemTime((1111111109 + 60) * 1000);
expect(await gen.validateTotp(code)).toBe(false);
});
it('rejects empty, non-numeric, and wrong-length codes', async () => {
vi.setSystemTime(1609459200000);
const gen = new TotpGenerator('JBSWY3DPEHPK3PXP', {digits: 6, window: 1});
expect(await gen.validateTotp('')).toBe(false);
expect(await gen.validateTotp('abcdef')).toBe(false);
expect(await gen.validateTotp('12345')).toBe(false);
expect(await gen.validateTotp('1234567')).toBe(false);
});
});
describe('startTime handling', () => {
it('applies T0 (startTime) correctly', async () => {
const secret = asciiToBase32('12345678901234567890');
const genA = new TotpGenerator(secret, {
algorithm: 'SHA-1',
digits: 8,
timeStep: 30,
window: 0,
startTime: 0,
});
const genB = new TotpGenerator(secret, {
algorithm: 'SHA-1',
digits: 8,
timeStep: 30,
window: 0,
startTime: 100,
});
vi.setSystemTime(0);
const [a0] = await genA.generateTotp();
vi.setSystemTime(100_000);
const [b0] = await genB.generateTotp();
expect(a0).toBe(b0);
vi.setSystemTime(130_000);
const [b1] = await genB.generateTotp();
expect(b0).not.toBe(b1);
});
});
});

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 {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,364 @@
/*
* 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {extractURLs, idnaEncodeURL, isFluxerAppExcludedURL, isValidURL} from './UnfurlerUtils';
const loggerMocks = vi.hoisted(() => ({
loggerErrorMock: vi.fn<(context: Record<string, unknown>, message: string) => void>(),
}));
const configMock = vi.hoisted(() => ({
Config: {
endpoints: {
webApp: 'https://web.fluxer.app',
},
hosts: {
invite: 'fluxer.gg',
gift: 'fluxer.gift',
marketing: 'fluxer.app',
unfurlIgnored: [],
},
},
}));
vi.mock('~/Logger', () => ({
Logger: {
error: loggerMocks.loggerErrorMock,
},
}));
vi.mock('~/Config', () => configMock);
const {loggerErrorMock} = loggerMocks;
describe('UnfurlerUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('idnaEncodeURL', () => {
it('should encode international domain names', () => {
const url = 'https://测试.example.com/path';
const result = idnaEncodeURL(url);
expect(result).toContain('xn--');
expect(result).toMatch(/^https:\/\/.*example\.com\/path$/);
});
it('should handle regular ASCII URLs', () => {
const url = 'https://example.com/path?query=value';
const result = idnaEncodeURL(url);
expect(result).toBe('https://example.com/path?query=value');
});
it('should remove username and password from URLs', () => {
const url = 'https://user:pass@example.com/path';
const result = idnaEncodeURL(url);
expect(result).toBe('https://example.com/path');
expect(result).not.toContain('user');
expect(result).not.toContain('pass');
});
it('should convert hostname to lowercase', () => {
const url = 'https://EXAMPLE.COM/Path';
const result = idnaEncodeURL(url);
expect(result).toBe('https://example.com/Path');
});
it('should handle invalid URLs gracefully', () => {
const invalidUrl = 'not-a-url';
const result = idnaEncodeURL(invalidUrl);
expect(result).toBe('');
expect(loggerErrorMock).toHaveBeenCalled();
const call = loggerErrorMock.mock.calls.at(-1);
expect(call).toBeDefined();
if (!call) return;
const [context, message] = call;
expect(message).toBe('Failed to encode URL');
expect(context).toHaveProperty('error');
expect((context as {error: unknown}).error).toBeInstanceOf(Error);
});
it('should handle URLs with ports', () => {
const url = 'https://example.com:8080/path';
const result = idnaEncodeURL(url);
expect(result).toBe('https://example.com:8080/path');
});
it('should handle URLs with fragments', () => {
const url = 'https://example.com/path#fragment';
const result = idnaEncodeURL(url);
expect(result).toBe('https://example.com/path#fragment');
});
});
describe('isValidURL', () => {
it('should return true for valid HTTP URLs', () => {
expect(isValidURL('http://example.com')).toBe(true);
expect(isValidURL('http://example.com/path')).toBe(true);
expect(isValidURL('http://localhost:3000')).toBe(true);
});
it('should return true for valid HTTPS URLs', () => {
expect(isValidURL('https://example.com')).toBe(true);
expect(isValidURL('https://example.com/path?query=value')).toBe(true);
expect(isValidURL('https://subdomain.example.com')).toBe(true);
});
it('should return false for non-HTTP/HTTPS protocols', () => {
expect(isValidURL('ftp://example.com')).toBe(false);
expect(isValidURL('file:///path/to/file')).toBe(false);
expect(isValidURL('javascript:alert("xss")')).toBe(false);
expect(isValidURL('data:text/plain;base64,SGVsbG8=')).toBe(false);
});
it('should return false for invalid URLs', () => {
expect(isValidURL('not-a-url')).toBe(false);
expect(isValidURL('')).toBe(false);
expect(isValidURL('example.com')).toBe(false);
expect(isValidURL('://example.com')).toBe(false);
});
it('should handle malformed URLs', () => {
expect(isValidURL('http://')).toBe(false);
expect(isValidURL('https://')).toBe(false);
expect(isValidURL('http://.')).toBe(true);
});
});
describe('isFluxerAppExcludedURL', () => {
it('should return true for invite shortlink domain', () => {
expect(isFluxerAppExcludedURL('https://fluxer.gg/abc123')).toBe(true);
expect(isFluxerAppExcludedURL('https://fluxer.gg/')).toBe(true);
expect(isFluxerAppExcludedURL('http://fluxer.gg/test')).toBe(true);
});
it('should return true for gift shortlink domain', () => {
expect(isFluxerAppExcludedURL('https://fluxer.gift/xyz789')).toBe(true);
expect(isFluxerAppExcludedURL('https://fluxer.gift/')).toBe(true);
expect(isFluxerAppExcludedURL('http://fluxer.gift/test')).toBe(true);
});
it('should return true for all web app URLs', () => {
expect(isFluxerAppExcludedURL('https://web.fluxer.app/')).toBe(true);
expect(isFluxerAppExcludedURL('https://web.fluxer.app/login')).toBe(true);
expect(isFluxerAppExcludedURL('https://web.fluxer.app/channels/@me')).toBe(true);
expect(isFluxerAppExcludedURL('https://web.fluxer.app/any/path')).toBe(true);
});
it('should return false for marketing site URLs', () => {
expect(isFluxerAppExcludedURL('https://fluxer.app/')).toBe(false);
expect(isFluxerAppExcludedURL('https://fluxer.app/about')).toBe(false);
expect(isFluxerAppExcludedURL('https://fluxer.app/blog')).toBe(false);
expect(isFluxerAppExcludedURL('https://fluxer.app/docs')).toBe(false);
});
it('should treat marketing channel paths as Fluxer app URLs', () => {
expect(isFluxerAppExcludedURL('https://fluxer.app/channels/@me')).toBe(true);
expect(isFluxerAppExcludedURL('https://fluxer.app/channels/general')).toBe(true);
});
it('should return false for different hosts', () => {
expect(isFluxerAppExcludedURL('https://example.com/login')).toBe(false);
expect(isFluxerAppExcludedURL('https://other.com/invite/abc')).toBe(false);
});
it('should handle URLs with query strings and fragments', () => {
expect(isFluxerAppExcludedURL('https://web.fluxer.app/login?redirect=/home')).toBe(true);
expect(isFluxerAppExcludedURL('https://fluxer.gg/abc?ref=email')).toBe(true);
expect(isFluxerAppExcludedURL('https://fluxer.gift/xyz#promo')).toBe(true);
});
it('should handle invalid URLs gracefully', () => {
expect(isFluxerAppExcludedURL('not-a-url')).toBe(false);
expect(isFluxerAppExcludedURL('')).toBe(false);
});
});
describe('extractURLs', () => {
it('should extract valid URLs from text', () => {
const text = 'Check out https://example.com and http://test.org for more info';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).toContain('http://test.org/');
expect(result).toHaveLength(2);
});
it('should ignore URLs in code blocks', () => {
const text = 'Visit https://example.com but ignore `https://code.example.com` in code';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).not.toContain('https://code.example.com/');
});
it('should ignore URLs in multiline code blocks', () => {
const text = `
Check https://example.com
\`\`\`
const url = 'https://code.example.com';
\`\`\`
Also visit https://test.org
`;
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).toContain('https://test.org/');
expect(result).not.toContain('https://code.example.com/');
});
it('should extract URLs from markdown links', () => {
const text = 'Check out [Example](https://example.com) and [Test](http://test.org)';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).toContain('http://test.org/');
});
it('should ignore URLs in angle brackets', () => {
const text = 'Visit https://example.com but ignore <https://ignored.com>';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).not.toContain('https://ignored.com/');
});
it('should extract URLs wrapped in spoiler markers', () => {
const text = 'Hidden link: ||https://google.com|| is still a URL';
const result = extractURLs(text);
expect(result).toContain('https://google.com/');
expect(result).toHaveLength(1);
});
it('should filter out invite URLs', () => {
const text = 'Visit https://example.com but not https://fluxer.gg/test123';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).not.toContain('https://fluxer.gg/test123');
});
it('should deduplicate URLs', () => {
const text = 'Visit https://example.com and https://example.com again';
const result = extractURLs(text);
expect(result).toEqual(['https://example.com/']);
});
it('should limit to 5 URLs', () => {
const text = Array.from({length: 10}, (_, i) => `https://example${i}.com`).join(' ');
const result = extractURLs(text);
expect(result).toHaveLength(5);
});
it('should handle URLs with international domains', () => {
const text = 'Visit https://测试.example.com for testing';
const result = extractURLs(text);
expect(result).toHaveLength(1);
expect(result[0]).toMatch(/^https:\/\/.*example\.com\/$/);
});
it('should filter out invalid URLs', () => {
const text = 'Visit https://example.com and ftp://invalid.com';
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).not.toContain('ftp://invalid.com');
});
it('should handle empty input', () => {
expect(extractURLs('')).toEqual([]);
expect(extractURLs(' ')).toEqual([]);
});
it('should handle text with no URLs', () => {
const text = 'This is just plain text with no URLs';
const result = extractURLs(text);
expect(result).toEqual([]);
});
it('should handle complex mixed content', () => {
const text = `
Check out https://example.com for docs.
Code example:
\`\`\`javascript
fetch('https://api.example.com/data')
\`\`\`
Also see [GitHub](https://github.com/user/repo) and ignore <https://spam.com>.
Visit \`https://inline-code.com\` but not that.
Don't forget https://fluxer.gg/abc123 (invite link).
Final link: https://final.example.org
`;
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).toContain('https://github.com/user/repo');
expect(result).toContain('https://final.example.org/');
expect(result).not.toContain('https://api.example.com/data');
expect(result).not.toContain('https://inline-code.com');
expect(result).not.toContain('https://spam.com');
expect(result).not.toContain('https://fluxer.gg/abc123');
});
it('should filter out all web app URLs', () => {
const text = `
Visit https://example.com and https://web.fluxer.app/login
Check out https://web.fluxer.app/channels/@me/123
Also see https://fluxer.app/about which is allowed
`;
const result = extractURLs(text);
expect(result).toContain('https://example.com/');
expect(result).toContain('https://fluxer.app/about');
expect(result).not.toContain('https://web.fluxer.app/login');
expect(result).not.toContain('https://web.fluxer.app/channels/@me/123');
});
it('should filter invite and gift shortlinks', () => {
const excludedUrls = ['https://fluxer.gg/code', 'https://fluxer.gift/code', 'https://web.fluxer.app/any/path'];
excludedUrls.forEach((url) => {
const result = extractURLs(`Visit ${url} and https://example.com`);
expect(result).not.toContain(url);
expect(result).toContain('https://example.com/');
});
});
});
});

View File

@@ -0,0 +1,105 @@
/*
* 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 * as idna from 'idna-uts46-hx';
import {Config} from '~/Config';
import {URL_REGEX} from '~/Constants';
import {Logger} from '~/Logger';
import * as InviteUtils from '~/utils/InviteUtils';
const MARKETING_PATH_PREFIXES = ['/channels/', '/theme/'];
const WEB_APP_HOSTNAME = (() => {
try {
return new URL(Config.endpoints.webApp).hostname;
} catch {
return '';
}
})();
const EXCLUDED_HOSTNAMES = new Set<string>();
const normalizeHostname = (hostname: string | undefined) => hostname?.trim().toLowerCase() || '';
const MARKETING_HOSTNAME = normalizeHostname(Config.hosts.marketing);
const isMarketingPath = (hostname: string, pathname: string) =>
hostname === MARKETING_HOSTNAME && MARKETING_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
const addHostname = (hostname: string | undefined) => {
const normalized = normalizeHostname(hostname);
if (!normalized) return;
EXCLUDED_HOSTNAMES.add(normalized);
};
addHostname(Config.hosts.invite);
addHostname(Config.hosts.gift);
Config.hosts.unfurlIgnored.forEach(addHostname);
addHostname(WEB_APP_HOSTNAME);
export const 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 const isValidURL = (url: string) => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
} catch {
return false;
}
};
export const isFluxerAppExcludedURL = (url: string) => {
try {
const parsedUrl = new URL(url);
const hostname = normalizeHostname(parsedUrl.hostname);
const isMarketingPathMatch = isMarketingPath(hostname, parsedUrl.pathname);
return isMarketingPathMatch || EXCLUDED_HOSTNAMES.has(hostname);
} catch {
return false;
}
};
export const 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 validURLs = urls.filter(isValidURL);
const filteredURLs = validURLs
.filter((url) => InviteUtils.findInvite(url) == null)
.filter((url) => !isFluxerAppExcludedURL(url));
const encodedURLs = filteredURLs.map(idnaEncodeURL).filter(Boolean);
const uniqueURLs = Array.from(new Set(encodedURLs));
return uniqueURLs.slice(0, 5);
};

View File

@@ -0,0 +1,217 @@
/*
* 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 '~/BrandedTypes';
import {FriendSourceFlags, GroupDmAddPermissionFlags, IncomingCallFlags, RelationshipTypes} from '~/Constants';
import {FriendRequestBlockedError, MissingAccessError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {Relationship} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
type UserPermissionRepository = Pick<IUserRepository, 'findSettings' | 'listRelationships' | 'getRelationship'>;
type GuildPermissionRepository = Pick<IGuildRepository, '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) {
return;
}
if ((friendSourceFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS) {
const hasMutualFriends = await this.checkMutualFriends({userId, targetId});
if (hasMutualFriends) return;
}
if ((friendSourceFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS) {
const hasMutualGuilds = await this.checkMutualGuildsAsync({userId, targetId});
if (hasMutualGuilds) return;
}
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) {
throw new MissingAccessError();
}
if ((groupDmAddPermissionFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
return;
}
if (
(groupDmAddPermissionFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) ===
GroupDmAddPermissionFlags.FRIENDS_ONLY
) {
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
if (friendship) return;
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) {
throw new MissingAccessError();
}
}
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) {
throw new MissingAccessError();
}
if ((incomingCallFlags & IncomingCallFlags.EVERYONE) === IncomingCallFlags.EVERYONE) {
return;
}
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
const friendship = await this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND);
if (friendship) return;
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) {
throw new MissingAccessError();
}
}
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,80 @@
/*
* 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 {transliterate as tr} from 'transliteration';
import {generateRandomUsername} from '~/utils/UsernameGenerator';
function sanitizeForFluxerTag(input: string): string {
let result = tr(input.trim());
result = result.replace(/[\s\-.]+/g, '_');
result = result.replace(/[^a-zA-Z0-9_]/g, '');
if (!result) {
result = 'user';
}
if (result.length > 32) {
result = result.substring(0, 32);
}
return result.toLowerCase();
}
export function generateUsernameSuggestions(globalName: string, count: number = 5): Array<string> {
const suggestions: Array<string> = [];
const transliterated = tr(globalName.trim());
const hasMeaningfulContent = /[a-zA-Z]/.test(transliterated);
if (!hasMeaningfulContent) {
for (let i = 0; i < count; i++) {
const randomUsername = generateRandomUsername();
const sanitizedRandom = sanitizeForFluxerTag(randomUsername);
if (sanitizedRandom && sanitizedRandom.length <= 32) {
suggestions.push(sanitizedRandom.toLowerCase());
}
}
return Array.from(new Set(suggestions)).slice(0, count);
}
const baseUsername = sanitizeForFluxerTag(globalName);
suggestions.push(baseUsername);
const suffixes = ['_', '__', '___', '123', '_1', '_official', '_real'];
for (const suffix of suffixes) {
if (suggestions.length >= count) break;
const suggestion = baseUsername + suffix;
if (suggestion.length <= 32) {
suggestions.push(suggestion);
}
}
let counter = 2;
while (suggestions.length < count) {
const suggestion = `${baseUsername}${counter}`;
if (suggestion.length <= 32) {
suggestions.push(suggestion);
}
counter++;
}
return Array.from(new Set(suggestions)).slice(0, count);
}