Compare commits

..
63 changed files with 319 additions and 167 deletions
+32 -19
View File
@@ -359,13 +359,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+1 -1
View File
@@ -267,7 +267,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
@@ -176,9 +177,15 @@ export abstract class BaseChannelAuthService {
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
};
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
await checkPermission(Permissions.VIEW_CHANNEL);
const parentCategory = await this.getParentCategoryContentWarningView({
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const canManageRoles = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
stripe_payment_intent_id: Nullish<string>;
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
version: number;
}
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
'stripe_payment_intent_id',
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {IUserRepository} from '../../user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
@@ -30,6 +32,7 @@ export class GuildChannelService {
snowflakeService: ISnowflakeService,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
@@ -131,5 +134,12 @@ export class GuildChannelService {
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(params.permission);
}
}
@@ -51,7 +51,7 @@ export class GuildDataService {
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
@@ -50,7 +50,7 @@ export class GuildMemberService {
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService);
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserID} from '../../BrandedTypes';
import type {IUserRepository} from '../../user/IUserRepository';
export const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export async function createGuildMfaEnforcer(params: {
userRepository: IUserRepository;
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
userId: UserID;
}): Promise<(permission: bigint) => void> {
const {userRepository, guildData, userId} = params;
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
return (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
}
@@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
export class GuildModerationService {
@@ -44,6 +45,19 @@ export class GuildModerationService {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
const {guildId, userId, permission} = params;
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
enforceGuildMfa(permission);
}
async banMember(
params: {
userId: UserID;
@@ -57,12 +71,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
if (userId === targetId) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
@@ -145,12 +154,7 @@ export class GuildModerationService {
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
@@ -164,12 +168,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import {GuildRole} from '../../models/GuildRole';
import type {IUserRepository} from '../../user/IUserRepository';
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
import {computePermissionsDiff} from '../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildRoleToResponse} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
@@ -62,6 +64,7 @@ export class GuildRoleService {
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {}
async systemCreateRole(params: {
@@ -446,9 +449,11 @@ export class GuildRoleService {
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
@@ -2,9 +2,8 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
import {GuildContentService} from './GuildContentService';
import {GuildDataService} from './GuildDataService';
import {GuildMemberService} from './GuildMemberService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
@@ -95,17 +95,6 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -182,6 +171,7 @@ export class GuildService {
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
@@ -211,6 +201,7 @@ export class GuildService {
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
@@ -561,17 +552,7 @@ export class GuildService {
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await this.userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
const enforceGuildMfa = (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
await this.syncPermissionsWithParent({
guildId,
userId: params.userId,
channelId: target.id,
parentId: desiredParent,
});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child || child.guildId !== params.guildId) return;
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: child.id,
});
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
throw new MissingPermissionsError();
}
for (const [targetId, existing] of child.permissionOverwrites) {
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
for (const [targetId, incoming] of parent.permissionOverwrites) {
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import {Logger} from '../../../Logger';
import type {Guild} from '../../../models/Guild';
import type {IUserRepository} from '../../../user/IUserRepository';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
import {requirePermission} from '../../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
import {mapGuildToGuildResponse} from '../../GuildModel';
import {GuildRepository} from '../../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly userRepository: IUserRepository,
) {}
private readonly guildRepository = new GuildRepository();
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
try {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
return this.createGuildAuth({guildData, guildId, userId});
return await this.createGuildAuth({guildData, guildId, userId});
} catch (error) {
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
throw new AccessDeniedError();
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
}
}
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
private async createGuildAuth(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
}): Promise<GuildAuth> {
const {guildData, guildId, userId} = params;
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
enforceGuildMfa(permission);
};
return {guildData, checkPermission};
}
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -16,14 +18,19 @@ interface GuildAuth {
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
constructor(
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
+3
View File
@@ -113,6 +113,7 @@ export class GiftCode {
readonly stripePaymentIntentId: string | null;
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -128,6 +129,7 @@ export class GiftCode {
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.version = row.version;
}
@@ -144,6 +146,7 @@ export class GiftCode {
stripe_payment_intent_id: this.stripePaymentIntentId,
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
version: this.version,
};
}
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
reason: 'gift_refund',
chargeId: charge.id,
});
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
}
return;
}
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
'Premium revoked due to gift chargeback',
);
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug(
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
'Revoked unredeemed gift code after chargeback',
);
}
await this.paymentFraudService.enforceAccountFraudAction({
userId: giftCode.createdByUserId,
@@ -40,7 +40,7 @@ export class StripeGiftService {
async getGiftCode(code: string): Promise<GiftCode> {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
throw new UnknownGiftCodeError();
}
return giftCode;
@@ -62,7 +62,7 @@ export class StripeGiftService {
}
try {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
throw new UnknownGiftCodeError();
}
@@ -258,6 +258,7 @@ export class StripeGiftService {
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - redeemedGracePeriodMs;
return gifts
.filter((gift) => gift.revokedAt === null)
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -252,11 +252,11 @@ export class StripeRefundService {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
if (!user) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId) {
if (subscriptionId && !user.firstRefundAt) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
duration_type: durationType,
duration_quantity: durationQuantity,
duration_months: durationMonths,
revoked_at: data.revoked_at ?? null,
};
}
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
await batch.execute();
}
async revokeGiftCode(code: string): Promise<void> {
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, DbOp<unknown>> = {};
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<void>;
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
revokeGiftCode(code: string): Promise<void>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.giftCodeRepository.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.giftCodeRepository.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.contentRepo.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
@@ -51,6 +51,8 @@ const MAX_VOICE_PROCESSING_DEVICE_OVERRIDES = 16;
const VIDEO_FRAME_RATE_MIN = 15;
const VIDEO_FRAME_RATE_MAX = 120;
const VIDEO_FRAME_RATE_DEFAULT = 30;
const DEFAULT_BROWSER_NOISE_SUPPRESSION = true;
const DEFAULT_DEEP_FILTER_NOISE_SUPPRESSION = false;
export const CAMERA_EFFECT_STRENGTH_MIN = 0;
export const CAMERA_EFFECT_STRENGTH_MAX = 100;
export const CAMERA_EFFECT_STRENGTH_DEFAULT = 50;
@@ -257,6 +259,16 @@ function applyOutputVolumeRecalibrationMigrationV1(parsed: Record<string, unknow
return true;
}
function applyNoiseSuppressionStandardDefaultMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.noiseSuppressionStandardDefaultMigratedV1 === true) {
return false;
}
parsed.noiseSuppression = DEFAULT_BROWSER_NOISE_SUPPRESSION;
parsed.deepFilterNoiseSuppressionPrefV2 = DEFAULT_DEEP_FILTER_NOISE_SUPPRESSION;
parsed.noiseSuppressionStandardDefaultMigratedV1 = true;
return true;
}
function validateBackgroundImages(images: unknown): Array<BackgroundImage> {
if (!Array.isArray(images)) return [];
const validated: Array<BackgroundImage> = [];
@@ -283,10 +295,11 @@ class VoiceSettings {
inputVolume = 100;
outputVolume = 100;
echoCancellation = true;
noiseSuppression = true;
noiseSuppression = DEFAULT_BROWSER_NOISE_SUPPRESSION;
autoGainControl = true;
deepFilterNoiseSuppressionPrefV2 = true;
deepFilterNoiseSuppressionPrefV2 = DEFAULT_DEEP_FILTER_NOISE_SUPPRESSION;
deepFilterNoiseSuppressionLevelPrefV2 = 80;
noiseSuppressionStandardDefaultMigratedV1 = false;
voiceProcessingMode: VoiceProcessingMode = DEFAULT_VOICE_PROCESSING_MODE;
voiceProcessingModeByDeviceLabel: Record<string, VoiceProcessingMode> = {};
cameraResolution: CameraResolution = 'medium';
@@ -440,6 +453,7 @@ class VoiceSettings {
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
if (changed) {
AppStorage.setItem('VoiceSettings', JSON.stringify(parsed));
}
@@ -462,6 +476,7 @@ class VoiceSettings {
'autoGainControl',
'deepFilterNoiseSuppressionPrefV2',
'deepFilterNoiseSuppressionLevelPrefV2',
'noiseSuppressionStandardDefaultMigratedV1',
'voiceProcessingMode',
'voiceProcessingModeByDeviceLabel',
'cameraResolution',
@@ -28,7 +28,6 @@ export interface ResolvedVoiceProcessing {
export const DEFAULT_VOICE_PROCESSING_MODE: VoiceProcessingMode = 'voice';
export const DEEP_FILTER_NOISE_REDUCTION_LEVEL_MIN = 0;
export const DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX = 100;
export const FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL = 100;
export function clampDeepFilterNoiseReductionLevel(level: number): number {
if (!Number.isFinite(level)) {
@@ -67,10 +66,10 @@ export function resolveVoiceProcessing(settings: VoiceProcessingSettingsLike): R
return {
mode: 'voice',
echoCancellation: true,
browserNoiseSuppression: false,
browserNoiseSuppression: true,
autoGainControl: settings.autoGainControl,
deepFilter: true,
deepFilterNoiseReductionLevel: FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL,
deepFilter: false,
deepFilterNoiseReductionLevel: DEEP_FILTER_NOISE_REDUCTION_LEVEL_MIN,
contentHint: 'speech',
};
}
@@ -31,8 +31,7 @@ pub async fn assetlinks() -> Response {
"namespace": "android_app",
"package_name": "com.fluxer.canary",
"sha256_cert_fingerprints": [
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
"CD:19:82:28:32:A8:DE:E0:97:D8:60:D9:21:28:C9:C7:C4:73:A3:72:7E:63:71:9B:A7:BB:3B:98:06:94:1F:6F"
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
]
}
}
@@ -88,8 +87,7 @@ mod tests {
"namespace": "android_app",
"package_name": "com.fluxer.canary",
"sha256_cert_fingerprints": [
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
"CD:19:82:28:32:A8:DE:E0:97:D8:60:D9:21:28:C9:C7:C4:73:A3:72:7E:63:71:9B:A7:BB:3B:98:06:94:1F:6F"
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
]
}
}
+9 -8
View File
@@ -2287,7 +2287,8 @@ typedef int (*bmff_box_cb)(const uint8_t *payload, size_t payload_len, void *use
static int bmff_walk(const uint8_t *data, size_t start, size_t end,
const char *target, bmff_box_cb cb, void *user,
int meta_full_box) {
int meta_full_box, int depth) {
if (depth > 32) return 0;
size_t off = start;
if (meta_full_box) {
if (off + 4 > end) return 0;
@@ -2320,10 +2321,10 @@ static int bmff_walk(const uint8_t *data, size_t start, size_t end,
memcmp(btype, "stbl", 4) == 0 ||
memcmp(btype, "edts", 4) == 0 ||
memcmp(btype, "dinf", 4) == 0) {
int r = bmff_walk(data, child_start, child_end, target, cb, user, 0);
int r = bmff_walk(data, child_start, child_end, target, cb, user, 0, depth + 1);
if (r) return r;
} else if (memcmp(btype, "meta", 4) == 0) {
int r = bmff_walk(data, child_start, child_end, target, cb, user, 1);
int r = bmff_walk(data, child_start, child_end, target, cb, user, 1, depth + 1);
if (r) return r;
}
@@ -2395,9 +2396,9 @@ static int cb_each_trak(const uint8_t *payload, size_t len, void *user) {
}
trak_info *t = &list->traks[list->count];
memset(t, 0, sizeof(*t));
bmff_walk(payload, 0, len, "mdhd", cb_collect_mdhd, t, 0);
bmff_walk(payload, 0, len, "hdlr", cb_collect_hdlr, t, 0);
bmff_walk(payload, 0, len, "stts", cb_collect_stts, t, 0);
bmff_walk(payload, 0, len, "mdhd", cb_collect_mdhd, t, 0, 0);
bmff_walk(payload, 0, len, "hdlr", cb_collect_hdlr, t, 0, 0);
bmff_walk(payload, 0, len, "stts", cb_collect_stts, t, 0, 0);
list->count++;
return 0;
}
@@ -2412,7 +2413,7 @@ static int parse_isobmff_track_delays(const void *buf, size_t len,
const uint8_t *data = (const uint8_t *)buf;
trak_list list = { NULL, 0, 0 };
if (bmff_walk(data, 0, len, "trak", cb_each_trak, &list, 0) != 0) {
if (bmff_walk(data, 0, len, "trak", cb_each_trak, &list, 0, 0) != 0) {
free(list.traks);
return -1;
}
@@ -2540,7 +2541,7 @@ static int parse_isobmff_has_tmap_item(const void *buf, size_t len) {
if (buf == NULL || len < 16) return 0;
int found = 0;
const uint8_t *data = (const uint8_t *)buf;
bmff_walk(data, 0, len, "iinf", cb_find_iinf_tmap, &found, 0);
bmff_walk(data, 0, len, "iinf", cb_find_iinf_tmap, &found, 0, 0);
return found;
}
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: cs\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: en-GB\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: en-US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: es-419\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: es-ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: he\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: hi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: lt\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: no\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: pl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: pt-BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: ro\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: sv-SE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: th\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: tr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: uk\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: vi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: zh-CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"Language: zh-TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"