Compare commits

...
Author SHA1 Message Date
Hampus 00249f9b4d fix(deploy): drop healthcheck start_interval for older engines (#3472) 2026-10-14 17:57:34 +02:00
Hampus 7c16d424ce fix(app): show download icon for shell updates (#3471) 2026-10-14 17:48:41 +02:00
Hampus 5f5e26a8d4 fix(config): default self-hosted instances to production (#3470) 2026-10-14 17:14:35 +02:00
Hampus 8c05497c37 fix(auth): stop flagging dev instance signups as staff (#3469) 2026-10-14 17:09:14 +02:00
Hampus d2f19660e5 fix(app): skip zod eval probe blocked by csp (#3468) 2026-10-14 16:41:47 +02:00
Hampus d5ca5d8293 fix(auth): fix approval signups and auto-join invites (#3467) 2026-10-14 16:41:33 +02:00
Hampus e403fb6cce feat(desktop): add FLUXER_DISABLE_MODULE_UPDATES opt-out (#3466) 2026-10-14 16:30:34 +02:00
Hampus e8dff6a8a1 fix(api): page postgres kv full scans by keyset (#3465) 2026-10-14 16:27:27 +02:00
Hampus 2b656a1186 chore(deps): bump rustls in desktop native and fuzz locks (#3464) 2026-10-14 16:07:15 +02:00
Hampus 381699623e fix(app): stop own profile preview trapping mobile web (#3463) 2026-10-14 16:04:09 +02:00
Hampus d7bbe933fa fix(app): unify guild acronyms and shrink them to fit (#3462) 2026-10-14 16:02:18 +02:00
Hampus 069b13c382 chore(deps): bump rustls to 0.23.46 (#3461) 2026-10-14 15:59:23 +02:00
Fenane 538b5d69bb fix(app): convert emoji shortcodes in a single transform pass (#3459) 2026-10-14 15:53:22 +02:00
Hampus 3c8fe74660 fix(app): refetch oauth bot destinations after account switch (#3460) 2026-10-14 15:39:33 +02:00
128 changed files with 2138 additions and 796 deletions

No files matched your search

Generated
+4 -4
View File
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -3890,9 +3890,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
@@ -4613,7 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
-1
View File
@@ -211,7 +211,6 @@ x-fluxer-app-healthcheck: &fluxer-app-healthcheck
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
start_interval: 1s
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
<<: *fluxer-app-healthcheck
+7 -3
View File
@@ -4258,7 +4258,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -16526,7 +16526,8 @@
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
"client_ip": {"nullable": true, "type": "string"}
"client_ip": {"nullable": true, "type": "string"},
"invite_code": {"nullable": true, "type": "string", "maxLength": 256}
},
"required": [
"user_id",
@@ -16536,7 +16537,8 @@
"email",
"requested_at",
"registration_url_id",
"client_ip"
"client_ip",
"invite_code"
],
"additionalProperties": false
},
@@ -17409,6 +17411,7 @@
"REACTION_EMAIL_VERIFICATION_REQUIRED",
"REPORT_EMAIL_VERIFICATION_REQUIRED",
"REGISTRATION_CLOSED",
"REGISTRATION_CREDENTIALS_REQUIRED",
"REGISTRATION_PENDING_APPROVAL",
"REGISTRATION_REJECTED",
"REGISTRATION_URL_INVALID",
@@ -17704,6 +17707,7 @@
"Email verification is required for this action.",
"Email verification is required for this action.",
"Registration is closed on this instance.",
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
"This registration is waiting for admin approval.",
"This registration request was rejected.",
"This registration link is invalid or has expired.",
@@ -3,6 +3,7 @@
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
@@ -768,7 +769,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
operationId: 'update_admin_pending_registration',
summary: 'Approve or reject a pending registration',
description:
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -777,7 +778,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await updatePendingRegistrationUser(ctx, userId, decision);
const pendingRegistration = (await instanceConfigRepository.getPendingRegistrations()).find(
(entry) => entry.user_id === userId,
);
await updatePendingRegistrationUser(ctx, userId, decision, pendingRegistration?.invite_code ?? null);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
},
@@ -875,6 +879,7 @@ async function updatePendingRegistrationUser(
ctx: Context<HonoEnv>,
userId: string,
decision: 'approve' | 'reject',
inviteCode: string | null,
): Promise<void> {
const userRepository = ctx.get('userRepository');
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
@@ -896,6 +901,11 @@ async function updatePendingRegistrationUser(
}
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
if (decision === 'approve' && wasPendingApproval) {
await maybeAutoJoinInvite(ctx.get('inviteService'), {
userId: user.id,
inviteCode,
requestCache: ctx.get('requestCache'),
});
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
}
await recordAdminWrite(ctx, {
@@ -6,7 +6,8 @@ import {
createUniqueUsername,
setUserACLs,
} from '@app/api/auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {Config} from '@app/api/Config';
import {createChannelInvite, createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -21,6 +22,18 @@ interface PendingRegistrationResponse {
user_id: string;
}
function invitedRegistrationBody(prefix: string, inviteCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Invited person',
password: 'invited-since-1999',
date_of_birth: '2000-01-01',
consent: true,
invite_code: inviteCode,
};
}
describe('pending registration approval and the stock community', () => {
let harness: ApiTestHarness;
@@ -102,6 +115,198 @@ describe('pending registration approval and the stock community', () => {
.execute();
});
it('joins the invited community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedapproval', invite.code))
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
});
it('lists the invite code on a pending registration', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedlisted', invite.code))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
});
it('does not join the invited community on rejection', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedrejected', invite.code))
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('joins the auto-join community on approval of a registration without an invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('autojoinapproval'))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
it('does not join the auto-join community when the pending entry has no invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('noinviteapproval'))
.execute();
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('approves and joins the single community when the invite is gone', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild: stockGuild} = await setupTestGuildWithMembers(harness, 0);
const invitedGuild = await createGuild(harness, owner.token, 'Invited Guild');
const invite = await createChannelInvite(harness, owner.token, invitedGuild.system_channel_id!);
await getInstanceConfigRepository().setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: stockGuild.id,
});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitegone', invite.code))
.execute();
await createBuilder(harness, owner.token)
.delete(`/invites/${invite.code}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${stockGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${invitedGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('lets the review ACL alone list and decide registrations', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
+14 -5
View File
@@ -52,9 +52,10 @@ import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
import {RegistrationCredentialsRequiredError} from '@fluxer/errors/src/domains/auth/RegistrationCredentialsRequiredError';
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -163,6 +164,9 @@ async function registerAccount(
const registrationAccess = setupTokenVerified
? {pendingApproval: false, registrationUrl: null}
: await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval && !hasSignInCredentials(data, usernameMode)) {
throw new RegistrationCredentialsRequiredError();
}
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
@@ -253,7 +257,6 @@ async function registerAccount(
const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await hashPassword(data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
const userRow: UserRow = {
...EMPTY_USER_ROW,
user_id: userId,
@@ -271,7 +274,7 @@ async function registerAccount(
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
date_of_birth: dateOfBirth,
locale: userLocale,
flags,
flags: 0n,
terms_agreed_at: requiresTermsConsent ? now : null,
privacy_agreed_at: requiresPrivacyConsent ? now : null,
last_active_at: now,
@@ -303,6 +306,7 @@ async function registerAccount(
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
invite_code: data.invite_code?.trim() || config.instance.autoJoinInviteCode || null,
});
}
grantBootstrapAdmin = bootstrapAdminEligible && (await instanceConfigRepository.claimAdminBootstrap(userId));
@@ -405,6 +409,11 @@ function assertUsernameModeRegistration(data: RegisterRequest): void {
}
}
function hasSignInCredentials(data: RegisterRequest, usernameMode: boolean): boolean {
if (!data.password) return false;
return usernameMode || Boolean(data.email);
}
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
if (email == null) return undefined;
const localPart = getLocalPartAtInstance(config, email.trim());
@@ -495,7 +504,7 @@ async function resolveRegistrationAccess(
};
}
async function maybeAutoJoinInvite(
export async function maybeAutoJoinInvite(
inviteService: InviteService,
params: {
userId: UserID;
@@ -513,7 +522,7 @@ async function maybeAutoJoinInvite(
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite');
}
}
+12 -1
View File
@@ -2,6 +2,7 @@
import {createHash, randomBytes} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
import * as AuthSession from '@app/api/auth/AuthSession';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {SsoIdentityRepository} from '@app/api/auth/services/SsoIdentityRepository';
@@ -28,6 +29,7 @@ import {
isTestSsoProvider,
validateSsoPublicOutboundUrl,
} from '@app/api/instance/SsoConfigValidation';
import type {InviteService} from '@app/api/invite/InviteService';
import {Logger} from '@app/api/Logger';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
@@ -265,6 +267,7 @@ export class SsoService {
private readonly discriminatorService: DiscriminatorService,
private readonly kvActivityTracker: KVActivityTracker,
private readonly singleCommunityService: SingleCommunityService,
private readonly inviteService: InviteService,
) {}
async getPublicStatus(): Promise<PublicSsoStatus> {
@@ -406,10 +409,16 @@ export class SsoService {
}
await assertEmailNotBlocklisted(emailLower, 'email');
const pendingApproval = registrationConfig.mode === 'approval';
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
const autoJoinInviteCode = this.apiContext.services.config.instance.autoJoinInviteCode?.trim() || null;
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval, autoJoinInviteCode});
if (pendingApproval) {
throw new RegistrationPendingApprovalError();
}
await maybeAutoJoinInvite(this.inviteService, {
userId: user.id,
inviteCode: autoJoinInviteCode,
requestCache,
});
await this.singleCommunityService.joinStockCommunity(user.id, requestCache);
return user;
}
@@ -468,6 +477,7 @@ export class SsoService {
config: ResolvedSsoConfig,
options?: {
pendingApproval?: boolean;
autoJoinInviteCode?: string | null;
},
): Promise<User> {
const {users, snowflake, cache} = this.apiContext.services;
@@ -535,6 +545,7 @@ export class SsoService {
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
invite_code: options.autoJoinInviteCode ?? null,
});
}
createAttempted = true;
@@ -23,6 +23,7 @@ import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {ms} from 'itty-time';
@@ -126,6 +127,19 @@ describe('Auth registration', () => {
expect(reg.user_id.length).toBeGreaterThan(0);
expect(Object.keys(reg).sort()).toEqual(['token', 'user', 'user_id']);
});
it('does not flag accounts registered on a development instance as staff', async () => {
expect(getConfig().nodeEnv).toBe('development');
const reg = await registerUser(harness, {
email: createUniqueEmail('devnotstaff'),
username: createUniqueUsername('devnotstaff'),
global_name: 'Dev User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(reg.user_id)));
expect(user.flags & UserFlags.STAFF).toBe(0n);
});
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
@@ -91,4 +91,67 @@ describe('Auth registration policy', () => {
.expect(403, APIErrorCodes.REGISTRATION_PENDING_APPROVAL)
.execute();
});
it('refuses an approval registration without sign-in credentials', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'Nobody Signs In', date_of_birth: '2000-01-01', consent: true})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('refuses an approval registration with an email but no password', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const {password: _password, ...body} = registrationBody('nopassword');
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body(body)
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('refuses an approval registration with a password but no email', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const {email: _email, ...body} = registrationBody('noemail');
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body(body)
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('keeps the registration URL seat when an approval registration is refused', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
const {code} = await repository.createRegistrationUrl({
label: 'Reviewed link',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: true,
});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'Seat Keeper', date_of_birth: '2000-01-01', consent: true, registration_url_code: code})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
const registration = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({...registrationBody('seatkeeper'), registration_url_code: code})
.execute();
expect(registration.registration_pending_approval).toBe(true);
});
it('still accepts an unclaimed registration in open mode', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'open'});
const registration = await createBuilderWithoutAuth<RegistrationTokenResponse>(harness)
.post('/auth/register')
.body({global_name: 'Open Unclaimed', date_of_birth: '2000-01-01', consent: true})
.execute();
expect(registration.token.length).toBeGreaterThan(0);
});
});
@@ -428,8 +428,11 @@ describe('signups racing on registration URLs and pending approvals', () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const pendingRegistrations = await readPendingRegistrations();
expect(pendingRegistrations.map((entry) => entry.user_id).toSorted()).toEqual(
[account.userId, fresh.json.user_id].toSorted(),
);
expect(pendingRegistrations.find((entry) => entry.user_id === account.userId)?.invite_code).toBeNull();
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
+77 -1
View File
@@ -9,10 +9,13 @@ import {
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {Config} from '@app/api/Config';
import {createChannelInvite, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface SsoStartResponse {
@@ -503,6 +506,79 @@ describe('Auth SSO flow', () => {
.expect(200)
.execute();
});
it('joins a provisioned user to the auto-join invite community', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await enableSso(harness, admin.token);
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: `sso-auto-join-${Date.now()}@example.com`,
state: startData.state,
})
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${completeData.user_id}`)
.expect(200)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
it('replays the auto-join invite when an SSO registration is approved', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await enableSso(harness, admin.token);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: `sso-auto-join-approval-${Date.now()}@example.com`,
state: startData.state,
})
.expect(403, 'REGISTRATION_PENDING_APPROVAL')
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.get('/admin/instance/pending-registrations')
.expect(200)
.execute();
expect(listed.pending_registrations).toEqual([expect.objectContaining({invite_code: invite.code})]);
const pendingUserId = listed.pending_registrations[0]!.user_id;
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
.expect(404)
.execute();
await createBuilder(harness, reviewer.token)
.patch(`/admin/instance/pending-registrations/${pendingUserId}`)
.body({status: 'approved'})
.expect(200)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
.expect(200)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
});
describe('existing user login', () => {
let admin: TestAccount;
@@ -404,6 +404,29 @@ describe('Username sign-in', () => {
expect(user.username.toLowerCase().startsWith('sharedname')).toBe(true);
expect(user.isUnclaimedAccount()).toBe(true);
});
it('refuses a credential-less approval registration on a username instance', async () => {
await useUsernameMode();
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'No Credentials', date_of_birth: '2000-01-01'})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('accepts an approval registration with a username and password', async () => {
await useUsernameMode();
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const registration = await createBuilderWithoutAuth<{registration_pending_approval: true; user_id: string}>(
harness,
)
.post('/auth/register')
.body({username: 'reviewedperson', password: PASSWORD, date_of_birth: '2000-01-01'})
.execute();
expect(registration).toEqual({registration_pending_approval: true, user_id: expect.any(String)});
});
});
describe('login', () => {
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineTable} from '@app/api/database/CassandraTableDsl';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {keyFromColumns, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {dockerAvailable, startPostgresKv, stopPostgresKv} from '@app/api/test/DockerTestContainer';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const KV_TABLE = 'kv_keyset_scan';
type Row = Record<string, unknown>;
interface UserRow {
user_id: bigint;
name: string;
kind: number;
}
interface BucketRow {
channel_id: bigint;
bucket: number;
note: string;
}
interface NameRow {
name: string;
value: number;
}
const KeysetUsers = defineTable<UserRow, 'user_id'>({
name: 'keyset_users',
columns: ['user_id', 'name', 'kind'],
primaryKey: ['user_id'],
});
const KeysetBuckets = defineTable<BucketRow, 'channel_id' | 'bucket', 'channel_id'>({
name: 'keyset_buckets',
columns: ['channel_id', 'bucket', 'note'],
primaryKey: ['channel_id', 'bucket'],
partitionKey: ['channel_id'],
});
const KeysetNames = defineTable<NameRow, 'name'>({
name: 'keyset_names',
columns: ['name', 'value'],
primaryKey: ['name'],
});
interface Statement {
text: string;
rows: number;
}
function recordingClient(client: IPostgresClient, statements: Array<Statement>): IPostgresClient {
return {
query: (async (text: string, values?: Array<unknown>, name?: string) => {
const result = await client.query(text, values, name);
statements.push({text, rows: result.rows.length});
return result;
}) as IPostgresClient['query'],
connect: () => client.connect(),
shutdown: () => client.shutdown(),
transaction: (fn) => client.transaction(fn),
kvTable: () => client.kvTable(),
};
}
function pageState(state: Row): string {
return Buffer.from(JSON.stringify(state)).toString('base64url');
}
function readPageState(state: string): Row {
return JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as Row;
}
function userId(index: number): bigint {
return 1_000_000_000_000_000_000n + BigInt(index) * 7_919n;
}
describe.skipIf(!dockerAvailable)('Postgres KV keyset scans', () => {
let raw: IPostgresClient;
let statements: Array<Statement>;
let executor: PostgresKvQueryExecutor;
let postgresContainer: string;
beforeAll(async () => {
const started = await startPostgresKv({namePrefix: 'fluxer-kvkeyset', kvTable: KV_TABLE, maxConnections: 4});
postgresContainer = started.container;
raw = started.client;
}, 900_000);
beforeEach(async () => {
await raw.query(`DELETE FROM ${KV_TABLE}`);
statements = [];
executor = new PostgresKvQueryExecutor(recordingClient(raw, statements));
});
afterAll(async () => {
await stopPostgresKv(postgresContainer);
});
async function seed(queries: ReadonlyArray<PreparedQuery>): Promise<void> {
await executor.executeBatch(queries.map((query) => ({query: query.cql, params: query.params, meta: query.kvMeta})));
}
async function seedUsers(count: number): Promise<Array<UserRow>> {
const rows = Array.from({length: count}, (_, index) => ({
user_id: userId((index * 37) % count),
name: `user-${index}`,
kind: index % 7,
}));
await seed(rows.map((row) => KeysetUsers.insert(row)));
return rows;
}
async function seedBuckets(channels: number, buckets: number): Promise<void> {
const queries: Array<PreparedQuery> = [];
for (let channel = 0; channel < channels; channel += 1) {
for (let bucket = 0; bucket < buckets; bucket += 1) {
queries.push(
KeysetBuckets.insert({
channel_id: userId((channel * 13) % channels),
bucket: bucket * 11 - 20,
note: `note-${(channel + bucket) % 5}`,
}),
);
}
}
await seed(queries);
}
async function oracle(query: PreparedQuery): Promise<Array<Row>> {
const rows = await executor.executeQuery<Row>(query);
statements.length = 0;
return rows;
}
async function walk(
query: PreparedQuery,
pageSize: number,
start: string | null = null,
): Promise<{pages: Array<Array<Row>>; states: Array<string>}> {
const pages: Array<Array<Row>> = [];
const states: Array<string> = [];
let state = start;
do {
const page = await executor.executePagedQuery<Row>(query, {pageSize, pageState: state});
pages.push(page.rows);
state = page.pageState;
if (state !== null) states.push(state);
expect(pages.length).toBeLessThan(10_000);
} while (state !== null);
return {pages, states};
}
function expectBounded(batch: number): void {
expect(statements.length).toBeGreaterThan(0);
for (const statement of statements) {
expect(statement.rows).toBeLessThanOrEqual(batch);
if (statement.text.includes('row_data FROM')) expect(statement.text).toMatch(/LIMIT \$2$/);
}
}
function byKey(columns: ReadonlyArray<string>, rows: ReadonlyArray<Row>): Array<Row> {
return [...rows].sort((left, right) => {
const l = keyFromColumns(columns, left);
const r = keyFromColumns(columns, right);
return l < r ? -1 : l > r ? 1 : 0;
});
}
it('walks a numeric single key table in the same order without a full load', async () => {
await seedUsers(250);
const query = KeysetUsers.select({}).bind({});
const expected = await oracle(query);
expect(expected).toHaveLength(250);
const {pages, states} = await walk(query, 40);
expect(pages.flat()).toEqual(expected);
expect(pages.slice(0, -1).every((page) => page.length === 40)).toBe(true);
expect(readPageState(states[0]!)).toEqual({
offset: 40,
after: keyFromColumns(['user_id'], expected[39]!),
keyed: true,
});
expectBounded(41);
});
it('walks a composite key table by row key without reloading it per page', async () => {
await seedBuckets(30, 7);
const query = KeysetBuckets.select({}).bind({});
const expected = await oracle(query);
expect(expected).toHaveLength(210);
const {pages} = await walk(query, 25);
const rows = pages.flat();
expect(byKey(['channel_id', 'bucket'], rows)).toEqual(byKey(['channel_id', 'bucket'], expected));
expect(new Set(rows.map((row) => keyFromColumns(['channel_id', 'bucket'], row))).size).toBe(210);
const channelRuns = rows.filter((row, index) => index === 0 || rows[index - 1]!.channel_id !== row.channel_id);
expect(channelRuns).toHaveLength(30);
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(pages.length);
expectBounded(26);
});
it('walks a text key table in the same order', async () => {
await seed(
Array.from({length: 90}, (_, index) => KeysetNames.insert({name: `name-${(index * 31) % 90}-x`, value: index})),
);
const query = KeysetNames.select({}).bind({});
const expected = await oracle(query);
const {pages} = await walk(query, 16);
expect(pages.flat()).toEqual(expected);
expectBounded(17);
});
it('fills filtered pages from bounded batches on numeric and composite tables', async () => {
await seedUsers(300);
await seedBuckets(20, 9);
const users = KeysetUsers.select({where: KeysetUsers.where.eq('kind')}).bind({kind: 3});
const expectedUsers = await oracle(users);
const userWalk = await walk(users, 10);
expect(userWalk.pages.flat()).toEqual(expectedUsers);
expect(userWalk.pages.slice(0, -1).every((page) => page.length === 10)).toBe(true);
expectBounded(11);
const buckets = KeysetBuckets.select({where: KeysetBuckets.where.eq('note')}).bind({note: 'note-2'});
const expectedBuckets = await oracle(buckets);
const bucketWalk = await walk(buckets, 6);
expect(byKey(['channel_id', 'bucket'], bucketWalk.pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets),
);
expect(bucketWalk.pages.slice(0, -1).every((page) => page.length === 6)).toBe(true);
expectBounded(7);
});
it('skips expired rows', async () => {
await seedUsers(60);
await seedBuckets(6, 5);
await raw.query(
`UPDATE ${KV_TABLE} SET expires_at = now() - interval '1 minute' WHERE (row_data ->> 'kind')::int = 2 OR row_data ->> 'note' = 'note-1'`,
);
const users = KeysetUsers.select({}).bind({});
const expectedUsers = await oracle(users);
expect(expectedUsers.length).toBeLessThan(60);
expect((await walk(users, 7)).pages.flat()).toEqual(expectedUsers);
const buckets = KeysetBuckets.select({}).bind({});
const expectedBuckets = await oracle(buckets);
expect(expectedBuckets.length).toBeLessThan(30);
expect(byKey(['channel_id', 'bucket'], (await walk(buckets, 7)).pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets),
);
expectBounded(8);
});
it('returns one empty page for an empty table', async () => {
for (const query of [KeysetUsers.select({}).bind({}), KeysetBuckets.select({}).bind({})]) {
expect(await executor.executePagedQuery(query, {pageSize: 10})).toEqual({rows: [], pageState: null});
}
expectBounded(1);
});
it('never drops rows with non-numeric keys from a single key table', async () => {
await seedUsers(40);
await executor.executeQuery(KeysetUsers.insert({user_id: 'stray' as unknown as bigint, name: 'stray', kind: 0}));
const {pages} = await walk(KeysetUsers.select({}).bind({}), 9);
const names = pages.flat().map((row) => row.name);
expect(names).toHaveLength(41);
expect(new Set(names).size).toBe(41);
expect(names).toContain('stray');
expectBounded(10);
});
it('resumes every page state shape the previous release produced', async () => {
await seedUsers(120);
await seedBuckets(12, 6);
const users = KeysetUsers.select({}).bind({});
const expectedUsers = await oracle(users);
const userKey = keyFromColumns(['user_id'], expectedUsers[49]!);
for (const state of [{offset: 50, after: userKey, keyed: true}, {offset: 50, after: userKey}, {offset: 50}]) {
expect((await walk(users, 15, pageState(state))).pages.flat()).toEqual(expectedUsers.slice(50));
}
const buckets = KeysetBuckets.select({}).bind({});
const expectedBuckets = await oracle(buckets);
const bucketKey = keyFromColumns(['channel_id', 'bucket'], expectedBuckets[29]!);
statements.length = 0;
const resumed = await walk(buckets, 11, pageState({offset: 30, after: bucketKey}));
expect(byKey(['channel_id', 'bucket'], resumed.pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets.slice(30)),
);
expect(readPageState(resumed.states[0]!)).toMatchObject({byRowKey: true, since: bucketKey});
expectBounded(12);
});
it('streams limited scans in key order instead of loading the table', async () => {
const seeded = await seedUsers(200);
const sorted = [...seeded].sort((left, right) => (left.user_id < right.user_id ? -1 : 1));
const first = KeysetUsers.select({limit: 20}).bind({});
const next = KeysetUsers.select({where: KeysetUsers.where.tokenGt('user_id', 'last_user_id'), limit: 20});
const expectedFirst = await oracle(KeysetUsers.select({}).bind({}));
expect(await executor.executeQuery(first)).toEqual(expectedFirst.slice(0, 20));
expectBounded(21);
statements.length = 0;
const rows = await executor.executeQuery(next.bind({last_user_id: sorted[99]!.user_id}));
expect(rows).toEqual(expectedFirst.slice(100, 120));
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(1);
expectBounded(21);
statements.length = 0;
expect(await executor.executeQuery(next.bind({last_user_id: sorted[195]!.user_id}))).toEqual(
expectedFirst.slice(196),
);
expectBounded(21);
});
it('probes for non-numeric keys through the primary key while other tables hold string keys', async () => {
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_users', k, k, '{}'::jsonb FROM generate_series(1, 20000) g, LATERAL (SELECT '{"__fluxer_type":"bigint","value":"' || g || '"}' AS k) s`,
);
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_strings', g::text, '"' || lpad(g::text, 8, '0') || '"', '{}'::jsonb FROM generate_series(1, 200000) g`,
);
await raw.query(`ANALYZE ${KV_TABLE}`);
await executor.executePagedQuery(KeysetUsers.select({}).bind({}), {pageSize: 10});
const probe = statements.find((statement) => statement.text.endsWith('AS found'));
expect(probe).toBeDefined();
const plan = await raw.query<{'QUERY PLAN': string}>(`EXPLAIN (COSTS OFF) ${probe!.text}`, ['keyset_users']);
expect(plan.rows.map((row) => row['QUERY PLAN']).join('\n')).not.toContain('Seq Scan');
});
it('keeps the sorted load when a limited scan asks for an order the key walk cannot give', async () => {
await seedUsers(30);
const descending = KeysetUsers.select({orderBy: {col: 'user_id', direction: 'DESC'}, limit: 5}).bind({});
const rows = await executor.executeQuery<Row>(descending);
const all = await oracle(KeysetUsers.select({}).bind({}));
expect(rows).toEqual(all.slice(-5).reverse());
});
});
@@ -28,6 +28,19 @@ interface PageState {
offset: number;
after?: string;
keyed?: boolean;
byRowKey?: boolean;
since?: string;
}
interface ScanWalk {
numeric: boolean;
after: string | null;
since: string | null;
}
interface NumericFloor {
value: string;
inclusive: boolean;
}
interface PageEntry {
@@ -90,6 +103,12 @@ const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
const NO_EXPIRY = 'infinity';
const BIGINT_ROW_KEY_PREFIX = `{"${ENCODED_TYPE_KEY}":"bigint","value":"`;
const NON_NUMERIC_ROW_KEY_RANGES = [
`kv.row_key COLLATE "C" < '-'`,
`kv.row_key COLLATE "C" >= ':' AND kv.row_key COLLATE "C" < '${BIGINT_ROW_KEY_PREFIX}'`,
`kv.row_key COLLATE "C" >= '${BIGINT_ROW_KEY_PREFIX}:'`,
];
export async function postgresKvPassIsFresh(
client: IPostgresClient,
@@ -676,15 +695,18 @@ function logError(details: Record<string, unknown>, message: string): void {
const fullScanLoggedAt = new Map<string, number>();
function logFullScan(meta: KvQueryMeta): void {
function logFullScan(meta: KvQueryMeta, walk?: 'numeric' | 'row_key'): void {
const shape = queryShape(meta);
const key = `${meta.table.name}|${meta.action}|${shape.summary}`;
const key = `${meta.table.name}|${meta.action}|${shape.summary}|${walk ?? 'load'}`;
const now = Date.now();
const last = fullScanLoggedAt.get(key);
if (last !== undefined && now - last < FULL_SCAN_LOG_INTERVAL_MS) return;
if (fullScanLoggedAt.size >= FULL_SCAN_LOG_KEY_LIMIT) fullScanLoggedAt.clear();
fullScanLoggedAt.set(key, now);
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
logWarn(
{table: meta.table.name, action: meta.action, where: shape.summary || 'none', walk: walk ?? 'load'},
'Postgres KV full table scan',
);
}
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
@@ -715,7 +737,13 @@ function decodePageState(pageState: string | null | undefined): PageState {
throw new Error('Invalid Postgres KV page state');
}
if (typeof decoded.after !== 'string') return {offset: decoded.offset};
return {offset: decoded.offset, after: decoded.after, keyed: decoded.keyed === true};
return {
offset: decoded.offset,
after: decoded.after,
keyed: decoded.keyed === true,
byRowKey: decoded.byRowKey === true,
...(typeof decoded.since === 'string' ? {since: decoded.since} : {}),
};
}
function pageableSelect(meta: KvQueryMeta, pageSize: number): boolean {
@@ -734,22 +762,30 @@ function numericKeyValue(value: unknown): string | null {
return null;
}
function numericScanPlan(meta: KvQueryMeta, plan: QueryPlan): boolean {
return plan.exact && plan.candidates.kind === 'scan' && (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
function numericRowKeyValue(key: string): string | null {
const values = decodeRowKey(key, 1);
return values === null ? null : numericKeyValue(values[0]);
}
function numericScanKeyed(meta: KvQueryMeta, plan: QueryPlan, entries: ReadonlyArray<PageEntry>): boolean {
if (!numericScanPlan(meta, plan)) return false;
const column = (meta.table.primaryKey as ReadonlyArray<string>)[0]!;
return entries.every((entry) => numericKeyValue(entry.row[column]) !== null);
function singlePrimaryKey(meta: KvQueryMeta): boolean {
return (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
}
function numericScanCursor(state: PageState): {rowKey: string; value: string} | null {
if (state.keyed !== true || state.after === undefined) return null;
const values = decodeRowKey(state.after, 1);
if (values === null) return null;
const value = numericKeyValue(values[0]);
return value === null ? null : {rowKey: state.after, value};
function walksInPrimaryKeyOrder(meta: KvQueryMeta): boolean {
if (!singlePrimaryKey(meta)) return false;
if (meta.orderBy === undefined) return true;
return meta.orderBy.col === meta.table.primaryKey[0] && meta.orderBy.direction !== 'DESC';
}
function numericFloor(meta: KvQueryMeta, params: CassandraParams): NumericFloor | null {
const column = meta.table.primaryKey[0];
for (const clause of whereClauses(meta)) {
if (clause.col !== column) continue;
if (clause.kind !== 'gt' && clause.kind !== 'tokenGt' && clause.kind !== 'gte') continue;
const value = numericKeyValue(getParam(params, clause.param));
if (value !== null) return {value, inclusive: clause.kind === 'gte'};
}
return null;
}
function pageStart(meta: KvQueryMeta, entries: ReadonlyArray<PageEntry>, state: PageState): number {
@@ -1018,10 +1054,10 @@ export class PostgresKvQueryExecutor {
const meta = this.meta(query);
if (pageableSelect(meta, options.pageSize) && (state.after !== undefined || state.offset === 0)) {
const plan = buildCandidatePlan(meta, query.params);
const cursor = numericScanPlan(meta, plan) ? numericScanCursor(state) : null;
const page = cursor
? await this.numericScanPage(meta, query.params, cursor, state.offset, options.pageSize)
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
const page =
plan.candidates.kind === 'scan'
? await this.scanPage(meta, query.params, await this.scanWalk(meta, state), state.offset, options.pageSize)
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
return {rows: page.rows as Array<T>, pageState: page.pageState};
}
const rows = await this.executeQuery<T, P>(query);
@@ -1033,29 +1069,111 @@ export class PostgresKvQueryExecutor {
};
}
private async numericScanPage(
private async scanWalk(meta: KvQueryMeta, state: PageState): Promise<ScanWalk> {
if (state.byRowKey) return {numeric: false, after: state.after ?? null, since: state.since ?? null};
const after = state.after ?? null;
const numericAfter = after === null || numericRowKeyValue(after) !== null;
if (state.keyed && after !== null && numericAfter && singlePrimaryKey(meta)) {
return {numeric: true, after, since: null};
}
const numeric = singlePrimaryKey(meta) && !(await this.hasNonNumericRows(meta, this.client));
if (numeric && numericAfter) return {numeric: true, after, since: null};
return {numeric: false, after: null, since: after};
}
private async hasNonNumericRows(meta: KvQueryMeta, db: PostgresQueryable): Promise<boolean> {
const probes = NON_NUMERIC_ROW_KEY_RANGES.map(
(range) =>
`(SELECT kv.row_key FROM ${this.table} kv WHERE kv.table_name = $1 AND ${range} AND (kv.expires_at IS NULL OR kv.expires_at > now()) AND ${NUMERIC_ROW_KEY} IS NULL ORDER BY kv.row_key COLLATE "C" LIMIT 1) IS NOT NULL`,
);
const result = await db.query<{found: boolean}>(`SELECT ${probes.join(' OR ')} AS found`, [meta.table.name]);
return result.rows[0]?.found === true;
}
private async scanPage(
meta: KvQueryMeta,
params: CassandraParams,
cursor: {rowKey: string; value: string},
walk: ScanWalk,
offset: number,
pageSize: number,
): Promise<{rows: Array<Row>; pageState: string | null}> {
logFullScan(meta);
const result = await this.client.query<StoredRow>(
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1 AND ${NUMERIC_ROW_KEY} IS NOT NULL AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($2::numeric, $3) AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C" LIMIT $4`,
[meta.table.name, cursor.value, cursor.rowKey, pageSize + 1],
);
const entries = this.matchingEntries(meta, result.rows.slice(0, pageSize), params);
const last = entries[entries.length - 1];
const page = await this.walkScan(meta, params, walk, pageSize, this.client);
if (!page.more || page.after === null) return {rows: this.projected(meta, page.entries), pageState: null};
const position = {offset: offset + page.entries.length, after: page.after};
return {
rows: this.projected(meta, entries),
pageState:
result.rows.length > pageSize && last
? encodePageState({offset: offset + entries.length, after: last.key, keyed: true})
: null,
rows: this.projected(meta, page.entries),
pageState: encodePageState(
walk.numeric
? {...position, keyed: true}
: {...position, byRowKey: true, ...(walk.since === null ? {} : {since: walk.since})},
),
};
}
private async walkScan(
meta: KvQueryMeta,
params: CassandraParams,
walk: ScanWalk,
want: number,
db: PostgresQueryable,
): Promise<{entries: Array<PageEntry>; after: string | null; more: boolean}> {
logFullScan(meta, walk.numeric ? 'numeric' : 'row_key');
const floor = walk.numeric ? numericFloor(meta, params) : null;
const since = walk.since === null ? null : decodeRowKey(walk.since, meta.table.primaryKey.length);
if (walk.since !== null && since === null) throw new Error('Invalid Postgres KV page state');
const limit = want + 1;
const entries: Array<PageEntry> = [];
let after = walk.after;
while (entries.length < want) {
const batch = await this.scanBatch(meta, walk.numeric, floor, after, limit, db);
for (let index = 0; index < batch.length; index += 1) {
const stored = batch[index]!;
after = stored.row_key;
const entry = this.matchingEntry(meta, stored, params);
if (entry && (since === null || compareRowToKeyValues(meta, entry.row, since) > 0)) entries.push(entry);
if (entries.length === want) {
return {entries, after, more: index < batch.length - 1 || batch.length === limit};
}
}
if (batch.length < limit) return {entries, after, more: false};
}
return {entries, after, more: false};
}
private async scanBatch(
meta: KvQueryMeta,
numeric: boolean,
floor: NumericFloor | null,
after: string | null,
limit: number,
db: PostgresQueryable,
): Promise<Array<StoredRow>> {
const params: Array<unknown> = [meta.table.name, limit];
let predicate = '';
if (numeric) {
predicate = ` AND ${NUMERIC_ROW_KEY} IS NOT NULL`;
if (floor) {
params.push(floor.value);
predicate += ` AND ${NUMERIC_ROW_KEY} ${floor.inclusive ? '>=' : '>'} $${params.length}::numeric`;
}
if (after !== null) {
const value = numericRowKeyValue(after);
if (value === null) throw new Error('Invalid Postgres KV page state');
params.push(value, after);
predicate += ` AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($${params.length - 1}::numeric, $${params.length})`;
}
} else if (after !== null) {
params.push(after);
predicate = ` AND kv.row_key COLLATE "C" > $${params.length}`;
}
const order = numeric ? `${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C"` : 'kv.row_key COLLATE "C"';
const result = await db.query<StoredRow>(
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1${predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${order} LIMIT $2`,
params,
);
return result.rows;
}
private async sortedPage(
meta: KvQueryMeta,
params: CassandraParams,
@@ -1071,12 +1189,9 @@ export class PostgresKvQueryExecutor {
const last = page[page.length - 1];
const nextOffset = start + page.length;
if (nextOffset >= entries.length || !last) return {rows: this.projected(meta, page), pageState: null};
const keyed = numericScanKeyed(meta, plan, entries);
return {
rows: this.projected(meta, page),
pageState: encodePageState(
keyed ? {offset: nextOffset, after: last.key, keyed: true} : {offset: nextOffset, after: last.key},
),
pageState: encodePageState({offset: nextOffset, after: last.key}),
};
}
@@ -1146,17 +1261,22 @@ export class PostgresKvQueryExecutor {
stored: ReadonlyArray<StoredRow>,
params: CassandraParams,
): Array<PageEntry> {
const required = queryShape(meta).requiredColumns;
const entries: Array<PageEntry> = [];
for (const entry of stored) {
const row = required ? decodeRowColumns(entry.row_data, required) : decodeRow(entry.row_data);
if (matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)) {
entries.push({key: entry.row_key, row});
}
const matched = this.matchingEntry(meta, entry, params);
if (matched) entries.push(matched);
}
return entries;
}
private matchingEntry(meta: KvQueryMeta, stored: StoredRow, params: CassandraParams): PageEntry | null {
const required = queryShape(meta).requiredColumns;
const row = required ? decodeRowColumns(stored.row_data, required) : decodeRow(stored.row_data);
return matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)
? {key: stored.row_key, row}
: null;
}
private matchingRows(meta: KvQueryMeta, stored: ReadonlyArray<StoredRow>, params: CassandraParams): Array<Row> {
return this.matchingEntries(meta, stored, params).map((entry) => entry.row);
}
@@ -1185,6 +1305,16 @@ export class PostgresKvQueryExecutor {
) {
return this.unorderedScan(meta, meta.limit, db);
}
if (
typeof meta.limit === 'number' &&
Number.isInteger(meta.limit) &&
plan.candidates.kind === 'scan' &&
walksInPrimaryKeyOrder(meta) &&
!(await this.hasNonNumericRows(meta, db))
) {
const walk = {numeric: true, after: null, since: null};
return this.projected(meta, (await this.walkScan(meta, params, walk, meta.limit, db)).entries);
}
let rows = this.matchingRows(meta, await this.candidates(meta, plan, db), params);
rows = sortRows(meta, rows);
if (typeof meta.limit === 'number') rows = rows.slice(0, meta.limit);
@@ -55,15 +55,15 @@ describe('Postgres KV unordered scan', () => {
expect(rows).toEqual([{user_id: '2'}, {user_id: '1'}]);
});
it('keeps the sorted full scan when order is asked for', async () => {
it('keeps the sorted full scan when the order cannot be walked by key', async () => {
const statements: Array<{text: string; values: Array<unknown> | undefined}> = [];
const executor = new PostgresKvQueryExecutor(recordingClient(statements));
const rows = await executor.executeQuery({
cql: '__ordered_probe',
params: {},
kvMeta: selectMeta({limit: 1}),
kvMeta: selectMeta({limit: 1, orderBy: {col: 'user_id', direction: 'DESC'}}),
});
expect(statements[0]!.text).not.toContain('LIMIT');
expect(rows).toEqual([{user_id: '1'}]);
expect(rows).toEqual([{user_id: '2'}]);
});
});
@@ -936,6 +936,7 @@ function pendingRegistration(userId: string) {
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
registration_url_id: null,
client_ip: '127.0.0.1',
invite_code: null,
};
}
@@ -1093,6 +1093,7 @@ const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
requested_at: StoredRegistrationTimestampSchema,
registration_url_id: StoredNullableStringSchema.pipe(StoredRegistrationUrlIdSchema.nullable()),
client_ip: StoredNullableStringSchema,
invite_code: StoredNullableStringSchema,
}) satisfies z.ZodType<InstancePendingRegistration>;
function isRegistrationUrlUsable(registrationUrl: InstanceRegistrationUrl, now: Date): boolean {
@@ -521,6 +521,7 @@ class RequestServices implements RequestScopedServices {
getDiscriminatorService(),
getKVActivityTracker(),
this.singleCommunityService,
this.inviteService,
);
return this.cachedSsoService;
}
+2
View File
@@ -39859,6 +39859,7 @@
"REACTION_EMAIL_VERIFICATION_REQUIRED",
"REPORT_EMAIL_VERIFICATION_REQUIRED",
"REGISTRATION_CLOSED",
"REGISTRATION_CREDENTIALS_REQUIRED",
"REGISTRATION_PENDING_APPROVAL",
"REGISTRATION_REJECTED",
"REGISTRATION_URL_INVALID",
@@ -40154,6 +40155,7 @@
"Email verification is required for this action.",
"Email verification is required for this action.",
"Registration is closed on this instance.",
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
"This registration is waiting for admin approval.",
"This registration request was rejected.",
"This registration link is invalid or has expired.",
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {clsx} from 'clsx';
import type React from 'react';
@@ -26,8 +26,6 @@ export interface GuildComboboxRenderersConfig<T extends GuildComboboxOption> {
getNotice?: (option: T, disabled: boolean) => React.ReactNode;
}
const ROW_INITIALS_MAX_LENGTH = 1;
const renderRow = <T extends GuildComboboxOption>(
option: T,
disabled: boolean,
@@ -37,7 +35,6 @@ const renderRow = <T extends GuildComboboxOption>(
) => {
const isGlobal = !option.value;
const iconUrl = option.iconUrl ?? null;
const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase();
const notice = getNotice?.(option, disabled);
return (
<div
@@ -58,7 +55,10 @@ const renderRow = <T extends GuildComboboxOption>(
aria-hidden
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder"
>
{initial}
<GuildAcronym
name={option.label}
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder-acronym"
/>
</div>
))}
<span className={styles.label} data-flx="app.guild-combobox-renderers.render-row.label">
@@ -185,19 +185,12 @@
background-color: transparent;
background-size: cover;
background-position: center;
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
overflow: hidden;
transition:
border-radius 70ms ease-out,
background-color 70ms ease-out,
color 70ms ease-out;
container-type: size;
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
--initials-fit-inline-size: 92cqi;
--initials-letter-spacing: 0px;
--initials-scale: 1;
}
.guildIconSelected:not(.guildIconNoImage) {
@@ -242,28 +235,6 @@
}
}
.guildIconInitials {
white-space: nowrap;
display: inline-block;
text-align: center;
line-height: 1;
color: inherit;
font-weight: 600;
font-size: min(
var(--initials-font-size),
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
);
letter-spacing: var(--initials-letter-spacing);
transform: scale(var(--initials-scale));
transform-origin: center;
}
.guildIcon[data-initials-length='medium'] .guildIconInitials {
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
--initials-letter-spacing: 0px;
--initials-scale: 0.92;
}
.guildIndicator {
position: absolute;
left: var(--guild-list-indicator-inset);
@@ -765,10 +736,7 @@
align-items: center;
justify-content: center;
overflow: hidden;
font-size: 0.5rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.combinePreviewIconInitials {
@@ -124,15 +124,7 @@
.miniGuildIconWithInitials {
background-color: var(--folder-mini-surface);
}
.miniGuildInitials {
font-size: 0.5rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
text-align: center;
white-space: nowrap;
}
.folderBadge {
@@ -23,10 +23,10 @@ import {
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {useHover} from '@app/features/app/hooks/useHover';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded';
import GuildReadState from '@app/features/guild/state/GuildReadState';
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
@@ -760,8 +760,6 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
);
});
const MINI_GUILD_INITIALS_MAX_LENGTH = 2;
interface MiniGuildIconProps {
readonly guild: Guild;
}
@@ -782,12 +780,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
className={flxElementClassName(styles.miniGuildIcon, styles.miniGuildIconWithInitials)}
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon--2"
>
<span
className={styles.miniGuildInitials}
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-initials"
>
{truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)}
</span>
<GuildAcronym name={guild.name} data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.acronym" />
</flx-app-mini-guild-icon>
);
});
@@ -9,16 +9,9 @@ import type {GuildListItemDragAndDrop} from '@app/features/app/components/layout
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
import {DropPlacement} from '@app/features/app/components/layout/types/DndTypes';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import {
type GuildInitialsLength,
getGuildIconDisplayInitials,
getGuildInitialsFitStyle,
getInitialsFromName,
getInitialsLength,
truncateInitials,
} from '@app/features/guild/utils/GuildInitialsUtils';
import {ariaCurrentPage} from '@app/features/platform/ARIAProps';
import {Edge} from '@app/features/ui/AxisOrientation';
import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
@@ -58,13 +51,6 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot
return STANDARD_GUILD_LIST_ITEM_MOTION;
}
function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null {
if (displayInitials.length === 0) {
return null;
}
return getInitialsLength(displayInitials);
}
function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null {
if (dragAndDrop == null) {
return null;
@@ -164,7 +150,6 @@ function GuildListItemBadges({
function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
const url = AvatarSourceUtils.getGuildIconURL(guild, false);
const initials = truncateInitials(getInitialsFromName(guild.name), 2);
const backgroundStyle: React.CSSProperties = {};
if (url.length > 0) {
backgroundStyle.backgroundImage = `url(${url})`;
@@ -176,7 +161,10 @@ function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.combine-preview-icon"
>
{url.length === 0 && (
<span data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.span">{initials}</span>
<GuildAcronym
name={guild.name}
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.acronym"
/>
)}
</flx-app-combine-preview-icon>
);
@@ -304,8 +292,6 @@ export const GuildListItemPresentation = forwardRef<
forwardedRef,
) {
const rootRef = useMergeRefs([surfaceRef, forwardedRef]);
const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name));
const initialsLength = resolveInitialsLength(displayInitials);
const dropIndicator = resolveDropIndicator(dragAndDrop);
const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion);
const draggingCursor = resolveDraggingCursor(dragAndDrop);
@@ -372,18 +358,14 @@ export const GuildListItemPresentation = forwardRef<
to={guildIconTarget}
from={false}
tween={{duration: motion.iconTweenDuration, ease: 'easeOut'}}
data-initials-length={initialsLength}
style={guildIconStyle}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon"
>
{!hasPaintedIcon && (
<span
className={styles.guildIconInitials}
style={getGuildInitialsFitStyle(displayInitials)}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
>
{displayInitials}
</span>
<GuildAcronym
name={guild.name}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-acronym"
/>
)}
</GuildListItemIcon>
<GuildListItemBadges
@@ -5,6 +5,7 @@ import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeG
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import {Button} from '@app/features/ui/button/Button';
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
import {Input} from '@app/features/ui/components/form/FormInput';
@@ -751,13 +752,7 @@ export const AdminAccountStep = observer(
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
@@ -155,6 +155,7 @@ class Updater {
private unsubscribeNativeEvents: (() => void) | null = null;
private unsubscribeDesktopUpdate: (() => void) | null = null;
private desktopUpdateReported = false;
private desktopUpdateReportedReplacesShell = false;
private desktopUpdateRunning = false;
private olderShellModuleUpdateReady = false;
private olderShellUpdateDownloaded = false;
@@ -203,6 +204,10 @@ class Updater {
return this.desktopUpdateReported || this.olderShellModuleUpdateReady || this.olderShellUpdateAvailable;
}
get desktopUpdateReplacesShell(): boolean {
return (this.desktopUpdateReported && this.desktopUpdateReportedReplacesShell) || this.olderShellUpdateAvailable;
}
private get olderShellUpdateAvailable(): boolean {
const electronApi = getElectronAPI();
if (electronApi == null || electronApi.desktopUpdate != null || electronApi.updaterInstall == null) {
@@ -281,6 +286,7 @@ class Updater {
this.unsubscribeDesktopUpdate = desktopUpdate.onStateChanged((state) => {
runInAction(() => {
this.desktopUpdateReported = state.available;
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
this.desktopUpdateRunning = state.updating === true;
});
});
@@ -314,6 +320,7 @@ class Updater {
const state = await desktopUpdate.state();
runInAction(() => {
this.desktopUpdateReported = state.available;
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
this.desktopUpdateRunning = state.updating === true;
});
} catch (error) {
@@ -669,6 +676,7 @@ class Updater {
this.unsubscribeDesktopUpdate = null;
}
this.desktopUpdateReported = false;
this.desktopUpdateReportedReplacesShell = false;
this.desktopUpdateRunning = false;
this.olderShellModuleUpdateReady = false;
this.olderShellUpdateDownloaded = false;
@@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({
return response.body;
}
export interface CompleteLoginOptions {
interface CompleteLoginOptions {
redirectPath?: string | null;
}
@@ -7,6 +7,7 @@ import authStyles from '@app/features/auth/components/accounts/account_switcher_
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {AuthSsoPanel, isSsoLoginEnforced, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
import Theme from '@app/features/theme/state/Theme';
import {flxElementClassName} from '@app/lib/react';
@@ -50,13 +51,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
data-flx="auth.accounts.account-switcher-modal.account-switcher-register-step.container"
>
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={<Trans>Create account</Trans>}
redirectPath=""
offerRecoveryKit={usernameSignIn}
@@ -64,7 +64,7 @@ const LoginPage = observer(function LoginPage({onBackActionChange}: LoginPagePro
title={null}
onLoginComplete={runtimeTarget.reset}
onBackActionChange={onBackActionChange}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -320,9 +320,7 @@
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 0.9rem;
line-height: 1.75rem;
overflow: hidden;
border: 0.0625rem solid var(--background-modifier-accent);
}
@@ -11,6 +11,7 @@ import {AUTH_LOGIN_METHOD_QUERY_PARAM} from '@app/features/auth/flow/auth_login_
import {AuthClientPermissionStep} from '@app/features/auth/flow/client_intro/AuthClientPermissionStep';
import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/AuthClientPreferencesStep';
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -116,13 +117,7 @@ const RegisterPageContent = observer(function RegisterPageContent({runtimeSnapsh
</h1>
<div className={sharedStyles.container} data-flx="auth.register-page.register-page-content.div">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={<Trans>Create account</Trans>}
redirectPath={redirectTo}
offerRecoveryKit={usernameSignIn}
@@ -150,7 +150,7 @@ export const OAuthCommunityStep: React.FC<OAuthCommunityStepProps> = ({
{destinationsError}
</div>
)}
{!destinationsLoading && destinationOptions.length === 0 && (
{!destinationsLoading && !destinationsError && destinationOptions.length === 0 && (
<div className={styles.emptyState} data-flx="auth.o-auth-authorize-page.community-step.empty-state">
{guildSelectorEmptyState}
</div>
@@ -10,6 +10,8 @@ import {
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
import Accounts from '@app/features/auth/state/Accounts';
import {formatList} from '@app/features/i18n/utils/IntlCache';
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
import {http} from '@app/features/platform/transport/RestTransport';
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
@@ -106,9 +108,14 @@ function getGroupDmLabel(channel: ChannelResponse, unknownGroupDmLabel: string,
return unknownGroupDmLabel;
}
export function useBotInviteDestinations(enabled: boolean, rawPermissions: string | null): BotInviteDestinationsResult {
export function useBotInviteDestinations(
enabled: boolean,
rawPermissions: string | null,
revalidate: boolean,
): BotInviteDestinationsResult {
const {i18n} = useLingui();
const currentAccountKey = Accounts.currentAccountKey;
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]);
const i18nRef = useRef(i18n);
i18nRef.current = i18n;
@@ -116,17 +123,32 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
const [rawGuilds, setRawGuilds] = useState<ReadonlyArray<GuildSummary>>([]);
const [rawChannels, setRawChannels] = useState<ReadonlyArray<ChannelResponse>>([]);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const loadedAccountKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!revalidate) return;
const bumpRevision = () => setRevision((value) => value + 1);
bumpRevision();
window.addEventListener('focus', bumpRevision);
return () => {
window.removeEventListener('focus', bumpRevision);
};
}, [revalidate]);
useEffect(() => {
if (!enabled) {
loadedAccountKeyRef.current = null;
setStatus('idle');
setRawGuilds([]);
setRawChannels([]);
setError(null);
return;
}
if (loadedAccountKeyRef.current !== currentAccountKey) {
setStatus('loading');
setError(null);
}
if (!accountWorkAdmitted) return;
let cancelled = false;
setStatus('loading');
setError(null);
(async () => {
try {
const [guildsResp, channelsResp] = await Promise.all([
@@ -136,10 +158,13 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
if (cancelled) return;
setRawGuilds(guildsResp.body);
setRawChannels(channelsResp.body.filter((channel) => channel.type === ChannelTypes.GROUP_DM));
loadedAccountKeyRef.current = currentAccountKey;
setError(null);
setStatus('ready');
} catch (err) {
if (cancelled) return;
if (cancelled || isAccountTransitionAbortError(err)) return;
logger.error('Failed to fetch bot invite destinations', err);
loadedAccountKeyRef.current = null;
setError(failureMessage(err) ?? i18nRef.current._(FAILED_TO_LOAD_INVITE_DESTINATIONS_DESCRIPTOR));
setRawGuilds([]);
setRawChannels([]);
@@ -149,7 +174,7 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
return () => {
cancelled = true;
};
}, [enabled, currentAccountKey]);
}, [enabled, currentAccountKey, accountWorkAdmitted, revision]);
const guilds = useMemo<ReadonlyArray<GuildWithPermissions>>(
() => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)),
[rawGuilds, requestedPermissions, i18n.locale],
@@ -8,6 +8,8 @@ import {
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
import accountStorage from '@app/features/auth/state/AccountStorage';
import Accounts from '@app/features/auth/state/Accounts';
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {useEffect, useState} from 'react';
@@ -23,6 +25,7 @@ export interface PublicAppState {
export function useOAuthPublicApp(clientId: string | null): PublicAppState {
const currentUserId = Accounts.currentUserId;
const currentAccountKey = Accounts.currentAccountKey;
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
const [state, setState] = useState<PublicAppState>(() => ({
status: clientId ? 'loading' : 'idle',
data: null,
@@ -33,8 +36,9 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
setState({status: 'idle', data: null, error: null});
return;
}
let cancelled = false;
setState({status: 'loading', data: null, error: null});
if (!accountWorkAdmitted) return;
let cancelled = false;
(async () => {
try {
const resp = await http.get<PublicAppData>(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId));
@@ -56,7 +60,7 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
}
setState({status: 'ready', data: resp.body, error: null});
} catch (err) {
if (cancelled) return;
if (cancelled || isAccountTransitionAbortError(err)) return;
if (err instanceof HttpError && err.status === 401) {
logger.warn('OAuth public app fetch returned 401', err);
setState({status: 'session_expired', data: null, error: err});
@@ -69,6 +73,6 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
return () => {
cancelled = true;
};
}, [clientId, currentAccountKey, currentUserId]);
}, [clientId, currentAccountKey, currentUserId, accountWorkAdmitted]);
return state;
}
@@ -185,7 +185,11 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
const initialMissingClientId = !params;
const publicAppState = useOAuthPublicApp(params?.clientId ?? null);
const scopeSelection = useScopeSelection(scopes);
const destinations = useBotInviteDestinations(hasBotScope, params?.permissions ?? null);
const destinations = useBotInviteDestinations(
hasBotScope,
params?.permissions ?? null,
phase.kind === 'review' && phase.step === 'community',
);
const selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey);
const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null;
const permissionSelection = usePermissionSelection(
@@ -280,18 +284,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
});
}, [phase.kind]);
const currentAccountKey = Accounts.currentAccountKey;
const destinationInitRef = useRef(false);
useEffect(() => {
destinationInitRef.current = false;
setSelectedDestinationKey(null);
}, [currentAccountKey]);
useEffect(() => {
if (!hasBotScope) {
destinationInitRef.current = false;
setSelectedDestinationKey(null);
return;
}
if (destinationInitRef.current) return;
if (destinations.options.some((option) => option.value === selectedDestinationKey)) return;
const requestedDestinationKey = params?.channelId
? createBotInviteDestinationKey('group_dm', params.channelId)
: params?.guildId
@@ -304,23 +305,23 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
? requestedDestinationKey
: null,
);
destinationInitRef.current = true;
return;
}
if (destinations.status !== 'ready') return;
const preferredDestinationKey = preferredDestinationKeyRef.current;
if (preferredDestinationKey && destinations.options.some((option) => option.value === preferredDestinationKey)) {
setSelectedDestinationKey(preferredDestinationKey);
destinationInitRef.current = true;
return;
}
if (destinations.options.length > 0) {
setSelectedDestinationKey(destinations.options[0].value);
destinationInitRef.current = true;
return;
}
destinationInitRef.current = true;
}, [hasBotScope, params?.guildId, params?.channelId, destinations.options, destinations.status]);
setSelectedDestinationKey(destinations.options[0]?.value ?? null);
}, [
hasBotScope,
params?.guildId,
params?.channelId,
destinations.options,
destinations.status,
selectedDestinationKey,
]);
const selectedDestination = useMemo(
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
[destinations.options, selectedDestinationKey],
@@ -121,7 +121,7 @@ export interface AuthLoginLayoutProps {
readonly registerLink: ReactElement<Record<string, unknown>>;
readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise<void> | void) | null;
readonly onBackActionChange?: ((action: (() => void) | null) => void) | null;
readonly completeLoginRedirectPath?: string | null;
readonly completeLoginRedirectPath: string | null;
readonly forceCredentials?: boolean;
readonly startWithAddAccount?: boolean;
readonly runtimeTarget: AuthRuntimeTarget;
@@ -343,15 +343,6 @@ function resolveMethodSsoAction({
return {isStarting, label, onStart};
}
function resolveCompleteLoginOptions(
completeLoginRedirectPath: string | null,
): AuthenticationCommands.CompleteLoginOptions {
if (completeLoginRedirectPath == null) {
return {};
}
return {redirectPath: completeLoginRedirectPath};
}
function resolveAuthBackAction({
accountListBackAction,
authLoginStep,
@@ -400,7 +391,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
registerLink,
onLoginComplete = null,
onBackActionChange = null,
completeLoginRedirectPath = null,
completeLoginRedirectPath,
forceCredentials = false,
startWithAddAccount = false,
runtimeTarget,
@@ -509,7 +500,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
}
await AuthenticationCommands.completeLogin(
{...payload, runtimeSnapshot: requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot)},
resolveCompleteLoginOptions(completeLoginRedirectPath),
{redirectPath: completeLoginRedirectPath},
);
if (onLoginComplete != null) {
await onLoginComplete(payload);
@@ -572,7 +563,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
);
const handleStoredAccountLogin = useCallback(
async (payload: StoredAccountLoginPayload) => {
await AuthenticationCommands.completeLogin(payload, resolveCompleteLoginOptions(completeLoginRedirectPath));
await AuthenticationCommands.completeLogin(payload, {redirectPath: completeLoginRedirectPath});
if (onLoginComplete != null) {
await onLoginComplete(payload);
}
@@ -7,6 +7,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
import FormField from '@app/features/auth/flow/AuthFormField';
import styles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {DateOfBirthField} from '@app/features/auth/flow/DateOfBirthField';
import {type RegisterFormFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {
getRegistrationLegalConsentConfig,
RegistrationLegalConsent,
@@ -83,17 +84,8 @@ const USERNAME_AVAILABLE_DESCRIPTOR = msg({
const MAX_USERNAME_LENGTH = 32;
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
interface FieldConfig {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsername?: boolean;
showUsernameValidation?: boolean;
requireUsername?: boolean;
}
interface AuthRegisterFormCoreProps {
fields?: FieldConfig;
fields?: RegisterFormFields;
submitLabel: React.ReactNode;
redirectPath: string;
runtimeSnapshot: RuntimeConfigSnapshot;
@@ -136,10 +128,10 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
const firstFieldName = showEmail ? 'email' : requireUsername ? 'username' : 'global_name';
const location = useLocation();
const draftKey = `register:${location.pathname}${location.search}`;
const registrationUrlCode = useMemo(() => {
const value = new URLSearchParams(location.search).get('registration_url')?.trim();
return value || undefined;
}, [location.search]);
const registrationUrlCode = useMemo(
() => readRegistrationUrlCode(new URLSearchParams(location.search)),
[location.search],
);
const registrationTarget = useMemo(() => instanceTargetFromSnapshot(runtimeSnapshot), [runtimeSnapshot]);
const isPublicRegistrationClosed = runtimeSnapshot.registration.mode === 'closed' && !registrationUrlCode;
const collectDateOfBirth = runtimeSnapshot.appPublic.registration.collect_date_of_birth;
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {
credentialRegisterFields,
linkRegisterFields,
readRegistrationUrlCode,
} from '@app/features/auth/flow/RegisterFormFields';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import type {InstanceRegistration} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {describe, expect, it} from 'vitest';
function snapshot(usernameSignIn: boolean, mode: InstanceRegistration['mode']): RuntimeConfigSnapshot {
return {
features: {account_identity: usernameSignIn ? AccountIdentityModes.USERNAME : AccountIdentityModes.EMAIL},
registration: {mode, admin_registration_urls_enabled: false},
} as RuntimeConfigSnapshot;
}
const EMAIL_CREDENTIALS = {
showEmail: true,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: false,
};
const USERNAME_CREDENTIALS = {
showEmail: false,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
};
describe('register form fields', () => {
it('asks for credentials on the full register form in both sign-in modes', () => {
expect(credentialRegisterFields(false)).toEqual(EMAIL_CREDENTIALS);
expect(credentialRegisterFields(true)).toEqual(USERNAME_CREDENTIALS);
});
it('keeps link sign-ups minimal only where the account can sign in right away', () => {
expect(linkRegisterFields(snapshot(false, 'open'), undefined)).toEqual({showUsername: false});
expect(linkRegisterFields(snapshot(false, 'closed'), undefined)).toEqual({showUsername: false});
expect(linkRegisterFields(snapshot(false, 'approval'), undefined)).toEqual(EMAIL_CREDENTIALS);
});
it('asks for credentials on link sign-ups through a registration URL, which can require approval', () => {
const code = readRegistrationUrlCode(new URLSearchParams('?registration_url=%20abc%20'));
expect(code).toBe('abc');
for (const mode of ['open', 'approval', 'closed'] as const) {
expect(linkRegisterFields(snapshot(false, mode), code)).toEqual(EMAIL_CREDENTIALS);
}
expect(readRegistrationUrlCode(new URLSearchParams('?registration_url=%20'))).toBeUndefined();
});
it('always asks for a username and password on link sign-ups of a username instance', () => {
for (const mode of ['open', 'approval', 'closed'] as const) {
expect(linkRegisterFields(snapshot(true, mode), undefined)).toEqual(USERNAME_CREDENTIALS);
}
});
});
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
export interface RegisterFormFields {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsername?: boolean;
showUsernameValidation?: boolean;
requireUsername?: boolean;
}
export function credentialRegisterFields(usernameSignIn: boolean): RegisterFormFields {
return {
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
};
}
export function readRegistrationUrlCode(params: URLSearchParams): string | undefined {
return params.get('registration_url')?.trim() || undefined;
}
export function linkRegisterFields(
snapshot: RuntimeConfigSnapshot,
registrationUrlCode: string | undefined,
): RegisterFormFields {
const usernameSignIn = usesUsernameSignIn(snapshot.features);
if (usernameSignIn || registrationUrlCode || snapshot.registration.mode === 'approval') {
return credentialRegisterFields(usernameSignIn);
}
return {showUsername: false};
}
@@ -13,7 +13,12 @@ import {renderToStaticMarkup} from 'react-dom/server';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const mocks = vi.hoisted(() => ({
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7, features: {}} as RuntimeConfigSnapshot,
snapshot: {
apiEndpoint: 'https://instance.example/api',
apiCodeVersion: 7,
features: {},
registration: {mode: 'open', admin_registration_urls_enabled: false},
} as RuntimeConfigSnapshot,
reset: vi.fn(),
completeLogin: vi.fn(),
search: '',
@@ -110,6 +115,7 @@ beforeEach(() => {
mocks.search = '';
mocks.loginProps = mocks.registerProps = mocks.ssoProps = mocks.carouselProps = null;
delete (mocks.snapshot as any).sso;
(mocks.snapshot as any).registration = {mode: 'open', admin_registration_urls_enabled: false};
});
describe('resource auth guards', () => {
it('blocks redeemed resources before login, registration or enforced SSO', () => {
@@ -207,4 +213,21 @@ describe('resource auth guards', () => {
expect(mocks.ssoProps.redirectPath).toBe('/gift/code/register');
expect(mocks.registerProps).toBeNull();
});
it('asks for an email and password on an approval instance', () => {
(mocks.snapshot as any).registration = {mode: 'approval', admin_registration_urls_enabled: false};
renderToStaticMarkup(
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--8" />,
);
expect(mocks.registerProps.fields).toMatchObject({
showEmail: true,
showPassword: true,
showPasswordConfirmation: true,
});
});
it('keeps the minimal form on an open email instance', () => {
renderToStaticMarkup(
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--9" />,
);
expect(mocks.registerProps.fields).toEqual({showUsername: false});
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
@@ -16,6 +17,7 @@ import {AuthRuntimeTargetResetAction} from '@app/features/auth/flow/AuthRuntimeT
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -141,7 +143,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
showTitle={false}
title={null}
onBackActionChange={null}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -187,16 +189,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
/>
) : (
<AuthRegisterFormCore
fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
submitLabel={config.submitLabel}
redirectPath={config.registerRedirectPath(id, safeRedirect)}
offerRecoveryKit={usernameSignIn}
@@ -8,7 +8,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowClockwiseIcon} from '@phosphor-icons/react';
import {ArrowClockwiseIcon, DownloadSimpleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback} from 'react';
@@ -67,6 +67,10 @@ export const UpdaterIcon = observer(() => {
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: RuntimeConfig.productName})
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: RuntimeConfig.productName});
}
const Icon =
affordance === 'manual' || (affordance === 'desktop' && Updater.desktopUpdateReplacesShell)
? DownloadSimpleIcon
: ArrowClockwiseIcon;
return (
<Tooltip text={tooltip} position="bottom" data-flx="channel.channel-header-components.updater-icon.tooltip">
<FocusRing offset={-2} data-flx="channel.channel-header-components.updater-icon.focus-ring">
@@ -79,7 +83,7 @@ export const UpdaterIcon = observer(() => {
aria-label={tooltip}
data-flx="channel.channel-header-components.updater-icon.button.click"
>
<ArrowClockwiseIcon
<Icon
weight="bold"
className={affordance === 'updating' ? styles.updateIconSpinning : styles.updateIcon}
data-flx="channel.channel-header-components.updater-icon.update-icon"
@@ -137,8 +137,7 @@
justify-content: center;
background-color: var(--background-modifier-accent);
color: var(--text-primary);
font-weight: 700;
font-size: 0.75rem;
overflow: hidden;
}
.optionLabel {
@@ -0,0 +1,27 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.acronym {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: inherit;
container-type: inline-size;
user-select: none;
-webkit-user-select: none;
}
.text {
flex: 0 0 auto;
font-size: max(0.5rem, calc(var(--guild-acronym-font-scale) * 100cqi));
font-style: normal;
font-weight: 500;
letter-spacing: normal;
line-height: 1;
text-align: center;
text-transform: none;
white-space: nowrap;
}
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/guild/components/GuildAcronym.module.css';
import {
GUILD_ACRONYM_FALLBACK,
getFittedGuildAcronymFontScale,
getGuildAcronym,
} from '@app/features/guild/utils/GuildAcronymUtils';
import {clsx} from 'clsx';
import type React from 'react';
import {useMemo} from 'react';
interface GuildAcronymProps {
readonly name: string;
readonly className?: string;
readonly 'data-flx'?: string;
}
type GuildAcronymStyle = React.CSSProperties & {
'--guild-acronym-font-scale': string;
};
export function GuildAcronym({name, className, 'data-flx': dataFlx}: GuildAcronymProps) {
const acronym = useMemo(() => getGuildAcronym(name) || GUILD_ACRONYM_FALLBACK, [name]);
const style = useMemo<GuildAcronymStyle>(
() => ({'--guild-acronym-font-scale': getFittedGuildAcronymFontScale(acronym).toFixed(4)}),
[acronym],
);
return (
<span className={clsx(styles.acronym, className)} style={style} data-flx={dataFlx ?? 'guild.guild-acronym.acronym'}>
<span className={styles.text} data-flx="guild.guild-acronym.text">
{acronym}
</span>
</span>
);
}
@@ -131,35 +131,7 @@
border-radius: 50%;
border: 0.0625rem solid var(--background-modifier-accent);
background: var(--background-tertiary);
container-type: size;
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
--initials-fit-inline-size: 92cqi;
--initials-letter-spacing: 0px;
--initials-scale: 1;
}
.iconInitials {
user-select: none;
-webkit-user-select: none;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
white-space: nowrap;
display: inline-block;
text-align: center;
font-size: min(
var(--initials-font-size),
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
);
letter-spacing: var(--initials-letter-spacing);
transform: scale(var(--initials-scale));
transform-origin: center;
}
.iconPlaceholder[data-initials-length='medium'] .iconInitials {
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
--initials-letter-spacing: 0px;
--initials-scale: 0.92;
}
.iconActions {
@@ -13,18 +13,13 @@ import {openAssetSourceModal} from '@app/features/expressions/components/modals/
import {isAnimatedFile} from '@app/features/expressions/utils/AnimatedImageUtils';
import {formatImageUploadRecommendedHint} from '@app/features/expressions/utils/AssetUploadHintCopy';
import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions/utils/ImageUploadFileUtils';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import styles from '@app/features/guild/components/modals/AddGuildModal.module.css';
import {
ANIMATED_ICONS_ARE_NOT_SUPPORTED_WHEN_CREATING_A_DESCRIPTOR,
COMMUNITY_NAME_DESCRIPTOR,
ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR,
} from '@app/features/guild/components/modals/add_guild_modal/shared';
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
import {
getGuildIconDisplayInitials,
getGuildInitialsFitStyle,
getInitialsLength,
} from '@app/features/guild/utils/GuildInitialsUtils';
import {
FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR,
INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR,
@@ -45,7 +40,7 @@ import {ph} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {EnvelopeSimpleIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useCallback, useMemo, useState} from 'react';
import {useCallback, useState} from 'react';
import type {UseFormReturn} from 'react-hook-form';
interface GuildCreateFormValues {
@@ -72,12 +67,7 @@ export function useGuildCreateIconField({
}: GuildCreateIconFieldOptions) {
const {i18n} = useLingui();
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
const initials = useMemo(() => {
const raw = (nameValue || '').trim();
if (!raw) return '';
return getGuildIconDisplayInitials(StringUtils.getInitialsFromName(raw));
}, [nameValue]);
const initialsLength = initials ? getInitialsLength(initials) : null;
const iconName = (nameValue || '').trim();
const showIconUploadErrorModal = useCallback(
(message: string) => {
showGenericErrorModal({
@@ -156,7 +146,7 @@ export function useGuildCreateIconField({
showGifOption: false,
});
}, [handleIconUpload, i18n, messages]);
return {previewIconUrl, initials, initialsLength, handleOpenIconUpload, handleClearIcon};
return {previewIconUrl, iconName, handleOpenIconUpload, handleClearIcon};
}
export function renderGuildCreateAccountGate(i18n: I18n, dataFlx: string): React.ReactNode {
@@ -224,8 +214,7 @@ export function renderGuildCreateFields({
nameInputDataFlx,
showIconError,
previewIconUrl,
initials,
initialsLength,
iconName,
handleOpenIconUpload,
handleClearIcon,
}: GuildCreateFieldsOptions): React.ReactNode {
@@ -244,20 +233,8 @@ export function renderGuildCreateFields({
data-flx={`${dataFlx}.icon-image`}
/>
) : (
<div
className={styles.iconPlaceholder}
data-initials-length={initialsLength}
data-flx={`${dataFlx}.icon-placeholder`}
>
{initials ? (
<span
className={styles.iconInitials}
style={getGuildInitialsFitStyle(initials)}
data-flx={`${dataFlx}.icon-initials`}
>
{initials}
</span>
) : null}
<div className={styles.iconPlaceholder} data-flx={`${dataFlx}.icon-placeholder`}>
{iconName ? <GuildAcronym name={iconName} data-flx={`${dataFlx}.icon-acronym`} /> : null}
</div>
)}
<div className={styles.iconActions} data-flx={`${dataFlx}.icon-actions`}>
@@ -11,54 +11,14 @@
justify-content: center;
overflow: hidden;
border-radius: 624.9375rem;
font-weight: 600;
color: var(--text-tertiary);
background-color: var(--background-tertiary);
background-image: var(--guild-icon-image, none);
background-position: center;
background-size: cover;
container-type: inline-size;
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
--initials-fit-inline-size: 92cqi;
--initials-letter-spacing: 0px;
--initials-scale: 1;
--initials-scale-cap: 1;
}
.containerNoIcon {
background-image: none;
background-color: var(--background-tertiary);
}
.initials {
align-self: center;
line-height: 1;
white-space: nowrap;
display: inline-block;
text-align: center;
font-size: min(
var(--initials-font-size),
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
);
letter-spacing: var(--initials-letter-spacing);
transform: scale(calc(var(--initials-scale) * var(--initials-scale-cap)));
transform-origin: center;
}
.container[data-initials-length='medium'] .initials {
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
--initials-scale: 0.92;
--initials-letter-spacing: 0px;
}
@container (max-width: 32px) {
.initials {
--initials-scale-cap: 0.88;
}
}
@container (max-width: 26px) {
.initials {
--initials-scale-cap: 0.78;
}
}
@@ -2,13 +2,8 @@
import {useHover} from '@app/features/app/hooks/useHover';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import styles from '@app/features/guild/components/popouts/GuildIcon.module.css';
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
import {
getGuildIconDisplayInitials,
getGuildInitialsFitStyle,
getInitialsLength,
} from '@app/features/guild/utils/GuildInitialsUtils';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -16,7 +11,7 @@ import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useMemo, useState} from 'react';
import {useEffect, useState} from 'react';
interface GuildIconProps {
id: string;
@@ -43,8 +38,6 @@ export const GuildIcon = observer(function GuildIcon({
sizePx,
containerProps,
}: GuildIconProps) {
const initials = useMemo(() => getGuildIconDisplayInitials(StringUtils.getInitialsFromName(name)), [name]);
const initialsLength = getInitialsLength(initials);
const [hoverRef, isHovering] = useHover();
const iconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}) : null;
const hoverIconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}, true) : null;
@@ -72,18 +65,9 @@ export const GuildIcon = observer(function GuildIcon({
className={clsx(styles.container, className, paintedUrl == null && styles.containerNoIcon)}
data-flx="guild.guild-icon.container"
{...containerProps}
data-initials-length={initialsLength}
style={styleVars}
>
{paintedUrl == null && (
<span
className={styles.initials}
style={getGuildInitialsFitStyle(initials)}
data-flx="guild.guild-icon.initials"
>
{initials}
</span>
)}
{paintedUrl == null && <GuildAcronym name={name} data-flx="guild.guild-icon.acronym" />}
</div>
);
});
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getGuildAcronym,
getGuildAcronymFontScale,
getGuildAcronymLength,
} from '@app/features/guild/utils/GuildAcronymUtils';
import {describe, expect, test} from 'vitest';
const ACRONYM_VECTORS: ReadonlyArray<readonly [name: string, acronym: string, length: number]> = [
['Movies? We Like Movies', 'M?WLM', 5],
['Movies Movies', 'MM', 2],
['(Internal) Fluxer DevOps', '(I)FD', 5],
['secret server', 'ss', 2],
['Hampus server', 'Hs', 2],
['Fluxer', 'F', 1],
[' Fluxer ', 'F', 1],
['a b\tc\nd', 'abcd', 4],
["Bob's Burgers", 'BB', 2],
['Bob’s Burgers', 'BB', 2],
["Bob's", "B's", 3],
["Don't Panic", "D'tP", 4],
['Area 51', 'A5', 2],
['2024 Reunion', '2R', 2],
['Rock-n-Roll', 'R-n-R', 5],
['Spider-Man Fans', 'S-MF', 4],
['snake_case_name', 's', 1],
['C++ & Rust', 'C++&R', 5],
['@everyone', '@e', 2],
['!!!', '!!!', 3],
['\u{1F3AE}\u{1F3B2}', '\u{1F3AE}\u{1F3B2}', 2],
['\u{1F496}tes tes', '\u{1F496}tt', 3],
['❤️love', '❤️l', 2],
['\u{1F468}‍\u{1F469}‍\u{1F467} Family', '\u{1F468}‍\u{1F469}‍\u{1F467}F', 2],
['\u{1F1F8}\u{1F1EA} Sweden', '\u{1F1F8}\u{1F1EA}S', 2],
['1️⃣ One', '1️⃣O', 2],
['日本語 サーバー', '日サ', 2],
['原神交流群', '原', 1],
['한국 서버', '한서', 2],
['Привет Мир', 'ПМ', 2],
['مرحبا بالعالم', 'مب', 2],
['שלום עולם', 'שע', 2],
['می‌خواهم بازی', 'مب', 2],
['École Normale', 'ÉN', 2],
['नमस्ते दुनिया', 'नदु', 2],
['one two three four five six seven eight nine ten', 'ottffssent', 10],
['', '', 0],
[' \t  ', '', 0],
];
const FONT_SCALE_VECTORS: ReadonlyArray<readonly [length: number, fontSizePxAt48: number]> = [
[0, 18],
[1, 18],
[2, 18],
[3, 16],
[4, 14],
[5, 12],
[6, 10],
[10, 10],
];
describe('getGuildAcronym', () => {
test.each(ACRONYM_VECTORS)('%j -> %j', (name, acronym, length) => {
expect(getGuildAcronym(name)).toBe(acronym);
expect(getGuildAcronymLength(acronym)).toBe(length);
});
});
describe('getGuildAcronymFontScale', () => {
test.each(FONT_SCALE_VECTORS)('%i graphemes -> %ipx on a 48px icon', (length, fontSizePx) => {
expect(getGuildAcronymFontScale(length) * 48).toBeCloseTo(fontSizePx);
});
});
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const POSSESSIVE_SUFFIX = /['’]s(?=\s)/gu;
const WORD_RUN = /([\p{L}\p{N}_]\p{M}*)[\p{L}\p{N}\p{M}_‌‍]*/gu;
const WHITESPACE = /\s+/gu;
const GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX = 48;
const GUILD_ACRONYM_FONT_SIZES_PX = [18, 18, 16, 14, 12, 10];
const GUILD_ACRONYM_FIT_PADDING_PX = 4;
const GUILD_ACRONYM_FIT_WIDTH_RATIO =
(GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX - GUILD_ACRONYM_FIT_PADDING_PX * 2) / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
const GUILD_ACRONYM_MEASURE_FONT_SIZE_PX = 100;
const GUILD_ACRONYM_WIDTH_CACHE_CAPACITY = 512;
const acronymWidthCache = new Map<string, number>();
let measureContext: CanvasRenderingContext2D | null | undefined;
export const GUILD_ACRONYM_FALLBACK = '?';
const graphemeSegmenter =
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, {granularity: 'grapheme'})
: null;
function firstGrapheme(wordRun: string, leadingCharacterWithMarks: string): string {
if (graphemeSegmenter == null) return leadingCharacterWithMarks;
for (const {segment} of graphemeSegmenter.segment(wordRun)) {
return segment;
}
return leadingCharacterWithMarks;
}
export function getGuildAcronym(name: string): string {
return name.replace(POSSESSIVE_SUFFIX, '').replace(WORD_RUN, firstGrapheme).replace(WHITESPACE, '');
}
export function getGuildAcronymLength(acronym: string): number {
if (graphemeSegmenter == null) return Array.from(acronym).length;
let length = 0;
for (const _segment of graphemeSegmenter.segment(acronym)) {
length += 1;
}
return length;
}
export function getGuildAcronymFontScale(acronymLength: number): number {
const index = Math.min(Math.max(acronymLength, 1), GUILD_ACRONYM_FONT_SIZES_PX.length) - 1;
return GUILD_ACRONYM_FONT_SIZES_PX[index] / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
}
function getMeasureContext(): CanvasRenderingContext2D | null {
if (measureContext !== undefined) return measureContext;
if (typeof document === 'undefined' || document.body == null) return null;
const context = document.createElement('canvas').getContext('2d');
if (context != null) {
context.font = `500 ${GUILD_ACRONYM_MEASURE_FONT_SIZE_PX}px ${getComputedStyle(document.body).fontFamily}`;
}
measureContext = context;
return context;
}
function getGuildAcronymWidthPerEm(acronym: string): number | null {
const cached = acronymWidthCache.get(acronym);
if (cached !== undefined) return cached;
const context = getMeasureContext();
if (context == null) return null;
const widthPerEm = context.measureText(acronym).width / GUILD_ACRONYM_MEASURE_FONT_SIZE_PX;
if (acronymWidthCache.size >= GUILD_ACRONYM_WIDTH_CACHE_CAPACITY) acronymWidthCache.clear();
acronymWidthCache.set(acronym, widthPerEm);
return widthPerEm;
}
export function fitGuildAcronymFontScale(acronymLength: number, widthPerEm: number | null): number {
const scale = getGuildAcronymFontScale(acronymLength);
if (widthPerEm == null || widthPerEm <= 0) return scale;
const maxScale = GUILD_ACRONYM_FIT_WIDTH_RATIO / widthPerEm;
if (scale <= maxScale) return scale;
for (const fontSize of GUILD_ACRONYM_FONT_SIZES_PX) {
const candidate = fontSize / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
if (candidate < scale && candidate <= maxScale) return candidate;
}
return maxScale;
}
export function getFittedGuildAcronymFontScale(acronym: string): number {
return fitGuildAcronymFontScale(getGuildAcronymLength(acronym), getGuildAcronymWidthPerEm(acronym));
}
@@ -1,76 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type React from 'react';
const GUILD_ICON_INITIALS_MAX_LENGTH = 4;
const MEASURE_FONT_SIZES_PX = [10, 12, 14, 16, 20, 24];
const MEASURE_FONT_WEIGHT = 600;
export const GuildInitialsLength = Object.freeze({
SHORT: 'short',
MEDIUM: 'medium',
} as const);
export type GuildInitialsLength = (typeof GuildInitialsLength)[keyof typeof GuildInitialsLength];
export type GuildInitialsFitStyle = React.CSSProperties & {
'--initials-width-em'?: string;
};
const measuredWidthsEm = new Map<string, number>();
let measureContext: OffscreenCanvasRenderingContext2D | null | undefined;
export function getInitialsFromName(name: string): string {
const words = name.split(/\s+/u).filter(Boolean);
return words
.map((word) => Array.from(word)[0])
.filter(Boolean)
.join('');
}
export function truncateInitials(initials: string, maxLength: number): string {
if (maxLength <= 0) return '';
return Array.from(initials).slice(0, maxLength).join('');
}
export function getGuildIconDisplayInitials(initials: string): string {
return truncateInitials(initials, GUILD_ICON_INITIALS_MAX_LENGTH);
}
export function getInitialsLength(displayInitials: string): GuildInitialsLength {
return Array.from(displayInitials).length <= 2 ? GuildInitialsLength.SHORT : GuildInitialsLength.MEDIUM;
}
function getMeasureContext(): OffscreenCanvasRenderingContext2D | null {
if (measureContext !== undefined) return measureContext;
if (typeof document === 'undefined' || typeof OffscreenCanvas === 'undefined') {
measureContext = null;
return measureContext;
}
measureContext = new OffscreenCanvas(1, 1).getContext('2d');
document.fonts?.addEventListener('loadingdone', () => measuredWidthsEm.clear());
return measureContext;
}
function measureInitialsWidthEm(initials: string): number | null {
const context = getMeasureContext();
if (context == null || document.body == null) return null;
const fontFamily = getComputedStyle(document.body).fontFamily;
const key = `${fontFamily}\u0000${initials}`;
const cached = measuredWidthsEm.get(key);
if (cached !== undefined) return cached;
let widthEm = 0;
for (const fontSize of MEASURE_FONT_SIZES_PX) {
context.font = `${MEASURE_FONT_WEIGHT} ${fontSize}px ${fontFamily}`;
widthEm = Math.max(widthEm, context.measureText(initials).width / fontSize);
}
measuredWidthsEm.set(key, widthEm);
return widthEm;
}
export function getGuildInitialsFitStyle(displayInitials: string): GuildInitialsFitStyle | undefined {
if (!displayInitials) return undefined;
const widthEm = measureInitialsWidthEm(displayInitials);
if (widthEm == null || widthEm <= 0) return undefined;
return {'--initials-width-em': widthEm.toFixed(3)};
}
@@ -63,7 +63,7 @@ const InviteLoginPage = observer(function InviteLoginPage({code, invite}: Invite
title={null}
onLoginComplete={runtimeTarget.reset}
onBackActionChange={null}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -10,6 +10,7 @@ import {AuthRuntimeTargetGate} from '@app/features/auth/flow/AuthRuntimeTargetGa
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
import {InviteHeader} from '@app/features/auth/flow/InviteHeader';
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -104,16 +105,7 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
<InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" />
<div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4">
<AuthRegisterFormCore
fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
redirectPath="/"
offerRecoveryKit={usernameSignIn}
@@ -61,6 +61,12 @@ function findNextEmojiToken(text: string, startIndex: number): EmojiToken | null
return surrogate.start < shortcode.start ? {...surrogate, surrogate: true} : {...shortcode, surrogate: false};
}
interface EmojiTokenEdit {
start: number;
end: number;
replacement: ResolvedTypedEmoji | string;
}
function isEscapedAt(text: string, index: number): boolean {
let backslashes = 0;
let cursor = index - 1;
@@ -80,30 +86,27 @@ function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver):
return;
}
const text = node.getTextContent();
const edits: Array<EmojiTokenEdit> = [];
let searchFrom = 0;
let backticks = 0;
while (true) {
const match = findNextEmojiToken(text, searchFrom);
if (match == null) {
return;
break;
}
const codeMarkers = text.slice(0, match.start).match(/`/g);
if ((codeMarkers == null ? 0 : codeMarkers.length) % 2 === 1) {
return;
for (let index = searchFrom; index < match.start; index++) {
if (text[index] === '`') {
backticks += 1;
}
}
searchFrom = match.end;
if (backticks % 2 === 1) {
continue;
}
if (isEscapedAt(text, match.start)) {
if (match.surrogate) {
const shortcodeText = `:${match.name}:`;
const selection = $captureSelectionOffsets();
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
const replaced = `${text.slice(0, match.start)}${shortcodeText}${text.slice(match.end)}`;
node.setTextContent(replaced);
if (selection != null) {
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, shortcodeText.length);
$selectComposerRange(adjusted.anchor, adjusted.focus);
}
return;
edits.push({start: match.start, end: match.end, replacement: `:${match.name}:`});
}
searchFrom = match.end;
continue;
}
const previous = match.start === 0 ? node.getPreviousSibling() : null;
@@ -143,28 +146,48 @@ function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver):
}
}
const resolved = resolve(match.name);
if (resolved == null) {
searchFrom = match.end;
continue;
}
const selection = $captureSelectionOffsets();
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
const emojiNode =
resolved.kind === 'standard'
? $createComposerStandardEmojiNode(resolved.name, resolved.surrogate, resolved.url, resolved.display)
: $createComposerCustomEmojiNode(resolved.emojiId, resolved.animated, resolved.display, resolved.wire);
const segments = node.splitText(match.start, match.end);
const target = segments[match.start > 0 ? 1 : 0];
if (target == null) {
return;
}
target.replace(emojiNode);
if (selection != null) {
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, resolved.display.length);
$selectComposerRange(adjusted.anchor, adjusted.focus);
if (resolved != null) {
edits.push({start: match.start, end: match.end, replacement: resolved});
}
}
if (edits.length === 0) {
return;
}
let selection = $captureSelectionOffsets();
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
for (let index = edits.length - 1; index >= 0; index--) {
const {start, end, replacement} = edits[index]!;
if (typeof replacement === 'string') {
node.spliceText(start, end - start, replacement);
} else {
const emojiNode =
replacement.kind === 'standard'
? $createComposerStandardEmojiNode(
replacement.name,
replacement.surrogate,
replacement.url,
replacement.display,
)
: $createComposerCustomEmojiNode(
replacement.emojiId,
replacement.animated,
replacement.display,
replacement.wire,
);
const target = node.splitText(start, end)[start > 0 ? 1 : 0];
if (target == null) {
break;
}
target.replace(emojiNode);
}
if (selection != null) {
const newLen = typeof replacement === 'string' ? replacement.length : replacement.display.length;
selection = $adjustEmbedCaret(selection, nodeDisplayStart, start, end, newLen);
}
}
if (selection != null) {
$selectComposerRange(selection.anchor, selection.focus);
}
}
function $adjustEmbedCaret(
@@ -14,10 +14,9 @@ import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/Ch
import DeveloperMode from '@app/features/devtools/state/DeveloperMode';
import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands';
import DiscoveryChannelPreviews from '@app/features/discovery/state/DiscoveryChannelPreviews';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {
COPY_LINK_DESCRIPTOR,
OKAY_DESCRIPTOR,
@@ -228,7 +227,6 @@ function CompactGuildMentionIcon({guild}: {guild: Pick<Guild, 'id' | 'name' | 'i
const style: CompactGuildMentionIconStyle | undefined = iconUrl
? {'--jump-link-guild-icon-image': `url(${iconUrl})`}
: undefined;
const initials = getGuildIconDisplayInitials(StringUtils.getInitialsFromName(guild.name));
return (
<span
className={clsx(jumpLinkStyles.icon, jumpLinkStyles.guildIcon)}
@@ -238,12 +236,10 @@ function CompactGuildMentionIcon({guild}: {guild: Pick<Guild, 'id' | 'name' | 'i
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon"
>
{!iconUrl && (
<span
className={jumpLinkStyles.guildInitials}
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon.initials"
>
{initials}
</span>
<GuildAcronym
name={guild.name}
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon.acronym"
/>
)}
</span>
);
@@ -59,21 +59,6 @@
background-position: center;
background-size: cover;
color: var(--text-primary);
font-weight: 600;
line-height: 1;
}
.guildInitials {
display: grid;
place-items: center;
width: 100%;
height: 100%;
font-size: 0.56em;
line-height: 1;
max-width: 100%;
overflow: hidden;
text-align: center;
white-space: nowrap;
}
.divider {
@@ -766,6 +766,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
return (
<Modal.Root
size="medium"
centered
initialFocusRef={autoFocusNote ? noteRef : undefined}
className={userProfileModalStyles.modalRoot}
data-flx="user.user-profile-modal.modal-root"
@@ -30,7 +30,10 @@ import * as UserCommands from '@app/features/user/commands/UserCommands';
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
import styles from '@app/features/user/components/modals/tabs/MyProfileTab.module.css';
import {AccentColorPicker} from '@app/features/user/components/modals/tabs/my_profile_tab/AccentColorPicker';
import {AvatarUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
import {
AvatarUploader,
useChangeAvatarAction,
} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
import {BannerUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/BannerUploader';
import {BIO_MARKDOWN_PARSER_FLAGS, BioEditor} from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor';
import {UsernameSection} from '@app/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection';
@@ -615,6 +618,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
form.setValue('avatar', null);
transitionAvatarAsset({type: 'asset.cleared'});
}, [form, transitionAvatarAsset]);
const {openChangeAvatar} = useChangeAvatarAction({onAvatarChange: handleAvatarChange});
const handleBannerChange = useCallback(
(base64: string) => {
form.setValue('banner', base64);
@@ -674,6 +678,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
const guilds = Guilds.getGuilds();
const selectedGuild = selectedGuildId ? guilds.find((g) => g.id === selectedGuildId) : null;
const isPerGuildProfileCustomizationDisabled = isPerGuildProfile && !hasPerGuildProfiles;
const isAvatarCustomizationDisabled = isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled;
const isPronounsDisabled = isProfileCustomizationLocked;
const profileCustomizationDescription = isProfileEmailLocked
? isPerGuildProfile
@@ -840,7 +845,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
hasAvatar={hasAvatar}
onAvatarChange={handleAvatarChange}
onAvatarClear={handleAvatarClear}
disabled={isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled}
disabled={isAvatarCustomizationDisabled}
disableModeSelection={isProfileCustomizationLocked}
isPerGuildProfile={isPerGuildProfile}
errorMessage={form.formState.errors.avatar?.message}
@@ -934,6 +939,9 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
}}
ignoreGuildAvatarInPreview={isPerGuildProfile && avatarAsset.mode === 'inherit'}
ignoreGuildBannerInPreview={isPerGuildProfile && bannerAsset.mode === 'inherit'}
onAvatarClick={
mobileLayout.enabled && !isAvatarCustomizationDisabled ? openChangeAvatar : undefined
}
data-flx="user.my-profile-tab.my-profile-tab-component.profile-preview"
/>
</div>
@@ -203,8 +203,7 @@
border: 0.0625rem solid var(--entrance-sound-border);
background: color-mix(in srgb, #0f1014 80%, var(--background-header-secondary) 20%);
color: white;
font-size: 0.75rem;
font-weight: 700;
overflow: hidden;
}
.scopeTileText {
@@ -3,6 +3,7 @@
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import Guilds from '@app/features/guild/state/Guilds';
import {DIRECT_MESSAGES_DESCRIPTOR, GET_PREMIUM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
@@ -186,7 +187,6 @@ interface ScopeArtworkProps {
function ScopeArtwork({option, selected, compact}: ScopeArtworkProps): React.ReactElement {
if (option.guildId) {
const iconUrl = option.guildIcon ? AvatarUtils.getGuildIconURL({id: option.guildId, icon: option.guildIcon}) : null;
const initial = option.label.trim().charAt(0).toUpperCase() || '?';
return iconUrl ? (
<span
className={clsx(styles.scopeAvatar, compact && styles.scopeAvatarCompact)}
@@ -200,7 +200,10 @@ function ScopeArtwork({option, selected, compact}: ScopeArtworkProps): React.Rea
aria-hidden
data-flx="user.entrance-sound-section.scope-artwork.scope-avatar-placeholder"
>
{initial}
<GuildAcronym
name={option.label}
data-flx="user.entrance-sound-section.scope-artwork.scope-avatar-placeholder-acronym"
/>
</span>
);
}
@@ -116,6 +116,203 @@ interface AvatarUploaderProps {
onAvatarModeChange?: (mode: AvatarMode) => void;
}
interface ChangeAvatarActionOptions {
onAvatarChange: (base64: string) => void;
requireAnimatedAvatarEntitlement?: boolean;
}
export function useChangeAvatarAction({
onAvatarChange,
requireAnimatedAvatarEntitlement = true,
}: ChangeAvatarActionOptions) {
const {i18n} = useLingui();
const PREMIUM_PRODUCT_NAME = getPremiumProductName();
const hasAnimatedAvatarEntitlement = isLimitToggleEnabled(
{feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})},
'feature_animated_avatar',
);
const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement;
const processAvatarFile = useCallback(
async (file: File) => {
if (file.size > GlobalLimits.getAvatarMaxSize()) {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
}),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
return;
}
const svg = isSvgFile(file);
if (!svg && !(await canCropFile(file))) {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
return;
}
const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file);
if (animated && !canUploadAnimatedAvatar) {
if (shouldShowPremiumFeatures()) {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
description={
<>
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to {PREMIUM_PRODUCT_NAME}{' '}
subscribers.
</Trans>
</p>
<p
className={styles.spacedParagraph}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
>
{RuntimeConfig.usesUniqueUsernames ? (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
) : (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
)}
</p>
</>
}
primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
primaryVariant="primary"
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
onPrimary={() => {
window.setTimeout(() => {
PremiumModalCommands.open();
}, 0);
}}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal"
/>
)),
);
} else {
ModalCommands.push(
modal(() => (
<GenericErrorModal
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
message={
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
image instead.
</Trans>
}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2"
/>
)),
);
}
return;
}
const isAnimatedAvif = animated && format === 'avif';
if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) {
showAnimatedAvifUnsupportedModal({i18n});
return;
}
const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file);
ModalCommands.push(
modal(() => (
<AssetCropModal
assetType={AssetType.AVATAR}
imageUrl={base64}
onCropComplete={(croppedBlob) => {
void readBlobAsDataUrl(croppedBlob).then(
(croppedBase64) => {
onAvatarChange(croppedBase64);
},
() => {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
},
);
}}
onSkip={
isAnimatedAvif
? undefined
: () => {
onAvatarChange(base64);
}
}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal"
/>
)),
);
},
[canUploadAnimatedAvatar, onAvatarChange, i18n],
);
const showAvatarUploadError = useCallback(() => {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
}, [i18n]);
const handlePickAvatarFile = useCallback(async () => {
try {
const [file] = await openFilePicker({accept: getAcceptString('avatar')});
if (!file) return;
await processAvatarFile(file);
} catch {
showAvatarUploadError();
}
}, [processAvatarFile, showAvatarUploadError]);
const handleSelectAvatarGif = useCallback(
async (gif: Gif) => {
try {
await processAvatarFile(await downloadGifAsImageFile(gif));
} catch {
showAvatarUploadError();
}
},
[processAvatarFile, showAvatarUploadError],
);
const handleAvatarUpload = useCallback(() => {
const uploadHint = canUploadAnimatedAvatar
? formatImageUploadRecommendedHint(i18n, {
formats: ANIMATED_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
})
: shouldShowPremiumFeatures()
? formatImageUploadRecommendedHintWithNote(i18n, {
formats: STATIC_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, {
animatedAvatarFormats: ANIMATED_AVATAR_FORMATS,
premiumProductName: getPremiumProductName(),
}),
})
: formatImageUploadRecommendedHint(i18n, {
formats: STATIC_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
});
openAssetSourceModal({
title: i18n._(CHANGE_AVATAR_DESCRIPTOR),
uploadHint,
onPickUpload: handlePickAvatarFile,
onSelectGif: (gif) => void handleSelectAvatarGif(gif),
});
}, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]);
return {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload};
}
export const AvatarUploader = observer(
({
hasAvatar,
@@ -129,13 +326,11 @@ export const AvatarUploader = observer(
avatarMode = 'inherit',
onAvatarModeChange,
}: AvatarUploaderProps) => {
const PREMIUM_PRODUCT_NAME = getPremiumProductName();
const {i18n} = useLingui();
const hasAnimatedAvatarEntitlement = isLimitToggleEnabled(
{feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})},
'feature_animated_avatar',
);
const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement;
const {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload} = useChangeAvatarAction({
onAvatarChange,
requireAnimatedAvatarEntitlement,
});
const getAvatarModeOptions = useCallback(
() => [
{
@@ -157,184 +352,6 @@ export const AvatarUploader = observer(
],
[canUploadAnimatedAvatar, i18n],
);
const processAvatarFile = useCallback(
async (file: File) => {
if (file.size > GlobalLimits.getAvatarMaxSize()) {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
}),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
return;
}
const svg = isSvgFile(file);
if (!svg && !(await canCropFile(file))) {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
return;
}
const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file);
if (animated && !canUploadAnimatedAvatar) {
if (shouldShowPremiumFeatures()) {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
description={
<>
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to{' '}
{PREMIUM_PRODUCT_NAME} subscribers.
</Trans>
</p>
<p
className={styles.spacedParagraph}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
>
{RuntimeConfig.usesUniqueUsernames ? (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
) : (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
)}
</p>
</>
}
primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
primaryVariant="primary"
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
onPrimary={() => {
window.setTimeout(() => {
PremiumModalCommands.open();
}, 0);
}}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal"
/>
)),
);
} else {
ModalCommands.push(
modal(() => (
<GenericErrorModal
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
message={
<Trans>
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
image instead.
</Trans>
}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2"
/>
)),
);
}
return;
}
const isAnimatedAvif = animated && format === 'avif';
if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) {
showAnimatedAvifUnsupportedModal({i18n});
return;
}
const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file);
ModalCommands.push(
modal(() => (
<AssetCropModal
assetType={AssetType.AVATAR}
imageUrl={base64}
onCropComplete={(croppedBlob) => {
void readBlobAsDataUrl(croppedBlob).then(
(croppedBase64) => {
onAvatarChange(croppedBase64);
},
() => {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
},
);
}}
onSkip={
isAnimatedAvif
? undefined
: () => {
onAvatarChange(base64);
}
}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal"
/>
)),
);
},
[canUploadAnimatedAvatar, onAvatarChange, i18n],
);
const showAvatarUploadError = useCallback(() => {
showGenericErrorModal({
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR),
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
});
}, [i18n]);
const handlePickAvatarFile = useCallback(async () => {
try {
const [file] = await openFilePicker({accept: getAcceptString('avatar')});
if (!file) return;
await processAvatarFile(file);
} catch {
showAvatarUploadError();
}
}, [processAvatarFile, showAvatarUploadError]);
const handleSelectAvatarGif = useCallback(
async (gif: Gif) => {
try {
await processAvatarFile(await downloadGifAsImageFile(gif));
} catch {
showAvatarUploadError();
}
},
[processAvatarFile, showAvatarUploadError],
);
const handleAvatarUpload = useCallback(() => {
const uploadHint = canUploadAnimatedAvatar
? formatImageUploadRecommendedHint(i18n, {
formats: ANIMATED_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
})
: shouldShowPremiumFeatures()
? formatImageUploadRecommendedHintWithNote(i18n, {
formats: STATIC_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, {
animatedAvatarFormats: ANIMATED_AVATAR_FORMATS,
premiumProductName: getPremiumProductName(),
}),
})
: formatImageUploadRecommendedHint(i18n, {
formats: STATIC_IMAGE_FORMATS,
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
});
openAssetSourceModal({
title: i18n._(CHANGE_AVATAR_DESCRIPTOR),
uploadHint,
onPickUpload: handlePickAvatarFile,
onSelectGif: (gif) => void handleSelectAvatarGif(gif),
});
}, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]);
const handleModeChange = useCallback(
(mode: AvatarMode) => {
if (mode === 'custom') {
@@ -48,8 +48,7 @@
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 0.85rem;
overflow: hidden;
border: 0.0625rem solid var(--background-modifier-accent);
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildComboboxOption} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import Guilds from '@app/features/guild/state/Guilds';
import {Combobox} from '@app/features/ui/components/form/FormCombobox';
import styles from '@app/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css';
@@ -31,7 +32,6 @@ export const ProfileTypeSelector = observer(({selectedGuildId, onChange, disable
const renderGuildRow = useCallback((option: GuildComboboxOption) => {
const isGlobal = !option.value;
const iconUrl = option.icon ? AvatarUtils.getGuildIconURL({id: option.value, icon: option.icon}) : null;
const initial = option.label.charAt(0).toUpperCase();
return (
<div
className={clsx(styles.guildOption, isGlobal && styles.guildOptionGlobal)}
@@ -51,7 +51,10 @@ export const ProfileTypeSelector = observer(({selectedGuildId, onChange, disable
aria-hidden
data-flx="user.my-profile-tab.profile-type-selector.render-guild-row.guild-avatar-placeholder"
>
{initial}
<GuildAcronym
name={option.label}
data-flx="user.my-profile-tab.profile-type-selector.render-guild-row.guild-avatar-placeholder-acronym"
/>
</div>
))}
<span
@@ -65,6 +65,7 @@ interface ProfilePreviewProps {
showMessageButton?: boolean;
showPreviewLabel?: boolean;
previewCustomStatus?: CustomStatus | null;
onAvatarClick?: () => void;
}
export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
@@ -90,6 +91,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
showMessageButton = true,
showPreviewLabel = true,
previewCustomStatus,
onAvatarClick,
}) => {
const {i18n} = useLingui();
const previewOverrides = useMemo<ProfilePreviewOverrides>(
@@ -245,7 +247,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
avatarUrl={finalAvatarUrl}
hoverAvatarUrl={finalHoverAvatarUrl}
isClickable={true}
onAvatarClick={openMockProfile}
onAvatarClick={onAvatarClick ?? openMockProfile}
data-flx="user.profile.profile-preview.profile-card-banner"
/>
<UserProfileBadges
+1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import '@fluxer/schema/src/ZodJitless';
import '@app/app/AbortSignalPolyfill';
import '@app/app/BootShell.css';
import {startAppStorage} from '@app/features/platform/state/AppStorageBootstrap';
+12 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {execFile} = require('node:child_process');
const {readFileSync} = require('node:fs');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
@@ -102,6 +103,7 @@ const isMacBuild = process.argv.includes('--mac');
const isWindowsBuild = process.argv.includes('--win');
const targetPlatform = isLinuxBuild ? 'linux' : isMacBuild ? 'darwin' : isWindowsBuild ? 'win32' : process.platform;
const metadataName = isLinuxBuild ? linuxPackageName : packageName;
const shellVersion = process.env.VERSION || readBundledRendererVersion();
const supportedTargetArchs = ['x64', 'arm64'];
const supportedMacTargetArchs = [...supportedTargetArchs, 'universal'];
const electronArch = process.env.ELECTRON_ARCH;
@@ -644,6 +646,15 @@ function packagePathParts(packageName) {
return [packageName];
}
function readBundledRendererVersion() {
try {
const {version} = JSON.parse(readFileSync(path.join(__dirname, 'dist', 'renderer', 'version.json'), 'utf8'));
return typeof version === 'string' && /^\d+\.\d+\.\d+$/u.test(version) ? version : null;
} catch {
return null;
}
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.length > 0;
}
@@ -1623,7 +1634,7 @@ module.exports = {
extraMetadata: {
main: 'dist/main/index.js',
name: metadataName,
...(process.env.VERSION ? {version: process.env.VERSION} : {}),
...(shellVersion ? {version: shellVersion} : {}),
...(targetPlatform === 'linux' ? {desktopName: `${linuxDesktopId}.desktop`} : {}),
},
extraResources: [
+2 -2
View File
@@ -614,9 +614,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
+24 -11
View File
@@ -39,18 +39,20 @@ const isProduction =
process.env.FLUXER_DESKTOP_PRODUCTION === 'true' ||
process.env.GITHUB_ACTIONS === 'true';
const skipNative = process.env.FLUXER_SKIP_NATIVE === 'true';
const embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || '';
let embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || '';
const embeddedReleaseChannel = process.env.PUBLIC_RELEASE_CHANNEL || process.env.RELEASE_CHANNEL || '';
const modulesEnabled = process.env.FLUXER_MODULES === '1' || process.env.FLUXER_MODULES === 'true';
const offlineBuild = process.env.FLUXER_OFFLINE === '1' || process.env.FLUXER_OFFLINE === 'true';
const publicBuildDefines = {
'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
'process.env.FLUXER_MODULES': JSON.stringify(modulesEnabled ? '1' : ''),
'process.env.FLUXER_OFFLINE': JSON.stringify(offlineBuild ? '1' : ''),
};
function publicBuildDefines() {
return {
'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
'process.env.FLUXER_MODULES': JSON.stringify(modulesEnabled ? '1' : ''),
'process.env.FLUXER_OFFLINE': JSON.stringify(offlineBuild ? '1' : ''),
};
}
const electronExternals = [
'electron',
'electron-log',
@@ -116,6 +118,16 @@ function readBundledRendererVersion() {
}
}
function fallBackToBundledRendererVersion() {
if (embeddedBuildVersion !== '') {
return;
}
const bundledVersion = readBundledRendererVersion();
if (BUNDLED_RENDERER_VERSION_PATTERN.test(bundledVersion)) {
embeddedBuildVersion = bundledVersion;
}
}
class RendererServiceWorkerPresentError extends Error {
constructor(present) {
super(
@@ -674,7 +686,7 @@ async function buildMain() {
define: {
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
[MAIN_APP_OUTPUT_FILE_DEFINE]: JSON.stringify(MAIN_APP_OUTPUT_FILE_NAME),
...publicBuildDefines,
...publicBuildDefines(),
},
banner: {
js: `import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);`,
@@ -806,7 +818,7 @@ function preloadBuildOptions(entryFileName, outputFileName) {
tsconfig: DESKTOP_TSCONFIG,
define: {
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
...publicBuildDefines,
...publicBuildDefines(),
},
};
}
@@ -887,6 +899,7 @@ async function build() {
if (!options.useSharedRenderer) {
copyRendererIntoDist();
}
fallBackToBundledRendererVersion();
if (modulesEnabled) {
prunePackedRendererModules();
}
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import os from 'node:os';
import path from 'node:path';
import {after, describe, test} from 'node:test';
const require = createRequire(import.meta.url);
const DESKTOP_DIR = path.resolve(import.meta.dirname, '..', '..');
const PREFLIGHT_MODULES = path.join('src', 'main', 'NativeModulePreflightModules.json');
const temporaryRoots = [];
after(() => {
for (const root of temporaryRoots) {
fs.rmSync(root, {recursive: true, force: true});
}
});
function loadMetadataVersion({version, rendererVersionJson}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'builder-shell-version-'));
temporaryRoots.push(root);
fs.copyFileSync(
path.join(DESKTOP_DIR, 'electron-builder.config.cjs'),
path.join(root, 'electron-builder.config.cjs'),
);
fs.mkdirSync(path.join(root, 'src', 'main'), {recursive: true});
fs.copyFileSync(path.join(DESKTOP_DIR, PREFLIGHT_MODULES), path.join(root, PREFLIGHT_MODULES));
if (rendererVersionJson != null) {
fs.mkdirSync(path.join(root, 'dist', 'renderer'), {recursive: true});
fs.writeFileSync(path.join(root, 'dist', 'renderer', 'version.json'), rendererVersionJson);
}
const previous = process.env.VERSION;
if (version == null) {
delete process.env.VERSION;
} else {
process.env.VERSION = version;
}
try {
return require(path.join(root, 'electron-builder.config.cjs')).extraMetadata.version;
} finally {
if (previous === undefined) {
delete process.env.VERSION;
} else {
process.env.VERSION = previous;
}
}
}
describe('the packaged shell version', () => {
test('comes from VERSION whenever it is set', () => {
assert.equal(
loadMetadataVersion({version: '2026.1014.2', rendererVersionJson: '{"version":"2026.1014.9"}'}),
'2026.1014.2',
);
});
test('falls back to the bundled renderer version when VERSION is unset', () => {
assert.equal(loadMetadataVersion({rendererVersionJson: '{"version":"2026.1014.9"}'}), '2026.1014.9');
});
test('keeps the package.json version when the renderer has no numeric version', () => {
assert.equal(loadMetadataVersion({rendererVersionJson: '{"version":"dev"}'}), undefined);
assert.equal(loadMetadataVersion({rendererVersionJson: 'null'}), undefined);
assert.equal(loadMetadataVersion({}), undefined);
});
});
+59 -47
View File
@@ -159,7 +159,7 @@ async function runModuleBootstrap(): Promise<void> {
const {configureUserDataPath} = await import('@electron/common/UserDataPath');
const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache');
const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch');
const {isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
const {areModuleUpdatesDisabled, isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath');
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
const {
@@ -410,6 +410,10 @@ async function runModuleBootstrap(): Promise<void> {
logger.warn('Failed to record whether an update is waiting', error);
}
};
const moduleUpdatesDisabled = areModuleUpdatesDisabled();
if (moduleUpdatesDisabled) {
logger.info('Module updates are disabled by FLUXER_DISABLE_MODULE_UPDATES');
}
const updater = new ModuleUpdater({
store,
shellVersion: app.getVersion(),
@@ -417,6 +421,7 @@ async function runModuleBootstrap(): Promise<void> {
bundledRendererVersion: BUNDLED_RENDERER_VERSION,
preferUnversionedBundle: app.isPackaged,
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
moduleUpdatesDisabled,
selfUpdateShellFirst:
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
? async (latestVersion) => {
@@ -705,35 +710,37 @@ async function runModuleBootstrap(): Promise<void> {
},
};
const desktopUpdate = new DesktopUpdateRun({
probe: () => updater.checkForUpdate(),
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
installModules: async () => {
const launchAttempt = await updater.installPending();
if (launchAttempt != null) {
await refreshModuleRoots(launchAttempt.committed);
}
return launchAttempt;
},
takeover: updateTakeover,
publish: (check) => {
recordPendingUpdate(check);
publishDesktopUpdateCheck(check);
},
reportFailure: (failure) => {
void import('@electron/main/DesktopUpdatePrompt')
.then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure))
.catch((error: unknown) => {
logger.error('Failed to report the desktop update failure', error);
});
},
openDownloadsPage: async () => {
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
await shell.openExternal(DOWNLOAD_PAGE_URL);
},
logger,
});
const desktopUpdate = moduleUpdatesDisabled
? null
: new DesktopUpdateRun({
probe: () => updater.checkForUpdate(),
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
installModules: async () => {
const launchAttempt = await updater.installPending();
if (launchAttempt != null) {
await refreshModuleRoots(launchAttempt.committed);
}
return launchAttempt;
},
takeover: updateTakeover,
publish: (check) => {
recordPendingUpdate(check);
publishDesktopUpdateCheck(check);
},
reportFailure: (failure) => {
void import('@electron/main/DesktopUpdatePrompt')
.then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure))
.catch((error: unknown) => {
logger.error('Failed to report the desktop update failure', error);
});
},
openDownloadsPage: async () => {
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
await shell.openExternal(DOWNLOAD_PAGE_URL);
},
logger,
});
const permit = await runModuleUpdateLoop();
localNetworkHint.disarm();
@@ -741,25 +748,30 @@ async function runModuleBootstrap(): Promise<void> {
recordPendingUpdate({shellNewer: false, modulesChanged: false});
}
await refreshModuleRoots(permit.launchAttempt.committed);
setOnDemandModuleInstaller(
createOnDemandModuleInstaller({
ensure: (moduleName) => updater.ensureModule(moduleName),
refresh: serveOnDemandModules,
onFailure: (moduleName, error) => {
logger.error('Failed to install a module on demand', {module: moduleName, error});
},
}),
);
markSplashLaunching();
armDesktopUpdate({
check: async (manual) => {
const check = await desktopUpdate.check({manual});
recordPendingUpdate(check);
return check;
},
start: () => desktopUpdate.start(),
});
if (desktopUpdate != null) {
setOnDemandModuleInstaller(
createOnDemandModuleInstaller({
ensure: (moduleName) => updater.ensureModule(moduleName),
refresh: serveOnDemandModules,
onFailure: (moduleName, error) => {
logger.error('Failed to install a module on demand', {module: moduleName, error});
},
}),
);
armDesktopUpdate({
check: async (manual) => {
const check = await desktopUpdate.check({manual});
recordPendingUpdate(check);
return check;
},
start: () => desktopUpdate.start(),
});
}
armMainWindowHandoff(updater, permit.launchAttempt, () => {
if (desktopUpdate == null) {
return;
}
desktopUpdate.markLaunchSettled();
preloadUpdateSplash();
armDesktopUpdatePoll(
@@ -275,3 +275,26 @@ describe('Bootstrap entry point', () => {
);
});
});
describe('FLUXER_DISABLE_MODULE_UPDATES', () => {
test('reads the environment variable like the other FLUXER_DISABLE switches', async () => {
const {areModuleUpdatesDisabled} = await import('./LaunchOptions.ts');
const previous = process.env.FLUXER_DISABLE_MODULE_UPDATES;
try {
delete process.env.FLUXER_DISABLE_MODULE_UPDATES;
assert.equal(areModuleUpdatesDisabled(), false);
for (const value of ['1', 'true', 'YES', 'on']) {
process.env.FLUXER_DISABLE_MODULE_UPDATES = value;
assert.equal(areModuleUpdatesDisabled(), true);
}
process.env.FLUXER_DISABLE_MODULE_UPDATES = '0';
assert.equal(areModuleUpdatesDisabled(), false);
} finally {
if (previous === undefined) {
delete process.env.FLUXER_DISABLE_MODULE_UPDATES;
} else {
process.env.FLUXER_DISABLE_MODULE_UPDATES = previous;
}
}
});
});
+10 -7
View File
@@ -13,6 +13,7 @@ import type {
} from '@electron/common/Types';
import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate';
import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt';
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
import {onLocaleChange, t} from '@electron/main/MainI18n';
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
@@ -399,13 +400,15 @@ function buildTrayMenu(): Menu {
click: () => runTrayMenuAction(startDesktopUpdateFromShell),
});
}
menuTemplate.push({
label: t('desktop.tray.checkForUpdates'),
click: () =>
runTrayMenuAction(() => {
void checkForUpdatesFromShell();
}),
});
if (!areModuleUpdatesDisabled()) {
menuTemplate.push({
label: t('desktop.tray.checkForUpdates'),
click: () =>
runTrayMenuAction(() => {
void checkForUpdatesFromShell();
}),
});
}
if (trayState.buildInfo) {
menuTemplate.push({
label: t('desktop.tray.copyBuildInfo'),
@@ -25,8 +25,8 @@ function deferred() {
describe('DesktopUpdateGate', () => {
test('nothing is available and nothing starts before the bootstrap arms the gate', async () => {
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false});
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false, replacesShell: false});
assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false, replacesShell: false});
assert.equal(startDesktopUpdate(), false);
});
@@ -44,17 +44,17 @@ describe('DesktopUpdateGate', () => {
},
});
assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false});
assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false, replacesShell: false});
assert.equal(startDesktopUpdate(), true);
assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op');
assert.deepEqual(
getDesktopUpdateState(),
{available: false, updating: true},
{available: false, updating: true, replacesShell: false},
'the affordance hides the moment it is clicked',
);
assert.deepEqual(
await checkDesktopUpdateNow(),
{available: false, updating: true},
{available: false, updating: true, replacesShell: false},
'a poll during the update changes nothing',
);
@@ -62,11 +62,11 @@ describe('DesktopUpdateGate', () => {
await new Promise((resolve) => setImmediate(resolve));
assert.equal(starts, 1);
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false, replacesShell: false});
assert.deepEqual(states, [
{available: true, updating: false},
{available: false, updating: true},
{available: false, updating: false},
{available: true, updating: false, replacesShell: false},
{available: false, updating: true, replacesShell: false},
{available: false, updating: false, replacesShell: false},
]);
stop();
});
@@ -111,7 +111,7 @@ describe('DesktopUpdateGate', () => {
pending.resolve();
await background;
assert.deepEqual(await manual, {available: true, updating: false});
assert.deepEqual(await manual, {available: true, updating: false, replacesShell: true});
assert.deepEqual(calls, [false, true]);
});
@@ -127,7 +127,7 @@ describe('DesktopUpdateGate', () => {
assert.equal(startDesktopUpdate(), true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false});
assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false, replacesShell: true});
assert.equal(startDesktopUpdate(), true);
});
});
+12 -2
View File
@@ -24,6 +24,7 @@ interface DesktopUpdateGateState {
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
notifiedAvailable: boolean;
notifiedUpdating: boolean;
notifiedReplacesShell: boolean;
readonly listeners: Set<DesktopUpdateStateListener>;
}
@@ -34,6 +35,7 @@ const gateState: DesktopUpdateGateState = {
checking: null,
notifiedAvailable: false,
notifiedUpdating: false,
notifiedReplacesShell: false,
listeners: new Set(),
};
@@ -42,9 +44,11 @@ function isAvailable(check: DesktopUpdateCheck | null): boolean {
}
export function getDesktopUpdateState(): DesktopUpdateState {
const available = gateState.running == null && isAvailable(gateState.lastCheck);
return {
available: gateState.running == null && isAvailable(gateState.lastCheck),
available,
updating: gateState.running != null,
replacesShell: available && gateState.lastCheck?.shellNewer === true,
};
}
@@ -55,11 +59,17 @@ export function desktopUpdateReplacesShell(): boolean {
function notify(): void {
const state = getDesktopUpdateState();
const updating = state.updating === true;
if (state.available === gateState.notifiedAvailable && updating === gateState.notifiedUpdating) {
const replacesShell = state.replacesShell === true;
if (
state.available === gateState.notifiedAvailable &&
updating === gateState.notifiedUpdating &&
replacesShell === gateState.notifiedReplacesShell
) {
return;
}
gateState.notifiedAvailable = state.available;
gateState.notifiedUpdating = updating;
gateState.notifiedReplacesShell = replacesShell;
for (const listener of Array.from(gateState.listeners)) {
try {
listener(state);
+5
View File
@@ -159,9 +159,14 @@ export function describeLaunchDiagnosticOptions(argv: ReadonlyArray<string> = pr
spellcheck: getSpellcheckLaunchMode(argv),
v8CodeCacheDisabled: shouldDisableV8CodeCache(argv),
nativeGatewayDisableReason: getNativeGatewayDisableReason(argv),
moduleUpdatesDisabled: areModuleUpdatesDisabled(),
};
}
export function areModuleUpdatesDisabled(): boolean {
return truthyEnv('FLUXER_DISABLE_MODULE_UPDATES');
}
export function isDesktopUpdateRequested(argv: ReadonlyArray<string> = process.argv): boolean {
return hasFlag(argv, DESKTOP_UPDATE_ARGS);
}
+3 -2
View File
@@ -2,6 +2,7 @@
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt';
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
import {onLocaleChange, t} from '@electron/main/MainI18n';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting';
@@ -31,7 +32,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
role: 'about',
label: t('desktop.appMenu.about', {appName}),
},
checkForUpdatesItem(),
...(areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem()]),
{type: 'separator'},
{
label: t('desktop.appMenu.preferences'),
@@ -209,7 +210,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
? `${t('desktop.appMenu.help')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}`
: t('desktop.appMenu.help'),
submenu: [
...(isMac ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
...(isMac || areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
{
label: t('desktop.appMenu.website'),
click: async () => {
@@ -127,6 +127,7 @@ function createUpdater(store, modules, options = {}) {
hasOfflineRenderer: options.bundledRendererVersion != null,
bundledRendererVersion: options.bundledRendererVersion ?? null,
forceStartupUpdate: options.forceStartupUpdate ?? false,
moduleUpdatesDisabled: options.moduleUpdatesDisabled ?? false,
selfUpdateShellFirst: options.selfUpdateShellFirst,
onState: options.onState,
fetch: async (url, init) => {
@@ -506,6 +507,75 @@ describe('ModuleUpdater.run', () => {
});
});
describe('ModuleUpdater with module updates disabled', () => {
test('launches the verified set without fetching even when the manifest demands a shell update', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
await store.commit({fluxer_renderer: RENDERER_SHA});
assert.equal((await createUpdater(store, {fluxer_renderer: RENDERER_SHA}).updater.run()).status, 'launching');
const statuses = [];
let shellUpdates = 0;
const {updater, requests, sleeps} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{
shellLatest: '2026.824.0',
shellMinimum: '2026.824.0',
forceStartupUpdate: true,
moduleUpdatesDisabled: true,
onState: (state) => statuses.push(state.status),
selfUpdateShellFirst: async () => {
shellUpdates += 1;
return {reason: 'no-update'};
},
},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'unreachable-launch');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(requests, []);
assert.deepEqual(sleeps, []);
assert.equal(shellUpdates, 0);
assert.deepEqual(statuses, ['unreachable-launch']);
assert.equal(outcome.launchAttempt.committed.fluxer_renderer, RENDERER_SHA);
});
test('launches the bundled renderer on a fresh store without fetching', async () => {
const store = await openStore(createUserData());
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{bundledRendererVersion: SHELL_VERSION, moduleUpdatesDisabled: true},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'unreachable-launch');
assert.deepEqual(outcome.committed, {});
assert.deepEqual(requests, []);
});
test('blocks at once on the unreachable splash without fetching when nothing is installed', async () => {
const store = await openStore(createUserData());
const {updater, requests, sleeps} = createUpdater(
store,
{fluxer_renderer: RENDERER_SHA},
{moduleUpdatesDisabled: true},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'blocked-update-required');
assert.equal(outcome.reason, 'nothing-installed');
assert.equal(outcome.updateServerUnreachable, true);
assert.deepEqual(requests, []);
assert.deepEqual(sleeps, []);
});
});
describe('ModuleUpdater checks without downloading and installs only when asked', () => {
async function storeOnRenderer() {
const userDataPath = createUserData();
+26 -7
View File
@@ -267,6 +267,7 @@ interface ModuleUpdaterOptions {
readonly bundledRendererVersion?: string | null;
readonly preferUnversionedBundle?: boolean;
readonly forceStartupUpdate?: boolean;
readonly moduleUpdatesDisabled?: boolean;
readonly selfUpdateShellFirst?: (latestVersion: string) => Promise<{readonly reason: string}>;
readonly onState?: (state: ModuleUpdaterSplashState) => void;
readonly report?: (report: ModuleUpdaterReport) => void;
@@ -286,6 +287,7 @@ export class ModuleUpdater {
private readonly shellVersion: ModuleVersion;
private readonly hasOfflineRenderer: boolean;
private readonly forceStartupUpdate: boolean;
private readonly moduleUpdatesDisabled: boolean;
private readonly selfUpdateShellFirst: ((latestVersion: string) => Promise<{readonly reason: string}>) | null;
private readonly shellFirstAttempts = new Map<string, {readonly reason: string} | null>();
private readonly manifestFeed: ModuleManifestFeedIdentity;
@@ -314,6 +316,7 @@ export class ModuleUpdater {
this.shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
this.hasOfflineRenderer = options.hasOfflineRenderer ?? false;
this.forceStartupUpdate = options.forceStartupUpdate ?? false;
this.moduleUpdatesDisabled = options.moduleUpdatesDisabled ?? false;
this.selfUpdateShellFirst = options.selfUpdateShellFirst ?? null;
const releaseChannel = options.releaseChannel ?? BUILD_CHANNEL;
this.platform = options.platform ?? process.platform;
@@ -418,6 +421,18 @@ export class ModuleUpdater {
return reverted;
}
}
if (this.moduleUpdatesDisabled) {
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
return await this.launchUnreachable();
}
return this.blockUpdateRequired(
decision.reason,
[],
'module updates are disabled by FLUXER_DISABLE_MODULE_UPDATES',
{updateServerUnreachable: true},
);
}
if (this.platform === 'linux' && !(await this.startupUpdateRequired())) {
return await this.launchIfAtFloor();
}
@@ -486,13 +501,7 @@ export class ModuleUpdater {
if (exhausted || !fetched) {
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
return {
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
committed: this.store.getCommitted(),
launchAttempt,
};
return await this.launchUnreachable();
}
if (exhausted) {
return this.blockUpdateRequired(decision.reason, [], describeErrorChain(error), {
@@ -802,6 +811,16 @@ export class ModuleUpdater {
};
}
private async launchUnreachable(): Promise<ModuleUpdaterOutcome> {
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
return {
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
committed: this.store.getCommitted(),
launchAttempt,
};
}
private async launchRevertedSetBypassingFloor(
committed: Readonly<Record<string, string>>,
): Promise<ModuleUpdaterOutcome | null> {
+2 -2
View File
@@ -73,7 +73,7 @@ import {
registerIpcHandlers,
} from '@electron/main/IpcHandlers';
import {initializeJumpList} from '@electron/main/JumpList';
import {describeLaunchDiagnosticOptions} from '@electron/main/LaunchOptions';
import {areModuleUpdatesDisabled, describeLaunchDiagnosticOptions} from '@electron/main/LaunchOptions';
import {cleanupLegacyHarvestHandlers, registerLegacyHarvestHandlers} from '@electron/main/LegacyHarvestIpc';
import {initializeLegacyOriginHarvest} from '@electron/main/LegacyOriginHarvest';
import {cleanupVirtmic, registerVirtmicHandlers} from '@electron/main/LinuxAudioCapture';
@@ -600,7 +600,7 @@ if (launchConfigurationError) {
showWindow,
});
});
if (process.env.FLUXER_OFFLINE !== '1') {
if (process.env.FLUXER_OFFLINE !== '1' && !areModuleUpdatesDisabled()) {
const {registerUpdater} = await import('@electron/main/Updater');
registerUpdater(getMainWindow);
}
@@ -309,9 +309,12 @@ One account that registered and is still waiting for an Admin decision.
| requested_at | ISO8601 timestamp | Time the account registered |
| registration_url_id | ?string | [Registration URL](#registration-url-object) the account signed up through (1-128 characters), or null when it registered directly |
| client_ip<sup>1</sup> | ?string | IP address the account registered from, or null when none was recorded |
| invite_code<sup>2</sup> | ?string | Invite the account joins on approval (0-256 characters), or null when it has none |
<sup>1</sup> Stored at registration time and never refreshed, so it can be stale by the time an Admin reads it
<sup>2</sup> The invite code sent with the registration, or the instance's configured auto-join invite when it sent none. An account that registered through SSO sends no invite code, so it stores the auto-join invite or null
## Pending registration list object
Every account still waiting for an Admin decision.
@@ -940,7 +943,7 @@ Both decisions remove the pending registration, but the endpoint does not requir
### Side effects
Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait, and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait. When the account still had the `registration_pending_approval` trait, approval then accepts the stored `invite_code` when there is one and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails, such as one through an invite that was deleted, has expired or needs the account to be online, is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
When the stored pending registration list fails validation, the request fails before Fluxer changes the account. A failure after Fluxer writes the account traits leaves those traits written and can leave the entry in the pending list. Check the account traits and the pending list before retrying.
@@ -590,7 +590,7 @@ Registration verifies a [CAPTCHA](/topics/captcha/). It permits 3 attempts per h
Fluxer resolves the region from the client IP address, and an age below the minimum for that region returns the field code `MUST_BE_MINIMUM_AGE`. That minimum is 13 years unless the region sets a different minimum, and the applied minimum appears only in the localised message.
Registration returns 403 `REGISTRATION_CLOSED` when the instance is closed and no valid registration URL was supplied. A username whose discriminator space is exhausted returns the field code `TOO_MANY_USERS_WITH_THIS_USERNAME`. A username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
Registration returns 403 `REGISTRATION_CLOSED` when the instance is closed and no valid registration URL was supplied. A registration that would wait for approval, through the approval mode or a registration URL that requires approval, returns 400 `REGISTRATION_CREDENTIALS_REQUIRED` unless it has a `password`, and on an `email` instance also an `email`, because an account without them could never sign in once approved. That refusal counts against no registration allowance and uses no registration URL seat. A username whose discriminator space is exhausted returns the field code `TOO_MANY_USERS_WITH_THIS_USERNAME`. A username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
After the route rate limit, the CAPTCHA and schema validation, the setup token is checked before every other registration rule. A registration that presents the correct token is admitted whatever the registration mode is, is exempt from the registration allowances above, and receives the wildcard [Admin ACL](/admin-api/#acl-registry) when it creates a claimed account, which needs `email` on an `email` instance and `password` on a `username` instance. A client IP address is permitted 10 wrong tokens per 30 minutes, and further requests that present a token from that address return 429 until the window passes, the correct token included. A request with no token does not count.
@@ -607,7 +607,7 @@ There is no retry key. A repeated request with the same values creates a second
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [authentication token response](#authentication-token-response) \| [registration pending approval response](#registration-pending-approval-response-object) | Registration completed or entered the approval state |
| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, account fields, registration URL code, or setup token is invalid |
| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, account fields, sign-in credentials, registration URL code, or setup token is invalid |
| 403 | [error response](/http-api/#error-response) | SSO enforcement, registration policy, or content moderation rejects the registration |
| 423 | [error response](/http-api/#error-response) | `GENERAL_ERROR`, because a self-hosted instance with no account yet is still settling its [sign-in method](/http-api/instance/#set-the-setup-sign-in-method) after 15 seconds |
| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, IP, or subnet bucket denies the request, or the address has used its wrong setup token allowance |
@@ -620,7 +620,7 @@ The operation applies the instance's registration, email-domain, breached-passwo
Registration accepts the `invite_code` from the body, or the instance's configured auto-join invite when the body has none. On an instance with single-community mode enabled, the account also joins the community guild. Each join emits [Guild Member Add](/gateway/events/#guild-member-add) to the affected guild's sessions.
Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. Every other successful registration creates one session and returns its token.
Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. The pending registration keeps that invite, and [approving it](/admin-api/instance/#update-pending-registration) joins the account to that guild and to the single community guild. Every other successful registration creates one session and returns its token.
### Rate limit
@@ -890,6 +890,10 @@ Email verification is required for this action
Registration is closed on this instance
### `REGISTRATION_CREDENTIALS_REQUIRED`
This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved
### `REGISTRATION_PENDING_APPROVAL`
This registration is waiting for admin approval
@@ -1150,7 +1150,7 @@ Set by Docker. Node identity in metrics and logs.
#### `FLUXER_ENV`
Default `development`. The runtime mode. `development`, `production`, or `test`. Compose pins `production`, which gates Postgres validation and the admin cookie flags. On a self-hosted instance, `production` also enforces the [setup token](#setup-token).
Default `production` when `FLUXER_SELF_HOSTED` is `true`, otherwise `development`. The runtime mode. `development`, `production`, or `test`. Compose pins `production`, which gates Postgres validation and the admin cookie flags. On a self-hosted instance, `production` also enforces the [setup token](#setup-token).
#### `FLUXER_SELF_HOSTED`
+2 -2
View File
@@ -1525,9 +1525,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"once_cell",
+11 -1
View File
@@ -665,6 +665,13 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
return config;
}
function applySelfHostedEnvDefault(config: MasterConfig, explicitEnv: string | undefined): MasterConfig {
if (explicitEnv !== undefined || !config.instance.self_hosted) {
return config;
}
return {...config, env: 'production'};
}
function applyPublicOrigin(config: MasterConfig): MasterConfig {
const raw = config.domain.public_origin;
if (raw.trim().length === 0 && !/\p{Cc}/u.test(raw)) {
@@ -787,7 +794,10 @@ export async function loadConfig(): Promise<MasterConfig> {
return cachedConfig;
}
const overrides = buildNamedFluxerEnvOverrides(process.env);
const merged = applyPublicOrigin(mergeConfig(defaultConfig(), overrides));
const merged = applySelfHostedEnvDefault(
applyPublicOrigin(mergeConfig(defaultConfig(), overrides)),
readEnvValue(process.env, 'FLUXER_ENV'),
);
const normalized = normalizeConfig(merged);
const derived = deriveEndpointsFromDomain(normalized.domain);
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
@@ -80,6 +80,29 @@ describe('ConfigLoader', () => {
expect((await loadConfig()).domain.base_domain).toBe('localhost');
});
test('a self-hosted instance without FLUXER_ENV runs in production', async () => {
stubMinimalEnv({
FLUXER_SELF_HOSTED: 'true',
FLUXER_PROFILE_PSEUDONYM_SECRET: 'self-hosted-secret',
FLUXER_POSTGRES_HOST: 'postgres',
FLUXER_POSTGRES_DATABASE: 'fluxer',
FLUXER_POSTGRES_PASSWORD: 'self-hosted-password',
});
vi.stubEnv('FLUXER_ENV', undefined);
expect((await loadConfig()).env).toBe('production');
});
test('a self-hosted instance keeps an explicit FLUXER_ENV', async () => {
stubMinimalEnv({FLUXER_SELF_HOSTED: 'true', FLUXER_ENV: 'development'});
expect((await loadConfig()).env).toBe('development');
});
test('an instance that is not self-hosted still defaults to development', async () => {
stubMinimalEnv();
vi.stubEnv('FLUXER_ENV', undefined);
expect((await loadConfig()).env).toBe('development');
});
test('endpoint overrides take precedence over derived endpoints', async () => {
stubMinimalEnv({
FLUXER_API_ENDPOINT: 'https://custom-api.example.com',
+1
View File
@@ -84,6 +84,7 @@ export const APIErrorCodes = {
REACTION_EMAIL_VERIFICATION_REQUIRED: 'REACTION_EMAIL_VERIFICATION_REQUIRED',
REPORT_EMAIL_VERIFICATION_REQUIRED: 'REPORT_EMAIL_VERIFICATION_REQUIRED',
REGISTRATION_CLOSED: 'REGISTRATION_CLOSED',
REGISTRATION_CREDENTIALS_REQUIRED: 'REGISTRATION_CREDENTIALS_REQUIRED',
REGISTRATION_PENDING_APPROVAL: 'REGISTRATION_PENDING_APPROVAL',
REGISTRATION_REJECTED: 'REGISTRATION_REJECTED',
REGISTRATION_URL_INVALID: 'REGISTRATION_URL_INVALID',
@@ -57,6 +57,7 @@ export interface DesktopModuleAPI {
export interface DesktopUpdateState {
readonly available: boolean;
readonly updating?: boolean;
readonly replacesShell?: boolean;
}
export interface DesktopUpdateAPI {
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
export class RegistrationCredentialsRequiredError extends BadRequestError {
constructor() {
super({code: APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED});
this.name = 'RegistrationCredentialsRequiredError';
}
}
@@ -232,6 +232,7 @@ export const ErrorCodeToI18nKey = {
[APIErrorCodes.PROCESSING_FAILED]: 'admin_and_system.processing_failed',
[APIErrorCodes.RATE_LIMITED]: 'rate_limits.rate_limited',
[APIErrorCodes.REGISTRATION_CLOSED]: 'auth_and_oauth.registration_closed',
[APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED]: 'auth_and_oauth.registration_credentials_required',
[APIErrorCodes.REGISTRATION_PENDING_APPROVAL]: 'auth_and_oauth.registration_pending_approval',
[APIErrorCodes.REGISTRATION_REJECTED]: 'auth_and_oauth.registration_rejected',
[APIErrorCodes.REGISTRATION_URL_INVALID]: 'auth_and_oauth.registration_url_invalid',
@@ -106,6 +106,8 @@ export const ERROR_I18N_MESSAGES = {
'auth_and_oauth.origin_handoff_nonce_invalid': "This sign-in transfer doesn't match the one you started.",
'auth_and_oauth.passkey_bridge_nonce_invalid': 'This passkey request could not be confirmed. Try again.',
'auth_and_oauth.registration_closed': 'Registration is closed on this instance.',
'auth_and_oauth.registration_credentials_required':
'This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.',
'auth_and_oauth.registration_pending_approval': 'This registration is waiting for admin approval.',
'auth_and_oauth.registration_rejected': 'This registration request was rejected.',
'auth_and_oauth.registration_url_invalid': 'This registration link is invalid or has expired.',
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "عملية نقل تسجيل الدخول هذه لا تطابق العملية التي بدأتها.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "تعذّر تأكيد طلب مفتاح المرور هذا. حاول مرة أخرى.",
"auth_and_oauth.registration_closed": "التسجيل مغلق في هذا المثيل.",
"auth_and_oauth.registration_credentials_required": "يراجع هذا المثيل الحسابات الجديدة. أنشئ حسابك من صفحة التسجيل باستخدام كلمة مرور حتى تتمكن من تسجيل الدخول بعد الموافقة عليه.",
"auth_and_oauth.registration_pending_approval": "هذا التسجيل في انتظار موافقة المسؤول.",
"auth_and_oauth.registration_rejected": "تم رفض طلب التسجيل هذا.",
"auth_and_oauth.registration_url_invalid": "رابط التسجيل هذا غير صالح أو انتهت صلاحيته.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Това прехвърляне на влизането не съвпада с започнатото от теб.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "Тази заявка за ключ за достъп не можа да бъде потвърдена. Опитай отново.",
"auth_and_oauth.registration_closed": "Регистрацията е затворена на тази инстанция.",
"auth_and_oauth.registration_credentials_required": "Тази инстанция преглежда новите акаунти. Регистрирайте се от страницата за регистрация с парола, за да можете да влезете след одобрение.",
"auth_and_oauth.registration_pending_approval": "Тази регистрация чака одобрение от администратор.",
"auth_and_oauth.registration_rejected": "Тази заявка за регистрация беше отхвърлена.",
"auth_and_oauth.registration_url_invalid": "Този линк за регистрация е невалиден или е изтекъл.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Tento přenos přihlášení neodpovídá tomu, který jste zahájili.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "Tento požadavek na přístupový klíč se nepodařilo potvrdit. Zkuste to znovu.",
"auth_and_oauth.registration_closed": "Registrace na této instanci je uzavřena.",
"auth_and_oauth.registration_credentials_required": "Tato instance kontroluje nové účty. Zaregistrujte se na registrační stránce s heslem, abyste se po schválení mohli přihlásit.",
"auth_and_oauth.registration_pending_approval": "Tato registrace čeká na schválení administrátorem.",
"auth_and_oauth.registration_rejected": "Tato žádost o registraci byla zamítnuta.",
"auth_and_oauth.registration_url_invalid": "Tento odkaz k registraci je neplatný nebo vypršel.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Denne overførsel af login matcher ikke den, du startede.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "Denne anmodning om adgangsnøgle kunne ikke bekræftes. Prøv igen.",
"auth_and_oauth.registration_closed": "Tilmelding er lukket på denne instans.",
"auth_and_oauth.registration_credentials_required": "Denne instans gennemgår nye konti. Tilmeld dig fra tilmeldingssiden med en adgangskode, så du kan logge ind, når kontoen er godkendt.",
"auth_and_oauth.registration_pending_approval": "Denne tilmelding afventer godkendelse fra administratoren.",
"auth_and_oauth.registration_rejected": "Denne anmodning om tilmelding blev afvist.",
"auth_and_oauth.registration_url_invalid": "Dette tilmeldingslink er ugyldigt eller er udløbet.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Diese Anmeldeübertragung stimmt nicht mit der von dir gestarteten überein.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "Diese Passkey-Anfrage konnte nicht bestätigt werden. Versuche es erneut.",
"auth_and_oauth.registration_closed": "Die Registrierung ist auf dieser Instanz geschlossen.",
"auth_and_oauth.registration_credentials_required": "Diese Instanz prüft neue Konten. Registriere dich über die Registrierungsseite mit einem Passwort, damit du dich nach der Freigabe anmelden kannst.",
"auth_and_oauth.registration_pending_approval": "Diese Registrierung wartet auf die Genehmigung durch einen Administrator.",
"auth_and_oauth.registration_rejected": "Diese Registrierungsanfrage wurde abgelehnt.",
"auth_and_oauth.registration_url_invalid": "Dieser Registrierungslink ist ungültig oder abgelaufen.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Αυτή η μεταφορά σύνδεσης δεν αντιστοιχεί σε αυτήν που ξεκινήσατε.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "Δεν ήταν δυνατή η επιβεβαίωση αυτού του αιτήματος κλειδιού πρόσβασης. Δοκίμασε ξανά.",
"auth_and_oauth.registration_closed": "Η εγγραφή είναι κλειστή σε αυτήν την εγκατάσταση.",
"auth_and_oauth.registration_credentials_required": "Αυτή η παρουσία ελέγχει τους νέους λογαριασμούς. Εγγραφείτε από τη σελίδα εγγραφής με κωδικό πρόσβασης, ώστε να μπορείτε να συνδεθείτε μετά την έγκριση.",
"auth_and_oauth.registration_pending_approval": "Αυτή η εγγραφή περιμένει έγκριση από τον διαχειριστή.",
"auth_and_oauth.registration_rejected": "Αυτό το αίτημα εγγραφής απορρίφθηκε.",
"auth_and_oauth.registration_url_invalid": "Αυτός ο σύνδεσμος εγγραφής είναι άκυρος ή έχει λήξει.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "This sign-in transfer doesn't match the one you started.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "This passkey request could not be confirmed. Try again.",
"auth_and_oauth.registration_closed": "Registration is closed on this instance.",
"auth_and_oauth.registration_credentials_required": "This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
"auth_and_oauth.registration_pending_approval": "This registration is waiting for admin approval.",
"auth_and_oauth.registration_rejected": "This registration request was rejected.",
"auth_and_oauth.registration_url_invalid": "This registration link is invalid or has expired.",
+1
View File
@@ -89,6 +89,7 @@
"auth_and_oauth.origin_handoff_nonce_invalid": "Esta transferencia de inicio de sesión no coincide con la que iniciaste.",
"auth_and_oauth.passkey_bridge_nonce_invalid": "No se pudo confirmar esta solicitud de llave de acceso. Inténtalo de nuevo.",
"auth_and_oauth.registration_closed": "El registro está cerrado en esta instancia.",
"auth_and_oauth.registration_credentials_required": "Esta instancia revisa las cuentas nuevas. Regístrate desde la página de registro con una contraseña para poder iniciar sesión cuando se apruebe.",
"auth_and_oauth.registration_pending_approval": "Este registro está esperando la aprobación del administrador.",
"auth_and_oauth.registration_rejected": "Esta solicitud de registro fue rechazada.",
"auth_and_oauth.registration_url_invalid": "Este enlace de registro es inválido o ha expirado.",
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more