Compare commits

...
Author SHA1 Message Date
HampusandGitHub 61b4511ae4 fix(schema): group category channels text before voice when ordering (#1823) 2026-08-23 17:49:26 +02:00
HampusandGitHub 137edc7cfb fix(app): share stream audio by default and reset the opt-out per stream (#1819) 2026-08-22 10:14:45 +02:00
HampusandGitHub 14e772a751 fix(api): serialise elapsed temp bans as null for the admin panel (#1817) 2026-08-21 20:34:43 +02:00
HampusandGitHub 32afbf12d6 fix(api): stop treating accounts pending deletion as already deleted (#1816) 2026-08-21 20:31:32 +02:00
HampusandGitHub ffaf5119d8 fix(app): only warn about software encoding when no layer is accelerated (#1815) 2026-08-21 18:49:51 +02:00
HampusandGitHub 10bc8c1efa fix(desktop): stop the windows audio probe timing out against its own budget (#1814) 2026-08-21 17:29:12 +02:00
HampusandGitHub 85a03a9e39 fix(app): apply the screen share audio toggle to the surface being shared (#1813) 2026-08-21 17:04:22 +02:00
51ee6567b4 chore(i18n): update public marketing catalogs (#1812)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:31:01 +02:00
090220a29d chore(marketing): advance pointer f39eced → 02e3a2c (#1811)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:30:54 +02:00
HampusandGitHub e7973b8be0 fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810) 2026-08-21 15:49:50 +02:00
HampusandGitHub b5324c9223 perf(gateway): stop materializing all members on the guild connect path (#1809) 2026-08-21 14:00:19 +02:00
HampusandGitHub edb8d80077 ci: source the s3 provider for downloads and static from repo variables (#1803) 2026-08-20 21:46:26 +02:00
HampusandGitHub ddee116339 feat(api): route downloads through the configured downloads provider (#1802) 2026-08-20 21:44:53 +02:00
HampusandGitHub bdacaea4a8 feat(config): add an optional separate s3 provider for downloads (#1801) 2026-08-20 21:34:19 +02:00
HampusandGitHub 9e28e02b5d ci(rust): pin the floating toolchains to the version images build with (#1800) 2026-08-20 21:27:12 +02:00
HampusandGitHub 3527dc95a2 fix(rust): silence result_large_err on axum response error paths (#1799) 2026-08-20 21:18:18 +02:00
HampusandGitHub 27c7b2722d chore(admin): regenerate openapi schemas for the admin acl cap (#1798) 2026-08-20 21:17:04 +02:00
HampusandGitHub ba96f52ed6 fix(schema): allow assigning every admin ACL to a user (#1797) 2026-08-20 21:09:56 +02:00
HampusandGitHub 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
75 changed files with 680 additions and 142 deletions
+4 -4
View File
@@ -106,10 +106,10 @@ jobs:
- name: upload assets to S3 static bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
STATIC_BUCKET: fluxer-static
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
--step upload_assets
+8 -8
View File
@@ -139,10 +139,10 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
@@ -524,11 +524,11 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: clippy, rustfmt
- name: Install pnpm
@@ -286,7 +286,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: rustfmt
- name: Sync ci helper dependencies
+2 -2
View File
@@ -14913,7 +14913,7 @@
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"has_totp": {"type": "boolean"},
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
@@ -15481,7 +15481,7 @@
"acls": {
"type": "array",
"items": {"type": "string"},
"maxItems": 100,
"maxItems": 115,
"description": "List of access control permissions to assign"
}
},
+1
View File
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
})
}
#[allow(clippy::result_large_err)]
async fn extract_csrf_from_form_body(
request: Request,
) -> Result<(Request, Option<String>), Response> {
+10
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -461,6 +463,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+2 -1
View File
@@ -84,7 +84,8 @@ export async function mapUserToAdminResponse(
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
temp_banned_until:
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
+10
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -150,6 +151,7 @@ export interface APIConfig {
static: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -359,6 +361,14 @@ export interface APIConfig {
taskName?: WorkerTaskName;
enableCronScheduler?: boolean;
enableVoiceReconciliation: boolean;
voiceReconciliation: {
intervalMs: number | undefined;
staggerDelayMs: number | undefined;
lockTtlSeconds: number | undefined;
cadenceTtlSeconds: number | undefined;
gatewayOnlyGraceMs: number | undefined;
liveKitOnlyGraceMs: number | undefined;
};
laneConcurrencyOverrides: {
realtime?: number;
unfurl?: number;
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
]);
});
});
describe('provider selection', () => {
interface ClientProbe {
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
}
it('defaults to the shared S3 configuration', async () => {
const service = new StorageService() as unknown as ClientProbe;
expect(await service.client.config.region()).toBe(Config.s3.region);
});
it('uses an explicitly supplied provider instead of the shared one', async () => {
const service = new StorageService({
endpoint: 'https://downloads.example.net',
forcePathStyle: false,
region: 'eu-central-9',
accessKeyId: 'DL_KEY',
secretAccessKey: 'DL_SECRET',
}) as unknown as ClientProbe;
expect(await service.client.config.region()).toBe('eu-central-9');
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
constructor() {
this.client = buildPooledS3Client({
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
endpoint: Config.s3.endpoint,
presignedUrlBase: Config.s3.presignedUrlBase,
forcePathStyle: Config.s3.forcePathStyle,
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: true,
});
this.presignClient = buildPooledS3Client({
endpoint: this.resolvePresignEndpoint(),
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
forcePathStyle: Config.s3.forcePathStyle,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: this.provider.forcePathStyle,
});
}
private resolvePresignEndpoint(): string {
const fallbackEndpoint = Config.s3.endpoint;
const configuredEndpoint = Config.s3.presignedUrlBase;
const fallbackEndpoint = this.provider.endpoint;
const configuredEndpoint = this.provider.presignedUrlBase;
if (!configuredEndpoint) {
return fallbackEndpoint;
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {createDownloadsStorageService} from './StorageServiceFactory';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import type {IStorageService} from './IStorageService';
import {StorageService} from './StorageService';
export function createStorageService(): IStorageService {
return new StorageService();
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return new StorageService(Config.s3Downloads.settings);
}
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createStorageService} from '../infrastructure/StorageServiceFactory';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
import {UserCacheService} from '../infrastructure/UserCacheService';
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
import {VirusScanService} from '../infrastructure/VirusScanService';
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
export const getLimitConfigService = singleton(
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
@@ -808,6 +808,9 @@ export class UserRelationshipService {
if (!user) {
return false;
}
if (user.pendingDeletionAt !== null) {
return false;
}
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
}
}
@@ -19,6 +19,16 @@ import {
} from './RelationshipTestUtils';
import {fetchUserMe} from './UserTestUtils';
async function markUserScheduledForDeletion(harness: ApiTestHarness, userId: string): Promise<void> {
const pendingDeletionAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
await createBuilder(harness, '')
.post(`/test/users/${userId}/set-pending-deletion`)
.body({pending_deletion_at: pendingDeletionAt, set_self_deleted_flag: false})
.expect(HTTP_STATUS.OK)
.execute();
await markUserDeleted(harness, userId);
}
async function markUserDeleted(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
@@ -221,6 +231,16 @@ describe('UserRelationshipStateTransitions', () => {
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
.execute();
});
test('can accept a friend request from a user scheduled for deletion', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, bob.token, alice.userId);
await markUserScheduledForDeletion(harness, bob.userId);
const {json: friendship} = await acceptFriendRequest(harness, alice.token, bob.userId);
assertRelationshipType(friendship, RelationshipTypes.FRIEND);
const {json: aliceAfter} = await listRelationships(harness, alice.token);
assertRelationshipType(findRelationship(aliceAfter, bob.userId)!, RelationshipTypes.FRIEND);
});
test('cannot send friend request to user who blocked you', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
const INTERVAL_MS = 15000;
const GATEWAY_ONLY_GRACE_MS = 10000;
function ttlFor(observedSweepSpacingMs: number): number {
return candidateTtlSecondsFor({
intervalMs: INTERVAL_MS,
observedSweepSpacingMs,
graceMs: GATEWAY_ONLY_GRACE_MS,
});
}
describe('candidateTtlSecondsFor', () => {
it('outlives the gap between two consecutive observations of the same key', () => {
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
}
});
it('outlives a sweep gap far longer than the tick interval', () => {
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
});
it('grows with the observed sweep spacing rather than the tick interval', () => {
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
});
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
});
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
});
});
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
const DEFAULT_LOCK_TTL_SECONDS = 180;
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
const MIN_CANDIDATE_TTL_SECONDS = 300;
const MAX_CANDIDATE_TTL_SECONDS = 3600;
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
const ROOM_KEY_PREFIX = 'voice:room:server:';
export function candidateTtlSecondsFor(input: {
intervalMs: number;
observedSweepSpacingMs: number;
graceMs: number;
}): number {
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
}
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
private readonly cadenceTtlSeconds: number;
private readonly gatewayOnlyGraceMs: number;
private readonly liveKitOnlyGraceMs: number;
private readonly gatewayOnlyCandidateTtlSeconds: number;
private readonly liveKitOnlyCandidateTtlSeconds: number;
private observedSweepSpacingMs = 0;
private intervalHandle: NodeJS.Timeout | null = null;
private reconciling = false;
private reconciliationLockLost = false;
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
this.gatewayOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
);
this.liveKitOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
);
}
start(): void {
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
intervalMs: this.intervalMs,
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
},
'Starting VoiceReconciliationWorker',
);
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
async reconcile(): Promise<void> {
const startTime = Date.now();
await this.recordSweepSpacing(startTime);
const discovery = await this.discoverActiveRooms();
this.logger.info(
{
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
const durationMs = Date.now() - startTime;
this.logger.info(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
roomsChecked,
totalConfirmed,
totalRepaired,
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
}
}
private candidateTtlSeconds(graceMs: number): number {
return candidateTtlSecondsFor({
intervalMs: this.intervalMs,
observedSweepSpacingMs: this.observedSweepSpacingMs,
graceMs,
});
}
private async recordSweepSpacing(startedAt: number): Promise<void> {
try {
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
const previousAt = previous === null ? Number.NaN : Number(previous);
if (Number.isFinite(previousAt) && startedAt > previousAt) {
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
this.logger.warn(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds,
},
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
);
}
}
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
} catch (error) {
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
}
}
private async acquireCadenceLease(): Promise<boolean> {
try {
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.gatewayOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.liveKitOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
voiceRoomStore,
kvClient,
logger: Logger,
intervalMs: Config.worker.voiceReconciliation.intervalMs,
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
})
: null;
if (Config.voice.enabled && voiceTopology !== null) {
@@ -327,11 +327,17 @@ export async function pushActiveStreamSettings(
interface StreamSettingsMenuContentProps {
applyToLiveStream?: boolean;
shareContext?: StreamSettingsShareContext;
shareContextResolved?: boolean;
displayShareEnvironment: DisplayShareEnvironment;
}
export const StreamSettingsMenuContent = observer(
({applyToLiveStream = true, shareContext = 'display', displayShareEnvironment}: StreamSettingsMenuContentProps) => {
({
applyToLiveStream = true,
shareContext = 'display',
shareContextResolved = true,
displayShareEnvironment,
}: StreamSettingsMenuContentProps) => {
const {i18n} = useLingui();
useMediaEngineVersion();
const hasHigherVideoQuality = useHasHigherVideoQuality();
@@ -511,16 +517,22 @@ export const StreamSettingsMenuContent = observer(
);
const handleCaptureAudioToggle = useCallback(
(checked: boolean) => {
if (isAppShare) {
VoiceSettingsCommands.update({shareAppAudio: checked, muteStreamAudio: !checked});
} else if (isDeviceShare) {
if (isDeviceShare) {
VoiceSettingsCommands.update({shareDeviceAudio: checked, muteStreamAudio: !checked});
} else if (!shareContextResolved) {
VoiceSettingsCommands.update({
shareAppAudio: checked,
shareDesktopAudio: checked,
muteStreamAudio: !checked,
});
} else if (isAppShare) {
VoiceSettingsCommands.update({shareAppAudio: checked, muteStreamAudio: !checked});
} else {
VoiceSettingsCommands.update({shareDesktopAudio: checked, muteStreamAudio: !checked});
}
runApply({audioSettingsChanged: true});
},
[isAppShare, isDeviceShare, runApply],
[isAppShare, isDeviceShare, shareContextResolved, runApply],
);
const handleHidePreviewToggle = useCallback((checked: boolean) => {
VoiceSettingsCommands.update({hideStreamPreview: checked});
@@ -400,6 +400,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
applyToLiveStream={isScreenShareEnabled}
displayShareEnvironment={displayShareEnvironment}
shareContext={ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'}
shareContextResolved={ActiveScreenShareSource.getSourceId() != null}
data-flx="voice.voice-control-bar.render-screen-share-menu.stream-settings-menu-content"
/>
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group--2">
@@ -2326,6 +2326,7 @@ class MediaEngineFacade extends Store {
sendUpdate?: boolean;
playSound?: boolean;
restartIfEnabled?: boolean;
preserveStreamAudioPreferences?: boolean;
},
publishOptions?: TrackPublishOptions,
): Promise<void> {
@@ -7,6 +7,7 @@ import {SoundType} from '@app/features/notification/utils/SoundUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
import AdaptiveScreenShareEngine from '@app/features/voice/engine/AdaptiveScreenShareEngine';
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
@@ -691,11 +692,20 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
playSound?: boolean;
restartIfEnabled?: boolean;
reason?: string;
preserveStreamAudioPreferences?: boolean;
},
publishOptions?: TrackPublishOptions,
): Promise<void> {
assert.equal(typeof enabled, 'boolean');
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
if (!enabled && options?.preserveStreamAudioPreferences !== true) {
VoiceSettingsCommands.update({
shareAppAudio: true,
shareDesktopAudio: true,
shareDeviceAudio: true,
muteStreamAudio: false,
});
}
}
async executeScreenShareSetEnabledDirect(
@@ -414,10 +414,6 @@ export type ScreenShareEncoderVerificationFailure =
| (StalledVideoEncoderInfo & {reason: 'stalled'})
| MissingExpectedVideoEncoderInfo;
function isSoftwareEncoderStats(implementation: string | null, powerEfficientEncoder: boolean | null): boolean {
return classifyVideoEncoderAcceleration(implementation, powerEfficientEncoder) === 'software';
}
function getStatsKind(report: OutboundVideoStatsEntry, codecs: Map<string, CodecStatsEntry>): string | undefined {
if (report.kind || report.mediaType) return report.kind ?? report.mediaType;
if (!report.codecId) return undefined;
@@ -462,6 +458,7 @@ export function findSoftwareVideoEncoder(stats: RTCStatsReport, codec?: VideoCod
reports.push(report);
}
}
let softwareEncoder: SoftwareVideoEncoderInfo | null = null;
for (const report of reports) {
if (getStatsKind(report, codecs) !== 'video') continue;
if (report.codecId && !codecMatchesTarget(codecs.get(report.codecId)?.mimeType, codec)) continue;
@@ -471,13 +468,16 @@ export function findSoftwareVideoEncoder(stats: RTCStatsReport, codec?: VideoCod
: null;
const powerEfficientEncoder =
typeof report.powerEfficientEncoder === 'boolean' ? report.powerEfficientEncoder : null;
if (!isSoftwareEncoderStats(implementation, powerEfficientEncoder)) continue;
return {
implementation: implementation ?? UNKNOWN_ENCODER_IMPLEMENTATION,
powerEfficientEncoder,
};
const acceleration = classifyVideoEncoderAcceleration(implementation, powerEfficientEncoder);
if (acceleration === 'hardware') return null;
if (acceleration === 'software' && softwareEncoder === null) {
softwareEncoder = {
implementation: implementation ?? UNKNOWN_ENCODER_IMPLEMENTATION,
powerEfficientEncoder,
};
}
}
return null;
return softwareEncoder;
}
export function shouldTriggerSoftwareEncoderWarning(codec: VideoCodec): boolean {
@@ -216,6 +216,18 @@ function applyScreenShareAudioConsentMigrationV1(parsed: Record<string, unknown>
return true;
}
function applyScreenShareAudioDefaultOnMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.screenShareAudioDefaultOnMigratedV1 === true) {
return false;
}
parsed.shareAppAudio = true;
parsed.shareDesktopAudio = true;
parsed.shareDeviceAudio = true;
parsed.muteStreamAudio = false;
parsed.screenShareAudioDefaultOnMigratedV1 = true;
return true;
}
function applyStreamingModeDefaultMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.streamingModeDefaultMigratedV1 === true) {
return false;
@@ -284,12 +296,13 @@ class VoiceSettings {
streamingMode: StreamingMode = 'screenshare';
streamingModeDefaultMigratedV1 = false;
hideStreamPreview = false;
muteStreamAudio = true;
shareAppAudio = false;
shareDesktopAudio = false;
shareDeviceAudio = false;
muteStreamAudio = false;
shareAppAudio = true;
shareDesktopAudio = true;
shareDeviceAudio = true;
screenShareAudioDeviceId = 'default';
screenShareAudioConsentMigratedV1 = false;
screenShareAudioDefaultOnMigratedV1 = false;
outputVolumeRecalibratedV1 = false;
backgroundImageId = NONE_BACKGROUND_ID;
backgroundImages: Array<BackgroundImage> = [];
@@ -424,6 +437,7 @@ class VoiceSettings {
changed = applyPiPPopoutDefaultsMigration(parsed) || changed;
changed = applyAdaptiveScreenShareQualityMigrationV2(parsed) || changed;
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
if (changed) {
@@ -463,6 +477,7 @@ class VoiceSettings {
'shareDeviceAudio',
'screenShareAudioDeviceId',
'screenShareAudioConsentMigratedV1',
'screenShareAudioDefaultOnMigratedV1',
'outputVolumeRecalibratedV1',
'backgroundImageId',
'backgroundImages',
@@ -832,7 +832,7 @@ export async function armNativeAudioForNextCapture(sourceId: string): Promise<bo
platform: electronApi.platform,
sourceId,
backend: availability?.backend ?? null,
reason: availability?.reason ?? 'os-version-too-old',
reason: availability?.reason ?? 'process-scope-unsupported',
detail: availability?.detail ?? null,
});
logger.warn('Cannot arm per-window audio capture: native audio addon unavailable', {
@@ -291,6 +291,14 @@ function getConfiguredScreenShareOptions(
sourceId,
preferredDisplaySurface,
);
if (!includeAudio && shareContext !== 'device' && supportsDesktopScreenShareAudioCapture()) {
logger.info('Screen share audio not requested for this surface', {
sourceId,
preferredDisplaySurface,
shareAppAudio: VoiceSettings.getShareAppAudio(),
shareDesktopAudio: VoiceSettings.getShareDesktopAudio(),
});
}
const {captureOptions, publishOptions} = buildScreenShareOptions({
resolution,
frameRate,
@@ -349,7 +357,11 @@ async function runConfiguredDisplayScreenShare(
logger.warn('No active screen share to restart for Wayland portal source switch');
return false;
}
await MediaEngine.setScreenShareEnabled(false, {sendUpdate: false, playSound: false});
await MediaEngine.setScreenShareEnabled(false, {
sendUpdate: false,
playSound: false,
preserveStreamAudioPreferences: true,
});
}
let nativeAudioArmed = false;
const isOwnWindowShare = options?.isOwnWindow === true && sourceId?.startsWith('window:');
+1
View File
@@ -231,6 +231,7 @@ fn discovery_endpoint(discovery: &DiscoveryResponse, key: &str) -> Option<String
.map(ToOwned::to_owned)
}
#[allow(clippy::result_large_err)]
async fn load_spa_index_html(state: &AppState) -> Result<String, Response> {
if let Some(index_upstream_url) = &state.config.index_upstream_url {
let response = state
@@ -1071,6 +1071,7 @@ mod platform {
const BACKSLASH_UTF16: &[u16] = &[b'\\' as u16, 0];
const PROCESS_LOOPBACK_PROBE_TIMEOUT_MS: u32 = 1_500;
const PROCESS_LOOPBACK_PROBE_COUNT: u64 = 2;
const SESSION_MIXER_REFRESH_INTERVAL: Duration = Duration::from_millis(1_000);
const SESSION_MIXER_WAIT_TIMEOUT_MS: u32 = 250;
const MAX_SESSION_MIXER_CAPTURES: usize = 48;
@@ -2197,7 +2198,7 @@ mod platform {
});
match rx.recv_timeout(std::time::Duration::from_millis(
u64::from(PROCESS_LOOPBACK_PROBE_TIMEOUT_MS) + 500,
u64::from(PROCESS_LOOPBACK_PROBE_TIMEOUT_MS) * PROCESS_LOOPBACK_PROBE_COUNT + 500,
)) {
Ok((include_result, exclude_result)) => ProcessLoopbackRuntimeProbe {
include_supported: include_result.is_ok(),
@@ -17,6 +17,9 @@
-define(SESSION_CONNECT_MAX_WORKERS, 8).
-define(SESSION_CONNECT_DEFAULT_MAX_QUEUE, 1024).
-define(CONNECT_SNAPSHOT_HEAVY_MEMBER_KEYS, [
<<"members">>, members_normalized, <<"member_role_index">>, members_sorted_ids
]).
-spec ensure_session_connect_queue(term()) -> queue:queue().
ensure_session_connect_queue(Value) when is_list(Value) ->
@@ -363,14 +366,14 @@ queued_session_pid(_) ->
-spec start_worker(map(), map()) -> map().
start_worker(Item, State) ->
Self = self(),
Snapshot = build_connect_snapshot(State),
Snapshot = build_connect_snapshot(Item, State),
{_Pid, Ref} = spawn_monitor(fun() -> compute_and_send_done(Item, Self, Snapshot) end),
WorkerRefs = maps:get(session_connect_worker_refs, State, #{}),
State#{session_connect_worker_refs => WorkerRefs#{Ref => true}}.
-spec build_connect_snapshot(map()) -> map().
build_connect_snapshot(State) ->
maps:with(
-spec build_connect_snapshot(map(), map()) -> map().
build_connect_snapshot(Item, State) ->
Base = maps:with(
[
id,
data,
@@ -382,8 +385,76 @@ build_connect_snapshot(State) ->
virtual_channel_access
],
State
),
maybe_trim_connect_snapshot(Item, Base, State).
-spec maybe_trim_connect_snapshot(map(), map(), map()) -> map().
maybe_trim_connect_snapshot(Item, Base, State) ->
case has_members_ets(State) of
true -> trim_connect_snapshot(Item, Base);
false -> Base
end.
-spec has_members_ets(map()) -> boolean().
has_members_ets(#{data := #{members_ets := Tab}}) -> is_reference(Tab);
has_members_ets(_) -> false.
-spec trim_connect_snapshot(map(), map()) -> map().
trim_connect_snapshot(Item, #{data := Data} = Base) when is_map(Data) ->
Retained = retained_member_map(Item, Base, Data),
Trimmed = maps:without(?CONNECT_SNAPSHOT_HEAVY_MEMBER_KEYS, Data),
Base#{
data => Trimmed#{
<<"members">> => Retained,
members_normalized => Retained,
<<"member_role_index">> =>
guild_data_index_members:build_member_role_index(Retained)
}
};
trim_connect_snapshot(_Item, Base) ->
Base.
-spec retained_member_map(map(), map(), map()) -> #{integer() => map()}.
retained_member_map(Item, Base, Data) ->
UserIds = [connect_user_id(Item) | voice_state_user_ids(Base)],
lists:foldl(
fun(UserId, Acc) -> retain_member(UserId, Data, Acc) end,
#{},
UserIds
).
-spec retain_member(term(), map(), #{integer() => map()}) -> #{integer() => map()}.
retain_member(UserId, Data, Acc) when is_integer(UserId) ->
case guild_data_index_members:get_member_ets(UserId, Data) of
Member when is_map(Member) -> Acc#{UserId => Member};
_ -> Acc
end;
retain_member(_UserId, _Data, Acc) ->
Acc.
-spec connect_user_id(map()) -> integer() | undefined.
connect_user_id(Item) ->
Request = maps:get(request, Item, #{}),
case maps:get(user_id, Request, undefined) of
UserId when is_integer(UserId) -> UserId;
_ -> undefined
end.
-spec voice_state_user_ids(map()) -> [integer()].
voice_state_user_ids(Base) ->
maps:fold(
fun(_Key, VoiceState, Acc) -> add_voice_state_user_id(VoiceState, Acc) end,
[],
voice_state_utils:voice_states(Base)
).
-spec add_voice_state_user_id(term(), [integer()]) -> [integer()].
add_voice_state_user_id(VoiceState, Acc) ->
case voice_state_utils:voice_state_user_id(VoiceState) of
UserId when is_integer(UserId) -> [UserId | Acc];
_ -> Acc
end.
-spec compute_and_send_done(map(), pid(), map()) -> ok.
compute_and_send_done(Item, GuildPid, Snapshot) ->
Request = maps:get(request, Item, #{}),
+27 -3
View File
@@ -78,7 +78,6 @@ get_guild_state(UserId, State) ->
Data = guild_data_index:ensure_data_map(State),
GuildId = guild_id(State),
AllChannels = guild_data_channels:channels_from_data(Data),
AllMembers = guild_data_index:member_values(Data),
Member = guild_data_members:find_member_by_user_id(UserId, State),
{ViewableChannels, JoinedAt} = guild_data_channels:derive_member_view(
UserId, Member, State, AllChannels
@@ -89,9 +88,9 @@ get_guild_state(UserId, State) ->
AllVoiceStates = guild_voice:get_voice_states_list(StateWithVoice),
ViewableChannelIds = channel_id_set(ViewableChannels),
VoiceStates = filter_voice_states(AllVoiceStates, ViewableChannelIds),
VoiceMembers = guild_data_channels:voice_members_from_states(VoiceStates, AllMembers),
VoiceMembers = resolve_voice_members(VoiceStates, Data),
Members = guild_data_channels:merge_members(OwnMemberList, VoiceMembers),
MemberCount = maps:get(member_count, State, length(AllMembers)),
MemberCount = maps:get(member_count, State, guild_data_index:member_count(Data)),
build_guild_state_map(
GuildId,
Data,
@@ -103,6 +102,31 @@ get_guild_state(UserId, State) ->
JoinedAt
).
-spec resolve_voice_members([map()], map()) -> [map()].
resolve_voice_members(VoiceStates, Data) ->
lists:filtermap(fun(VS) -> resolve_voice_member_entry(VS, Data) end, VoiceStates).
-spec resolve_voice_member_entry(map(), map()) -> {true, map()} | false.
resolve_voice_member_entry(VoiceState, Data) ->
case maps:get(<<"member">>, VoiceState, undefined) of
Member when is_map(Member), map_size(Member) > 0 ->
{true, Member};
_ ->
resolve_voice_member_by_lookup(VoiceState, Data)
end.
-spec resolve_voice_member_by_lookup(map(), map()) -> {true, map()} | false.
resolve_voice_member_by_lookup(VoiceState, Data) ->
case voice_state_utils:voice_state_user_id(VoiceState) of
UserId when is_integer(UserId) ->
case guild_data_index_members:get_member_ets(UserId, Data) of
Member when is_map(Member) -> {true, Member};
_ -> false
end;
_ ->
false
end.
-spec fetch_latest_voice_states(guild_state()) -> guild_state().
fetch_latest_voice_states(State) ->
case maps:get(voice_server_pid, State, undefined) of
+1
View File
@@ -335,6 +335,7 @@ fn replace_image_extension(filename: &str, ext: AssetExtension) -> String {
}
}
#[allow(clippy::result_large_err)]
async fn rasterize_metadata_svg(
app: &Arc<AppState>,
input: InputData,
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /usr/src/app
COPY . .
RUN cargo build --release -p fluxer-messages
RUN cargo build --release -p fluxer-messages --features scylla
FROM debian:bookworm-slim
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /usr/src/app
COPY . .
RUN cargo build --release -p fluxer-users
RUN cargo build --release -p fluxer-users --features scylla
FROM debian:bookworm-slim
+16
View File
@@ -79,6 +79,14 @@ export interface MasterConfig {
static: string;
};
};
s3_downloads?: {
endpoint: string;
presigned_url_base?: string;
force_path_style?: boolean;
region?: string;
access_key_id?: string;
secret_access_key?: string;
};
services: {
api: {
port: number;
@@ -104,6 +112,14 @@ export interface MasterConfig {
task?: string;
enable_cron_scheduler?: boolean;
enable_voice_reconciliation?: boolean;
voice_reconciliation?: {
interval_ms?: number;
stagger_delay_ms?: number;
lock_ttl_seconds?: number;
cadence_ttl_seconds?: number;
gateway_only_grace_ms?: number;
livekit_only_grace_ms?: number;
};
lane_concurrency_overrides?: {
realtime?: number;
unfurl?: number;
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
export interface S3ProviderSettings {
endpoint: string;
presignedUrlBase?: string;
forcePathStyle: boolean;
region: string;
accessKeyId: string;
secretAccessKey: string;
}
export interface ResolvedDownloadsProvider {
settings: S3ProviderSettings;
isOverridden: boolean;
}
export function resolveDownloadsProvider(master: Pick<MasterConfig, 's3' | 's3_downloads'>): ResolvedDownloadsProvider {
const base = master.s3;
if (!base) {
throw new Error('S3 configuration is required to resolve the downloads provider');
}
const baseSettings: S3ProviderSettings = {
endpoint: base.endpoint,
presignedUrlBase: base.presigned_url_base,
forcePathStyle: base.force_path_style,
region: base.region,
accessKeyId: base.access_key_id,
secretAccessKey: base.secret_access_key,
};
const override = master.s3_downloads;
if (!override?.endpoint) {
return {settings: baseSettings, isOverridden: false};
}
return {
settings: {
endpoint: override.endpoint,
presignedUrlBase: override.presigned_url_base ?? undefined,
forcePathStyle: override.force_path_style ?? baseSettings.forcePathStyle,
region: override.region ?? baseSettings.region,
accessKeyId: override.access_key_id ?? baseSettings.accessKeyId,
secretAccessKey: override.secret_access_key ?? baseSettings.secretAccessKey,
},
isOverridden: true,
};
}
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {describe, expect, test} from 'vitest';
const base: Pick<MasterConfig, 's3' | 's3_downloads'>['s3'] = {
endpoint: 'https://main.example.com',
presigned_url_base: 'https://public.example.com',
force_path_style: true,
region: 'us-east-1',
access_key_id: 'MAIN_KEY',
secret_access_key: 'MAIN_SECRET',
buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h', static: 's'},
};
describe('resolveDownloadsProvider', () => {
test('falls back to the main provider when no override is set', () => {
const resolved = resolveDownloadsProvider({s3: base});
expect(resolved.isOverridden).toBe(false);
expect(resolved.settings.endpoint).toBe('https://main.example.com');
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
expect(resolved.settings.secretAccessKey).toBe('MAIN_SECRET');
expect(resolved.settings.region).toBe('us-east-1');
expect(resolved.settings.presignedUrlBase).toBe('https://public.example.com');
});
test('ignores a partial override that does not set an endpoint', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: '', access_key_id: 'OTHER'}});
expect(resolved.isOverridden).toBe(false);
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
});
test('uses the override provider when an endpoint is set', () => {
const resolved = resolveDownloadsProvider({
s3: base,
s3_downloads: {
endpoint: 'https://downloads.example.net',
region: 'eu-central-1',
access_key_id: 'DL_KEY',
secret_access_key: 'DL_SECRET',
},
});
expect(resolved.isOverridden).toBe(true);
expect(resolved.settings.endpoint).toBe('https://downloads.example.net');
expect(resolved.settings.region).toBe('eu-central-1');
expect(resolved.settings.accessKeyId).toBe('DL_KEY');
});
test('inherits unspecified fields from the main provider', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: 'https://downloads.example.net'}});
expect(resolved.isOverridden).toBe(true);
expect(resolved.settings.endpoint).toBe('https://downloads.example.net');
expect(resolved.settings.region).toBe('us-east-1');
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
expect(resolved.settings.forcePathStyle).toBe(true);
});
test('does not inherit the main presigned base for an override provider', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: 'https://downloads.example.net'}});
expect(resolved.settings.presignedUrlBase).toBeUndefined();
});
});
@@ -65,6 +65,12 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_S3_BUCKET_REPORTS: {path: ['s3', 'buckets', 'reports']},
FLUXER_S3_BUCKET_HARVESTS: {path: ['s3', 'buckets', 'harvests']},
FLUXER_S3_BUCKET_STATIC: {path: ['s3', 'buckets', 'static']},
FLUXER_S3_DOWNLOADS_ENDPOINT: {path: ['s3_downloads', 'endpoint']},
FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT: {path: ['s3_downloads', 'presigned_url_base']},
FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE: {path: ['s3_downloads', 'force_path_style'], parse: parseEnvValue},
FLUXER_S3_DOWNLOADS_REGION: {path: ['s3_downloads', 'region']},
FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID: {path: ['s3_downloads', 'access_key_id']},
FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY: {path: ['s3_downloads', 'secret_access_key']},
FLUXER_NATS_URL: {path: ['services', 'nats', 'core_url']},
FLUXER_NATS_CORE_URL: {path: ['services', 'nats', 'core_url']},
FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']},
@@ -90,6 +96,30 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
path: ['services', 'api', 'worker', 'enable_voice_reconciliation'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'interval_ms'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'stagger_delay_ms'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'lock_ttl_seconds'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'cadence_ttl_seconds'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'gateway_only_grace_ms'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS: {
path: ['services', 'api', 'worker', 'voice_reconciliation', 'livekit_only_grace_ms'],
parse: parseEnvValue,
},
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: {
path: ['services', 'api', 'worker', 'lane_concurrency_overrides'],
parse: parseEnvValue,
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 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-19 00:00+0000\n"
"PO-Revision-Date: 2026-08-19 00:00+0000\n"
"POT-Creation-Date: 2026-08-21 00:00+0000\n"
"PO-Revision-Date: 2026-08-21 00:00+0000\n"
"Language: zh-TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {SetUserAclsRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {describe, expect, test} from 'vitest';
describe('SetUserAclsRequest', () => {
test('accepts every ACL the instance defines', () => {
const acls = Object.values(AdminACLs);
const result = SetUserAclsRequest.safeParse({user_id: '1', acls});
expect(result.success).toBe(true);
});
test('rejects more entries than there are ACLs', () => {
const acls = [...Object.values(AdminACLs), 'overflow:one'];
const result = SetUserAclsRequest.safeParse({user_id: '1', acls});
expect(result.success).toBe(false);
});
});
@@ -8,6 +8,7 @@ import {
UserFlags,
UserFlagsDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {NSFWLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators';
import {
createBitflagInt32Type,
@@ -21,6 +22,8 @@ import {
import {DiscriminatorType, EmailType, UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
const ADMIN_ACL_COUNT = Object.keys(AdminACLs).length;
export const UserAdminResponseSchema = z.object({
id: SnowflakeStringType,
username: z.string(),
@@ -65,7 +68,7 @@ export const UserAdminResponseSchema = z.object({
pending_bulk_message_deletion_at: z.string().nullable(),
deletion_reason_code: Int32Type.nullable(),
deletion_public_reason: z.string().nullable(),
acls: z.array(z.string()).max(100),
acls: z.array(z.string()).max(ADMIN_ACL_COUNT),
traits: z.array(z.string()).max(100),
has_totp: z.boolean(),
authenticator_types: z.array(Int32Type).max(10),
@@ -353,7 +356,7 @@ export type ScheduleAccountDeletionRequest = z.infer<typeof ScheduleAccountDelet
export const SetUserAclsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to set ACLs for'),
acls: z.array(createStringType(1, 64)).max(100).describe('List of access control permissions to assign'),
acls: z.array(createStringType(1, 64)).max(ADMIN_ACL_COUNT).describe('List of access control permissions to assign'),
});
export type SetUserAclsRequest = z.infer<typeof SetUserAclsRequest>;
@@ -47,6 +47,22 @@ export function compareChannelOrdering<Id extends string | bigint>(
return idToString(a.id).localeCompare(idToString(b.id));
}
function channelLayoutRank(channel: {type: number}): number {
if (channel.type === ChannelTypes.GUILD_TEXT || channel.type === ChannelTypes.GUILD_LINK) return 0;
if (channel.type === ChannelTypes.GUILD_VOICE) return 1;
return 2;
}
function sortChannelListGroup<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
channels: ReadonlyArray<Channel>,
): Array<Channel> {
return [...channels].sort((a, b) => {
const rankDelta = channelLayoutRank(a) - channelLayoutRank(b);
if (rankDelta !== 0) return rankDelta;
return compareChannelOrdering(a, b);
});
}
export function sortChannelsForOrdering<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
channels: ReadonlyArray<Channel>,
): Array<Channel> {
@@ -79,7 +95,7 @@ export function sortChannelsForOrdering<Id extends string | bigint, Channel exte
if (!children) {
continue;
}
for (const child of [...children].sort(compareChannelOrdering)) {
for (const child of sortChannelListGroup(children)) {
orderedChannels.push(child);
seen.add(child.id);
}