Files
fluxer/packages/admin/src/routes/System.tsx
2026-02-17 12:22:36 +00:00

612 lines
20 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {purgeAssets} from '@fluxer/admin/src/api/Assets';
import {
addSnowflakeReservation,
deleteSnowflakeReservation,
updateInstanceConfig,
} from '@fluxer/admin/src/api/InstanceConfig';
import {getLimitConfig, updateLimitConfig} from '@fluxer/admin/src/api/LimitConfig';
import {refreshSearchIndex, refreshSearchIndexWithGuild} from '@fluxer/admin/src/api/Search';
import {reloadAllGuilds} from '@fluxer/admin/src/api/System';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {getFirstAccessiblePath} from '@fluxer/admin/src/Navigation';
import {AssetPurgePage} from '@fluxer/admin/src/pages/AssetPurgePage';
import {AuditLogsPage} from '@fluxer/admin/src/pages/AuditLogsPage';
import {GatewayPage} from '@fluxer/admin/src/pages/GatewayPage';
import {InstanceConfigPage} from '@fluxer/admin/src/pages/InstanceConfigPage';
import {LimitConfigPage} from '@fluxer/admin/src/pages/LimitConfigPage';
import {SearchIndexPage} from '@fluxer/admin/src/pages/SearchIndexPage';
import {StrangePlacePage} from '@fluxer/admin/src/pages/StrangePlacePage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig, isSelfHostedOverride} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppContext, AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, type ParsedBody, parseDelimitedStringList} from '@fluxer/admin/src/utils/Forms';
import {
AddSnowflakeReservationRequest,
DeleteSnowflakeReservationRequest,
InstanceConfigUpdateRequest,
PurgeGuildAssetsRequest,
RefreshSearchIndexRequest,
ReloadGuildsRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {Hono} from 'hono';
function trimToUndefined(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}
function trimToNull(value: string | undefined): string | null {
return trimToUndefined(value) ?? null;
}
function parseBooleanFlag(value: string | undefined): boolean {
return value === 'true';
}
function parseHourValue(value: string | undefined): number | null {
if (!value) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function buildLimitFilters(formData: ParsedBody): {filters?: {traits?: Array<string>; guildFeatures?: Array<string>}} {
const traits = parseDelimitedStringList(getOptionalString(formData, 'traits'));
const guildFeatures = parseDelimitedStringList(getOptionalString(formData, 'guild_features'));
const filters =
traits.length > 0 || guildFeatures.length > 0
? {
...(traits.length > 0 ? {traits} : {}),
...(guildFeatures.length > 0 ? {guildFeatures} : {}),
}
: undefined;
return {filters};
}
function redirectInvalidForm(c: AppContext, redirectUrl: string): Response {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
export function createSystemRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
function getLandingPath(c: AppContext): string {
const {adminAcls} = getRouteContext(c);
const selfHosted = isSelfHostedOverride(c, config);
const firstPath = getFirstAccessiblePath(adminAcls, {selfHosted});
return `${config.basePath}${firstPath ?? '/strange-place'}`;
}
router.get('/dashboard', requireAuth, async (c) => {
return c.redirect(getLandingPath(c));
});
router.get('/', requireAuth, async (c) => {
return c.redirect(getLandingPath(c));
});
router.get('/strange-place', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
return c.html(
<StrangePlacePage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>,
);
});
router.get('/gateway', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const page = await GatewayPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
adminAcls,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/gateway', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/gateway`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'reload_all') {
const guildIds = parseDelimitedStringList(getOptionalString(formData, 'guild_ids'));
const validation = ReloadGuildsRequest.safeParse({guild_ids: guildIds});
if (!validation.success) {
return redirectInvalidForm(c, redirectUrl);
}
const result = await reloadAllGuilds(config, session, guildIds);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? `Reload triggered for ${result.data.count} guild(s)` : 'Failed to reload gateways',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectInvalidForm(c, redirectUrl);
}
});
router.get('/audit-logs', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const query = c.req.query('q');
const adminUserIdFilter = c.req.query('admin_user_id');
const targetId = c.req.query('target_id');
const currentPage = Math.max(0, parseInt(c.req.query('page') ?? '0', 10));
const page = await AuditLogsPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
query,
adminUserIdFilter,
targetId,
currentPage,
csrfToken,
});
return c.html(page ?? '');
});
router.get('/search-index', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const jobId = c.req.query('job_id');
const page = await SearchIndexPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
jobId,
});
return c.html(page ?? '');
});
router.post('/search-index', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/search-index`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const indexType = trimToUndefined(getOptionalString(formData, 'index_type'));
const guildId = trimToUndefined(getOptionalString(formData, 'guild_id'));
const reason = trimToUndefined(getOptionalString(formData, 'reason'));
if (!indexType) {
return redirectInvalidForm(c, redirectUrl);
}
const validation = RefreshSearchIndexRequest.safeParse({
index_type: indexType,
...(guildId ? {guild_id: guildId} : {}),
});
if (!validation.success) {
return redirectInvalidForm(c, redirectUrl);
}
const result = guildId
? await refreshSearchIndexWithGuild(config, session, indexType, guildId, reason)
: await refreshSearchIndex(config, session, indexType, reason);
if (result.ok) {
return redirectWithFlash(c, `${redirectUrl}?job_id=${result.data.job_id}`, {
message: `Search index refresh started (Job ID: ${result.data.job_id})`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to start search index refresh',
type: 'error',
});
} catch {
return redirectInvalidForm(c, redirectUrl);
}
});
router.get('/instance-config', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const page = await InstanceConfigPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/instance-config', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/instance-config`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'update') {
const startHour = parseHourValue(getOptionalString(formData, 'manual_review_schedule_start_hour_utc'));
const endHour = parseHourValue(getOptionalString(formData, 'manual_review_schedule_end_hour_utc'));
if (startHour === null || endHour === null) {
return redirectInvalidForm(c, redirectUrl);
}
const update = {
manual_review_enabled: parseBooleanFlag(getOptionalString(formData, 'manual_review_enabled')),
manual_review_schedule_enabled: parseBooleanFlag(
getOptionalString(formData, 'manual_review_schedule_enabled'),
),
manual_review_schedule_start_hour_utc: startHour,
manual_review_schedule_end_hour_utc: endHour,
registration_alerts_webhook_url: trimToNull(getOptionalString(formData, 'registration_alerts_webhook_url')),
system_alerts_webhook_url: trimToNull(getOptionalString(formData, 'system_alerts_webhook_url')),
};
const validation = InstanceConfigUpdateRequest.safeParse(update);
if (!validation.success) {
return redirectInvalidForm(c, redirectUrl);
}
const result = await updateInstanceConfig(config, session, validation.data);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Instance configuration updated' : 'Failed to update instance configuration',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'update_sso') {
const clearSecret = parseBooleanFlag(getOptionalString(formData, 'sso_clear_client_secret'));
const newSecret = trimToUndefined(getOptionalString(formData, 'sso_client_secret'));
const allowedDomains = parseDelimitedStringList(getOptionalString(formData, 'sso_allowed_domains'));
const sso = {
enabled: parseBooleanFlag(getOptionalString(formData, 'sso_enabled')),
auto_provision: parseBooleanFlag(getOptionalString(formData, 'sso_auto_provision')),
display_name: trimToNull(getOptionalString(formData, 'sso_display_name')),
issuer: trimToNull(getOptionalString(formData, 'sso_issuer')),
authorization_url: trimToNull(getOptionalString(formData, 'sso_authorization_url')),
token_url: trimToNull(getOptionalString(formData, 'sso_token_url')),
userinfo_url: trimToNull(getOptionalString(formData, 'sso_userinfo_url')),
jwks_url: trimToNull(getOptionalString(formData, 'sso_jwks_url')),
client_id: trimToNull(getOptionalString(formData, 'sso_client_id')),
scope: trimToNull(getOptionalString(formData, 'sso_scope')),
allowed_domains: allowedDomains,
client_secret: newSecret ?? (clearSecret ? null : undefined),
};
const validation = InstanceConfigUpdateRequest.safeParse({sso});
if (!validation.success) {
return redirectInvalidForm(c, redirectUrl);
}
const result = await updateInstanceConfig(config, session, validation.data);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'SSO configuration updated' : 'Failed to update SSO configuration',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'add_reservation') {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, redirectUrl, {
message: 'Snowflake reservations are not available on self-hosted instances',
type: 'error',
});
}
const email = trimToUndefined(getOptionalString(formData, 'reservation_email'));
const snowflake = trimToUndefined(getOptionalString(formData, 'reservation_snowflake'));
const validation = AddSnowflakeReservationRequest.safeParse({email, snowflake});
if (!validation.success) {
return redirectWithFlash(c, redirectUrl, {
message: 'Email and snowflake ID are required',
type: 'error',
});
}
const result = await addSnowflakeReservation(config, session, validation.data.email, validation.data.snowflake);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Snowflake reservation added' : 'Failed to add snowflake reservation',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'delete_reservation') {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, redirectUrl, {
message: 'Snowflake reservations are not available on self-hosted instances',
type: 'error',
});
}
const email = trimToUndefined(getOptionalString(formData, 'reservation_email'));
const validation = DeleteSnowflakeReservationRequest.safeParse({email});
if (!validation.success) {
return redirectWithFlash(c, redirectUrl, {
message: 'Email is required',
type: 'error',
});
}
const result = await deleteSnowflakeReservation(config, session, validation.data.email);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Snowflake reservation removed' : 'Failed to remove snowflake reservation',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectInvalidForm(c, redirectUrl);
}
});
router.get('/limit-config', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const selectedRule = c.req.query('rule')?.trim() || undefined;
const page = await LimitConfigPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
selectedRule,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/limit-config', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/limit-config`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
const ruleId = c.req.query('rule');
if (action === 'update' && ruleId) {
const currentConfigResult = await getLimitConfig(config, session);
if (!currentConfigResult.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to fetch current limit configuration',
type: 'error',
});
}
const currentConfig = currentConfigResult.data.limit_config;
const defaultLimits = currentConfigResult.data.defaults;
const ruleIndex = currentConfig.rules.findIndex((r) => r.id === ruleId);
if (ruleIndex === -1) {
return redirectWithFlash(c, redirectUrl, {message: 'Rule not found', type: 'error'});
}
const rule = currentConfig.rules[ruleIndex];
const limits: Record<string, number> = {};
for (const key in formData) {
if (key === 'csrf_token' || key === 'rule_id' || key === 'traits' || key === 'guild_features') {
continue;
}
const value = formData[key];
if (typeof value === 'string' && value.trim() !== '') {
const numericValue = parseInt(value, 10);
if (!Number.isNaN(numericValue)) {
limits[key] = numericValue;
}
}
}
const {filters} = buildLimitFilters(formData);
if (Object.keys(limits).length === 0) {
const fallbackDefaults = defaultLimits[ruleId] ?? defaultLimits.default;
if (fallbackDefaults) {
Object.assign(limits, fallbackDefaults);
}
}
currentConfig.rules[ruleIndex] = {
...rule,
limits,
filters,
};
const updatedConfigJson = JSON.stringify(currentConfig);
const result = await updateLimitConfig(config, session, updatedConfigJson);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Limit configuration updated' : 'Failed to update limit configuration',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'delete' && ruleId) {
const currentConfigResult = await getLimitConfig(config, session);
if (!currentConfigResult.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to fetch current limit configuration',
type: 'error',
});
}
if (ruleId === 'default') {
return redirectWithFlash(c, redirectUrl, {message: 'The default rule cannot be deleted', type: 'error'});
}
const currentConfig = currentConfigResult.data.limit_config;
const ruleIndex = currentConfig.rules.findIndex((r) => r.id === ruleId);
if (ruleIndex === -1) {
return redirectWithFlash(c, redirectUrl, {message: 'Rule not found', type: 'error'});
}
currentConfig.rules.splice(ruleIndex, 1);
const updatedConfigJson = JSON.stringify(currentConfig);
const result = await updateLimitConfig(config, session, updatedConfigJson);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Limit rule deleted' : 'Failed to delete limit rule',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'create') {
const ruleIdInput = getOptionalString(formData, 'rule_id') ?? '';
const ruleIdTrimmed = ruleIdInput.trim();
if (ruleIdTrimmed === '') {
return redirectWithFlash(c, redirectUrl, {message: 'Rule ID is required', type: 'error'});
}
if (ruleIdTrimmed === 'default') {
return redirectWithFlash(c, redirectUrl, {
message: 'The default rule ID is reserved',
type: 'error',
});
}
const currentConfigResult = await getLimitConfig(config, session);
if (!currentConfigResult.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to fetch current limit configuration',
type: 'error',
});
}
const currentConfig = currentConfigResult.data.limit_config;
const existingRule = currentConfig.rules.find((r) => r.id === ruleIdTrimmed);
if (existingRule) {
return redirectWithFlash(c, redirectUrl, {message: 'Rule ID already exists', type: 'error'});
}
const {filters} = buildLimitFilters(formData);
const defaultLimits = currentConfigResult.data.defaults.default ?? {};
const newRule = {
id: ruleIdTrimmed,
limits: {...defaultLimits},
...(filters ? {filters} : {}),
};
currentConfig.rules.push(newRule);
const updatedConfigJson = JSON.stringify(currentConfig);
const result = await updateLimitConfig(config, session, updatedConfigJson);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Limit rule created' : 'Failed to create limit rule',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectInvalidForm(c, redirectUrl);
}
});
router.get('/asset-purge', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const page = await AssetPurgePage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/asset-purge', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/asset-purge`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const assetIds = parseDelimitedStringList(getOptionalString(formData, 'asset_ids'));
if (assetIds.length === 0) {
return redirectWithFlash(c, redirectUrl, {message: 'No asset IDs provided', type: 'error'});
}
const validation = PurgeGuildAssetsRequest.safeParse({ids: assetIds});
if (!validation.success) {
return redirectInvalidForm(c, redirectUrl);
}
const auditLogReason = trimToUndefined(getOptionalString(formData, 'audit_log_reason'));
const result = await purgeAssets(config, session, assetIds, auditLogReason);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? `Purged ${assetIds.length} asset(s)` : 'Failed to purge assets',
type: result.ok ? 'success' : 'error',
});
} catch {
return redirectInvalidForm(c, redirectUrl);
}
});
return router;
}