mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b4511ae4 | ||
|
|
137edc7cfb | ||
|
|
14e772a751 | ||
|
|
32afbf12d6 | ||
|
|
ffaf5119d8 | ||
|
|
10bc8c1efa | ||
|
|
85a03a9e39 | ||
|
|
51ee6567b4 | ||
|
|
090220a29d | ||
|
|
e7973b8be0 | ||
|
|
b5324c9223 | ||
|
|
edb8d80077 | ||
|
|
ddee116339 | ||
|
|
bdacaea4a8 | ||
|
|
9e28e02b5d | ||
|
|
3527dc95a2 | ||
|
|
27c7b2722d | ||
|
|
ba96f52ed6 | ||
|
|
2c8b3ff45c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+10
@@ -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:');
|
||||
|
||||
@@ -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, #{}),
|
||||
|
||||
@@ -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
-1
Submodule fluxer_marketing updated: f39eced3b8...02e3a2cda7
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user