initial commit
This commit is contained in:
42
fluxer_api/src/utils/AgeUtils.ts
Normal file
42
fluxer_api/src/utils/AgeUtils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export 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;
|
||||
};
|
||||
213
fluxer_api/src/utils/AttachmentDecay.test.ts
Normal file
213
fluxer_api/src/utils/AttachmentDecay.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
188
fluxer_api/src/utils/AttachmentDecay.ts
Normal file
188
fluxer_api/src/utils/AttachmentDecay.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
28
fluxer_api/src/utils/AttachmentUtils.ts
Normal file
28
fluxer_api/src/utils/AttachmentUtils.ts
Normal 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;
|
||||
}
|
||||
90
fluxer_api/src/utils/AuditSerializationUtils.ts
Normal file
90
fluxer_api/src/utils/AuditSerializationUtils.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
158
fluxer_api/src/utils/AvatarColorUtils.ts
Normal file
158
fluxer_api/src/utils/AvatarColorUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
158
fluxer_api/src/utils/BucketUtils.test.ts
Normal file
158
fluxer_api/src/utils/BucketUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
fluxer_api/src/utils/BucketUtils.ts
Normal file
42
fluxer_api/src/utils/BucketUtils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
};
|
||||
65
fluxer_api/src/utils/CurrencyUtils.ts
Normal file
65
fluxer_api/src/utils/CurrencyUtils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type Currency = 'USD' | 'EUR';
|
||||
|
||||
const EEA_COUNTRIES = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'DE',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
];
|
||||
|
||||
function isEEACountry(countryCode: string): boolean {
|
||||
const upperCode = countryCode.toUpperCase();
|
||||
return EEA_COUNTRIES.includes(upperCode);
|
||||
}
|
||||
|
||||
export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
if (!countryCode) {
|
||||
return 'USD';
|
||||
}
|
||||
return isEEACountry(countryCode) ? 'EUR' : 'USD';
|
||||
}
|
||||
202
fluxer_api/src/utils/DOMUtils.test.ts
Normal file
202
fluxer_api/src/utils/DOMUtils.test.ts
Normal 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('&')).toBe('&');
|
||||
expect(decodeHTMLEntities('<')).toBe('<');
|
||||
expect(decodeHTMLEntities('>')).toBe('>');
|
||||
expect(decodeHTMLEntities('"')).toBe('"');
|
||||
expect(decodeHTMLEntities(''')).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('A')).toBe('A');
|
||||
expect(decodeHTMLEntities('€')).toBe('€');
|
||||
});
|
||||
|
||||
it('should handle text without entities', () => {
|
||||
expect(decodeHTMLEntities('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should decode mixed content', () => {
|
||||
expect(decodeHTMLEntities('Hello & goodbye <script>')).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>&lt;script&gt;</p>';
|
||||
const expected = '<script>';
|
||||
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```');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
fluxer_api/src/utils/DOMUtils.ts
Normal file
54
fluxer_api/src/utils/DOMUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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();
|
||||
}
|
||||
278
fluxer_api/src/utils/EmojiUtils.ts
Normal file
278
fluxer_api/src/utils/EmojiUtils.ts
Normal 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;
|
||||
}
|
||||
259
fluxer_api/src/utils/FetchUtils.ts
Normal file
259
fluxer_api/src/utils/FetchUtils.ts
Normal 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;
|
||||
37
fluxer_api/src/utils/GeoUtils.ts
Normal file
37
fluxer_api/src/utils/GeoUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
export function parseCoordinate(value?: string | null): number | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
131
fluxer_api/src/utils/GuildVerificationUtils.ts
Normal file
131
fluxer_api/src/utils/GuildVerificationUtils.ts
Normal 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)))),
|
||||
});
|
||||
}
|
||||
41
fluxer_api/src/utils/IdUtils.ts
Normal file
41
fluxer_api/src/utils/IdUtils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '~/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();
|
||||
}
|
||||
356
fluxer_api/src/utils/InviteUtils.test.ts
Normal file
356
fluxer_api/src/utils/InviteUtils.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
66
fluxer_api/src/utils/InviteUtils.ts
Normal file
66
fluxer_api/src/utils/InviteUtils.ts
Normal 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;
|
||||
}
|
||||
76
fluxer_api/src/utils/IpRangeUtils.test.ts
Normal file
76
fluxer_api/src/utils/IpRangeUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
272
fluxer_api/src/utils/IpRangeUtils.ts
Normal file
272
fluxer_api/src/utils/IpRangeUtils.ts
Normal 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;
|
||||
}
|
||||
136
fluxer_api/src/utils/IpUtils.test.ts
Normal file
136
fluxer_api/src/utils/IpUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
270
fluxer_api/src/utils/IpUtils.ts
Normal file
270
fluxer_api/src/utils/IpUtils.ts
Normal 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;
|
||||
}
|
||||
101
fluxer_api/src/utils/LocaleUtils.test.ts
Normal file
101
fluxer_api/src/utils/LocaleUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
fluxer_api/src/utils/LocaleUtils.ts
Normal file
76
fluxer_api/src/utils/LocaleUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
317
fluxer_api/src/utils/MediaProxyUtils.test.ts
Normal file
317
fluxer_api/src/utils/MediaProxyUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
90
fluxer_api/src/utils/MediaProxyUtils.ts
Normal file
90
fluxer_api/src/utils/MediaProxyUtils.ts
Normal 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}` : ''}`;
|
||||
};
|
||||
34
fluxer_api/src/utils/PasswordUtils.ts
Normal file
34
fluxer_api/src/utils/PasswordUtils.ts
Normal 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);
|
||||
}
|
||||
73
fluxer_api/src/utils/PermissionUtils.ts
Normal file
73
fluxer_api/src/utils/PermissionUtils.ts
Normal 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);
|
||||
}
|
||||
130
fluxer_api/src/utils/RandomUtils.test.ts
Normal file
130
fluxer_api/src/utils/RandomUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
fluxer_api/src/utils/RandomUtils.ts
Normal file
32
fluxer_api/src/utils/RandomUtils.ts
Normal 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;
|
||||
};
|
||||
120
fluxer_api/src/utils/RegexUtils.test.ts
Normal file
120
fluxer_api/src/utils/RegexUtils.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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('\\(\\(\\(');
|
||||
});
|
||||
});
|
||||
});
|
||||
20
fluxer_api/src/utils/RegexUtils.ts
Normal file
20
fluxer_api/src/utils/RegexUtils.ts
Normal 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, '\\$&');
|
||||
96
fluxer_api/src/utils/SnowflakeUtils.test.ts
Normal file
96
fluxer_api/src/utils/SnowflakeUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
24
fluxer_api/src/utils/SnowflakeUtils.ts
Normal file
24
fluxer_api/src/utils/SnowflakeUtils.ts
Normal 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;
|
||||
93
fluxer_api/src/utils/StringUtils.test.ts
Normal file
93
fluxer_api/src/utils/StringUtils.test.ts
Normal 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('&<>"', 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 = '&lt;div&gt;This is a test with HTML entities&lt;/div&gt;';
|
||||
const result = parseString(complexString, 20);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result.includes('<div>')).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('...');
|
||||
});
|
||||
});
|
||||
});
|
||||
23
fluxer_api/src/utils/StringUtils.ts
Normal file
23
fluxer_api/src/utils/StringUtils.ts
Normal 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});
|
||||
78
fluxer_api/src/utils/SudoCookieUtils.ts
Normal file
78
fluxer_api/src/utils/SudoCookieUtils.ts
Normal 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);
|
||||
}
|
||||
328
fluxer_api/src/utils/TotpGenerator.test.ts
Normal file
328
fluxer_api/src/utils/TotpGenerator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
181
fluxer_api/src/utils/TotpGenerator.ts
Normal file
181
fluxer_api/src/utils/TotpGenerator.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
}
|
||||
}
|
||||
364
fluxer_api/src/utils/UnfurlerUtils.test.ts
Normal file
364
fluxer_api/src/utils/UnfurlerUtils.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
105
fluxer_api/src/utils/UnfurlerUtils.ts
Normal file
105
fluxer_api/src/utils/UnfurlerUtils.ts
Normal 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);
|
||||
};
|
||||
217
fluxer_api/src/utils/UserPermissionUtils.ts
Normal file
217
fluxer_api/src/utils/UserPermissionUtils.ts
Normal 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()));
|
||||
}
|
||||
}
|
||||
71
fluxer_api/src/utils/UsernameGenerator.ts
Normal file
71
fluxer_api/src/utils/UsernameGenerator.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {adjectives, animals, colors, uniqueNamesGenerator} from 'unique-names-generator';
|
||||
|
||||
export function generateRandomUsername(): string {
|
||||
const MAX_LENGTH = 32;
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 3,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 2,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const username = uniqueNamesGenerator({
|
||||
dictionaries: [animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 1,
|
||||
});
|
||||
|
||||
if (username.length <= MAX_LENGTH) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [animals],
|
||||
separator: '',
|
||||
style: 'capital',
|
||||
length: 1,
|
||||
});
|
||||
}
|
||||
80
fluxer_api/src/utils/UsernameSuggestionUtils.ts
Normal file
80
fluxer_api/src/utils/UsernameSuggestionUtils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user