mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
632 lines
25 KiB
TypeScript
632 lines
25 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
|
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
|
import {
|
|
BrandingAssetUploadRequest,
|
|
CreateRegistrationUrlRequest,
|
|
CreateRegistrationUrlResponse,
|
|
InstanceConfigResponse,
|
|
InstanceConfigUpdateRequest,
|
|
InstanceEmailSmtpTestRequest,
|
|
InstanceEmailSmtpTestResponse,
|
|
PendingRegistrationActionRequest,
|
|
RegistrationUrlIdParam,
|
|
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
|
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
|
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
|
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
|
import type {Context} from 'hono';
|
|
import {createMiddleware} from 'hono/factory';
|
|
import {createUserID} from '../../BrandedTypes';
|
|
import {Config} from '../../Config';
|
|
import {
|
|
type InstanceBrandingConfig,
|
|
type InstancePolicyConfig,
|
|
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
|
REGISTRATION_REJECTED_TRAIT,
|
|
} from '../../instance/InstanceConfigRepository';
|
|
import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '../../instance/SsoConfigValidation';
|
|
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
|
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
|
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
|
import {getGatewayRolloutConfigPublisher, getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
|
import {RateLimitConfigs} from '../../RateLimitConfig';
|
|
import type {HonoApp, HonoEnv} from '../../types/HonoEnv';
|
|
import {Validator} from '../../Validator';
|
|
|
|
const INSTANCE_BRANDING_ENTITY_ID = 0n;
|
|
|
|
function readOptionalField<T extends object, K extends keyof T>(value: T, key: K): T[K] | undefined {
|
|
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
}
|
|
|
|
function mergeOptionalField<T>(currentValue: T, nextValue: T | undefined): T {
|
|
return nextValue === undefined ? currentValue : nextValue;
|
|
}
|
|
|
|
function omitUndefinedFields<T extends object>(value: T): Partial<T> {
|
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial<T>;
|
|
}
|
|
|
|
async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
|
const instanceConfigRepository = getInstanceConfigRepository();
|
|
const [ssoConfig, gatewayRollout, registrationConfig, registrationUrls, pendingRegistrations] = await Promise.all([
|
|
instanceConfigRepository.getSsoConfig(),
|
|
instanceConfigRepository.getGatewayRolloutConfig(),
|
|
instanceConfigRepository.getRegistrationConfig(),
|
|
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
|
instanceConfigRepository.getPendingRegistrations(),
|
|
]);
|
|
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
|
|
instanceConfigRepository.getAppPublicConfig(),
|
|
instanceConfigRepository.getInstancePolicyConfig(),
|
|
instanceConfigRepository.getResolvedServicesConfig(),
|
|
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
|
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
|
]);
|
|
return {
|
|
sso: {
|
|
enabled: ssoConfig.enabled,
|
|
enforced: ssoConfig.enforced,
|
|
display_name: ssoConfig.displayName,
|
|
issuer: ssoConfig.issuer,
|
|
authorization_url: ssoConfig.authorizationUrl,
|
|
token_url: ssoConfig.tokenUrl,
|
|
userinfo_url: ssoConfig.userInfoUrl,
|
|
jwks_url: ssoConfig.jwksUrl,
|
|
client_id: ssoConfig.clientId,
|
|
client_secret_set: ssoConfig.clientSecretSet ?? false,
|
|
scope: ssoConfig.scope,
|
|
allowed_domains: ssoConfig.allowedEmailDomains,
|
|
auto_provision: ssoConfig.autoProvision,
|
|
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
|
},
|
|
gateway_rollout: gatewayRollout,
|
|
registration: {
|
|
...registrationConfig,
|
|
urls: registrationUrls,
|
|
pending_registrations: pendingRegistrations,
|
|
},
|
|
self_hosted: Config.instance.selfHosted,
|
|
app_public: appPublic,
|
|
policy: {
|
|
single_community_enabled: policy.single_community_enabled,
|
|
single_community_guild_id: policy.single_community_guild_id,
|
|
direct_messages_disabled: policy.direct_messages_disabled,
|
|
direct_messages_locked: policy.direct_messages_locked,
|
|
premium_mode: policy.premium_mode,
|
|
services: {
|
|
gif_enabled: policy.gif_enabled,
|
|
youtube_enabled: policy.youtube_enabled,
|
|
bluesky_enabled: policy.bluesky_enabled,
|
|
},
|
|
deferred_phone_gate: {
|
|
enabled: policy.deferred_phone_gate_enabled,
|
|
window_hours: policy.deferred_phone_gate_window_hours,
|
|
member_threshold: policy.deferred_phone_gate_member_threshold,
|
|
},
|
|
services_resolved: resolvedServices,
|
|
services_available: {
|
|
gif: integrations.gif.effective_available,
|
|
youtube: integrations.youtube.effective_available,
|
|
bluesky: integrations.bluesky.effective_enabled,
|
|
},
|
|
},
|
|
integrations,
|
|
media,
|
|
};
|
|
}
|
|
|
|
function buildAdminIssuedRegistrationUrl(code: string): string {
|
|
const baseUrl = Config.endpoints.webApp.replace(/\/$/, '');
|
|
return `${baseUrl}/register?registration_url=${encodeURIComponent(code)}`;
|
|
}
|
|
|
|
function requireSetupSessionOrAdminACL(requiredACL: string) {
|
|
const requireAcl = requireAdminACL(requiredACL);
|
|
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
|
const user = ctx.get('user');
|
|
const tokenType = ctx.get('authTokenType');
|
|
if (user && tokenType === 'session') {
|
|
const appPublic = await getInstanceConfigRepository().getAppPublicConfig();
|
|
if (!appPublic.setup.configured) {
|
|
ctx.set('adminUserId', user.id);
|
|
ctx.set('adminUserAcls', user.acls);
|
|
await next();
|
|
return;
|
|
}
|
|
}
|
|
return requireAcl(ctx, next);
|
|
});
|
|
}
|
|
|
|
function hasAdminAuthenticationACL(acls: ReadonlySet<string>): boolean {
|
|
return acls.has(AdminACLs.AUTHENTICATE) || acls.has(AdminACLs.WILDCARD);
|
|
}
|
|
|
|
function completesInitialSetup(data: InstanceConfigUpdateRequest, setupConfigured: boolean): boolean {
|
|
return (
|
|
!setupConfigured &&
|
|
data.app_public?.setup != null &&
|
|
readOptionalField(data.app_public.setup, 'configured') === true
|
|
);
|
|
}
|
|
|
|
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<void> {
|
|
const user = ctx.get('user');
|
|
if (!user || ctx.get('authTokenType') !== 'session' || hasAdminAuthenticationACL(user.acls)) {
|
|
return;
|
|
}
|
|
const nextACLs = new Set(user.acls);
|
|
nextACLs.add(AdminACLs.WILDCARD);
|
|
const updatedUser = await ctx.get('userRepository').patchUpsert(user.id, {acls: nextACLs}, user.toRow());
|
|
ctx.set('user', updatedUser);
|
|
ctx.set('adminUserAcls', updatedUser.acls);
|
|
}
|
|
|
|
export function InstanceConfigAdminController(app: HonoApp) {
|
|
const instanceConfigRepository = getInstanceConfigRepository();
|
|
app.get(
|
|
'/admin/instance/config',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
|
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
|
|
OpenAPI({
|
|
operationId: 'get_admin_instance_config',
|
|
summary: 'Get instance configuration',
|
|
description:
|
|
'Retrieves instance-wide configuration including webhooks and SSO configuration. Requires INSTANCE_CONFIG_VIEW permission.',
|
|
responseSchema: InstanceConfigResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(await buildInstanceConfigResponse());
|
|
},
|
|
);
|
|
app.patch(
|
|
'/admin/instance/config',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('json', InstanceConfigUpdateRequest),
|
|
OpenAPI({
|
|
operationId: 'update_admin_instance_config',
|
|
summary: 'Update instance configuration',
|
|
description:
|
|
'Updates instance configuration settings including webhook URLs and SSO parameters. Changes apply immediately. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: InstanceConfigResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
const data = ctx.req.valid('json');
|
|
const appPublicBeforeUpdate = completesInitialSetup(data, false)
|
|
? await instanceConfigRepository.getAppPublicConfig()
|
|
: null;
|
|
const shouldGrantSetupCompleterAdmin =
|
|
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
|
|
if (data.gateway_rollout) {
|
|
const currentRollout = await instanceConfigRepository.getGatewayRolloutConfig();
|
|
const merged = {...currentRollout, ...data.gateway_rollout};
|
|
const validated = GatewayRolloutConfigSchema.parse(merged);
|
|
await instanceConfigRepository.setGatewayRolloutConfig(validated);
|
|
await getGatewayRolloutConfigPublisher().publish(validated);
|
|
}
|
|
if (data.sso) {
|
|
const sso = data.sso;
|
|
const current = await instanceConfigRepository.getSsoConfig({includeSecret: true});
|
|
const next = {
|
|
enabled: mergeOptionalField(current.enabled, readOptionalField(sso, 'enabled')),
|
|
enforced: mergeOptionalField(current.enforced, readOptionalField(sso, 'enforced')),
|
|
displayName: mergeOptionalField(current.displayName, readOptionalField(sso, 'display_name')),
|
|
issuer: mergeOptionalField(current.issuer, readOptionalField(sso, 'issuer')),
|
|
authorizationUrl: mergeOptionalField(current.authorizationUrl, readOptionalField(sso, 'authorization_url')),
|
|
tokenUrl: mergeOptionalField(current.tokenUrl, readOptionalField(sso, 'token_url')),
|
|
userInfoUrl: mergeOptionalField(current.userInfoUrl, readOptionalField(sso, 'userinfo_url')),
|
|
jwksUrl: mergeOptionalField(current.jwksUrl, readOptionalField(sso, 'jwks_url')),
|
|
clientId: mergeOptionalField(current.clientId, readOptionalField(sso, 'client_id')),
|
|
scope: mergeOptionalField(current.scope, readOptionalField(sso, 'scope')),
|
|
allowedEmailDomains: mergeOptionalField(
|
|
current.allowedEmailDomains,
|
|
readOptionalField(sso, 'allowed_domains'),
|
|
),
|
|
autoProvision: mergeOptionalField(current.autoProvision, readOptionalField(sso, 'auto_provision')),
|
|
};
|
|
const validated = await normalizeAndValidateSsoConfig(next, {
|
|
testModeEnabled: Config.dev.testModeEnabled,
|
|
});
|
|
await instanceConfigRepository.setSsoConfig({
|
|
enabled: validated.enabled,
|
|
enforced: validated.enforced,
|
|
displayName: next.displayName,
|
|
issuer: validated.issuer,
|
|
authorizationUrl: validated.authorizationUrl,
|
|
tokenUrl: validated.tokenUrl,
|
|
userInfoUrl: validated.userInfoUrl,
|
|
jwksUrl: validated.jwksUrl,
|
|
clientId: validated.clientId,
|
|
clientSecret: readOptionalField(sso, 'client_secret'),
|
|
scope: next.scope,
|
|
allowedEmailDomains: validated.allowedEmailDomains,
|
|
autoProvision: next.autoProvision,
|
|
redirectUri: null,
|
|
});
|
|
}
|
|
if (data.registration) {
|
|
await instanceConfigRepository.setRegistrationConfig({
|
|
mode: data.registration.mode,
|
|
admin_registration_urls_enabled: data.registration.admin_registration_urls_enabled,
|
|
});
|
|
}
|
|
if (data.app_public) {
|
|
await instanceConfigRepository.setAppPublicConfig({
|
|
branding: data.app_public.branding
|
|
? omitUndefinedFields({
|
|
product_name: readOptionalField(data.app_public.branding, 'product_name'),
|
|
icon_url: readOptionalField(data.app_public.branding, 'icon_url'),
|
|
symbol_url: readOptionalField(data.app_public.branding, 'symbol_url'),
|
|
logo_url: readOptionalField(data.app_public.branding, 'logo_url'),
|
|
wordmark_url: readOptionalField(data.app_public.branding, 'wordmark_url'),
|
|
favicon_url: readOptionalField(data.app_public.branding, 'favicon_url'),
|
|
theme_color: readOptionalField(data.app_public.branding, 'theme_color'),
|
|
})
|
|
: undefined,
|
|
legal: data.app_public.legal
|
|
? omitUndefinedFields({
|
|
terms_url: readOptionalField(data.app_public.legal, 'terms_url'),
|
|
privacy_url: readOptionalField(data.app_public.legal, 'privacy_url'),
|
|
})
|
|
: undefined,
|
|
registration: data.app_public.registration
|
|
? omitUndefinedFields({
|
|
collect_date_of_birth: readOptionalField(data.app_public.registration, 'collect_date_of_birth'),
|
|
})
|
|
: undefined,
|
|
});
|
|
}
|
|
if (data.integrations) {
|
|
await instanceConfigRepository.setInstanceIntegrationsConfig({
|
|
gif: data.integrations.gif
|
|
? omitUndefinedFields({
|
|
klipy_api_key: readOptionalField(data.integrations.gif, 'klipy_api_key'),
|
|
})
|
|
: undefined,
|
|
youtube: data.integrations.youtube
|
|
? omitUndefinedFields({
|
|
api_key: readOptionalField(data.integrations.youtube, 'api_key'),
|
|
})
|
|
: undefined,
|
|
captcha: data.integrations.captcha
|
|
? omitUndefinedFields({
|
|
provider: readOptionalField(data.integrations.captcha, 'provider'),
|
|
hcaptcha_site_key: readOptionalField(data.integrations.captcha, 'hcaptcha_site_key'),
|
|
hcaptcha_secret_key: readOptionalField(data.integrations.captcha, 'hcaptcha_secret_key'),
|
|
turnstile_site_key: readOptionalField(data.integrations.captcha, 'turnstile_site_key'),
|
|
turnstile_secret_key: readOptionalField(data.integrations.captcha, 'turnstile_secret_key'),
|
|
})
|
|
: undefined,
|
|
email: data.integrations.email
|
|
? {
|
|
...omitUndefinedFields({
|
|
enabled: readOptionalField(data.integrations.email, 'enabled'),
|
|
provider: readOptionalField(data.integrations.email, 'provider'),
|
|
from_email: readOptionalField(data.integrations.email, 'from_email'),
|
|
from_name: readOptionalField(data.integrations.email, 'from_name'),
|
|
disable_new_ip_authorization: readOptionalField(
|
|
data.integrations.email,
|
|
'disable_new_ip_authorization',
|
|
),
|
|
}),
|
|
smtp: data.integrations.email.smtp
|
|
? omitUndefinedFields({
|
|
host: readOptionalField(data.integrations.email.smtp, 'host'),
|
|
port: readOptionalField(data.integrations.email.smtp, 'port'),
|
|
username: readOptionalField(data.integrations.email.smtp, 'username'),
|
|
password: readOptionalField(data.integrations.email.smtp, 'password'),
|
|
secure: readOptionalField(data.integrations.email.smtp, 'secure'),
|
|
})
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
bluesky: data.integrations.bluesky
|
|
? {
|
|
...omitUndefinedFields({
|
|
enabled: readOptionalField(data.integrations.bluesky, 'enabled'),
|
|
client_name: readOptionalField(data.integrations.bluesky, 'client_name'),
|
|
client_uri: readOptionalField(data.integrations.bluesky, 'client_uri'),
|
|
logo_uri: readOptionalField(data.integrations.bluesky, 'logo_uri'),
|
|
tos_uri: readOptionalField(data.integrations.bluesky, 'tos_uri'),
|
|
policy_uri: readOptionalField(data.integrations.bluesky, 'policy_uri'),
|
|
}),
|
|
keys: readOptionalField(data.integrations.bluesky, 'keys'),
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
if (data.media) {
|
|
await instanceConfigRepository.setInstanceMediaConfig({
|
|
attachment_decay: data.media.attachment_decay
|
|
? omitUndefinedFields({
|
|
enabled: readOptionalField(data.media.attachment_decay, 'enabled'),
|
|
min_size_mb: readOptionalField(data.media.attachment_decay, 'min_size_mb'),
|
|
max_size_mb: readOptionalField(data.media.attachment_decay, 'max_size_mb'),
|
|
max_eligible_size_mb: readOptionalField(data.media.attachment_decay, 'max_eligible_size_mb'),
|
|
min_lifetime_days: readOptionalField(data.media.attachment_decay, 'min_lifetime_days'),
|
|
max_lifetime_days: readOptionalField(data.media.attachment_decay, 'max_lifetime_days'),
|
|
curve: readOptionalField(data.media.attachment_decay, 'curve'),
|
|
renew_threshold_days: readOptionalField(data.media.attachment_decay, 'renew_threshold_days'),
|
|
renew_window_days: readOptionalField(data.media.attachment_decay, 'renew_window_days'),
|
|
})
|
|
: undefined,
|
|
});
|
|
}
|
|
if (data.policy) {
|
|
await applyInstancePolicyUpdate(ctx, data.policy);
|
|
}
|
|
if (data.app_public?.setup) {
|
|
await instanceConfigRepository.setAppPublicConfig({
|
|
setup: omitUndefinedFields({
|
|
configured: readOptionalField(data.app_public.setup, 'configured'),
|
|
}),
|
|
});
|
|
}
|
|
if (shouldGrantSetupCompleterAdmin) {
|
|
await grantSetupCompleterAdminACL(ctx);
|
|
await instanceConfigRepository.markAdminBootstrapped();
|
|
}
|
|
return ctx.json(await buildInstanceConfigResponse());
|
|
},
|
|
);
|
|
app.post(
|
|
'/admin/instance/config/branding-assets',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('json', BrandingAssetUploadRequest),
|
|
OpenAPI({
|
|
operationId: 'create_admin_instance_branding_asset',
|
|
summary: 'Upload an instance branding asset',
|
|
description:
|
|
'Uploads a branding image served by the media proxy and stores its URL, or clears it when no image is provided. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: InstanceConfigResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
const {kind, image} = ctx.req.valid('json');
|
|
const prepared = await ctx.get('entityAssetService').prepareAssetUpload({
|
|
assetType: 'branding',
|
|
entityType: 'instance',
|
|
entityId: INSTANCE_BRANDING_ENTITY_ID,
|
|
previousHash: null,
|
|
base64Image: image ?? null,
|
|
errorPath: 'image',
|
|
});
|
|
const brandingPatch: Partial<InstanceBrandingConfig> = {[`${kind}_url`]: prepared.newCdnUrl};
|
|
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
|
|
return ctx.json(await buildInstanceConfigResponse());
|
|
},
|
|
);
|
|
app.post(
|
|
'/admin/instance/config/smtp-tests',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('json', InstanceEmailSmtpTestRequest),
|
|
OpenAPI({
|
|
operationId: 'create_admin_instance_smtp_test',
|
|
summary: 'Run an SMTP configuration test',
|
|
description:
|
|
'Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: InstanceEmailSmtpTestResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
const data = ctx.req.valid('json');
|
|
try {
|
|
const provider = new SmtpEmailProvider({
|
|
host: data.host,
|
|
port: data.port,
|
|
username: data.username,
|
|
password: data.password,
|
|
secure: data.secure,
|
|
connectionTimeoutMs: 10000,
|
|
greetingTimeoutMs: 10000,
|
|
socketTimeoutMs: 10000,
|
|
});
|
|
await provider.verify();
|
|
return ctx.json({ok: true, error: null});
|
|
} catch (error) {
|
|
return ctx.json({ok: false, error: error instanceof Error ? error.message : String(error)});
|
|
}
|
|
},
|
|
);
|
|
app.post(
|
|
'/admin/instance/registration-urls',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('json', CreateRegistrationUrlRequest),
|
|
OpenAPI({
|
|
operationId: 'create_admin_registration_url',
|
|
summary: 'Create an admin-issued registration URL',
|
|
description:
|
|
'Creates a one-time-display registration URL that can be sent manually by an administrator. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: CreateRegistrationUrlResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
const data = ctx.req.valid('json');
|
|
const created = await instanceConfigRepository.createRegistrationUrl({
|
|
label: data.label?.trim() || null,
|
|
createdByUserId: ctx.get('adminUserId').toString(),
|
|
expiresAt: data.expires_at ? new Date(data.expires_at) : null,
|
|
maxUses: data.max_uses ?? null,
|
|
approvalRequired: data.approval_required,
|
|
});
|
|
return ctx.json({
|
|
registration_url: created.registrationUrl,
|
|
code: created.code,
|
|
url: buildAdminIssuedRegistrationUrl(created.code),
|
|
});
|
|
},
|
|
);
|
|
app.delete(
|
|
'/admin/instance/registration-urls/:registration_url_id',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('param', RegistrationUrlIdParam),
|
|
OpenAPI({
|
|
operationId: 'revoke_admin_registration_url',
|
|
summary: 'Revoke an admin-issued registration URL',
|
|
description:
|
|
'Revokes an admin-issued registration URL so it can no longer be used. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: InstanceConfigResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
async (ctx) => {
|
|
await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('param').registration_url_id);
|
|
return ctx.json(await buildInstanceConfigResponse());
|
|
},
|
|
);
|
|
app.patch(
|
|
'/admin/instance/pending-registrations/:user_id',
|
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
|
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
|
Validator('param', UserIdParam),
|
|
Validator('json', PendingRegistrationActionRequest),
|
|
OpenAPI({
|
|
operationId: 'update_admin_pending_registration',
|
|
summary: 'Approve or reject a pending registration',
|
|
description:
|
|
'Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.',
|
|
responseSchema: InstanceConfigResponse,
|
|
statusCode: 200,
|
|
security: 'adminApiKey',
|
|
tags: 'Admin',
|
|
}),
|
|
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);
|
|
await instanceConfigRepository.removePendingRegistration(userId);
|
|
return ctx.json(await buildInstanceConfigResponse());
|
|
},
|
|
);
|
|
}
|
|
|
|
async function applyInstancePolicyUpdate(
|
|
ctx: Context<HonoEnv>,
|
|
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
|
|
): Promise<void> {
|
|
const instanceConfigRepository = getInstanceConfigRepository();
|
|
const [current, appPublic] = await Promise.all([
|
|
instanceConfigRepository.getInstancePolicyConfig(),
|
|
instanceConfigRepository.getAppPublicConfig(),
|
|
]);
|
|
const patch: Partial<InstancePolicyConfig> = {};
|
|
if (
|
|
policy.single_community_enabled !== undefined &&
|
|
policy.single_community_enabled !== current.single_community_enabled
|
|
) {
|
|
if (policy.single_community_enabled) {
|
|
if (appPublic.setup.configured && current.single_community_guild_id == null) {
|
|
throw new InstancePolicyTransitionNotAllowedError();
|
|
}
|
|
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
|
|
if (!adminUser) {
|
|
throw new InstancePolicyTransitionNotAllowedError();
|
|
}
|
|
await ctx.get('singleCommunityService').ensureStockCommunity({
|
|
owner: adminUser,
|
|
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
|
|
});
|
|
} else {
|
|
patch.single_community_enabled = false;
|
|
}
|
|
}
|
|
const unlockDirectMessages = policy.direct_messages_locked === false;
|
|
if (unlockDirectMessages && current.direct_messages_locked) {
|
|
patch.direct_messages_locked = false;
|
|
}
|
|
if (
|
|
policy.direct_messages_disabled !== undefined &&
|
|
policy.direct_messages_disabled !== current.direct_messages_disabled
|
|
) {
|
|
if (current.direct_messages_locked && !unlockDirectMessages) {
|
|
throw new InstancePolicyTransitionNotAllowedError();
|
|
}
|
|
patch.direct_messages_disabled = policy.direct_messages_disabled;
|
|
if (!policy.direct_messages_disabled) {
|
|
patch.direct_messages_locked = true;
|
|
}
|
|
}
|
|
if (policy.premium_mode !== undefined) {
|
|
patch.premium_mode = policy.premium_mode;
|
|
}
|
|
if (policy.services) {
|
|
if (policy.services.gif_enabled !== undefined) {
|
|
patch.gif_enabled = policy.services.gif_enabled ?? null;
|
|
}
|
|
if (policy.services.youtube_enabled !== undefined) {
|
|
patch.youtube_enabled = policy.services.youtube_enabled ?? null;
|
|
}
|
|
if (policy.services.bluesky_enabled !== undefined) {
|
|
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
|
|
}
|
|
}
|
|
if (policy.deferred_phone_gate) {
|
|
if (policy.deferred_phone_gate.enabled !== undefined) {
|
|
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
|
|
}
|
|
if (policy.deferred_phone_gate.window_hours !== undefined) {
|
|
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
|
|
}
|
|
if (policy.deferred_phone_gate.member_threshold !== undefined) {
|
|
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
|
|
}
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
await instanceConfigRepository.setInstancePolicyConfig(patch);
|
|
}
|
|
if (policy.premium_mode !== undefined && policy.premium_mode !== current.premium_mode) {
|
|
await ctx.get('limitConfigService').reloadForPolicyChange();
|
|
}
|
|
}
|
|
|
|
async function updatePendingRegistrationUser(
|
|
ctx: Context<HonoEnv>,
|
|
userId: string,
|
|
decision: 'approve' | 'reject',
|
|
): Promise<void> {
|
|
const userRepository = ctx.get('userRepository');
|
|
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
|
|
if (!user) {
|
|
return;
|
|
}
|
|
const traits = new Set(user.traits);
|
|
const wasPendingApproval = traits.delete(REGISTRATION_PENDING_APPROVAL_TRAIT);
|
|
if (decision === 'reject') {
|
|
traits.add(REGISTRATION_REJECTED_TRAIT);
|
|
} else {
|
|
traits.delete(REGISTRATION_REJECTED_TRAIT);
|
|
}
|
|
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
|
|
if (decision === 'approve' && wasPendingApproval) {
|
|
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
|
|
}
|
|
await ctx.get('adminService').auditService.createAuditLog({
|
|
adminUserId: ctx.get('adminUserId'),
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
action: decision === 'approve' ? 'approve_registration' : 'reject_registration',
|
|
auditLogReason: ctx.get('auditLogReason'),
|
|
});
|
|
}
|