feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
292
fluxer/packages/api/src/utils/EmojiUtils.tsx
Normal file
292
fluxer/packages/api/src/utils/EmojiUtils.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, UserID, WebhookID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createEmojiID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {
|
||||
PackExpressionAccessResolution,
|
||||
PackExpressionAccessResolver,
|
||||
} from '@fluxer/api/src/pack/PackExpressionAccessResolver';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
type EmojiGuildRepository = Pick<IGuildRepositoryAggregate, 'getEmoji' | 'getEmojiById'>;
|
||||
type EmojiUserRepository = Pick<IUserAccountRepository, 'findUnique'>;
|
||||
|
||||
const CUSTOM_EMOJI_MARKDOWN_REGEX = /<(a)?:([^:]+):(\d+)>/g;
|
||||
const CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL = new RegExp(CUSTOM_EMOJI_MARKDOWN_REGEX.source, 'g');
|
||||
|
||||
interface SanitizeCustomEmojisParams {
|
||||
content: string;
|
||||
userId: UserID | null;
|
||||
webhookId: WebhookID | null;
|
||||
guildId: GuildID | null;
|
||||
userRepository: EmojiUserRepository;
|
||||
guildRepository: EmojiGuildRepository;
|
||||
limitConfigService: LimitConfigService;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}
|
||||
|
||||
interface EmojiMatch {
|
||||
fullMatch: string;
|
||||
name: string;
|
||||
emojiId: EmojiID;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface CodeBlock {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export async function sanitizeCustomEmojis(params: SanitizeCustomEmojisParams): Promise<string> {
|
||||
const {
|
||||
content,
|
||||
userId,
|
||||
webhookId,
|
||||
guildId,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
limitConfigService,
|
||||
hasPermission,
|
||||
packResolver,
|
||||
} = params;
|
||||
|
||||
const escapedContexts = parseEscapedContexts(content);
|
||||
const isInEscapedContext = (index: number): boolean =>
|
||||
escapedContexts.some((ctx) => index >= ctx.start && index < ctx.end);
|
||||
|
||||
const emojiMatches = collectEmojiMatches(content, isInEscapedContext);
|
||||
if (emojiMatches.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const hasGlobalExpressions = userId
|
||||
? await checkUserGlobalExpressions(userId, userRepository, limitConfigService)
|
||||
: 0;
|
||||
const isWebhook = webhookId != null;
|
||||
|
||||
const emojiLookups = await batchFetchEmojis(emojiMatches, guildId, guildRepository);
|
||||
|
||||
let canUseExternalEmojis: boolean | null = null;
|
||||
if (hasGlobalExpressions > 0 && hasPermission && guildId) {
|
||||
const hasExternalEmojis = emojiLookups.some((lookup) => lookup.guildEmoji === null && lookup.globalEmoji !== null);
|
||||
if (hasExternalEmojis) {
|
||||
canUseExternalEmojis = await hasPermission(Permissions.USE_EXTERNAL_EMOJIS);
|
||||
}
|
||||
}
|
||||
|
||||
const replacements = await determineReplacements({
|
||||
emojiMatches,
|
||||
emojiLookups,
|
||||
guildId,
|
||||
isWebhook,
|
||||
hasGlobalExpressions,
|
||||
canUseExternalEmojis,
|
||||
packResolver,
|
||||
});
|
||||
|
||||
return applyReplacements(content, replacements);
|
||||
}
|
||||
|
||||
function parseEscapedContexts(content: string): Array<CodeBlock> {
|
||||
const contexts: Array<CodeBlock> = [];
|
||||
|
||||
const blockCodeRegex = /```[\s\S]*?```/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = blockCodeRegex.exec(content)) !== null) {
|
||||
contexts.push({start: match.index, end: match.index + match[0].length});
|
||||
}
|
||||
|
||||
const inlineCodeRegex = /`[^`]+`/g;
|
||||
while ((match = inlineCodeRegex.exec(content)) !== null) {
|
||||
const isInsideBlock = contexts.some((ctx) => match!.index >= ctx.start && match!.index < ctx.end);
|
||||
if (!isInsideBlock) {
|
||||
contexts.push({start: match.index, end: match.index + match[0].length});
|
||||
}
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function collectEmojiMatches(content: string, isInEscapedContext: (index: number) => boolean): Array<EmojiMatch> {
|
||||
const matches: Array<EmojiMatch> = [];
|
||||
CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CUSTOM_EMOJI_MARKDOWN_REGEX_GLOBAL.exec(content)) !== null) {
|
||||
if (isInEscapedContext(match.index)) continue;
|
||||
|
||||
const [fullMatch, , name, emojiId] = match;
|
||||
matches.push({
|
||||
fullMatch,
|
||||
name,
|
||||
emojiId: createEmojiID(BigInt(emojiId)),
|
||||
start: match.index,
|
||||
end: match.index + fullMatch.length,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function checkUserGlobalExpressions(
|
||||
userId: UserID,
|
||||
userRepository: EmojiUserRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
): Promise<number> {
|
||||
const user = await userRepository.findUnique(userId);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
return resolveLimitSafe(limitConfigService.getConfigSnapshot(), ctx, 'feature_global_expressions', 0);
|
||||
}
|
||||
|
||||
interface EmojiLookupResult {
|
||||
emojiId: EmojiID;
|
||||
guildEmoji: GuildEmoji | null;
|
||||
globalEmoji: GuildEmoji | null;
|
||||
}
|
||||
|
||||
async function batchFetchEmojis(
|
||||
matches: Array<EmojiMatch>,
|
||||
guildId: GuildID | null,
|
||||
guildRepository: EmojiGuildRepository,
|
||||
): Promise<Array<EmojiLookupResult>> {
|
||||
const uniqueEmojiIds = [...new Set(matches.map((m) => m.emojiId))];
|
||||
|
||||
const lookupResults = await Promise.all(
|
||||
uniqueEmojiIds.map(async (emojiId) => {
|
||||
const [guildEmoji, globalEmoji] = await Promise.all([
|
||||
guildId ? guildRepository.getEmoji(emojiId, guildId) : Promise.resolve(null),
|
||||
guildRepository.getEmojiById(emojiId),
|
||||
]);
|
||||
return {emojiId, guildEmoji, globalEmoji};
|
||||
}),
|
||||
);
|
||||
|
||||
const lookupMap = new Map<EmojiID, EmojiLookupResult>();
|
||||
for (const result of lookupResults) {
|
||||
lookupMap.set(result.emojiId, result);
|
||||
}
|
||||
|
||||
return matches.map((match) => lookupMap.get(match.emojiId)!);
|
||||
}
|
||||
|
||||
interface Replacement {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
async function determineReplacements(params: {
|
||||
emojiMatches: Array<EmojiMatch>;
|
||||
emojiLookups: Array<EmojiLookupResult>;
|
||||
guildId: GuildID | null;
|
||||
isWebhook: boolean;
|
||||
hasGlobalExpressions: number;
|
||||
canUseExternalEmojis: boolean | null;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}): Promise<Array<Replacement>> {
|
||||
const {emojiMatches, emojiLookups, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis} = params;
|
||||
const replacements: Array<Replacement> = [];
|
||||
|
||||
for (let i = 0; i < emojiMatches.length; i++) {
|
||||
const match = emojiMatches[i];
|
||||
const lookup = emojiLookups[i];
|
||||
const shouldReplace = await shouldReplaceEmoji({
|
||||
lookup,
|
||||
guildId,
|
||||
isWebhook,
|
||||
hasGlobalExpressions,
|
||||
canUseExternalEmojis,
|
||||
packResolver: params.packResolver,
|
||||
});
|
||||
|
||||
if (shouldReplace) {
|
||||
replacements.push({
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
replacement: `:${match.name}:`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
async function shouldReplaceEmoji(params: {
|
||||
lookup: EmojiLookupResult;
|
||||
guildId: GuildID | null;
|
||||
isWebhook: boolean;
|
||||
hasGlobalExpressions: number;
|
||||
canUseExternalEmojis: boolean | null;
|
||||
packResolver?: PackExpressionAccessResolver;
|
||||
}): Promise<boolean> {
|
||||
const {lookup, guildId, isWebhook, hasGlobalExpressions, canUseExternalEmojis, packResolver} = params;
|
||||
|
||||
if (!guildId) {
|
||||
if (!lookup.globalEmoji) return true;
|
||||
if (!isWebhook && hasGlobalExpressions === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookup.guildEmoji) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!lookup.globalEmoji) return true;
|
||||
|
||||
const packAccess = await resolvePackAccessStatus(lookup.globalEmoji.guildId, packResolver);
|
||||
if (packAccess === 'not-accessible') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isWebhook && hasGlobalExpressions === 0) return true;
|
||||
|
||||
if (hasGlobalExpressions > 0 && canUseExternalEmojis === false) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolvePackAccessStatus(
|
||||
packId: GuildID,
|
||||
packResolver?: PackExpressionAccessResolver,
|
||||
): Promise<PackExpressionAccessResolution> {
|
||||
if (!packResolver) return 'not-pack';
|
||||
return await packResolver.resolve(packId);
|
||||
}
|
||||
|
||||
function applyReplacements(content: string, replacements: Array<Replacement>): string {
|
||||
if (replacements.length === 0) return content;
|
||||
|
||||
const sorted = [...replacements].sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = content;
|
||||
for (const {start, end, replacement} of sorted) {
|
||||
result = result.substring(0, start) + replacement + result.substring(end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user